diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 49aa47c82..360a3a613 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -1,66 +1,60 @@ -import { - View, - Pressable, - Text, - ActivityIndicator, - Platform, -} from "react-native"; -import { useState, useEffect, useRef, useCallback } from "react"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native"; -import Animated, { useAnimatedStyle } from "react-native-reanimated"; -import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useIsFocused } from "@react-navigation/native"; -import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout"; -import { generateMessageId, type StreamItem } from "@/types/stream"; -import { AgentStatusBar } from "./agent-status-bar"; -import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker"; -import { useSessionStore } from "@/stores/session-store"; -import { useDraftStore } from "@/stores/draft-store"; +import { View, Pressable, Text, ActivityIndicator, Platform } from 'react-native' +import { useState, useEffect, useRef, useCallback } from 'react' +import { StyleSheet, useUnistyles } from 'react-native-unistyles' +import { ArrowUp, Square, Pencil, AudioLines } from 'lucide-react-native' +import Animated, { useAnimatedStyle } from 'react-native-reanimated' +import { useReanimatedKeyboardAnimation } from 'react-native-keyboard-controller' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { useIsFocused } from '@react-navigation/native' +import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from '@/constants/layout' +import { generateMessageId, type StreamItem } from '@/types/stream' +import { AgentStatusBar } from './agent-status-bar' +import { useImageAttachmentPicker } from '@/hooks/use-image-attachment-picker' +import { useSessionStore } from '@/stores/session-store' +import { useDraftStore } from '@/stores/draft-store' import { MessageInput, type MessagePayload, type ImageAttachment, type MessageInputRef, -} from "./message-input"; -import { Theme } from "@/styles/theme"; -import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query"; -import { encodeImages } from "@/utils/encode-images"; -import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; -import { focusWithRetries } from "@/utils/web-focus"; -import { useVoiceOptional } from "@/contexts/voice-context"; -import { useToast } from "@/contexts/toast-context"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { Shortcut } from "@/components/ui/shortcut"; -import { Autocomplete } from "@/components/ui/autocomplete"; -import { useAgentAutocomplete } from "@/hooks/use-agent-autocomplete"; +} from './message-input' +import { Theme } from '@/styles/theme' +import type { DraftCommandConfig } from '@/hooks/use-agent-commands-query' +import { encodeImages } from '@/utils/encode-images' +import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store' +import { focusWithRetries } from '@/utils/web-focus' +import { useVoiceOptional } from '@/contexts/voice-context' +import { useToast } from '@/contexts/toast-context' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { Shortcut } from '@/components/ui/shortcut' +import { Autocomplete } from '@/components/ui/autocomplete' +import { useAgentAutocomplete } from '@/hooks/use-agent-autocomplete' type QueuedMessage = { - id: string; - text: string; - images?: ImageAttachment[]; -}; - -interface AgentInputAreaProps { - agentId: string; - serverId: string; - onSubmitMessage?: (payload: MessagePayload) => Promise; - /** Externally controlled loading state. When true, disables the submit button. */ - isSubmitLoading?: boolean; - /** When true, blurs the input immediately when submitting. */ - blurOnSubmit?: boolean; - value?: string; - onChangeText?: (text: string) => void; - /** When true, auto-focuses the text input on web. */ - autoFocus?: boolean; - /** Callback to expose the addImages function to parent components */ - onAddImages?: (addImages: (images: ImageAttachment[]) => void) => void; - /** Optional draft context for listing commands before an agent exists. */ - commandDraftConfig?: DraftCommandConfig; + id: string + text: string + images?: ImageAttachment[] } -const EMPTY_ARRAY: readonly QueuedMessage[] = []; +interface AgentInputAreaProps { + agentId: string + serverId: string + onSubmitMessage?: (payload: MessagePayload) => Promise + /** Externally controlled loading state. When true, disables the submit button. */ + isSubmitLoading?: boolean + /** When true, blurs the input immediately when submitting. */ + blurOnSubmit?: boolean + value?: string + onChangeText?: (text: string) => void + /** When true, auto-focuses the text input on web. */ + autoFocus?: boolean + /** Callback to expose the addImages function to parent components */ + onAddImages?: (addImages: (images: ImageAttachment[]) => void) => void + /** Optional draft context for listing commands before an agent exists. */ + commandDraftConfig?: DraftCommandConfig +} + +const EMPTY_ARRAY: readonly QueuedMessage[] = [] export function AgentInputArea({ agentId, @@ -74,101 +68,98 @@ export function AgentInputArea({ onAddImages, commandDraftConfig, }: AgentInputAreaProps) { - const { theme } = useUnistyles(); - const insets = useSafeAreaInsets(); - const { height: keyboardHeight } = useReanimatedKeyboardAnimation(); - const isScreenFocused = useIsFocused(); - const messageInputActionRequest = useKeyboardShortcutsStore( - (s) => s.messageInputActionRequest - ); + const { theme } = useUnistyles() + const insets = useSafeAreaInsets() + const { height: keyboardHeight } = useReanimatedKeyboardAnimation() + const isScreenFocused = useIsFocused() + const messageInputActionRequest = useKeyboardShortcutsStore((s) => s.messageInputActionRequest) const clearMessageInputActionRequest = useKeyboardShortcutsStore( (s) => s.clearMessageInputActionRequest - ); + ) - const client = useSessionStore( - (state) => state.sessions[serverId]?.client ?? null - ); - const toast = useToast(); - const voice = useVoiceOptional(); - const isConnected = client?.isConnected ?? false; + const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null) + const toast = useToast() + const voice = useVoiceOptional() + const isConnected = client?.isConnected ?? false - const agent = useSessionStore((state) => - state.sessions[serverId]?.agents?.get(agentId) - ); + const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId)) - const getDraftInput = useDraftStore((state) => state.getDraftInput); - const saveDraftInput = useDraftStore((state) => state.saveDraftInput); + const getDraftInput = useDraftStore((state) => state.getDraftInput) + const saveDraftInput = useDraftStore((state) => state.saveDraftInput) const queuedMessagesRaw = useSessionStore((state) => state.sessions[serverId]?.queuedMessages?.get(agentId) - ); - const queuedMessages = queuedMessagesRaw ?? EMPTY_ARRAY; + ) + const queuedMessages = queuedMessagesRaw ?? EMPTY_ARRAY - const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages); - const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail); - const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead); + const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages) + const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail) + const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead) - const [internalInput, setInternalInput] = useState(""); - const userInput = value ?? internalInput; - const setUserInput = onChangeText ?? setInternalInput; - const [isProcessing, setIsProcessing] = useState(false); - const [selectedImages, setSelectedImages] = useState([]); - const [isCancellingAgent, setIsCancellingAgent] = useState(false); - const [sendError, setSendError] = useState(null); - const lastHandledMessageInputActionRequestIdRef = useRef(null); - const messageInputRef = useRef(null); + const [internalInput, setInternalInput] = useState('') + const userInput = value ?? internalInput + const setUserInput = onChangeText ?? setInternalInput + const [cursorIndex, setCursorIndex] = useState(0) + const [isProcessing, setIsProcessing] = useState(false) + const [selectedImages, setSelectedImages] = useState([]) + const [isCancellingAgent, setIsCancellingAgent] = useState(false) + const [sendError, setSendError] = useState(null) + const lastHandledMessageInputActionRequestIdRef = useRef(null) + const messageInputRef = useRef(null) const autocomplete = useAgentAutocomplete({ userInput, + cursorIndex, setUserInput, serverId, agentId, draftConfig: commandDraftConfig, onAutocompleteApplied: () => { - messageInputRef.current?.focus(); + messageInputRef.current?.focus() }, - }); + }) // Clear send error when user edits the input useEffect(() => { if (sendError && userInput) { - setSendError(null); + setSendError(null) } - }, [userInput, sendError]); + }, [userInput, sendError]) - const { pickImages } = useImageAttachmentPicker(); - const agentIdRef = useRef(agentId); + useEffect(() => { + setCursorIndex((current) => Math.min(current, userInput.length)) + }, [userInput.length]) + + const { pickImages } = useImageAttachmentPicker() + const agentIdRef = useRef(agentId) const sendAgentMessageRef = useRef< ((agentId: string, text: string, images?: ImageAttachment[]) => Promise) | null - >(null); - const onSubmitMessageRef = useRef(onSubmitMessage); + >(null) + const onSubmitMessageRef = useRef(onSubmitMessage) // Expose addImages function to parent for drag-and-drop support const addImages = useCallback((images: ImageAttachment[]) => { - setSelectedImages((prev) => [...prev, ...images]); - }, []); + setSelectedImages((prev) => [...prev, ...images]) + }, []) useEffect(() => { - onAddImages?.(addImages); - }, [addImages, onAddImages]); + onAddImages?.(addImages) + }, [addImages, onAddImages]) - const submitMessage = useCallback( - async (text: string, images?: ImageAttachment[]) => { - if (onSubmitMessageRef.current) { - await onSubmitMessageRef.current({ text, images }); - return; - } - if (!sendAgentMessageRef.current) { - throw new Error("Host is not connected"); - } - await sendAgentMessageRef.current(agentIdRef.current, text, images); - }, - [] - ); + const submitMessage = useCallback(async (text: string, images?: ImageAttachment[]) => { + if (onSubmitMessageRef.current) { + await onSubmitMessageRef.current({ text, images }) + return + } + if (!sendAgentMessageRef.current) { + throw new Error('Host is not connected') + } + await sendAgentMessageRef.current(agentIdRef.current, text, images) + }, []) useEffect(() => { - agentIdRef.current = agentId; - }, [agentId]); + agentIdRef.current = agentId + }, [agentId]) useEffect(() => { sendAgentMessageRef.current = async ( @@ -177,116 +168,116 @@ export function AgentInputArea({ images?: ImageAttachment[] ) => { if (!client) { - throw new Error("Host is not connected"); + throw new Error('Host is not connected') } - const messageId = generateMessageId(); + const messageId = generateMessageId() const userMessage: StreamItem = { - kind: "user_message", + kind: 'user_message', id: messageId, text, timestamp: new Date(), ...(images && images.length > 0 ? { images } : {}), - }; + } // Append to head if streaming (keeps the user message with the current // turn so late text_deltas still find the existing assistant_message). // Otherwise append to tail. - const currentHead = useSessionStore.getState().sessions[serverId]?.agentStreamHead?.get(agentId); + const currentHead = useSessionStore + .getState() + .sessions[serverId]?.agentStreamHead?.get(agentId) if (currentHead && currentHead.length > 0) { setAgentStreamHead(serverId, (prev) => { - const head = prev.get(agentId) || []; - const updated = new Map(prev); - updated.set(agentId, [...head, userMessage]); - return updated; - }); + const head = prev.get(agentId) || [] + const updated = new Map(prev) + updated.set(agentId, [...head, userMessage]) + return updated + }) } else { setAgentStreamTail(serverId, (prev) => { - const currentStream = prev.get(agentId) || []; - const updated = new Map(prev); - updated.set(agentId, [...currentStream, userMessage]); - return updated; - }); + const currentStream = prev.get(agentId) || [] + const updated = new Map(prev) + updated.set(agentId, [...currentStream, userMessage]) + return updated + }) } - const imagesData = await encodeImages(images); + const imagesData = await encodeImages(images) await client.sendAgentMessage(agentId, text, { messageId, ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), - }); - }; - }, [client, serverId, setAgentStreamTail, setAgentStreamHead]); + }) + } + }, [client, serverId, setAgentStreamTail, setAgentStreamHead]) useEffect(() => { - onSubmitMessageRef.current = onSubmitMessage; - }, [onSubmitMessage]); + onSubmitMessageRef.current = onSubmitMessage + }, [onSubmitMessage]) - const isAgentRunning = agent?.status === "running"; - const agentUpdatedAtMs = agent?.updatedAt?.getTime() ?? 0; + const isAgentRunning = agent?.status === 'running' + const agentUpdatedAtMs = agent?.updatedAt?.getTime() ?? 0 - const prevIsAgentRunningRef = useRef(isAgentRunning); - const latestAgentUpdatedAtRef = useRef(agentUpdatedAtMs); + const prevIsAgentRunningRef = useRef(isAgentRunning) + const latestAgentUpdatedAtRef = useRef(agentUpdatedAtMs) useEffect(() => { - const previousUpdatedAt = latestAgentUpdatedAtRef.current; + const previousUpdatedAt = latestAgentUpdatedAtRef.current if (agentUpdatedAtMs < previousUpdatedAt) { - return; + return } - const wasRunning = prevIsAgentRunningRef.current; - let shouldClearProcessing = false; + const wasRunning = prevIsAgentRunningRef.current + let shouldClearProcessing = false if (isProcessing) { - const hasEnteredRunning = !wasRunning && isAgentRunning; + const hasEnteredRunning = !wasRunning && isAgentRunning const hasFreshRunningUpdateWhileRunning = - wasRunning && isAgentRunning && agentUpdatedAtMs > previousUpdatedAt; - const hasStoppedRunning = wasRunning && !isAgentRunning; + wasRunning && isAgentRunning && agentUpdatedAtMs > previousUpdatedAt + const hasStoppedRunning = wasRunning && !isAgentRunning shouldClearProcessing = - hasEnteredRunning || - hasFreshRunningUpdateWhileRunning || - hasStoppedRunning; + hasEnteredRunning || hasFreshRunningUpdateWhileRunning || hasStoppedRunning } - prevIsAgentRunningRef.current = isAgentRunning; - latestAgentUpdatedAtRef.current = agentUpdatedAtMs; + prevIsAgentRunningRef.current = isAgentRunning + latestAgentUpdatedAtRef.current = agentUpdatedAtMs if (shouldClearProcessing) { - setIsProcessing(false); + setIsProcessing(false) } - }, [agentUpdatedAtMs, isAgentRunning, isProcessing]); + }, [agentUpdatedAtMs, isAgentRunning, isProcessing]) const updateQueue = useCallback( (updater: (current: QueuedMessage[]) => QueuedMessage[]) => { setQueuedMessages(serverId, (prev: Map) => { - const next = new Map(prev); - next.set(agentId, updater(prev.get(agentId) ?? [])); - return next; - }); + const next = new Map(prev) + next.set(agentId, updater(prev.get(agentId) ?? [])) + return next + }) }, [agentId, serverId, setQueuedMessages] - ); + ) function queueMessage(message: string, imageAttachments?: ImageAttachment[]) { - const trimmedMessage = message.trim(); - if (!trimmedMessage && !imageAttachments?.length) return; + const trimmedMessage = message.trim() + if (!trimmedMessage && !imageAttachments?.length) return const newItem = { id: generateMessageId(), text: trimmedMessage, images: imageAttachments, - }; + } setQueuedMessages(serverId, (prev: Map) => { - const next = new Map(prev); - next.set(agentId, [...(prev.get(agentId) ?? []), newItem]); - return next; - }); + const next = new Map(prev) + next.set(agentId, [...(prev.get(agentId) ?? []), newItem]) + return next + }) - const isControlled = value !== undefined; + const isControlled = value !== undefined if (!isControlled) { - setUserInput(""); + setUserInput('') } - setSelectedImages([]); + setSelectedImages([]) } async function sendMessageWithContent( @@ -294,278 +285,256 @@ export function AgentInputArea({ imageAttachments?: ImageAttachment[], forceSend?: boolean ) { - const trimmedMessage = message.trim(); - if (!trimmedMessage) return; + const trimmedMessage = message.trim() + if (!trimmedMessage) return // When the parent controls submission (e.g. draft agent creation), let it // decide what to do even if the socket is currently disconnected (so we // don't no-op and lose deterministic error handling in the UI/tests). - if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return; + if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return - if (agent?.status === "running" && !forceSend) { - queueMessage(trimmedMessage, imageAttachments); - return; + if (agent?.status === 'running' && !forceSend) { + queueMessage(trimmedMessage, imageAttachments) + return } // Clear input optimistically before awaiting server ack. // Save values so we can restore on error. - const savedImages = imageAttachments; + const savedImages = imageAttachments if (!onSubmitMessageRef.current) { if (value !== undefined) { - onChangeText?.(""); + onChangeText?.('') } else { - setUserInput(""); + setUserInput('') } } - setSelectedImages([]); - setSendError(null); - setIsProcessing(true); + setSelectedImages([]) + setSendError(null) + setIsProcessing(true) try { - await submitMessage(trimmedMessage, imageAttachments); + await submitMessage(trimmedMessage, imageAttachments) } catch (error) { - console.error("[AgentInput] Failed to send message:", error); + console.error('[AgentInput] Failed to send message:', error) // Restore input so the user never loses their message if (!onSubmitMessageRef.current) { if (value !== undefined) { - onChangeText?.(trimmedMessage); + onChangeText?.(trimmedMessage) } else { - setUserInput(trimmedMessage); + setUserInput(trimmedMessage) } } if (savedImages) { - setSelectedImages(savedImages); + setSelectedImages(savedImages) } - setSendError( - error instanceof Error ? error.message : "Failed to send message" - ); - setIsProcessing(false); + setSendError(error instanceof Error ? error.message : 'Failed to send message') + setIsProcessing(false) } } function handleSubmit(payload: MessagePayload) { if (blurOnSubmit) { - messageInputRef.current?.blur(); + messageInputRef.current?.blur() } - void sendMessageWithContent( - payload.text, - payload.images, - payload.forceSend - ); + void sendMessageWithContent(payload.text, payload.images, payload.forceSend) } async function handlePickImage() { - const result = await pickImages(); + const result = await pickImages() if (!result?.assets?.length) { - return; + return } const newImages = result.assets.map((asset) => ({ uri: asset.uri, - mimeType: asset.mimeType || "image/jpeg", - })); - setSelectedImages((prev) => [...prev, ...newImages]); + mimeType: asset.mimeType || 'image/jpeg', + })) + setSelectedImages((prev) => [...prev, ...newImages]) } function handleRemoveImage(index: number) { - setSelectedImages((prev) => prev.filter((_, i) => i !== index)); + setSelectedImages((prev) => prev.filter((_, i) => i !== index)) } useEffect(() => { if (!isAgentRunning || !isConnected) { - setIsCancellingAgent(false); + setIsCancellingAgent(false) } - }, [isAgentRunning, isConnected]); + }, [isAgentRunning, isConnected]) // Hydrate draft only when switching agents (uncontrolled mode only) - const isControlled = value !== undefined; + const isControlled = value !== undefined useEffect(() => { // Skip draft hydration for controlled inputs - parent manages state if (isControlled) { - return; + return } - const draft = getDraftInput(agentId); + const draft = getDraftInput(agentId) if (!draft) { - setUserInput(""); - setSelectedImages([]); - return; + setUserInput('') + setSelectedImages([]) + return } - setUserInput(draft.text); - setSelectedImages(draft.images as ImageAttachment[]); - }, [agentId, getDraftInput, isControlled]); + setUserInput(draft.text) + setSelectedImages(draft.images as ImageAttachment[]) + }, [agentId, getDraftInput, isControlled]) // Persist drafts into the shared session store with change detection to avoid redundant work useEffect(() => { - const existing = getDraftInput(agentId); - const isSameText = existing?.text === userInput; - const existingImages: ImageAttachment[] = (existing?.images ?? - []) as ImageAttachment[]; + const existing = getDraftInput(agentId) + const isSameText = existing?.text === userInput + const existingImages: ImageAttachment[] = (existing?.images ?? []) as ImageAttachment[] const isSameImages = existingImages.length === selectedImages.length && existingImages.every((img, idx) => { return ( - img.uri === selectedImages[idx]?.uri && - img.mimeType === selectedImages[idx]?.mimeType - ); - }); + img.uri === selectedImages[idx]?.uri && img.mimeType === selectedImages[idx]?.mimeType + ) + }) if (isSameText && isSameImages) { - return; + return } - saveDraftInput(agentId, { text: userInput, images: selectedImages }); - }, [agentId, userInput, selectedImages, getDraftInput, saveDraftInput]); + saveDraftInput(agentId, { text: userInput, images: selectedImages }) + }, [agentId, userInput, selectedImages, getDraftInput, saveDraftInput]) // Keyboard-dispatched message-input actions are routed through store requests. useEffect(() => { - if (!isScreenFocused) return; - if (!messageInputActionRequest) return; + if (!isScreenFocused) return + if (!messageInputActionRequest) return - const currentKey = `${serverId}:${agentId}`; + const currentKey = `${serverId}:${agentId}` if (messageInputActionRequest.agentKey !== currentKey) { - return; + return } - if ( - lastHandledMessageInputActionRequestIdRef.current === - messageInputActionRequest.id - ) { - return; + if (lastHandledMessageInputActionRequestIdRef.current === messageInputActionRequest.id) { + return } - lastHandledMessageInputActionRequestIdRef.current = - messageInputActionRequest.id; + lastHandledMessageInputActionRequestIdRef.current = messageInputActionRequest.id - if (messageInputActionRequest.kind !== "focus") { - messageInputRef.current?.runKeyboardAction(messageInputActionRequest.kind); - clearMessageInputActionRequest(messageInputActionRequest.id); - return; + if (messageInputActionRequest.kind !== 'focus') { + messageInputRef.current?.runKeyboardAction(messageInputActionRequest.kind) + clearMessageInputActionRequest(messageInputActionRequest.id) + return } - if (Platform.OS !== "web") { - messageInputRef.current?.focus(); - clearMessageInputActionRequest(messageInputActionRequest.id); - return; + if (Platform.OS !== 'web') { + messageInputRef.current?.focus() + clearMessageInputActionRequest(messageInputActionRequest.id) + return } return focusWithRetries({ focus: () => messageInputRef.current?.focus(), isFocused: () => { - const el = messageInputRef.current?.getNativeElement?.() ?? null; - const active = - typeof document !== "undefined" ? document.activeElement : null; - return Boolean(el) && active === el; + const el = messageInputRef.current?.getNativeElement?.() ?? null + const active = typeof document !== 'undefined' ? document.activeElement : null + return Boolean(el) && active === el }, - onSuccess: () => - clearMessageInputActionRequest(messageInputActionRequest.id), - onTimeout: () => - clearMessageInputActionRequest(messageInputActionRequest.id), - }); + onSuccess: () => clearMessageInputActionRequest(messageInputActionRequest.id), + onTimeout: () => clearMessageInputActionRequest(messageInputActionRequest.id), + }) }, [ agentId, clearMessageInputActionRequest, isScreenFocused, messageInputActionRequest, serverId, - ]); + ]) const keyboardAnimatedStyle = useAnimatedStyle(() => { - "worklet"; - const absoluteHeight = Math.abs(keyboardHeight.value); - const shift = Math.max(0, absoluteHeight - insets.bottom); + 'worklet' + const absoluteHeight = Math.abs(keyboardHeight.value) + const shift = Math.max(0, absoluteHeight - insets.bottom) return { transform: [{ translateY: -shift }], - }; - }); + } + }) function handleCancelAgent() { - if (!agent || agent.status !== "running" || isCancellingAgent) { - return; + if (!agent || agent.status !== 'running' || isCancellingAgent) { + return } if (!isConnected || !client) { - return; + return } - setIsCancellingAgent(true); - void client.cancelAgent(agentIdRef.current); - messageInputRef.current?.focus(); + setIsCancellingAgent(true) + void client.cancelAgent(agentIdRef.current) + messageInputRef.current?.focus() } - const isVoiceModeForAgent = - voice?.isVoiceModeForAgent(serverId, agentId) ?? false; + const isVoiceModeForAgent = voice?.isVoiceModeForAgent(serverId, agentId) ?? false const handleToggleRealtimeVoice = useCallback(() => { if (!voice || !isConnected) { - return; + return } if (voice.isVoiceSwitching) { - return; + return } if (voice.isVoiceModeForAgent(serverId, agentId)) { - return; + return } void voice.startVoice(serverId, agentId).catch((error) => { - console.error("[AgentInputArea] Failed to start voice mode", error); + console.error('[AgentInputArea] Failed to start voice mode', error) const message = - error instanceof Error - ? error.message - : typeof error === "string" - ? error - : null; + error instanceof Error ? error.message : typeof error === 'string' ? error : null if (message && message.trim().length > 0) { - toast.error(message); + toast.error(message) } - }); - }, [agentId, isConnected, serverId, toast, voice]); + }) + }, [agentId, isConnected, serverId, toast, voice]) function handleEditQueuedMessage(id: string) { - const item = queuedMessages.find((q) => q.id === id); - if (!item) return; + const item = queuedMessages.find((q) => q.id === id) + if (!item) return - updateQueue((current) => current.filter((q) => q.id !== id)); - setUserInput(item.text); - setSelectedImages(item.images ?? []); + updateQueue((current) => current.filter((q) => q.id !== id)) + setUserInput(item.text) + setSelectedImages(item.images ?? []) } async function handleSendQueuedNow(id: string) { - const item = queuedMessages.find((q) => q.id === id); - if (!item) return; - if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return; + const item = queuedMessages.find((q) => q.id === id) + if (!item) return + if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return - updateQueue((current) => current.filter((q) => q.id !== id)); + updateQueue((current) => current.filter((q) => q.id !== id)) // Cancels current agent run before sending queued prompt - handleCancelAgent(); + handleCancelAgent() try { - await submitMessage(item.text, item.images); + await submitMessage(item.text, item.images) } catch (error) { - updateQueue((current) => [item, ...current]); - setSendError( - error instanceof Error ? error.message : "Failed to send message" - ); + updateQueue((current) => [item, ...current]) + setSendError(error instanceof Error ? error.message : 'Failed to send message') } } const handleQueue = useCallback((payload: MessagePayload) => { - queueMessage(payload.text, payload.images); - }, []); + queueMessage(payload.text, payload.images) + }, []) - const hasSendableContent = userInput.trim().length > 0 || selectedImages.length > 0; + const hasSendableContent = userInput.trim().length > 0 || selectedImages.length > 0 // Handle keyboard navigation for command autocomplete and stop action. const handleCommandKeyPress = useCallback( (event: { key: string; preventDefault: () => void }) => { if ( - event.key === "Escape" && + event.key === 'Escape' && isAgentRunning && !hasSendableContent && !isCancellingAgent && isConnected ) { - event.preventDefault(); - handleCancelAgent(); - return true; + event.preventDefault() + handleCancelAgent() + return true } - return autocomplete.onKeyPress(event); + return autocomplete.onKeyPress(event) }, [ autocomplete, @@ -575,36 +544,35 @@ export function AgentInputArea({ isConnected, handleCancelAgent, ] - ); + ) - const cancelButton = isAgentRunning && !hasSendableContent && !isProcessing ? ( - - - {isCancellingAgent ? ( - - ) : ( - - )} - - - - Interrupt - - - - - ) : null; + const cancelButton = + isAgentRunning && !hasSendableContent && !isProcessing ? ( + + + {isCancellingAgent ? ( + + ) : ( + + )} + + + + Interrupt + + + + + ) : null const rightContent = ( @@ -617,9 +585,7 @@ export function AgentInputArea({ accessibilityRole="button" style={[ styles.realtimeVoiceButton as any, - (!isConnected || voice?.isVoiceSwitching - ? styles.buttonDisabled - : undefined) as any, + (!isConnected || voice?.isVoiceSwitching ? styles.buttonDisabled : undefined) as any, ]} > {voice?.isVoiceSwitching ? ( @@ -631,24 +597,20 @@ export function AgentInputArea({ Voice mode - + ) : null} {cancelButton} - ); + ) - const leftContent = ; + const leftContent = return ( {/* Input area */} @@ -658,11 +620,7 @@ export function AgentInputArea({ {queuedMessages.map((item) => ( - + {item.text} @@ -691,15 +649,13 @@ export function AgentInputArea({ selectedIndex={autocomplete.selectedIndex} isLoading={autocomplete.isLoading} errorMessage={autocomplete.errorMessage} - loadingText="Loading commands..." - emptyText="No commands found" + loadingText={autocomplete.loadingText} + emptyText={autocomplete.emptyText} onSelect={autocomplete.onSelectOption} /> )} - {sendError && ( - {sendError} - )} + {sendError && {sendError}} {/* MessageInput handles everything: text, dictation, attachments, all buttons */} { + setCursorIndex(selection.start) + }} /> - ); + ) } -const BUTTON_SIZE = 40; +const BUTTON_SIZE = 40 const styles = StyleSheet.create(((theme: Theme) => ({ container: { - flexDirection: "column", - position: "relative", + flexDirection: 'column', + position: 'relative', }, borderSeparator: { height: theme.borderWidth[1], backgroundColor: theme.colors.border, }, inputAreaContainer: { - position: "relative", + position: 'relative', minHeight: FOOTER_HEIGHT, - marginHorizontal: "auto", - alignItems: "center", - width: "100%", - overflow: "hidden", + marginHorizontal: 'auto', + alignItems: 'center', + width: '100%', + overflow: 'hidden', padding: theme.spacing[4], }, inputAreaContent: { - width: "100%", + width: '100%', maxWidth: MAX_CONTENT_WIDTH, gap: theme.spacing[3], }, @@ -762,12 +721,12 @@ const styles = StyleSheet.create(((theme: Theme) => ({ height: 34, borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.palette.red[600], - alignItems: "center", - justifyContent: "center", + alignItems: 'center', + justifyContent: 'center', }, rightControls: { - flexDirection: "row", - alignItems: "center", + flexDirection: 'row', + alignItems: 'center', gap: theme.spacing[2], }, realtimeVoiceButton: { @@ -777,16 +736,16 @@ const styles = StyleSheet.create(((theme: Theme) => ({ backgroundColor: theme.colors.surface0, borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, - alignItems: "center", - justifyContent: "center", + alignItems: 'center', + justifyContent: 'center', }, realtimeVoiceButtonActive: { backgroundColor: theme.colors.palette.green[600], borderColor: theme.colors.palette.green[800], }, tooltipRow: { - flexDirection: "row", - alignItems: "center", + flexDirection: 'row', + alignItems: 'center', gap: theme.spacing[2], }, tooltipText: { @@ -801,13 +760,13 @@ const styles = StyleSheet.create(((theme: Theme) => ({ opacity: 0.5, }, queueContainer: { - flexDirection: "column", + flexDirection: 'column', gap: theme.spacing[2], }, queueItem: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], backgroundColor: theme.colors.surface1, @@ -822,16 +781,16 @@ const styles = StyleSheet.create(((theme: Theme) => ({ fontSize: theme.fontSize.base, }, queueActions: { - flexDirection: "row", - alignItems: "center", + flexDirection: 'row', + alignItems: 'center', gap: theme.spacing[2], }, queueActionButton: { width: 32, height: 32, borderRadius: theme.borderRadius.full, - alignItems: "center", - justifyContent: "center", + alignItems: 'center', + justifyContent: 'center', backgroundColor: theme.colors.surface2, }, queueSendButton: { @@ -841,4 +800,4 @@ const styles = StyleSheet.create(((theme: Theme) => ({ color: theme.colors.palette.red[500], fontSize: theme.fontSize.sm, }, -})) as any) as Record; +})) as any) as Record diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 66fd4fcba..c5bbdfc78 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -7,276 +7,263 @@ import { NativeSyntheticEvent, TextInputContentSizeChangeEventData, TextInputKeyPressEventData, + TextInputSelectionChangeEventData, Image, Platform, BackHandler, -} from "react-native"; -import { - useState, - useRef, - useCallback, - useEffect, - useImperativeHandle, - forwardRef, -} from "react"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { Mic, MicOff, ArrowUp, Paperclip, Plus, X, Square } from "lucide-react-native"; -import Animated, { - useSharedValue, - useAnimatedStyle, - withTiming, -} from "react-native-reanimated"; -import { useDictation } from "@/hooks/use-dictation"; -import { DictationOverlay } from "./dictation-controls"; -import { RealtimeVoiceOverlay } from "./realtime-voice-overlay"; -import type { DaemonClient } from "@server/client/daemon-client"; -import { useSessionStore } from "@/stores/session-store"; -import { useVoiceOptional } from "@/contexts/voice-context"; -import { useToast } from "@/contexts/toast-context"; -import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { Shortcut } from "@/components/ui/shortcut"; -import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; +} from 'react-native' +import { useState, useRef, useCallback, useEffect, useImperativeHandle, forwardRef } from 'react' +import { StyleSheet, useUnistyles } from 'react-native-unistyles' +import { Mic, MicOff, ArrowUp, Paperclip, Plus, X, Square } from 'lucide-react-native' +import Animated, { useSharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated' +import { useDictation } from '@/hooks/use-dictation' +import { DictationOverlay } from './dictation-controls' +import { RealtimeVoiceOverlay } from './realtime-voice-overlay' +import type { DaemonClient } from '@server/client/daemon-client' +import { useSessionStore } from '@/stores/session-store' +import { useVoiceOptional } from '@/contexts/voice-context' +import { useToast } from '@/contexts/toast-context' +import { resolveVoiceUnavailableMessage } from '@/utils/server-info-capabilities' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { Shortcut } from '@/components/ui/shortcut' +import type { MessageInputKeyboardActionKind } from '@/keyboard/actions' export interface ImageAttachment { - uri: string; - mimeType: string; + uri: string + mimeType: string } export interface MessagePayload { - text: string; - images?: ImageAttachment[]; + text: string + images?: ImageAttachment[] /** When true, bypasses queue and sends immediately even if agent is running */ - forceSend?: boolean; + forceSend?: boolean } export interface MessageInputProps { - value: string; - onChangeText: (text: string) => void; - onSubmit: (payload: MessagePayload) => void; - isSubmitDisabled?: boolean; - isSubmitLoading?: boolean; - images?: ImageAttachment[]; - onPickImages?: () => void; - onRemoveImage?: (index: number) => void; - client: DaemonClient | null; - placeholder?: string; - autoFocus?: boolean; - disabled?: boolean; + value: string + onChangeText: (text: string) => void + onSubmit: (payload: MessagePayload) => void + isSubmitDisabled?: boolean + isSubmitLoading?: boolean + images?: ImageAttachment[] + onPickImages?: () => void + onRemoveImage?: (index: number) => void + client: DaemonClient | null + placeholder?: string + autoFocus?: boolean + disabled?: boolean /** True when the containing screen is focused (React Navigation). Used to disable global hotkeys and cancel dictation when unfocused. */ - isScreenFocused?: boolean; + isScreenFocused?: boolean /** Content to render on the left side of the button row (e.g., AgentStatusBar) */ - leftContent?: React.ReactNode; + leftContent?: React.ReactNode /** Content to render on the right side after voice button (e.g., realtime button, cancel button) */ - rightContent?: React.ReactNode; - voiceServerId?: string; - voiceAgentId?: string; + rightContent?: React.ReactNode + voiceServerId?: string + voiceAgentId?: string /** When true and there's sendable content, calls onQueue instead of onSubmit */ - isAgentRunning?: boolean; + isAgentRunning?: boolean /** Callback for queue button when agent is running */ - onQueue?: (payload: MessagePayload) => void; + onQueue?: (payload: MessagePayload) => void /** Optional handler used when submit button is in loading state. */ - onSubmitLoadingPress?: () => void; + onSubmitLoadingPress?: () => void /** Intercept key press events before default handling. Return true to prevent default. */ - onKeyPress?: (event: { key: string; preventDefault: () => void }) => boolean; + onKeyPress?: (event: { key: string; preventDefault: () => void }) => boolean + /** Reports cursor selection updates from the underlying input. */ + onSelectionChange?: (selection: { start: number; end: number }) => void } export interface MessageInputRef { - focus: () => void; - blur: () => void; - runKeyboardAction: (action: MessageInputKeyboardActionKind) => void; + focus: () => void + blur: () => void + runKeyboardAction: (action: MessageInputKeyboardActionKind) => void /** * Web-only: return the underlying DOM element for focus assertions/retries. * May return null if not mounted or on native. */ - getNativeElement?: () => HTMLElement | null; + getNativeElement?: () => HTMLElement | null } -const MIN_INPUT_HEIGHT = 30; -const MAX_INPUT_HEIGHT = 160; -const IS_WEB = Platform.OS === "web"; +const MIN_INPUT_HEIGHT = 30 +const MAX_INPUT_HEIGHT = 160 +const IS_WEB = Platform.OS === 'web' type WebTextInputKeyPressEvent = NativeSyntheticEvent< TextInputKeyPressEventData & { - metaKey?: boolean; - ctrlKey?: boolean; - shiftKey?: boolean; + metaKey?: boolean + ctrlKey?: boolean + shiftKey?: boolean } ->; +> type TextAreaHandle = { - scrollHeight?: number; + scrollHeight?: number style?: { - height?: string; - overflowY?: string; - } & Record; -}; + height?: string + overflowY?: string + } & Record +} -export const MessageInput = forwardRef( - function MessageInput( - { - value, - onChangeText, - onSubmit, - isSubmitDisabled = false, - isSubmitLoading = false, - images = [], - onPickImages, - onRemoveImage, - client, - placeholder = "Message...", - autoFocus = false, - disabled = false, - isScreenFocused = true, - leftContent, - rightContent, - voiceServerId, - voiceAgentId, - isAgentRunning = false, - onQueue, - onSubmitLoadingPress, - onKeyPress: onKeyPressCallback, +export const MessageInput = forwardRef(function MessageInput( + { + value, + onChangeText, + onSubmit, + isSubmitDisabled = false, + isSubmitLoading = false, + images = [], + onPickImages, + onRemoveImage, + client, + placeholder = 'Message...', + autoFocus = false, + disabled = false, + isScreenFocused = true, + leftContent, + rightContent, + voiceServerId, + voiceAgentId, + isAgentRunning = false, + onQueue, + onSubmitLoadingPress, + onKeyPress: onKeyPressCallback, + onSelectionChange: onSelectionChangeCallback, + }, + ref +) { + const { theme } = useUnistyles() + const toast = useToast() + const voice = useVoiceOptional() + const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT) + const textInputRef = useRef unknown }) | null>( + null + ) + const isInputFocusedRef = useRef(false) + + useImperativeHandle(ref, () => ({ + focus: () => { + textInputRef.current?.focus() }, - ref - ) { - const { theme } = useUnistyles(); - const toast = useToast(); - const voice = useVoiceOptional(); - const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT); - const textInputRef = useRef< - TextInput | (TextInput & { getNativeRef?: () => unknown }) | null - >(null); - const isInputFocusedRef = useRef(false); + blur: () => { + textInputRef.current?.blur?.() + }, + runKeyboardAction: (action) => { + if (action === 'focus') { + textInputRef.current?.focus() + return + } - useImperativeHandle(ref, () => ({ - focus: () => { - textInputRef.current?.focus(); + if (action === 'voice-toggle') { + handleToggleRealtimeVoiceShortcut() + return + } + + if (action === 'voice-mute-toggle') { + if (isRealtimeVoiceForCurrentAgent) { + voice?.toggleMute() + } + return + } + + if (action === 'dictation-cancel') { + if (isDictatingRef.current) { + cancelDictation() + } + return + } + + if (action === 'dictation-toggle') { + if (isDictatingRef.current) { + sendAfterTranscriptRef.current = true + confirmDictation() + } else { + void startDictationIfAvailable() + } + } + }, + getNativeElement: () => { + if (!IS_WEB) return null + const current = textInputRef.current as (TextInput & { getNativeRef?: () => unknown }) | null + const native = typeof current?.getNativeRef === 'function' ? current.getNativeRef() : current + return native instanceof HTMLElement ? native : null + }, + })) + const inputHeightRef = useRef(MIN_INPUT_HEIGHT) + const baselineInputHeightRef = useRef(null) + const overlayTransition = useSharedValue(0) + const sendAfterTranscriptRef = useRef(false) + const valueRef = useRef(value) + const serverInfo = useSessionStore( + useCallback( + (state) => { + if (!voiceServerId) { + return null + } + return state.sessions[voiceServerId]?.serverInfo ?? null }, - blur: () => { - textInputRef.current?.blur?.(); - }, - runKeyboardAction: (action) => { - if (action === "focus") { - textInputRef.current?.focus(); - return; - } - - if (action === "voice-toggle") { - handleToggleRealtimeVoiceShortcut(); - return; - } - - if (action === "voice-mute-toggle") { - if (isRealtimeVoiceForCurrentAgent) { - voice?.toggleMute(); - } - return; - } - - if (action === "dictation-cancel") { - if (isDictatingRef.current) { - cancelDictation(); - } - return; - } - - if (action === "dictation-toggle") { - if (isDictatingRef.current) { - sendAfterTranscriptRef.current = true; - confirmDictation(); - } else { - void startDictationIfAvailable(); - } - } - }, - getNativeElement: () => { - if (!IS_WEB) return null; - const current = textInputRef.current as - | (TextInput & { getNativeRef?: () => unknown }) - | null; - const native = - typeof current?.getNativeRef === "function" - ? current.getNativeRef() - : current; - return native instanceof HTMLElement ? native : null; - }, - })); - const inputHeightRef = useRef(MIN_INPUT_HEIGHT); - const baselineInputHeightRef = useRef(null); - const overlayTransition = useSharedValue(0); - const sendAfterTranscriptRef = useRef(false); - const valueRef = useRef(value); - const serverInfo = useSessionStore( - useCallback( - (state) => { - if (!voiceServerId) { - return null; - } - return state.sessions[voiceServerId]?.serverInfo ?? null; - }, - [voiceServerId] - ) - ); + [voiceServerId] + ) + ) useEffect(() => { - valueRef.current = value; - }, [value]); + valueRef.current = value + }, [value]) // Autofocus on web when autoFocus prop is true useEffect(() => { - if (!IS_WEB || !autoFocus) return; + if (!IS_WEB || !autoFocus) return // Use requestAnimationFrame to ensure DOM is ready const rafId = requestAnimationFrame(() => { - textInputRef.current?.focus(); - }); - return () => cancelAnimationFrame(rafId); - }, [autoFocus]); + textInputRef.current?.focus() + }) + return () => cancelAnimationFrame(rafId) + }, [autoFocus]) const handleDictationTranscript = useCallback( (text: string, _meta: { requestId: string }) => { - if (!text) return; - const current = valueRef.current; - const shouldPad = current.length > 0 && !/\s$/.test(current); - const nextValue = `${current}${shouldPad ? " " : ""}${text}`; + if (!text) return + const current = valueRef.current + const shouldPad = current.length > 0 && !/\s$/.test(current) + const nextValue = `${current}${shouldPad ? ' ' : ''}${text}` - const shouldAutoSend = sendAfterTranscriptRef.current; - sendAfterTranscriptRef.current = false; + const shouldAutoSend = sendAfterTranscriptRef.current + sendAfterTranscriptRef.current = false if (shouldAutoSend) { - const imageAttachments = images.length > 0 ? images : undefined; - onSubmit({ text: nextValue, images: imageAttachments }); + const imageAttachments = images.length > 0 ? images : undefined + onSubmit({ text: nextValue, images: imageAttachments }) } else { - onChangeText(nextValue); + onChangeText(nextValue) } - if (IS_WEB && typeof requestAnimationFrame === "function") { + if (IS_WEB && typeof requestAnimationFrame === 'function') { requestAnimationFrame(() => { - measureWebInputHeight("dictation"); - }); + measureWebInputHeight('dictation') + }) } }, [onChangeText, onSubmit, images] - ); + ) const handleDictationError = useCallback((error: Error) => { - console.error("[MessageInput] Dictation error:", error); - }, []); + console.error('[MessageInput] Dictation error:', error) + }, []) const dictationUnavailableMessage = resolveVoiceUnavailableMessage({ serverInfo, - mode: "dictation", - }); + mode: 'dictation', + }) const canStartDictation = useCallback(() => { - const socketConnected = client?.isConnected ?? false; - return socketConnected && !disabled && !dictationUnavailableMessage; - }, [client, disabled, dictationUnavailableMessage]); + const socketConnected = client?.isConnected ?? false + return socketConnected && !disabled && !dictationUnavailableMessage + }, [client, disabled, dictationUnavailableMessage]) const canConfirmDictation = useCallback(() => { - const socketConnected = client?.isConnected ?? false; - return socketConnected; - }, [client]); - const isConnected = client?.isConnected ?? false; + const socketConnected = client?.isConnected ?? false + return socketConnected + }, [client]) + const isConnected = client?.isConnected ?? false const { isRecording: isDictating, @@ -299,64 +286,63 @@ export const MessageInput = forwardRef( canConfirm: canConfirmDictation, autoStopWhenHidden: { isVisible: isScreenFocused }, enableDuration: true, - }); + }) - const isDictatingRef = useRef(isDictating); + const isDictatingRef = useRef(isDictating) useEffect(() => { - isDictatingRef.current = isDictating; - }, [isDictating]); + isDictatingRef.current = isDictating + }, [isDictating]) const isRealtimeVoiceForCurrentAgent = !!voice && !!voiceServerId && !!voiceAgentId && - voice.isVoiceModeForAgent(voiceServerId, voiceAgentId); - const showDictationOverlay = - isDictating || isDictationProcessing || dictationStatus === "failed"; - const showRealtimeOverlay = isRealtimeVoiceForCurrentAgent; - const showOverlay = showDictationOverlay || showRealtimeOverlay; + voice.isVoiceModeForAgent(voiceServerId, voiceAgentId) + const showDictationOverlay = isDictating || isDictationProcessing || dictationStatus === 'failed' + const showRealtimeOverlay = isRealtimeVoiceForCurrentAgent + const showOverlay = showDictationOverlay || showRealtimeOverlay useEffect(() => { if (isDictating || isDictationProcessing) { - return; + return } - sendAfterTranscriptRef.current = false; - }, [dictationStatus, isDictating, isDictationProcessing]); + sendAfterTranscriptRef.current = false + }, [dictationStatus, isDictating, isDictationProcessing]) const startDictationIfAvailable = useCallback(async () => { if (dictationUnavailableMessage) { - toast.error(dictationUnavailableMessage); - return; + toast.error(dictationUnavailableMessage) + return } - await startDictation(); - }, [dictationUnavailableMessage, startDictation, toast]); + await startDictation() + }, [dictationUnavailableMessage, startDictation, toast]) // Animate overlay useEffect(() => { overlayTransition.value = withTiming(showOverlay ? 1 : 0, { duration: 200, - }); - }, [overlayTransition, showOverlay]); + }) + }, [overlayTransition, showOverlay]) const overlayAnimatedStyle = useAnimatedStyle(() => ({ opacity: overlayTransition.value, - pointerEvents: overlayTransition.value > 0.5 ? "auto" : "none", - })); + pointerEvents: overlayTransition.value > 0.5 ? 'auto' : 'none', + })) const inputAnimatedStyle = useAnimatedStyle(() => ({ opacity: 1 - overlayTransition.value, - })); + })) const handleVoicePress = useCallback(async () => { if (isRealtimeVoiceForCurrentAgent && voice) { - voice.toggleMute(); - return; + voice.toggleMute() + return } if (isDictating) { - await cancelDictation(); + await cancelDictation() } else { - await startDictationIfAvailable(); + await startDictationIfAvailable() } }, [ cancelDictation, @@ -364,231 +350,217 @@ export const MessageInput = forwardRef( isRealtimeVoiceForCurrentAgent, startDictationIfAvailable, voice, - ]); + ]) const handleCancelRecording = useCallback(async () => { - await cancelDictation(); - }, [cancelDictation]); + await cancelDictation() + }, [cancelDictation]) const handleAcceptRecording = useCallback(async () => { - sendAfterTranscriptRef.current = false; - await confirmDictation(); - }, [confirmDictation]); + sendAfterTranscriptRef.current = false + await confirmDictation() + }, [confirmDictation]) const handleAcceptAndSendRecording = useCallback(async () => { - sendAfterTranscriptRef.current = true; - await confirmDictation(); - }, [confirmDictation]); + sendAfterTranscriptRef.current = true + await confirmDictation() + }, [confirmDictation]) const handleRetryFailedRecording = useCallback(() => { - void retryFailedDictation(); - }, [retryFailedDictation]); + void retryFailedDictation() + }, [retryFailedDictation]) const handleDiscardFailedRecording = useCallback(() => { - discardFailedDictation(); - }, [discardFailedDictation]); + discardFailedDictation() + }, [discardFailedDictation]) const handleStopRealtimeVoice = useCallback(async () => { if (!voice || !isRealtimeVoiceForCurrentAgent) { - return; + return } - const tasks: Promise[] = []; + const tasks: Promise[] = [] if (isAgentRunning && client && voiceAgentId) { - tasks.push(client.cancelAgent(voiceAgentId)); + tasks.push(client.cancelAgent(voiceAgentId)) } - tasks.push(voice.stopVoice()); + tasks.push(voice.stopVoice()) - const results = await Promise.allSettled(tasks); + const results = await Promise.allSettled(tasks) results.forEach((result) => { - if (result.status === "rejected") { - console.error("[MessageInput] Failed to stop realtime voice", result.reason); + if (result.status === 'rejected') { + console.error('[MessageInput] Failed to stop realtime voice', result.reason) } - }); - }, [client, isAgentRunning, isRealtimeVoiceForCurrentAgent, voice, voiceAgentId]); + }) + }, [client, isAgentRunning, isRealtimeVoiceForCurrentAgent, voice, voiceAgentId]) const handleToggleRealtimeVoiceShortcut = useCallback(() => { if (!voice || !voiceServerId || !voiceAgentId || !isConnected || disabled) { - return; + return } if (voice.isVoiceSwitching) { - return; + return } if (voice.isVoiceModeForAgent(voiceServerId, voiceAgentId)) { - void handleStopRealtimeVoice(); - return; + void handleStopRealtimeVoice() + return } void voice.startVoice(voiceServerId, voiceAgentId).catch((error) => { - console.error("[MessageInput] Failed to start realtime voice", error); + console.error('[MessageInput] Failed to start realtime voice', error) const message = - error instanceof Error - ? error.message - : typeof error === "string" - ? error - : null; + error instanceof Error ? error.message : typeof error === 'string' ? error : null if (message && message.trim().length > 0) { - toast.error(message); + toast.error(message) } - }); - }, [ - disabled, - handleStopRealtimeVoice, - isConnected, - toast, - voice, - voiceAgentId, - voiceServerId, - ]); + }) + }, [disabled, handleStopRealtimeVoice, isConnected, toast, voice, voiceAgentId, voiceServerId]) const handleSendMessage = useCallback(() => { - const trimmed = value.trim(); - if (!trimmed && images.length === 0) return; + const trimmed = value.trim() + if (!trimmed && images.length === 0) return const payload = { text: trimmed, images: images.length > 0 ? images : undefined, forceSend: isAgentRunning || undefined, - }; - onSubmit(payload); - inputHeightRef.current = MIN_INPUT_HEIGHT; - setInputHeight(MIN_INPUT_HEIGHT); - }, [value, images, onSubmit, isAgentRunning]); + } + onSubmit(payload) + inputHeightRef.current = MIN_INPUT_HEIGHT + setInputHeight(MIN_INPUT_HEIGHT) + }, [value, images, onSubmit, isAgentRunning]) const handleQueueMessage = useCallback(() => { - if (!onQueue) return; - const trimmed = value.trim(); - if (!trimmed && images.length === 0) return; + if (!onQueue) return + const trimmed = value.trim() + if (!trimmed && images.length === 0) return const payload = { text: trimmed, images: images.length > 0 ? images : undefined, - }; - onQueue(payload); - onChangeText(""); - inputHeightRef.current = MIN_INPUT_HEIGHT; - setInputHeight(MIN_INPUT_HEIGHT); - }, [value, images, onQueue, onChangeText]); + } + onQueue(payload) + onChangeText('') + inputHeightRef.current = MIN_INPUT_HEIGHT + setInputHeight(MIN_INPUT_HEIGHT) + }, [value, images, onQueue, onChangeText]) // Web input height measurement function isTextAreaLike(v: unknown): v is TextAreaHandle { - return typeof v === "object" && v !== null && "scrollHeight" in v; + return typeof v === 'object' && v !== null && 'scrollHeight' in v } function getWebTextArea(): TextAreaHandle | null { - const ref = textInputRef.current; - if (!ref) return null; - if (typeof (ref as any).getNativeRef === "function") { - const native = (ref as any).getNativeRef(); - if (isTextAreaLike(native)) return native; + const ref = textInputRef.current + if (!ref) return null + if (typeof (ref as any).getNativeRef === 'function') { + const native = (ref as any).getNativeRef() + if (isTextAreaLike(native)) return native } - if (isTextAreaLike(ref)) return ref; - return null; + if (isTextAreaLike(ref)) return ref + return null } function measureWebInputHeight(source: string): boolean { - if (!IS_WEB) return false; - const textarea = getWebTextArea(); - if (!textarea || typeof textarea.scrollHeight !== "number") return false; + if (!IS_WEB) return false + const textarea = getWebTextArea() + if (!textarea || typeof textarea.scrollHeight !== 'number') return false - const prevHeight = textarea.style?.height; - const prevOverflow = textarea.style?.overflowY; + const prevHeight = textarea.style?.height + const prevOverflow = textarea.style?.overflowY if (textarea.style) { - textarea.style.height = "auto"; - textarea.style.overflowY = "hidden"; + textarea.style.height = 'auto' + textarea.style.overflowY = 'hidden' } - const scrollHeight = textarea.scrollHeight ?? 0; + const scrollHeight = textarea.scrollHeight ?? 0 if (textarea.style) { - textarea.style.height = prevHeight ?? ""; - textarea.style.overflowY = prevOverflow ?? ""; + textarea.style.height = prevHeight ?? '' + textarea.style.overflowY = prevOverflow ?? '' } if (baselineInputHeightRef.current === null && scrollHeight > 0) { - baselineInputHeightRef.current = scrollHeight; + baselineInputHeightRef.current = scrollHeight } - const baseline = baselineInputHeightRef.current ?? MIN_INPUT_HEIGHT; - const rawTarget = scrollHeight > 0 ? scrollHeight : baseline; - const bounded = Math.max( - MIN_INPUT_HEIGHT, - Math.min(MAX_INPUT_HEIGHT, rawTarget) - ); + const baseline = baselineInputHeightRef.current ?? MIN_INPUT_HEIGHT + const rawTarget = scrollHeight > 0 ? scrollHeight : baseline + const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, rawTarget)) if (Math.abs(inputHeightRef.current - bounded) >= 1) { - inputHeightRef.current = bounded; - setInputHeight(bounded); - return true; + inputHeightRef.current = bounded + setInputHeight(bounded) + return true } - return false; + return false } function setBoundedInputHeight(nextHeight: number) { - const bounded = Math.max( - MIN_INPUT_HEIGHT, - Math.min(MAX_INPUT_HEIGHT, nextHeight) - ); - if (Math.abs(inputHeightRef.current - bounded) < 1) return; - inputHeightRef.current = bounded; - setInputHeight(bounded); + const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, nextHeight)) + if (Math.abs(inputHeightRef.current - bounded) < 1) return + inputHeightRef.current = bounded + setInputHeight(bounded) } function handleContentSizeChange( event: NativeSyntheticEvent ) { if (IS_WEB) { - measureWebInputHeight("contentSizeChange"); - return; + measureWebInputHeight('contentSizeChange') + return } - const contentHeight = event.nativeEvent.contentSize.height; - setBoundedInputHeight(contentHeight); + const contentHeight = event.nativeEvent.contentSize.height + setBoundedInputHeight(contentHeight) } - const shouldHandleDesktopSubmit = IS_WEB; + function handleSelectionChange(event: NativeSyntheticEvent) { + const start = event.nativeEvent.selection?.start ?? 0 + const end = event.nativeEvent.selection?.end ?? start + onSelectionChangeCallback?.({ start, end }) + } + + const shouldHandleDesktopSubmit = IS_WEB function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) { - if (!shouldHandleDesktopSubmit) return; + if (!shouldHandleDesktopSubmit) return // Allow parent to intercept key events (e.g., for autocomplete navigation) if (onKeyPressCallback) { const handled = onKeyPressCallback({ key: event.nativeEvent.key, preventDefault: () => event.preventDefault(), - }); - if (handled) return; + }) + if (handled) return } - const { shiftKey, metaKey, ctrlKey } = event.nativeEvent; + const { shiftKey, metaKey, ctrlKey } = event.nativeEvent - if (event.nativeEvent.key !== "Enter") return; + if (event.nativeEvent.key !== 'Enter') return // Shift+Enter: add newline (default behavior, don't intercept) - if (shiftKey) return; + if (shiftKey) return // Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux): queue when agent is running if ((metaKey || ctrlKey) && isAgentRunning && onQueue) { - if (isSubmitDisabled || isSubmitLoading || disabled) return; - event.preventDefault(); - handleQueueMessage(); - return; + if (isSubmitDisabled || isSubmitLoading || disabled) return + event.preventDefault() + handleQueueMessage() + return } // Enter: send (interrupts agent if running) - if (isSubmitDisabled || isSubmitLoading || disabled) return; - event.preventDefault(); - handleSendMessage(); + if (isSubmitDisabled || isSubmitLoading || disabled) return + event.preventDefault() + handleSendMessage() } - const hasImages = images.length > 0; - const hasSendableContent = value.trim().length > 0 || hasImages; - const shouldShowSendButton = hasSendableContent || isSubmitLoading; - const canPressLoadingButton = - isSubmitLoading && typeof onSubmitLoadingPress === "function"; + const hasImages = images.length > 0 + const hasSendableContent = value.trim().length > 0 || hasImages + const shouldShowSendButton = hasSendableContent || isSubmitLoading + const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === 'function' const isSendButtonDisabled = - disabled || - (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading)); + disabled || (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading)) const submitAccessibilityLabel = canPressLoadingButton - ? "Interrupt agent" + ? 'Interrupt agent' : isAgentRunning - ? "Send and interrupt" - : "Send message"; + ? 'Send and interrupt' + : 'Send message' return ( @@ -605,10 +577,7 @@ export const MessageInput = forwardRef( > {({ hovered }) => ( <> - + {onRemoveImage && ( ( placeholder={placeholder} placeholderTextColor={theme.colors.mutedForeground} onFocus={() => { - isInputFocusedRef.current = true; + isInputFocusedRef.current = true }} onBlur={() => { - isInputFocusedRef.current = false; + isInputFocusedRef.current = false }} style={[ styles.textInput, @@ -655,12 +624,9 @@ export const MessageInput = forwardRef( multiline scrollEnabled={IS_WEB ? inputHeight >= MAX_INPUT_HEIGHT : true} onContentSizeChange={handleContentSizeChange} - editable={ - !isDictating && !isRealtimeVoiceForCurrentAgent && !disabled - } - onKeyPress={ - shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined - } + editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled} + onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined} + onSelectionChange={handleSelectionChange} autoFocus={IS_WEB && autoFocus} /> @@ -675,10 +641,7 @@ export const MessageInput = forwardRef( disabled={!isConnected || disabled} accessibilityLabel="Attach images" accessibilityRole="button" - style={[ - styles.attachButton, - (!isConnected || disabled) && styles.buttonDisabled, - ]} + style={[styles.attachButton, (!isConnected || disabled) && styles.buttonDisabled]} > @@ -700,11 +663,11 @@ export const MessageInput = forwardRef( accessibilityLabel={ isRealtimeVoiceForCurrentAgent ? voice?.isMuted - ? "Unmute Voice mode" - : "Mute Voice mode" + ? 'Unmute Voice mode' + : 'Mute Voice mode' : isDictating - ? "Stop dictation" - : "Start dictation" + ? 'Stop dictation' + : 'Start dictation' } style={[ styles.voiceButton, @@ -725,12 +688,12 @@ export const MessageInput = forwardRef( {isRealtimeVoiceForCurrentAgent ? voice?.isMuted - ? "Unmute voice" - : "Mute voice" - : "Dictation"} + ? 'Unmute voice' + : 'Mute voice' + : 'Dictation'} @@ -744,17 +707,14 @@ export const MessageInput = forwardRef( disabled={!isConnected || disabled} accessibilityLabel="Queue message" accessibilityRole="button" - style={[ - styles.queueButton, - (!isConnected || disabled) && styles.buttonDisabled, - ]} + style={[styles.queueButton, (!isConnected || disabled) && styles.buttonDisabled]} > Queue - + @@ -762,16 +722,11 @@ export const MessageInput = forwardRef( {shouldShowSendButton && ( {isSubmitLoading ? ( @@ -782,7 +737,7 @@ export const MessageInput = forwardRef( Send - + @@ -800,20 +755,12 @@ export const MessageInput = forwardRef( isRecording={isDictating} isProcessing={isDictationProcessing} status={dictationStatus} - errorText={dictationStatus === "failed" ? dictationError ?? undefined : undefined} + errorText={dictationStatus === 'failed' ? (dictationError ?? undefined) : undefined} onCancel={handleCancelRecording} onAccept={handleAcceptRecording} onAcceptAndSend={handleAcceptAndSendRecording} - onRetry={ - dictationStatus === "failed" - ? handleRetryFailedRecording - : undefined - } - onDiscard={ - dictationStatus === "failed" - ? handleDiscardFailedRecording - : undefined - } + onRetry={dictationStatus === 'failed' ? handleRetryFailedRecording : undefined} + onDiscard={dictationStatus === 'failed' ? handleDiscardFailedRecording : undefined} /> ) : showRealtimeOverlay && voice ? ( ( isSwitching={voice.isVoiceSwitching} onToggleMute={voice.toggleMute} onStop={() => { - void handleStopRealtimeVoice(); + void handleStopRealtimeVoice() }} /> ) : null} - ); - } -); + ) +}) const styles = StyleSheet.create(((theme: any) => ({ container: { - position: "relative", + position: 'relative', }, inputWrapper: { - flexDirection: "column", + flexDirection: 'column', gap: theme.spacing[3], backgroundColor: theme.colors.surface1, borderWidth: theme.borderWidth[1], borderColor: theme.colors.borderAccent, - borderRadius: theme.borderRadius["2xl"], + borderRadius: theme.borderRadius['2xl'], paddingVertical: { xs: theme.spacing[2], md: theme.spacing[4], @@ -855,26 +801,26 @@ const styles = StyleSheet.create(((theme: any) => ({ }, ...(IS_WEB ? { - transitionProperty: "border-color", - transitionDuration: "200ms", - transitionTimingFunction: "ease-in-out", + transitionProperty: 'border-color', + transitionDuration: '200ms', + transitionTimingFunction: 'ease-in-out', } : {}), }, imagePreviewContainer: { - flexDirection: "row", + flexDirection: 'row', gap: theme.spacing[2], - flexWrap: "wrap", + flexWrap: 'wrap', }, imagePill: { - position: "relative", + position: 'relative', borderRadius: theme.borderRadius.md, borderWidth: 1, borderColor: theme.colors.borderAccent, - overflow: "hidden", + overflow: 'hidden', ...(IS_WEB ? { - cursor: "pointer", + cursor: 'pointer', } : {}), }, @@ -883,19 +829,19 @@ const styles = StyleSheet.create(((theme: any) => ({ height: 48, }, removeImageButton: { - position: "absolute", + position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, - alignItems: "center", - justifyContent: "center", - backgroundColor: "rgba(0, 0, 0, 0.5)", + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.5)', opacity: 0, ...(IS_WEB ? { - transitionProperty: "opacity", - transitionDuration: "150ms", + transitionProperty: 'opacity', + transitionDuration: '150ms', } : {}), }, @@ -903,47 +849,47 @@ const styles = StyleSheet.create(((theme: any) => ({ opacity: 1, }, textInput: { - width: "100%", + width: '100%', color: theme.colors.foreground, fontSize: theme.fontSize.lg, fontWeight: theme.fontWeight.normal, lineHeight: theme.fontSize.lg * 1.4, ...(IS_WEB ? { - outlineStyle: "none" as const, + outlineStyle: 'none' as const, outlineWidth: 0, - outlineColor: "transparent", + outlineColor: 'transparent', } : {}), }, buttonRow: { - flexDirection: "row", - alignItems: "flex-end", - justifyContent: "space-between", + flexDirection: 'row', + alignItems: 'flex-end', + justifyContent: 'space-between', }, leftButtonGroup: { - flexDirection: "row", - alignItems: "center", + flexDirection: 'row', + alignItems: 'center', gap: theme.spacing[2], }, rightButtonGroup: { - flexDirection: "row", - alignItems: "center", + flexDirection: 'row', + alignItems: 'center', gap: theme.spacing[2], }, attachButton: { width: 34, height: 34, borderRadius: theme.borderRadius.full, - alignItems: "center", - justifyContent: "center", + alignItems: 'center', + justifyContent: 'center', }, voiceButton: { width: 34, height: 34, borderRadius: theme.borderRadius.full, - alignItems: "center", - justifyContent: "center", + alignItems: 'center', + justifyContent: 'center', }, voiceButtonRecording: { backgroundColor: theme.colors.destructive, @@ -953,20 +899,20 @@ const styles = StyleSheet.create(((theme: any) => ({ height: 34, borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.surface1, - alignItems: "center", - justifyContent: "center", + alignItems: 'center', + justifyContent: 'center', }, sendButton: { width: 34, height: 34, borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.accent, - alignItems: "center", - justifyContent: "center", + alignItems: 'center', + justifyContent: 'center', }, tooltipRow: { - flexDirection: "row", - alignItems: "center", + flexDirection: 'row', + alignItems: 'center', gap: theme.spacing[2], }, tooltipText: { @@ -981,15 +927,15 @@ const styles = StyleSheet.create(((theme: any) => ({ opacity: 0.5, }, overlayContainer: { - position: "absolute", - display: "flex", - alignItems: "center", - justifyContent: "center", + position: 'absolute', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', top: 0, left: 0, - width: "100%", - height: "100%", + width: '100%', + height: '100%', right: 0, bottom: 0, }, -})) as any) as Record; +})) as any) as Record diff --git a/packages/app/src/components/ui/autocomplete.tsx b/packages/app/src/components/ui/autocomplete.tsx index 3c5fdbc6f..43d038628 100644 --- a/packages/app/src/components/ui/autocomplete.tsx +++ b/packages/app/src/components/ui/autocomplete.tsx @@ -1,25 +1,27 @@ -import { useCallback, useEffect, useRef } from "react"; -import { ScrollView, Text, View, Pressable, type LayoutChangeEvent } from "react-native"; -import { StyleSheet } from "react-native-unistyles"; -import { Theme } from "@/styles/theme"; -import { getAutocompleteScrollOffset } from "./autocomplete-utils"; +import { useCallback, useEffect, useRef } from 'react' +import { ScrollView, Text, View, Pressable, type LayoutChangeEvent } from 'react-native' +import { StyleSheet, useUnistyles } from 'react-native-unistyles' +import { File, Folder } from 'lucide-react-native' +import { Theme } from '@/styles/theme' +import { getAutocompleteScrollOffset } from './autocomplete-utils' export interface AutocompleteOption { - id: string; - label: string; - detail?: string; - description?: string; + id: string + label: string + detail?: string + description?: string + kind?: 'command' | 'file' | 'directory' } interface AutocompleteProps { - options: readonly AutocompleteOption[]; - selectedIndex: number; - onSelect: (option: AutocompleteOption) => void; - isLoading?: boolean; - errorMessage?: string; - loadingText?: string; - emptyText?: string; - maxHeight?: number; + options: readonly AutocompleteOption[] + selectedIndex: number + onSelect: (option: AutocompleteOption) => void + isLoading?: boolean + errorMessage?: string + loadingText?: string + emptyText?: string + maxHeight?: number } export function Autocomplete({ @@ -28,23 +30,24 @@ export function Autocomplete({ onSelect, isLoading = false, errorMessage, - loadingText = "Loading...", - emptyText = "No results found", + loadingText = 'Loading...', + emptyText = 'No results found', maxHeight = 220, }: AutocompleteProps) { - const scrollRef = useRef(null); - const rowLayoutsRef = useRef>(new Map()); - const viewportHeightRef = useRef(0); - const scrollOffsetRef = useRef(0); + const { theme } = useUnistyles() + const scrollRef = useRef(null) + const rowLayoutsRef = useRef>(new Map()) + const viewportHeightRef = useRef(0) + const scrollOffsetRef = useRef(0) const ensureActiveItemVisible = useCallback(() => { if (selectedIndex < 0) { - return; + return } - const layout = rowLayoutsRef.current.get(selectedIndex); + const layout = rowLayoutsRef.current.get(selectedIndex) if (!layout) { - return; + return } const nextOffset = getAutocompleteScrollOffset({ @@ -52,60 +55,60 @@ export function Autocomplete({ viewportHeight: viewportHeightRef.current, itemTop: layout.top, itemHeight: layout.height, - }); + }) if (Math.abs(nextOffset - scrollOffsetRef.current) < 1) { - return; + return } - scrollOffsetRef.current = nextOffset; - scrollRef.current?.scrollTo({ y: nextOffset, animated: false }); - }, [selectedIndex]); + scrollOffsetRef.current = nextOffset + scrollRef.current?.scrollTo({ y: nextOffset, animated: false }) + }, [selectedIndex]) const pinToBottom = useCallback(() => { - scrollRef.current?.scrollToEnd({ animated: false }); + scrollRef.current?.scrollToEnd({ animated: false }) requestAnimationFrame(() => { - scrollRef.current?.scrollToEnd({ animated: false }); - }); - }, []); + scrollRef.current?.scrollToEnd({ animated: false }) + }) + }, []) useEffect(() => { - rowLayoutsRef.current.clear(); - scrollOffsetRef.current = 0; - }, [options]); + rowLayoutsRef.current.clear() + scrollOffsetRef.current = 0 + }, [options]) useEffect(() => { if (options.length === 0) { - return; + return } - pinToBottom(); - }, [options, pinToBottom]); + pinToBottom() + }, [options, pinToBottom]) useEffect(() => { - const raf = requestAnimationFrame(ensureActiveItemVisible); + const raf = requestAnimationFrame(ensureActiveItemVisible) return () => { - cancelAnimationFrame(raf); - }; - }, [ensureActiveItemVisible, options.length]); + cancelAnimationFrame(raf) + } + }, [ensureActiveItemVisible, options.length]) const handleScrollViewLayout = useCallback( (event: LayoutChangeEvent) => { - viewportHeightRef.current = event.nativeEvent.layout.height; - ensureActiveItemVisible(); + viewportHeightRef.current = event.nativeEvent.layout.height + ensureActiveItemVisible() }, [ensureActiveItemVisible] - ); + ) const handleRowLayout = useCallback( (index: number, event: LayoutChangeEvent) => { rowLayoutsRef.current.set(index, { top: event.nativeEvent.layout.y, height: event.nativeEvent.layout.height, - }); - ensureActiveItemVisible(); + }) + ensureActiveItemVisible() }, [ensureActiveItemVisible] - ); + ) if (isLoading) { return ( @@ -114,7 +117,7 @@ export function Autocomplete({ {loadingText} - ); + ) } if (errorMessage) { @@ -124,7 +127,7 @@ export function Autocomplete({ Error: {errorMessage} - ); + ) } if (options.length === 0) { @@ -134,7 +137,7 @@ export function Autocomplete({ {emptyText} - ); + ) } return ( @@ -144,7 +147,7 @@ export function Autocomplete({ onLayout={handleScrollViewLayout} onContentSizeChange={pinToBottom} onScroll={(event) => { - scrollOffsetRef.current = event.nativeEvent.contentOffset.y; + scrollOffsetRef.current = event.nativeEvent.contentOffset.y }} scrollEventThrottle={16} style={styles.scrollView} @@ -152,7 +155,7 @@ export function Autocomplete({ keyboardShouldPersistTaps="always" > {options.map((option, index) => { - const isSelected = index === selectedIndex; + const isSelected = index === selectedIndex return ( + {option.kind === 'directory' || option.kind === 'file' ? ( + + {option.kind === 'directory' ? ( + + ) : ( + + )} + + ) : null} {option.label} - {option.detail ? ( - {option.detail} - ) : null} + {option.detail ? {option.detail} : null} {option.description ? ( @@ -177,11 +187,11 @@ export function Autocomplete({ ) : null} - ); + ) })} - ); + ) } const styles = StyleSheet.create(((theme: Theme) => ({ @@ -190,7 +200,7 @@ const styles = StyleSheet.create(((theme: Theme) => ({ borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, borderRadius: theme.borderRadius.lg, - overflow: "hidden", + overflow: 'hidden', }, scrollView: { flexGrow: 0, @@ -200,12 +210,18 @@ const styles = StyleSheet.create(((theme: Theme) => ({ paddingVertical: theme.spacing[1], }, item: { - flexDirection: "row", - alignItems: "center", + flexDirection: 'row', + alignItems: 'center', minHeight: 36, paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], }, + itemLeading: { + width: 18, + alignItems: 'center', + justifyContent: 'center', + marginRight: theme.spacing[1], + }, itemActive: { backgroundColor: theme.colors.surface1, }, @@ -214,8 +230,8 @@ const styles = StyleSheet.create(((theme: Theme) => ({ minWidth: 0, }, itemHeader: { - flexDirection: "row", - alignItems: "center", + flexDirection: 'row', + alignItems: 'center', gap: theme.spacing[2], }, itemLabel: { @@ -240,4 +256,4 @@ const styles = StyleSheet.create(((theme: Theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, -})) as any) as Record; +})) as any) as Record diff --git a/packages/app/src/components/ui/combobox-options.ts b/packages/app/src/components/ui/combobox-options.ts index b58f0b421..4f6c751b5 100644 --- a/packages/app/src/components/ui/combobox-options.ts +++ b/packages/app/src/components/ui/combobox-options.ts @@ -1,42 +1,44 @@ +export type ComboboxOptionKind = 'directory' | 'file' + export interface ComboboxOptionModel { - id: string; - label: string; - description?: string; - kind?: "directory"; + id: string + label: string + description?: string + kind?: ComboboxOptionKind } export interface BuildVisibleComboboxOptionsInput { - options: ComboboxOptionModel[]; - searchQuery: string; - searchable: boolean; - allowCustomValue: boolean; - customValuePrefix: string; - customValueDescription?: string; - customValueKind?: "directory"; + options: ComboboxOptionModel[] + searchQuery: string + searchable: boolean + allowCustomValue: boolean + customValuePrefix: string + customValueDescription?: string + customValueKind?: ComboboxOptionKind } export function shouldShowCustomComboboxOption(input: { - options: ComboboxOptionModel[]; - searchQuery: string; - searchable: boolean; - allowCustomValue: boolean; + options: ComboboxOptionModel[] + searchQuery: string + searchable: boolean + allowCustomValue: boolean }): boolean { - const sanitizedSearchValue = input.searchQuery.trim(); + const sanitizedSearchValue = input.searchQuery.trim() if (!input.searchable || !input.allowCustomValue || sanitizedSearchValue.length === 0) { - return false; + return false } return !input.options.some( (opt) => opt.id.toLowerCase() === sanitizedSearchValue.toLowerCase() || opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase() - ); + ) } export function buildVisibleComboboxOptions( input: BuildVisibleComboboxOptionsInput ): ComboboxOptionModel[] { - const normalizedSearch = input.searchable ? input.searchQuery.trim().toLowerCase() : ""; + const normalizedSearch = input.searchable ? input.searchQuery.trim().toLowerCase() : '' const filteredOptions = normalizedSearch ? input.options.filter( (opt) => @@ -44,52 +46,50 @@ export function buildVisibleComboboxOptions( opt.id.toLowerCase().includes(normalizedSearch) || opt.description?.toLowerCase().includes(normalizedSearch) ) - : input.options; + : input.options - const sanitizedSearchValue = input.searchQuery.trim(); + const sanitizedSearchValue = input.searchQuery.trim() const showCustomOption = shouldShowCustomComboboxOption({ options: input.options, searchQuery: input.searchQuery, searchable: input.searchable, allowCustomValue: input.allowCustomValue, - }); + }) - const visibleOptions: ComboboxOptionModel[] = []; + const visibleOptions: ComboboxOptionModel[] = [] if (showCustomOption) { - const trimmedPrefix = input.customValuePrefix.trim(); + const trimmedPrefix = input.customValuePrefix.trim() const customLabel = - trimmedPrefix.length > 0 - ? `${trimmedPrefix} "${sanitizedSearchValue}"` - : sanitizedSearchValue; + trimmedPrefix.length > 0 ? `${trimmedPrefix} "${sanitizedSearchValue}"` : sanitizedSearchValue visibleOptions.push({ id: sanitizedSearchValue, label: customLabel, description: input.customValueDescription, kind: input.customValueKind, - }); + }) } - visibleOptions.push(...filteredOptions); - return visibleOptions; + visibleOptions.push(...filteredOptions) + return visibleOptions } export function orderVisibleComboboxOptions( visibleOptions: ComboboxOptionModel[], - optionsPosition: "below-search" | "above-search" + optionsPosition: 'below-search' | 'above-search' ): ComboboxOptionModel[] { - if (optionsPosition !== "above-search") { - return visibleOptions; + if (optionsPosition !== 'above-search') { + return visibleOptions } - return [...visibleOptions].reverse(); + return [...visibleOptions].reverse() } export function getComboboxFallbackIndex( itemCount: number, - optionsPosition: "below-search" | "above-search" + optionsPosition: 'below-search' | 'above-search' ): number { if (itemCount <= 0) { - return -1; + return -1 } - return optionsPosition === "above-search" ? itemCount - 1 : 0; + return optionsPosition === 'above-search' ? itemCount - 1 : 0 } diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 1f48e28bb..45cd14e4e 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import type { ReactElement, ReactNode } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import type { ReactElement, ReactNode } from 'react' import { View, Text, @@ -10,87 +10,88 @@ import { Platform, StatusBar, useWindowDimensions, -} from "react-native"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +} from 'react-native' +import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' import { BottomSheetModal, BottomSheetScrollView, BottomSheetBackdrop, BottomSheetTextInput, BottomSheetBackgroundProps, -} from "@gorhom/bottom-sheet"; -import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; -import { Check, Folder, Search } from "lucide-react-native"; -import { flip, offset as floatingOffset, shift, size as floatingSize, useFloating } from "@floating-ui/react-native"; -import { getNextActiveIndex } from "./combobox-keyboard"; +} from '@gorhom/bottom-sheet' +import Animated, { FadeIn, FadeOut } from 'react-native-reanimated' +import { Check, File, Folder, Search } from 'lucide-react-native' +import { + flip, + offset as floatingOffset, + shift, + size as floatingSize, + useFloating, +} from '@floating-ui/react-native' +import { getNextActiveIndex } from './combobox-keyboard' import { buildVisibleComboboxOptions, getComboboxFallbackIndex, orderVisibleComboboxOptions, shouldShowCustomComboboxOption, -} from "./combobox-options"; -import type { ComboboxOptionModel } from "./combobox-options"; +} from './combobox-options' +import type { ComboboxOptionModel } from './combobox-options' -const IS_WEB = Platform.OS === "web"; +const IS_WEB = Platform.OS === 'web' -export type ComboboxOption = ComboboxOptionModel; +export type ComboboxOption = ComboboxOptionModel export interface ComboboxProps { - options: ComboboxOption[]; - value: string; - onSelect: (id: string) => void; - onSearchQueryChange?: (query: string) => void; - searchable?: boolean; - placeholder?: string; - searchPlaceholder?: string; - emptyText?: string; - allowCustomValue?: boolean; - customValuePrefix?: string; - customValueDescription?: string; - customValueKind?: "directory"; - optionsPosition?: "below-search" | "above-search"; - title?: string; - open?: boolean; - onOpenChange?: (open: boolean) => void; - desktopPlacement?: "top-start" | "bottom-start"; + options: ComboboxOption[] + value: string + onSelect: (id: string) => void + onSearchQueryChange?: (query: string) => void + searchable?: boolean + placeholder?: string + searchPlaceholder?: string + emptyText?: string + allowCustomValue?: boolean + customValuePrefix?: string + customValueDescription?: string + customValueKind?: 'directory' | 'file' + optionsPosition?: 'below-search' | 'above-search' + title?: string + open?: boolean + onOpenChange?: (open: boolean) => void + desktopPlacement?: 'top-start' | 'bottom-start' /** * Prevents an initial frame at 0,0 by hiding desktop content until floating * coordinates resolve. This intentionally disables fade enter/exit animation * for that combobox instance to avoid animation overriding hidden opacity. */ - desktopPreventInitialFlash?: boolean; - anchorRef: React.RefObject; - children?: ReactNode; + desktopPreventInitialFlash?: boolean + anchorRef: React.RefObject + children?: ReactNode } function toNumericStyleValue(value: unknown): number | null { - if (typeof value === "number" && Number.isFinite(value)) { - return value; + if (typeof value === 'number' && Number.isFinite(value)) { + return value } - if (typeof value === "string") { - const parsed = Number.parseFloat(value); + if (typeof value === 'string') { + const parsed = Number.parseFloat(value) if (Number.isFinite(parsed)) { - return parsed; + return parsed } } - return null; + return null } function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) { - return ( - - ); + return } interface SearchInputProps { - placeholder: string; - value: string; - onChangeText: (text: string) => void; - onSubmitEditing?: () => void; - autoFocus?: boolean; + placeholder: string + value: string + onChangeText: (text: string) => void + onSubmitEditing?: () => void + autoFocus?: boolean } function SearchInput({ @@ -100,18 +101,18 @@ function SearchInput({ onSubmitEditing, autoFocus = false, }: SearchInputProps): ReactElement { - const { theme } = useUnistyles(); - const inputRef = useRef(null); - const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput; + const { theme } = useUnistyles() + const inputRef = useRef(null) + const InputComponent = Platform.OS === 'web' ? TextInput : BottomSheetTextInput useEffect(() => { if (autoFocus && IS_WEB && inputRef.current) { const timer = setTimeout(() => { - inputRef.current?.focus(); - }, 50); - return () => clearTimeout(timer); + inputRef.current?.focus() + }, 50) + return () => clearTimeout(timer) } - }, [autoFocus]); + }, [autoFocus]) return ( @@ -119,7 +120,7 @@ function SearchInput({ - ); + ) } export interface ComboboxItemProps { - label: string; - description?: string; - kind?: "directory"; - selected?: boolean; - active?: boolean; - onPress: () => void; - testID?: string; + label: string + description?: string + kind?: 'directory' | 'file' + selected?: boolean + active?: boolean + onPress: () => void + testID?: string } export function ComboboxItem({ @@ -151,7 +152,7 @@ export function ComboboxItem({ onPress, testID, }: ComboboxItemProps): ReactElement { - const { theme } = useUnistyles(); + const { theme } = useUnistyles() return ( - {kind === "directory" ? ( + {kind === 'directory' || kind === 'file' ? ( - + {kind === 'directory' ? ( + + ) : ( + + )} ) : null} - {label} + + {label} + {description ? ( - {description} + + {description} + ) : null} {selected ? ( @@ -180,11 +189,15 @@ export function ComboboxItem({ ) : null} - ); + ) } export function ComboboxEmpty({ children }: { children: ReactNode }): ReactElement { - return {children}; + return ( + + {children} + + ) } export function Combobox({ @@ -193,202 +206,196 @@ export function Combobox({ onSelect, onSearchQueryChange, searchable = true, - placeholder = "Search...", + placeholder = 'Search...', searchPlaceholder, - emptyText = "No options match your search.", + emptyText = 'No options match your search.', allowCustomValue = false, - customValuePrefix = "Use", + customValuePrefix = 'Use', customValueDescription, customValueKind, - optionsPosition = "below-search", - title = "Select", + optionsPosition = 'below-search', + title = 'Select', open, onOpenChange, - desktopPlacement = "top-start", + desktopPlacement = 'top-start', desktopPreventInitialFlash = true, anchorRef, children, }: ComboboxProps): ReactElement { - const isMobile = - UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; - const effectiveOptionsPosition = - isMobile ? "below-search" : optionsPosition; + const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm' + const effectiveOptionsPosition = isMobile ? 'below-search' : optionsPosition const isDesktopAboveSearch = - !isMobile && Platform.OS === "web" && effectiveOptionsPosition === "above-search"; - const { height: windowHeight } = useWindowDimensions(); - const bottomSheetRef = useRef(null); - const snapPoints = useMemo(() => ["60%", "90%"], []); - const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(null); - const [referenceWidth, setReferenceWidth] = useState(null); - const [referenceTop, setReferenceTop] = useState(null); - const [referenceAtOrigin, setReferenceAtOrigin] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); - const [activeIndex, setActiveIndex] = useState(-1); - const desktopOptionsScrollRef = useRef(null); + !isMobile && Platform.OS === 'web' && effectiveOptionsPosition === 'above-search' + const { height: windowHeight } = useWindowDimensions() + const bottomSheetRef = useRef(null) + const snapPoints = useMemo(() => ['60%', '90%'], []) + const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>( + null + ) + const [referenceWidth, setReferenceWidth] = useState(null) + const [referenceTop, setReferenceTop] = useState(null) + const [referenceAtOrigin, setReferenceAtOrigin] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + const [activeIndex, setActiveIndex] = useState(-1) + const desktopOptionsScrollRef = useRef(null) - const isControlled = typeof open === "boolean"; - const [internalOpen, setInternalOpen] = useState(false); - const isOpen = isControlled ? open : internalOpen; + const isControlled = typeof open === 'boolean' + const [internalOpen, setInternalOpen] = useState(false) + const isOpen = isControlled ? open : internalOpen const setOpen = useCallback( (nextOpen: boolean) => { if (!isControlled) { - setInternalOpen(nextOpen); + setInternalOpen(nextOpen) } - onOpenChange?.(nextOpen); + onOpenChange?.(nextOpen) }, [isControlled, onOpenChange] - ); + ) const setSearchQueryWithCallback = useCallback( (nextQuery: string) => { - setSearchQuery(nextQuery); - onSearchQueryChange?.(nextQuery); + setSearchQuery(nextQuery) + onSearchQueryChange?.(nextQuery) }, [onSearchQueryChange] - ); + ) const handleClose = useCallback(() => { - setOpen(false); - setSearchQueryWithCallback(""); - }, [setOpen, setSearchQueryWithCallback]); + setOpen(false) + setSearchQueryWithCallback('') + }, [setOpen, setSearchQueryWithCallback]) useEffect(() => { if (isOpen) { - setSearchQueryWithCallback(""); + setSearchQueryWithCallback('') } - }, [isOpen, setSearchQueryWithCallback]); + }, [isOpen, setSearchQueryWithCallback]) const collisionPadding = useMemo(() => { - const basePadding = 16; - if (Platform.OS !== "android") return basePadding; - const statusBarHeight = StatusBar.currentHeight ?? 0; - return Math.max(basePadding, statusBarHeight + basePadding); - }, []); + const basePadding = 16 + if (Platform.OS !== 'android') return basePadding + const statusBarHeight = StatusBar.currentHeight ?? 0 + return Math.max(basePadding, statusBarHeight + basePadding) + }, []) const middleware = useMemo( () => [ - floatingOffset(Platform.OS === "web" ? 0 : 4), - ...(Platform.OS === "web" ? [] : [flip({ padding: collisionPadding })]), + floatingOffset(Platform.OS === 'web' ? 0 : 4), + ...(Platform.OS === 'web' ? [] : [flip({ padding: collisionPadding })]), ...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]), floatingSize({ padding: collisionPadding, apply({ availableWidth, availableHeight, rects }) { setAvailableSize((prev) => { - const next = { width: availableWidth, height: availableHeight }; - if (!prev) return next; - if (prev.width === next.width && prev.height === next.height) return prev; - return next; - }); + const next = { width: availableWidth, height: availableHeight } + if (!prev) return next + if (prev.width === next.width && prev.height === next.height) return prev + return next + }) setReferenceWidth((prev) => { - const next = rects.reference.width; - if (prev === next) return prev; - return next; - }); + const next = rects.reference.width + if (prev === next) return prev + return next + }) }, }), ], [collisionPadding, isDesktopAboveSearch] - ); + ) const { refs, floatingStyles, update } = useFloating({ - placement: Platform.OS === "web" ? desktopPlacement : "bottom-start", + placement: Platform.OS === 'web' ? desktopPlacement : 'bottom-start', middleware, sameScrollView: false, elements: { reference: anchorRef.current ?? undefined, }, - }); + }) useEffect(() => { if (!isOpen || isMobile) { - setAvailableSize(null); - setReferenceWidth(null); - return; + setAvailableSize(null) + setReferenceWidth(null) + return } - const raf = requestAnimationFrame(() => void update()); - return () => cancelAnimationFrame(raf); - }, [desktopPlacement, isMobile, update, isOpen]); + const raf = requestAnimationFrame(() => void update()) + return () => cancelAnimationFrame(raf) + }, [desktopPlacement, isMobile, update, isOpen]) useEffect(() => { if (!isOpen || isMobile) { - setReferenceAtOrigin(false); - setReferenceTop(null); - return; + setReferenceAtOrigin(false) + setReferenceTop(null) + return } - const referenceEl = anchorRef.current; + const referenceEl = anchorRef.current if (!referenceEl) { - setReferenceAtOrigin(false); - setReferenceTop(null); - return; + setReferenceAtOrigin(false) + setReferenceTop(null) + return } const measure = () => { referenceEl.measureInWindow((x, y) => { - setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1); - setReferenceTop((prev) => (prev === y ? prev : y)); - }); - }; + setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1) + setReferenceTop((prev) => (prev === y ? prev : y)) + }) + } - measure(); - const raf = requestAnimationFrame(measure); - return () => cancelAnimationFrame(raf); - }, [anchorRef, isMobile, isOpen, searchQuery, windowHeight]); + measure() + const raf = requestAnimationFrame(measure) + return () => cancelAnimationFrame(raf) + }, [anchorRef, isMobile, isOpen, searchQuery, windowHeight]) - const floatingTop = toNumericStyleValue(floatingStyles.top); - const floatingLeft = toNumericStyleValue(floatingStyles.left); + const floatingTop = toNumericStyleValue(floatingStyles.top) + const floatingLeft = toNumericStyleValue(floatingStyles.left) const desktopAboveSearchBottom = isDesktopAboveSearch && referenceTop !== null ? Math.max(windowHeight - referenceTop, collisionPadding) - : null; + : null const hasResolvedDesktopPosition = referenceWidth !== null && floatingLeft !== null && (isDesktopAboveSearch ? desktopAboveSearchBottom !== null : floatingTop !== null) && - ((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin); - const shouldHideDesktopContent = - desktopPreventInitialFlash && !hasResolvedDesktopPosition; - const shouldUseDesktopFade = !desktopPreventInitialFlash; + ((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin) + const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition + const shouldUseDesktopFade = !desktopPreventInitialFlash const desktopPositionStyle = isDesktopAboveSearch ? { left: floatingLeft ?? 0, bottom: desktopAboveSearchBottom ?? 0, } - : floatingStyles; + : floatingStyles useEffect(() => { - if (!isMobile) return; + if (!isMobile) return if (isOpen) { - bottomSheetRef.current?.present(); + bottomSheetRef.current?.present() } else { - bottomSheetRef.current?.dismiss(); + bottomSheetRef.current?.dismiss() } - }, [isOpen, isMobile]); + }, [isOpen, isMobile]) const handleSheetChange = useCallback( (index: number) => { if (index === -1) { - handleClose(); + handleClose() } }, [handleClose] - ); + ) const renderBackdrop = useCallback( (props: React.ComponentProps) => ( - + ), [] - ); + ) - const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : ""; - const sanitizedSearchValue = searchQuery.trim(); + const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : '' + const sanitizedSearchValue = searchQuery.trim() const showCustomOption = useMemo( () => shouldShowCustomComboboxOption({ @@ -398,7 +405,7 @@ export function Combobox({ allowCustomValue, }), [allowCustomValue, options, searchQuery, searchable] - ); + ) const visibleOptions = useMemo( () => @@ -420,139 +427,132 @@ export function Combobox({ searchQuery, searchable, ] - ); + ) const orderedVisibleOptions = useMemo( () => orderVisibleComboboxOptions(visibleOptions, effectiveOptionsPosition), [effectiveOptionsPosition, visibleOptions] - ); + ) const pinDesktopOptionsToBottom = useCallback(() => { - if (isMobile || effectiveOptionsPosition !== "above-search") { - return; + if (isMobile || effectiveOptionsPosition !== 'above-search') { + return } - desktopOptionsScrollRef.current?.scrollToEnd({ animated: false }); + desktopOptionsScrollRef.current?.scrollToEnd({ animated: false }) requestAnimationFrame(() => { - desktopOptionsScrollRef.current?.scrollToEnd({ animated: false }); - }); - }, [effectiveOptionsPosition, isMobile]); + desktopOptionsScrollRef.current?.scrollToEnd({ animated: false }) + }) + }, [effectiveOptionsPosition, isMobile]) const handleDesktopOptionsContentSizeChange = useCallback(() => { if (!isOpen) { - return; + return } - pinDesktopOptionsToBottom(); - }, [isOpen, pinDesktopOptionsToBottom]); + pinDesktopOptionsToBottom() + }, [isOpen, pinDesktopOptionsToBottom]) useEffect(() => { if (!isOpen) { - return; + return } - pinDesktopOptionsToBottom(); - }, [isOpen, orderedVisibleOptions, pinDesktopOptionsToBottom]); + pinDesktopOptionsToBottom() + }, [isOpen, orderedVisibleOptions, pinDesktopOptionsToBottom]) useLayoutEffect(() => { if (!isOpen || isMobile) { - return; + return } - void update(); - }, [isOpen, isMobile, orderedVisibleOptions.length, searchQuery, update]); + void update() + }, [isOpen, isMobile, orderedVisibleOptions.length, searchQuery, update]) useEffect(() => { - if (!isOpen) return; - if (!IS_WEB && isMobile) return; + if (!isOpen) return + if (!IS_WEB && isMobile) return if (orderedVisibleOptions.length === 0) { - setActiveIndex(-1); - return; + setActiveIndex(-1) + return } const fallbackIndex = getComboboxFallbackIndex( orderedVisibleOptions.length, effectiveOptionsPosition - ); + ) if (normalizedSearch) { - setActiveIndex(fallbackIndex); - return; + setActiveIndex(fallbackIndex) + return } - const selectedIndex = orderedVisibleOptions.findIndex((opt) => opt.id === value); - setActiveIndex(selectedIndex >= 0 ? selectedIndex : fallbackIndex); - }, [ - effectiveOptionsPosition, - isMobile, - isOpen, - normalizedSearch, - value, - orderedVisibleOptions, - ]); + const selectedIndex = orderedVisibleOptions.findIndex((opt) => opt.id === value) + setActiveIndex(selectedIndex >= 0 ? selectedIndex : fallbackIndex) + }, [effectiveOptionsPosition, isMobile, isOpen, normalizedSearch, value, orderedVisibleOptions]) const handleSelect = useCallback( (id: string) => { - onSelect(id); - handleClose(); + onSelect(id) + handleClose() }, [handleClose, onSelect] - ); + ) const handleSubmitSearch = useCallback(() => { if (showCustomOption) { - handleSelect(sanitizedSearchValue); + handleSelect(sanitizedSearchValue) } - }, [handleSelect, sanitizedSearchValue, showCustomOption]); + }, [handleSelect, sanitizedSearchValue, showCustomOption]) const handleDesktopKey = useCallback( - (key: "ArrowDown" | "ArrowUp" | "Enter" | "Escape", event?: KeyboardEvent) => { - if (!isOpen) return; - if (!IS_WEB && isMobile) return; + (key: 'ArrowDown' | 'ArrowUp' | 'Enter' | 'Escape', event?: KeyboardEvent) => { + if (!isOpen) return + if (!IS_WEB && isMobile) return - if (key === "ArrowDown" || key === "ArrowUp") { - event?.preventDefault(); + if (key === 'ArrowDown' || key === 'ArrowUp') { + event?.preventDefault() setActiveIndex((currentIndex) => getNextActiveIndex({ currentIndex, itemCount: orderedVisibleOptions.length, key, }) - ); - return; + ) + return } - if (key === "Enter") { - if (orderedVisibleOptions.length === 0) return; - event?.preventDefault(); + if (key === 'Enter') { + if (orderedVisibleOptions.length === 0) return + event?.preventDefault() const index = - activeIndex >= 0 && activeIndex < orderedVisibleOptions.length ? activeIndex : 0; - handleSelect(orderedVisibleOptions[index]!.id); - return; + activeIndex >= 0 && activeIndex < orderedVisibleOptions.length ? activeIndex : 0 + handleSelect(orderedVisibleOptions[index]!.id) + return } - if (key === "Escape") { - event?.preventDefault(); - handleClose(); + if (key === 'Escape') { + event?.preventDefault() + handleClose() } }, [activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions] - ); + ) useEffect(() => { - if (!IS_WEB || !isOpen) return; + if (!IS_WEB || !isOpen) return const handler = (event: KeyboardEvent) => { - const key = event.key; - if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") { - return; + const key = event.key + if (key !== 'ArrowDown' && key !== 'ArrowUp' && key !== 'Enter' && key !== 'Escape') { + return } - handleDesktopKey(key, event); - }; + handleDesktopKey(key, event) + } // react-native-web's TextInput can stop propagation on key events, so listen in capture phase. - window.addEventListener("keydown", handler, true); + window.addEventListener('keydown', handler, true) return () => { - window.removeEventListener("keydown", handler, true); - }; - }, [handleDesktopKey, isOpen]); + window.removeEventListener('keydown', handler, true) + } + }, [handleDesktopKey, isOpen]) const searchInput = ( - ); + ) const optionsList = ( <> @@ -582,17 +582,17 @@ export function Combobox({ {emptyText} )} - ); + ) const defaultContent = ( <> - {effectiveOptionsPosition === "above-search" ? optionsList : null} + {effectiveOptionsPosition === 'above-search' ? optionsList : null} {searchable ? searchInput : null} - {effectiveOptionsPosition === "below-search" ? optionsList : null} + {effectiveOptionsPosition === 'below-search' ? optionsList : null} - ); + ) - const content = children ?? defaultContent; + const content = children ?? defaultContent if (isMobile) { return ( @@ -620,18 +620,13 @@ export function Combobox({ {content} - ); + ) } - if (!isOpen) return <>; + if (!isOpen) return <> return ( - + ) : ( <> - {effectiveOptionsPosition === "above-search" ? ( + {effectiveOptionsPosition === 'above-search' ? ( ) : null} {searchable ? searchInput : null} - {effectiveOptionsPosition === "below-search" ? ( + {effectiveOptionsPosition === 'below-search' ? ( - ); + ) } const styles = StyleSheet.create((theme) => ({ searchInputContainer: { - flexDirection: "row", - alignItems: "center", + flexDirection: 'row', + alignItems: 'center', borderWidth: 1, borderColor: theme.colors.border, backgroundColor: theme.colors.surface1, @@ -719,8 +716,8 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, }, comboboxItem: { - flexDirection: "row", - alignItems: "center", + flexDirection: 'row', + alignItems: 'center', minHeight: 36, gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], @@ -744,9 +741,9 @@ const styles = StyleSheet.create((theme) => ({ }, comboboxItemTrailingSlot: { width: 16, - alignItems: "center", - justifyContent: "center", - marginLeft: "auto", + alignItems: 'center', + justifyContent: 'center', + marginLeft: 'auto', }, comboboxItemContent: { flex: 1, @@ -754,8 +751,8 @@ const styles = StyleSheet.create((theme) => ({ }, comboboxItemLeadingSlot: { width: 16, - alignItems: "center", - justifyContent: "center", + alignItems: 'center', + justifyContent: 'center', }, comboboxItemLabel: { fontSize: theme.fontSize.sm, @@ -774,8 +771,8 @@ const styles = StyleSheet.create((theme) => ({ }, bottomSheetBackground: { backgroundColor: theme.colors.surface0, - borderTopLeftRadius: theme.borderRadius["2xl"], - borderTopRightRadius: theme.borderRadius["2xl"], + borderTopLeftRadius: theme.borderRadius['2xl'], + borderTopRightRadius: theme.borderRadius['2xl'], }, bottomSheetHandle: { backgroundColor: theme.colors.palette.zinc[600], @@ -788,7 +785,7 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.lg, fontWeight: theme.fontWeight.medium, color: theme.colors.foreground, - textAlign: "left", + textAlign: 'left', }, comboboxScrollContent: { paddingBottom: theme.spacing[8], @@ -799,7 +796,7 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, }, desktopBackdrop: { - position: "absolute", + position: 'absolute', top: 0, right: 0, bottom: 0, @@ -810,13 +807,13 @@ const styles = StyleSheet.create((theme) => ({ borderRadius: theme.borderRadius.lg, borderWidth: 1, borderColor: theme.colors.border, - shadowColor: "#000", + shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.2, shadowRadius: 8, elevation: 8, maxHeight: 400, - overflow: "hidden", + overflow: 'hidden', }, desktopScroll: { flexShrink: 1, @@ -827,6 +824,6 @@ const styles = StyleSheet.create((theme) => ({ }, desktopScrollContentAboveSearch: { flexGrow: 1, - justifyContent: "flex-end", + justifyContent: 'flex-end', }, -})); +})) diff --git a/packages/app/src/hooks/use-agent-autocomplete.ts b/packages/app/src/hooks/use-agent-autocomplete.ts index 00fa35ffb..90552e93f 100644 --- a/packages/app/src/hooks/use-agent-autocomplete.ts +++ b/packages/app/src/hooks/use-agent-autocomplete.ts @@ -1,125 +1,290 @@ -import { useCallback, useMemo } from "react"; -import type { AutocompleteOption } from "@/components/ui/autocomplete"; -import { useAgentCommandsQuery, type DraftCommandConfig } from "./use-agent-commands-query"; -import { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils"; -import { useAutocomplete } from "./use-autocomplete"; +import { useCallback, useMemo } from 'react' +import { useQuery } from '@tanstack/react-query' +import type { AutocompleteOption } from '@/components/ui/autocomplete' +import { useAgentCommandsQuery, type DraftCommandConfig } from './use-agent-commands-query' +import { orderAutocompleteOptions } from '@/components/ui/autocomplete-utils' +import { useAutocomplete } from './use-autocomplete' +import { useSessionStore } from '@/stores/session-store' +import { + applyFileMentionReplacement, + findActiveFileMention, + type FileMentionRange, +} from '@/utils/file-mention-autocomplete' interface UseAgentAutocompleteInput { - userInput: string; - setUserInput: (nextValue: string) => void; - serverId: string; - agentId: string; - draftConfig?: DraftCommandConfig; - onAutocompleteApplied?: () => void; + userInput: string + cursorIndex: number + setUserInput: (nextValue: string) => void + serverId: string + agentId: string + draftConfig?: DraftCommandConfig + onAutocompleteApplied?: () => void } +type AgentAutocompleteOption = + | (AutocompleteOption & { type: 'command' }) + | (AutocompleteOption & { + type: 'workspace_entry' + entryPath: string + mention: FileMentionRange + }) + interface AgentAutocompleteResult { - isVisible: boolean; - options: AutocompleteOption[]; - selectedIndex: number; - isLoading: boolean; - errorMessage?: string; - onSelectOption: (option: AutocompleteOption) => void; - onKeyPress: (event: { key: string; preventDefault: () => void }) => boolean; + isVisible: boolean + options: AutocompleteOption[] + selectedIndex: number + isLoading: boolean + errorMessage?: string + loadingText: string + emptyText: string + onSelectOption: (option: AutocompleteOption) => void + onKeyPress: (event: { key: string; preventDefault: () => void }) => boolean +} + +interface DirectorySuggestionEntry { + path: string + kind: 'file' | 'directory' } function normalizeDraftCommandConfig( draftConfig?: DraftCommandConfig ): DraftCommandConfig | undefined { if (!draftConfig) { - return undefined; + return undefined } - const cwd = draftConfig.cwd.trim(); + const cwd = draftConfig.cwd.trim() if (!cwd) { - return undefined; + return undefined } - const modeId = draftConfig.modeId?.trim() ?? ""; - const model = draftConfig.model?.trim() ?? ""; - const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? ""; + const modeId = draftConfig.modeId?.trim() ?? '' + const model = draftConfig.model?.trim() ?? '' + const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? '' return { provider: draftConfig.provider, cwd, ...(modeId ? { modeId } : {}), ...(model ? { model } : {}), ...(thinkingOptionId ? { thinkingOptionId } : {}), - }; + } } -export function useAgentAutocomplete( - input: UseAgentAutocompleteInput -): AgentAutocompleteResult { +function mapDirectorySuggestionsToEntries(payload: { + entries?: Array<{ path: string; kind: string }> + directories?: string[] +}): DirectorySuggestionEntry[] { + if (Array.isArray(payload.entries) && payload.entries.length > 0) { + return payload.entries.flatMap((entry) => { + if ( + !entry || + typeof entry.path !== 'string' || + (entry.kind !== 'file' && entry.kind !== 'directory') + ) { + return [] + } + return [{ path: entry.path, kind: entry.kind }] + }) + } + + return (payload.directories ?? []).map((path) => ({ + path, + kind: 'directory' as const, + })) +} + +export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAutocompleteResult { const { userInput, + cursorIndex, setUserInput, serverId, agentId, draftConfig, onAutocompleteApplied, - } = input; + } = input - const showAutocomplete = userInput.startsWith("/") && !userInput.includes(" "); - const filterQuery = showAutocomplete ? userInput.slice(1) : ""; + const showCommandAutocomplete = userInput.startsWith('/') && !userInput.includes(' ') + const commandFilterQuery = showCommandAutocomplete ? userInput.slice(1) : '' + + const activeFileMention = useMemo( + () => + findActiveFileMention({ + text: userInput, + cursorIndex, + }), + [cursorIndex, userInput] + ) + const showFileAutocomplete = activeFileMention !== null + const fileFilterQuery = activeFileMention?.query ?? '' const normalizedDraftConfig = useMemo( () => normalizeDraftCommandConfig(draftConfig), [draftConfig] - ); + ) - const isRealAgent = Boolean(agentId) && !agentId.startsWith("__"); - const queryDraftConfig = isRealAgent ? undefined : normalizedDraftConfig; - const canLoadOptions = Boolean(serverId) && (isRealAgent || !!queryDraftConfig); - const isVisible = showAutocomplete && canLoadOptions; + const isRealAgent = Boolean(agentId) && !agentId.startsWith('__') + const queryDraftConfig = isRealAgent ? undefined : normalizedDraftConfig + const canLoadCommands = Boolean(serverId) && (isRealAgent || !!queryDraftConfig) - const { commands, isLoading, isError, error } = useAgentCommandsQuery({ + const agentCwd = useSessionStore( + (state) => state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? '' + ) + const autocompleteCwd = useMemo(() => { + if (isRealAgent) { + return agentCwd.trim() + } + return queryDraftConfig?.cwd ?? '' + }, [agentCwd, isRealAgent, queryDraftConfig]) + + const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null) + const isConnected = useSessionStore( + (state) => state.sessions[serverId]?.connection.isConnected ?? false + ) + + const mode: 'command' | 'file' | null = showFileAutocomplete + ? 'file' + : showCommandAutocomplete + ? 'command' + : null + const isVisible = + mode === 'command' + ? canLoadCommands + : mode === 'file' + ? Boolean(serverId) && autocompleteCwd.length > 0 + : false + + const { + commands, + isLoading: isCommandsLoading, + isError, + error, + } = useAgentCommandsQuery({ serverId, agentId, - enabled: canLoadOptions, + enabled: mode === 'command' && canLoadCommands, draftConfig: queryDraftConfig, - }); + }) - const options = useMemo(() => { + const fileSuggestionsQuery = useQuery({ + queryKey: ['directorySuggestions', serverId, autocompleteCwd, fileFilterQuery, true, true], + queryFn: async (): Promise => { + if (!client) { + throw new Error('Daemon client unavailable') + } + const response = await client.getDirectorySuggestions({ + cwd: autocompleteCwd, + query: fileFilterQuery, + limit: 50, + includeFiles: true, + includeDirectories: true, + }) + if (response.error) { + throw new Error(response.error) + } + return mapDirectorySuggestionsToEntries(response) + }, + enabled: + mode === 'file' && + Boolean(serverId) && + autocompleteCwd.length > 0 && + Boolean(client) && + isConnected, + retry: false, + staleTime: 15_000, + }) + + const options = useMemo(() => { if (!isVisible) { - return []; + return [] } - const filterLower = filterQuery.toLowerCase(); - const matches = commands.filter((cmd) => - cmd.name.toLowerCase().includes(filterLower) - ); - const orderedMatches = orderAutocompleteOptions(matches); - return orderedMatches.map((cmd) => ({ - id: cmd.name, - label: `/${cmd.name}`, - detail: cmd.argumentHint || undefined, - description: cmd.description, - })); - }, [commands, filterQuery, isVisible]); + if (mode === 'command') { + const filterLower = commandFilterQuery.toLowerCase() + const matches = commands.filter((cmd) => cmd.name.toLowerCase().includes(filterLower)) + const orderedMatches = orderAutocompleteOptions(matches) + return orderedMatches.map((cmd) => ({ + type: 'command' as const, + id: cmd.name, + label: `/${cmd.name}`, + detail: cmd.argumentHint || undefined, + description: cmd.description, + kind: 'command', + })) + } + + if (mode === 'file' && activeFileMention) { + return (fileSuggestionsQuery.data ?? []).map((entry) => ({ + type: 'workspace_entry' as const, + id: `${entry.kind}:${entry.path}`, + label: entry.path, + detail: entry.kind === 'directory' ? 'Directory' : 'File', + kind: entry.kind, + entryPath: entry.path, + mention: activeFileMention, + })) + } + + return [] + }, [activeFileMention, commandFilterQuery, commands, fileSuggestionsQuery.data, isVisible, mode]) const onSelectOption = useCallback( (option: AutocompleteOption) => { - setUserInput(`/${option.id} `); - onAutocompleteApplied?.(); + const selected = option as AgentAutocompleteOption + if (selected.type === 'command') { + setUserInput(`/${selected.id} `) + onAutocompleteApplied?.() + return + } + + const nextInput = applyFileMentionReplacement({ + text: userInput, + mention: selected.mention, + relativePath: selected.entryPath, + }) + setUserInput(nextInput) + onAutocompleteApplied?.() }, - [onAutocompleteApplied, setUserInput] - ); + [onAutocompleteApplied, setUserInput, userInput] + ) const { selectedIndex, onKeyPress } = useAutocomplete({ isVisible, options, - query: filterQuery, + query: mode === 'command' ? commandFilterQuery : fileFilterQuery, onSelectOption, - onEscape: () => setUserInput(""), - }); + onEscape: mode === 'command' ? () => setUserInput('') : undefined, + }) + + const isLoading = + mode === 'command' + ? isCommandsLoading + : mode === 'file' + ? fileSuggestionsQuery.isPending || fileSuggestionsQuery.isLoading + : false + const errorMessage = + mode === 'command' + ? isError + ? (error?.message ?? 'Failed to load') + : undefined + : mode === 'file' + ? fileSuggestionsQuery.error instanceof Error + ? fileSuggestionsQuery.error.message + : undefined + : undefined + + const loadingText = mode === 'file' ? 'Searching workspace...' : 'Loading commands...' + const emptyText = mode === 'file' ? 'No files or directories found' : 'No commands found' return { isVisible, options, selectedIndex, isLoading, - errorMessage: isError ? (error?.message ?? "Failed to load") : undefined, + errorMessage, + loadingText, + emptyText, onSelectOption, onKeyPress, - }; + } } diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index 60fd70fe2..e4e93585c 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -1,69 +1,56 @@ -import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; -import { createNameId } from "mnemonic-id"; -import type { ImageAttachment } from "@/components/message-input"; -import { - View, - Text, - Pressable, - ScrollView, - Keyboard, - Platform, -} from "react-native"; -import { useLocalSearchParams, useRouter } from "expo-router"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; -import { Gesture, GestureDetector } from "react-native-gesture-handler"; -import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated"; -import { Folder, GitBranch, PanelRight } from "lucide-react-native"; -import { SidebarMenuToggle } from "@/components/headers/menu-header"; -import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; -import { AgentInputArea } from "@/components/agent-input-area"; -import { AgentStreamView } from "@/components/agent-stream-view"; -import { AgentConfigRow, FormSelectTrigger } from "@/components/agent-form/agent-form-dropdowns"; -import { ExplorerSidebar } from "@/components/explorer-sidebar"; -import { Combobox } from "@/components/ui/combobox"; -import { FileDropZone } from "@/components/file-drop-zone"; -import { useQuery } from "@tanstack/react-query"; -import { useAgentFormState, type CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; -import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query"; +import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' +import { createNameId } from 'mnemonic-id' +import type { ImageAttachment } from '@/components/message-input' +import { View, Text, Pressable, ScrollView, Keyboard, Platform } from 'react-native' +import { useLocalSearchParams, useRouter } from 'expo-router' +import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { useReanimatedKeyboardAnimation } from 'react-native-keyboard-controller' +import { Gesture, GestureDetector } from 'react-native-gesture-handler' +import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from 'react-native-reanimated' +import { Folder, GitBranch, PanelRight } from 'lucide-react-native' +import { SidebarMenuToggle } from '@/components/headers/menu-header' +import { HeaderToggleButton } from '@/components/headers/header-toggle-button' +import { AgentInputArea } from '@/components/agent-input-area' +import { AgentStreamView } from '@/components/agent-stream-view' +import { AgentConfigRow, FormSelectTrigger } from '@/components/agent-form/agent-form-dropdowns' +import { ExplorerSidebar } from '@/components/explorer-sidebar' +import { Combobox } from '@/components/ui/combobox' +import { FileDropZone } from '@/components/file-drop-zone' +import { useQuery } from '@tanstack/react-query' +import { useAgentFormState, type CreateAgentInitialValues } from '@/hooks/use-agent-form-state' +import type { DraftCommandConfig } from '@/hooks/use-agent-commands-query' import { CHECKOUT_STATUS_STALE_TIME, checkoutStatusQueryKey, -} from "@/hooks/use-checkout-status-query"; -import { useAllAgentsList } from "@/hooks/use-all-agents-list"; -import { useDaemonConnections } from "@/contexts/daemon-connections-context"; -import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; -import { buildBranchComboOptions, normalizeBranchOptionName } from "@/utils/branch-suggestions"; -import { shortenPath } from "@/utils/shorten-path"; -import { collectAgentWorkingDirectorySuggestions } from "@/utils/agent-working-directory-suggestions"; -import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions"; -import { useSessionStore } from "@/stores/session-store"; -import { useCreateFlowStore } from "@/stores/create-flow-store"; -import { - ExplorerSidebarAnimationProvider, -} from "@/contexts/explorer-sidebar-animation-context"; -import { usePanelStore, type ExplorerCheckoutContext } from "@/stores/panel-store"; -import { MAX_CONTENT_WIDTH } from "@/constants/layout"; -import { WelcomeScreen } from "@/components/welcome-screen"; -import type { Agent } from "@/contexts/session-context"; -import { - generateMessageId, - type StreamItem, - type UserMessageImageAttachment, -} from "@/types/stream"; -import { encodeImages } from "@/utils/encode-images"; +} from '@/hooks/use-checkout-status-query' +import { useAllAgentsList } from '@/hooks/use-all-agents-list' +import { useDaemonConnections } from '@/contexts/daemon-connections-context' +import { useDaemonRegistry } from '@/contexts/daemon-registry-context' +import { buildBranchComboOptions, normalizeBranchOptionName } from '@/utils/branch-suggestions' +import { shortenPath } from '@/utils/shorten-path' +import { collectAgentWorkingDirectorySuggestions } from '@/utils/agent-working-directory-suggestions' +import { buildWorkingDirectorySuggestions } from '@/utils/working-directory-suggestions' +import { useSessionStore } from '@/stores/session-store' +import { useCreateFlowStore } from '@/stores/create-flow-store' +import { ExplorerSidebarAnimationProvider } from '@/contexts/explorer-sidebar-animation-context' +import { usePanelStore, type ExplorerCheckoutContext } from '@/stores/panel-store' +import { MAX_CONTENT_WIDTH } from '@/constants/layout' +import { WelcomeScreen } from '@/components/welcome-screen' +import type { Agent } from '@/contexts/session-context' +import { generateMessageId, type StreamItem, type UserMessageImageAttachment } from '@/types/stream' +import { encodeImages } from '@/utils/encode-images' import type { AgentProvider, AgentCapabilityFlags, AgentSessionConfig, -} from "@server/server/agent/agent-sdk-types"; -import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest"; -import { buildHostAgentDetailRoute } from "@/utils/host-routes"; +} from '@server/server/agent/agent-sdk-types' +import { AGENT_PROVIDER_DEFINITIONS } from '@server/server/agent/provider-manifest' +import { buildHostAgentDetailRoute } from '@/utils/host-routes' -const DRAFT_AGENT_ID = "__new_agent__"; -const EMPTY_PENDING_PERMISSIONS = new Map(); -const EMPTY_STREAM_ITEMS: StreamItem[] = []; +const DRAFT_AGENT_ID = '__new_agent__' +const EMPTY_PENDING_PERMISSIONS = new Map() +const EMPTY_STREAM_ITEMS: StreamItem[] = [] const DRAFT_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: true, supportsSessionPersistence: false, @@ -71,62 +58,57 @@ const DRAFT_CAPABILITIES: AgentCapabilityFlags = { supportsMcpServers: false, supportsReasoningStream: false, supportsToolInvocations: false, -}; +} const PROVIDER_DEFINITION_MAP = new Map( AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]) -); +) function getParamValue(value: string | string[] | undefined) { - if (typeof value === "string") { - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; + if (typeof value === 'string') { + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : undefined } if (Array.isArray(value)) { for (const entry of value) { - const trimmed = entry.trim(); + const trimmed = entry.trim() if (trimmed.length > 0) { - return trimmed; + return trimmed } } } - return undefined; + return undefined } function getValidProvider(value: string | undefined) { if (!value) { - return undefined; + return undefined } - return PROVIDER_DEFINITION_MAP.has(value as AgentProvider) - ? (value as AgentProvider) - : undefined; + return PROVIDER_DEFINITION_MAP.has(value as AgentProvider) ? (value as AgentProvider) : undefined } -function getValidMode( - provider: AgentProvider | undefined, - value: string | undefined -) { +function getValidMode(provider: AgentProvider | undefined, value: string | undefined) { if (!provider || !value) { - return undefined; + return undefined } - const definition = PROVIDER_DEFINITION_MAP.get(provider); - const modes = definition?.modes ?? []; - return modes.some((mode) => mode.id === value) ? value : undefined; + const definition = PROVIDER_DEFINITION_MAP.get(provider) + const modes = definition?.modes ?? [] + return modes.some((mode) => mode.id === value) ? value : undefined } type DraftAgentParams = { - serverId?: string; - provider?: string; - modeId?: string; - model?: string; - thinkingOptionId?: string; - workingDir?: string; -}; + serverId?: string + provider?: string + modeId?: string + model?: string + thinkingOptionId?: string + workingDir?: string +} type DraftAgentScreenProps = { - isVisible?: boolean; - onCreateFlowActiveChange?: (active: boolean) => void; - forcedServerId?: string; -}; + isVisible?: boolean + onCreateFlowActiveChange?: (active: boolean) => void + forcedServerId?: string +} export function DraftAgentScreen({ isVisible = true, @@ -141,7 +123,7 @@ export function DraftAgentScreen({ forcedServerId={forcedServerId} /> - ); + ) } function DraftAgentScreenContent({ @@ -149,75 +131,69 @@ function DraftAgentScreenContent({ onCreateFlowActiveChange, forcedServerId, }: DraftAgentScreenProps = {}) { - const { theme } = useUnistyles(); - const router = useRouter(); - const insets = useSafeAreaInsets(); - const { connectionStates } = useDaemonConnections(); - const { daemons } = useDaemonRegistry(); - const params = useLocalSearchParams(); + const { theme } = useUnistyles() + const router = useRouter() + const insets = useSafeAreaInsets() + const { connectionStates } = useDaemonConnections() + const { daemons } = useDaemonRegistry() + const params = useLocalSearchParams() - const { height: keyboardHeight } = useReanimatedKeyboardAnimation(); - const bottomInset = useSharedValue(insets.bottom); + const { height: keyboardHeight } = useReanimatedKeyboardAnimation() + const bottomInset = useSharedValue(insets.bottom) useEffect(() => { - bottomInset.value = insets.bottom; - }, [insets.bottom, bottomInset]); + bottomInset.value = insets.bottom + }, [insets.bottom, bottomInset]) const animatedKeyboardStyle = useAnimatedStyle(() => { - "worklet"; - const absoluteHeight = Math.abs(keyboardHeight.value); - const shift = Math.max(0, absoluteHeight - bottomInset.value); + 'worklet' + const absoluteHeight = Math.abs(keyboardHeight.value) + const shift = Math.max(0, absoluteHeight - bottomInset.value) return { transform: [{ translateY: -shift }], - }; - }); + } + }) - const forcedServerIdParam = forcedServerId?.trim(); + const forcedServerIdParam = forcedServerId?.trim() const resolvedServerId = forcedServerIdParam && forcedServerIdParam.length > 0 ? forcedServerIdParam - : getParamValue(params.serverId); - const resolvedProvider = getValidProvider(getParamValue(params.provider)); - const resolvedMode = getValidMode(resolvedProvider, getParamValue(params.modeId)); - const resolvedModel = getParamValue(params.model); - const resolvedThinkingOptionId = getParamValue(params.thinkingOptionId); - const resolvedWorkingDir = getParamValue(params.workingDir); + : getParamValue(params.serverId) + const resolvedProvider = getValidProvider(getParamValue(params.provider)) + const resolvedMode = getValidMode(resolvedProvider, getParamValue(params.modeId)) + const resolvedModel = getParamValue(params.model) + const resolvedThinkingOptionId = getParamValue(params.thinkingOptionId) + const resolvedWorkingDir = getParamValue(params.workingDir) const onlineServerIds = useMemo(() => { - if (daemons.length === 0) return []; - const out: string[] = []; + if (daemons.length === 0) return [] + const out: string[] = [] for (const daemon of daemons) { - const status = connectionStates.get(daemon.serverId)?.status ?? "idle"; - if (status === "online") out.push(daemon.serverId); + const status = connectionStates.get(daemon.serverId)?.status ?? 'idle' + if (status === 'online') out.push(daemon.serverId) } - return out; - }, [connectionStates, daemons]); + return out + }, [connectionStates, daemons]) const initialValues = useMemo((): CreateAgentInitialValues => { - const values: CreateAgentInitialValues = {}; + const values: CreateAgentInitialValues = {} if (resolvedWorkingDir) { - values.workingDir = resolvedWorkingDir; + values.workingDir = resolvedWorkingDir } if (resolvedProvider) { - values.provider = resolvedProvider; + values.provider = resolvedProvider } if (resolvedMode) { - values.modeId = resolvedMode; + values.modeId = resolvedMode } if (resolvedModel) { - values.model = resolvedModel; + values.model = resolvedModel } if (resolvedThinkingOptionId) { - values.thinkingOptionId = resolvedThinkingOptionId; + values.thinkingOptionId = resolvedThinkingOptionId } - return values; - }, [ - resolvedMode, - resolvedModel, - resolvedProvider, - resolvedThinkingOptionId, - resolvedWorkingDir, - ]); + return values + }, [resolvedMode, resolvedModel, resolvedProvider, resolvedThinkingOptionId, resolvedWorkingDir]) const { selectedServerId, @@ -246,134 +222,131 @@ function DraftAgentScreenContent({ isVisible, isCreateFlow: true, onlineServerIds, - }); - const hostEntry = selectedServerId - ? connectionStates.get(selectedServerId) - : undefined; - 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 closeFileExplorer = usePanelStore((state) => state.closeFileExplorer); - const setActiveExplorerCheckout = usePanelStore((state) => state.setActiveExplorerCheckout); + }) + const hostEntry = selectedServerId ? connectionStates.get(selectedServerId) : undefined + 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 closeFileExplorer = usePanelStore((state) => state.closeFileExplorer) + const setActiveExplorerCheckout = usePanelStore((state) => state.setActiveExplorerCheckout) const activateExplorerTabForCheckout = usePanelStore( (state) => state.activateExplorerTabForCheckout - ); - const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen; + ) + const isExplorerOpen = isMobile ? mobileView === 'file-explorer' : desktopFileExplorerOpen - const [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">("none"); - const [baseBranch, setBaseBranch] = useState(""); - const [worktreeSlug, setWorktreeSlug] = useState(""); - const [selectedWorktreePath, setSelectedWorktreePath] = useState(""); - const [isWorkingDirOpen, setIsWorkingDirOpen] = useState(false); - const [isWorktreePickerOpen, setIsWorktreePickerOpen] = useState(false); - const [isBranchOpen, setIsBranchOpen] = useState(false); - const [branchSearchQuery, setBranchSearchQuery] = useState(""); - const [debouncedBranchSearchQuery, setDebouncedBranchSearchQuery] = useState(""); - const [workingDirSearchQuery, setWorkingDirSearchQuery] = useState(""); - const [debouncedWorkingDirSearchQuery, setDebouncedWorkingDirSearchQuery] = useState(""); - const workingDirAnchorRef = useRef(null); - const worktreeAnchorRef = useRef(null); - const branchAnchorRef = useRef(null); - const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null); - const setPendingCreateAttempt = useCreateFlowStore((state) => state.setPending); - const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId); - const clearPendingCreateAttempt = useCreateFlowStore((state) => state.clear); + const [worktreeMode, setWorktreeMode] = useState<'none' | 'create' | 'attach'>('none') + const [baseBranch, setBaseBranch] = useState('') + const [worktreeSlug, setWorktreeSlug] = useState('') + const [selectedWorktreePath, setSelectedWorktreePath] = useState('') + const [isWorkingDirOpen, setIsWorkingDirOpen] = useState(false) + const [isWorktreePickerOpen, setIsWorktreePickerOpen] = useState(false) + const [isBranchOpen, setIsBranchOpen] = useState(false) + const [branchSearchQuery, setBranchSearchQuery] = useState('') + const [debouncedBranchSearchQuery, setDebouncedBranchSearchQuery] = useState('') + const [workingDirSearchQuery, setWorkingDirSearchQuery] = useState('') + const [debouncedWorkingDirSearchQuery, setDebouncedWorkingDirSearchQuery] = useState('') + const workingDirAnchorRef = useRef(null) + const worktreeAnchorRef = useRef(null) + const branchAnchorRef = useRef(null) + const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null) + const setPendingCreateAttempt = useCreateFlowStore((state) => state.setPending) + const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId) + const clearPendingCreateAttempt = useCreateFlowStore((state) => state.clear) useEffect(() => { - const trimmed = branchSearchQuery.trim(); - const timer = setTimeout(() => setDebouncedBranchSearchQuery(trimmed), 180); - return () => clearTimeout(timer); - }, [branchSearchQuery]); + const trimmed = branchSearchQuery.trim() + const timer = setTimeout(() => setDebouncedBranchSearchQuery(trimmed), 180) + return () => clearTimeout(timer) + }, [branchSearchQuery]) useEffect(() => { - const trimmed = workingDirSearchQuery.trim(); - const timer = setTimeout(() => setDebouncedWorkingDirSearchQuery(trimmed), 180); - return () => clearTimeout(timer); - }, [workingDirSearchQuery]); + const trimmed = workingDirSearchQuery.trim() + const timer = setTimeout(() => setDebouncedWorkingDirSearchQuery(trimmed), 180) + return () => clearTimeout(timer) + }, [workingDirSearchQuery]) type CreateAttempt = { - messageId: string; - text: string; - timestamp: Date; - images?: UserMessageImageAttachment[]; - }; + messageId: string + text: string + timestamp: Date + images?: UserMessageImageAttachment[] + } type DraftAgentMachineState = - | { tag: "draft"; promptText: string; errorMessage: string } - | { tag: "creating"; attempt: CreateAttempt }; + | { tag: 'draft'; promptText: string; errorMessage: string } + | { tag: 'creating'; attempt: CreateAttempt } type DraftAgentMachineEvent = - | { type: "DRAFT_SET_PROMPT"; text: string } - | { type: "DRAFT_SET_ERROR"; message: string } - | { type: "SUBMIT"; attempt: CreateAttempt } - | { type: "CREATE_FAILED"; message: string }; + | { type: 'DRAFT_SET_PROMPT'; text: string } + | { type: 'DRAFT_SET_ERROR'; message: string } + | { type: 'SUBMIT'; attempt: CreateAttempt } + | { type: 'CREATE_FAILED'; message: string } function assertNever(value: never): never { - throw new Error(`Unhandled state: ${JSON.stringify(value)}`); + throw new Error(`Unhandled state: ${JSON.stringify(value)}`) } const [machine, dispatch] = useReducer( (state: DraftAgentMachineState, event: DraftAgentMachineEvent): DraftAgentMachineState => { switch (event.type) { - case "DRAFT_SET_PROMPT": { - if (state.tag !== "draft") { - return state; + case 'DRAFT_SET_PROMPT': { + if (state.tag !== 'draft') { + return state } - return { ...state, promptText: event.text }; + return { ...state, promptText: event.text } } - case "DRAFT_SET_ERROR": { - if (state.tag !== "draft") { - return state; + case 'DRAFT_SET_ERROR': { + if (state.tag !== 'draft') { + return state } - return { ...state, errorMessage: event.message }; + return { ...state, errorMessage: event.message } } - case "SUBMIT": { - return { tag: "creating", attempt: event.attempt }; + case 'SUBMIT': { + return { tag: 'creating', attempt: event.attempt } } - case "CREATE_FAILED": { - if (state.tag !== "creating") { - return state; + case 'CREATE_FAILED': { + if (state.tag !== 'creating') { + return state } - return { tag: "draft", promptText: state.attempt.text, errorMessage: event.message }; + return { tag: 'draft', promptText: state.attempt.text, errorMessage: event.message } } default: - return assertNever(event); + return assertNever(event) } }, - { tag: "draft", promptText: "", errorMessage: "" } - ); + { tag: 'draft', promptText: '', errorMessage: '' } + ) const handleFilesDropped = useCallback((files: ImageAttachment[]) => { - addImagesRef.current?.(files); - }, []); + addImagesRef.current?.(files) + }, []) const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => { - addImagesRef.current = addImages; - }, []); + addImagesRef.current = addImages + }, []) const sessionAgents = useSessionStore((state) => selectedServerId ? state.sessions[selectedServerId]?.agents : undefined - ); - const { agents: allAgents } = useAllAgentsList({ serverId: selectedServerId }); + ) + const { agents: allAgents } = useAllAgentsList({ serverId: selectedServerId }) const worktreePathLastCreatedAt = useMemo(() => { - const map = new Map(); + const map = new Map() if (!sessionAgents) { - return map; + return map } sessionAgents.forEach((agent) => { if (!agent.cwd) { - return; + return } - const ts = agent.createdAt.getTime(); - const prev = map.get(agent.cwd); + const ts = agent.createdAt.getTime() + const prev = map.get(agent.cwd) if (!prev || ts > prev) { - map.set(agent.cwd, ts); + map.set(agent.cwd, ts) } - }); - return map; - }, [sessionAgents]); + }) + return map + }, [sessionAgents]) const agentWorkingDirSuggestions = useMemo(() => { const liveSources = sessionAgents ? Array.from(sessionAgents.values()).map((agent) => ({ @@ -381,46 +354,39 @@ function DraftAgentScreenContent({ createdAt: agent.createdAt, lastActivityAt: agent.lastActivityAt, })) - : []; + : [] const fetchedSources = allAgents.map((agent) => ({ cwd: agent.cwd, lastActivityAt: agent.lastActivityAt, - })); + })) - return collectAgentWorkingDirectorySuggestions([ - ...liveSources, - ...fetchedSources, - ]); - }, [allAgents, sessionAgents]); + return collectAgentWorkingDirectorySuggestions([...liveSources, ...fetchedSources]) + }, [allAgents, sessionAgents]) const sessionClient = useSessionStore((state) => - selectedServerId ? state.sessions[selectedServerId]?.client ?? null : null - ); + selectedServerId ? (state.sessions[selectedServerId]?.client ?? null) : null + ) const isConnected = useSessionStore((state) => - selectedServerId - ? state.sessions[selectedServerId]?.connection.isConnected ?? false - : false - ); - const trimmedWorkingDir = workingDir.trim(); - const shouldInspectRepo = trimmedWorkingDir.length > 0; + selectedServerId ? (state.sessions[selectedServerId]?.connection.isConnected ?? false) : false + ) + const trimmedWorkingDir = workingDir.trim() + const shouldInspectRepo = trimmedWorkingDir.length > 0 const daemonAvailabilityError = - !selectedServerId || hostEntry?.status !== "online" - ? "Host is offline" - : null; + !selectedServerId || hostEntry?.status !== 'online' ? 'Host is offline' : null const repoAvailabilityError = - shouldInspectRepo && (!hostEntry || hostEntry.status !== "online" || !isConnected) - ? daemonAvailabilityError ?? - "Repository details will load automatically once the selected host is back online." - : null; + shouldInspectRepo && (!hostEntry || hostEntry.status !== 'online' || !isConnected) + ? (daemonAvailabilityError ?? + 'Repository details will load automatically once the selected host is back online.') + : null const checkoutStatusQuery = useQuery({ - queryKey: checkoutStatusQueryKey(selectedServerId ?? "", trimmedWorkingDir), + queryKey: checkoutStatusQueryKey(selectedServerId ?? '', trimmedWorkingDir), queryFn: async () => { - const client = sessionClient; + const client = sessionClient if (!client) { - throw new Error("Daemon client unavailable"); + throw new Error('Daemon client unavailable') } - return await client.getCheckoutStatus(trimmedWorkingDir); + return await client.getCheckoutStatus(trimmedWorkingDir) }, enabled: Boolean(selectedServerId) && @@ -430,60 +396,60 @@ function DraftAgentScreenContent({ isConnected, retry: false, staleTime: CHECKOUT_STATUS_STALE_TIME, - refetchOnMount: "always", - }); + refetchOnMount: 'always', + }) - const checkout = checkoutStatusQuery.data ?? null; + const checkout = checkoutStatusQuery.data ?? null const checkoutQueryError = - checkoutStatusQuery.error instanceof Error ? checkoutStatusQuery.error.message : null; - const checkoutPayloadError = checkout?.error ? checkout.error.message : null; - const isGitDirectory = checkoutStatusQuery.isSuccess && checkout?.isGit === true; + checkoutStatusQuery.error instanceof Error ? checkoutStatusQuery.error.message : null + const checkoutPayloadError = checkout?.error ? checkout.error.message : null + const isGitDirectory = checkoutStatusQuery.isSuccess && checkout?.isGit === true const isNonGitDirectory = Boolean(trimmedWorkingDir) && checkoutStatusQuery.isSuccess && checkout?.isGit === false && - checkout?.error == null; + checkout?.error == null const isDirectoryNotExists = checkoutStatusQuery.isError && - /does not exist|no such file or directory|ENOENT/i.test(checkoutQueryError ?? ""); + /does not exist|no such file or directory|ENOENT/i.test(checkoutQueryError ?? '') - const repoInfoStatus: "idle" | "loading" | "ready" | "error" = !shouldInspectRepo - ? "idle" + const repoInfoStatus: 'idle' | 'loading' | 'ready' | 'error' = !shouldInspectRepo + ? 'idle' : repoAvailabilityError - ? "error" + ? 'error' : checkoutStatusQuery.isPending || checkoutStatusQuery.isFetching - ? "loading" + ? 'loading' : checkoutStatusQuery.isError || Boolean(checkoutPayloadError) - ? "error" + ? 'error' : checkout?.isGit - ? "ready" - : "idle"; + ? 'ready' + : 'idle' const repoInfoError = repoAvailabilityError ?? (checkoutStatusQuery.isError ? checkoutQueryError : null) ?? - checkoutPayloadError; - const isCreateWorktree = worktreeMode === "create"; - const isAttachWorktree = worktreeMode === "attach"; + checkoutPayloadError + const isCreateWorktree = worktreeMode === 'create' + const isAttachWorktree = worktreeMode === 'attach' - const worktreeListRoot = checkout?.isGit ? checkout.repoRoot : ""; + const worktreeListRoot = checkout?.isGit ? checkout.repoRoot : '' const worktreeListQuery = useQuery({ - queryKey: ["paseoWorktreeList", selectedServerId, worktreeListRoot], + queryKey: ['paseoWorktreeList', selectedServerId, worktreeListRoot], queryFn: async () => { - const client = sessionClient; + const client = sessionClient if (!client) { - throw new Error("Daemon client unavailable"); + throw new Error('Daemon client unavailable') } const payload = await client.getPaseoWorktreeList({ repoRoot: worktreeListRoot || undefined, cwd: worktreeListRoot ? undefined : trimmedWorkingDir || undefined, - }); + }) if (payload.error) { - throw new Error(payload.error.message); + throw new Error(payload.error.message) } - return payload.worktrees ?? []; + return payload.worktrees ?? [] }, enabled: isGitDirectory && @@ -494,59 +460,59 @@ function DraftAgentScreenContent({ !isNonGitDirectory, retry: false, staleTime: 0, - refetchOnMount: "always", - }); + refetchOnMount: 'always', + }) const worktreeOptions = useMemo(() => { const options = (worktreeListQuery.data ?? []).map((worktree) => ({ path: worktree.worktreePath, - label: worktree.branchName ?? worktree.head ?? "Unknown branch", - })); + label: worktree.branchName ?? worktree.head ?? 'Unknown branch', + })) return options.sort((a, b) => { - const aTs = worktreePathLastCreatedAt.get(a.path) ?? 0; - const bTs = worktreePathLastCreatedAt.get(b.path) ?? 0; + const aTs = worktreePathLastCreatedAt.get(a.path) ?? 0 + const bTs = worktreePathLastCreatedAt.get(b.path) ?? 0 if (aTs !== bTs) { - return bTs - aTs; + return bTs - aTs } - return a.label.localeCompare(b.label); - }); - }, [worktreeListQuery.data, worktreePathLastCreatedAt]); + return a.label.localeCompare(b.label) + }) + }, [worktreeListQuery.data, worktreePathLastCreatedAt]) const worktreeOptionsError = - worktreeListQuery.error instanceof Error ? worktreeListQuery.error.message : null; - const worktreeOptionsStatus: "idle" | "loading" | "ready" | "error" = + worktreeListQuery.error instanceof Error ? worktreeListQuery.error.message : null + const worktreeOptionsStatus: 'idle' | 'loading' | 'ready' | 'error' = worktreeListQuery.isPending || worktreeListQuery.isFetching - ? "loading" + ? 'loading' : worktreeListQuery.isError - ? "error" - : "ready"; + ? 'error' + : 'ready' const attachWorktreeError = isAttachWorktree && - worktreeOptionsStatus === "ready" && + worktreeOptionsStatus === 'ready' && worktreeOptions.length > 0 && !selectedWorktreePath - ? "Select a worktree to attach" - : null; + ? 'Select a worktree to attach' + : null const branchSuggestionsQuery = useQuery({ queryKey: [ - "branchSuggestions", + 'branchSuggestions', selectedServerId, trimmedWorkingDir, debouncedBranchSearchQuery, ], queryFn: async () => { - const client = sessionClient; + const client = sessionClient if (!client) { - throw new Error("Daemon client unavailable"); + throw new Error('Daemon client unavailable') } const payload = await client.getBranchSuggestions({ - cwd: trimmedWorkingDir || ".", + cwd: trimmedWorkingDir || '.', query: debouncedBranchSearchQuery || undefined, limit: 50, - }); + }) if (payload.error) { - throw new Error(payload.error); + throw new Error(payload.error) } - return payload.branches ?? []; + return payload.branches ?? [] }, enabled: isCreateWorktree && @@ -558,27 +524,30 @@ function DraftAgentScreenContent({ isConnected, retry: false, staleTime: 15_000, - }); + }) const directorySuggestionsQuery = useQuery({ - queryKey: [ - "directorySuggestions", - selectedServerId, - debouncedWorkingDirSearchQuery, - ], + queryKey: ['directorySuggestions', selectedServerId, debouncedWorkingDirSearchQuery], queryFn: async () => { - const client = sessionClient; + const client = sessionClient if (!client) { - throw new Error("Daemon client unavailable"); + throw new Error('Daemon client unavailable') } const payload = await client.getDirectorySuggestions({ query: debouncedWorkingDirSearchQuery, limit: 50, - }); + includeDirectories: true, + includeFiles: false, + }) if (payload.error) { - throw new Error(payload.error); + throw new Error(payload.error) } - return payload.directories ?? []; + if (payload.entries.length > 0) { + return payload.entries + .filter((entry) => entry.kind === 'directory') + .map((entry) => entry.path) + } + return payload.directories ?? [] }, enabled: Boolean(debouncedWorkingDirSearchQuery) && @@ -588,69 +557,61 @@ function DraftAgentScreenContent({ isConnected, retry: false, staleTime: 15_000, - }); + }) - const validateWorktreeName = useCallback( - (name: string): { valid: boolean; error?: string } => { - if (!name) { - return { valid: true }; + const validateWorktreeName = useCallback((name: string): { valid: boolean; error?: string } => { + if (!name) { + return { valid: true } + } + if (name.length > 100) { + return { + valid: false, + error: 'Worktree name too long (max 100 characters)', } - if (name.length > 100) { - return { - valid: false, - error: "Worktree name too long (max 100 characters)", - }; + } + if (!/^[a-z0-9-/]+$/.test(name)) { + return { + valid: false, + error: 'Must contain only lowercase letters, numbers, hyphens, and forward slashes', } - if (!/^[a-z0-9-/]+$/.test(name)) { - return { - valid: false, - error: "Must contain only lowercase letters, numbers, hyphens, and forward slashes", - }; - } - if (name.startsWith("-") || name.endsWith("-")) { - return { valid: false, error: "Cannot start or end with a hyphen" }; - } - if (name.includes("--")) { - return { valid: false, error: "Cannot have consecutive hyphens" }; - } - return { valid: true }; - }, - [] - ); + } + if (name.startsWith('-') || name.endsWith('-')) { + return { valid: false, error: 'Cannot start or end with a hyphen' } + } + if (name.includes('--')) { + return { valid: false, error: 'Cannot have consecutive hyphens' } + } + return { valid: true } + }, []) const gitBlockingError = useMemo(() => { if (!isCreateWorktree || isNonGitDirectory) { - return null; + return null } if (!worktreeSlug) { - return null; + return null } - const validation = validateWorktreeName(worktreeSlug); + const validation = validateWorktreeName(worktreeSlug) if (!validation.valid) { return `Invalid worktree name: ${ - validation.error ?? "Must use lowercase letters, numbers, or hyphens" - }`; + validation.error ?? 'Must use lowercase letters, numbers, or hyphens' + }` } - return null; - }, [ - isCreateWorktree, - isNonGitDirectory, - worktreeSlug, - validateWorktreeName, - ]); + return null + }, [isCreateWorktree, isNonGitDirectory, worktreeSlug, validateWorktreeName]) // Validate branch exists (checks local first, then remote) const branchValidationQuery = useQuery({ - queryKey: ["validateBranch", selectedServerId, trimmedWorkingDir, baseBranch], + queryKey: ['validateBranch', selectedServerId, trimmedWorkingDir, baseBranch], queryFn: async () => { - const client = sessionClient; + const client = sessionClient if (!client) { - throw new Error("Daemon client unavailable"); + throw new Error('Daemon client unavailable') } return client.validateBranch({ - cwd: trimmedWorkingDir || ".", + cwd: trimmedWorkingDir || '.', branchName: baseBranch, - }); + }) }, enabled: isCreateWorktree && @@ -662,119 +623,102 @@ function DraftAgentScreenContent({ isConnected, retry: false, staleTime: 30_000, - }); + }) const baseBranchError = useMemo(() => { if (!isCreateWorktree || isNonGitDirectory) { - return null; + return null } if (!baseBranch) { - return "Base branch is required"; + return 'Base branch is required' } // While validating, don't show error if (branchValidationQuery.isPending || branchValidationQuery.isFetching) { - return null; + return null } // If validation query errored, show generic error if (branchValidationQuery.isError) { - return "Failed to validate branch"; + return 'Failed to validate branch' } // If validation completed and branch doesn't exist - const validationResult = branchValidationQuery.data; + const validationResult = branchValidationQuery.data if (validationResult && !validationResult.exists) { - return `Branch "${baseBranch}" not found in repository`; + return `Branch "${baseBranch}" not found in repository` } - return null; - }, [isCreateWorktree, isNonGitDirectory, baseBranch, branchValidationQuery]); + return null + }, [isCreateWorktree, isNonGitDirectory, baseBranch, branchValidationQuery]) const handleBaseBranchChange = useCallback((value: string) => { - setBaseBranch(value); - }, []); + setBaseBranch(value) + }, []) - const handleSelectWorktreePath = useCallback( - (path: string) => { - setSelectedWorktreePath(path); - }, - [] - ); + const handleSelectWorktreePath = useCallback((path: string) => { + setSelectedWorktreePath(path) + }, []) useEffect(() => { if (!isCreateWorktree || isNonGitDirectory) { - return; + return } if (baseBranch) { - return; + return } - const current = checkout?.isGit ? checkout.currentBranch?.trim() : null; - if (!current || current === "HEAD") { - return; + const current = checkout?.isGit ? checkout.currentBranch?.trim() : null + if (!current || current === 'HEAD') { + return } if (current) { - setBaseBranch(current); + setBaseBranch(current) } - }, [isCreateWorktree, isNonGitDirectory, baseBranch, checkout]); + }, [isCreateWorktree, isNonGitDirectory, baseBranch, checkout]) useEffect(() => { - if (isNonGitDirectory && worktreeMode !== "none") { - setWorktreeMode("none"); - setSelectedWorktreePath(""); + if (isNonGitDirectory && worktreeMode !== 'none') { + setWorktreeMode('none') + setSelectedWorktreePath('') } - }, [isNonGitDirectory, worktreeMode]); + }, [isNonGitDirectory, worktreeMode]) const selectedWorktreeLabel = - worktreeOptions.find((option) => option.path === selectedWorktreePath)?.label ?? ""; + worktreeOptions.find((option) => option.path === selectedWorktreePath)?.label ?? '' const explorerCwd = useMemo( - () => - ( - isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir - ).trim(), + () => (isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim(), [isAttachWorktree, selectedWorktreePath, workingDir] - ); + ) const draftExplorerCheckout = useMemo(() => { if (!selectedServerId || !explorerCwd) { - return null; + return null } return { serverId: selectedServerId, cwd: explorerCwd, isGit: isAttachWorktree && selectedWorktreePath ? true : checkout?.isGit === true, - }; - }, [ - selectedServerId, - explorerCwd, - isAttachWorktree, - selectedWorktreePath, - checkout?.isGit, - ]); - const canOpenExplorer = draftExplorerCheckout !== null; + } + }, [selectedServerId, explorerCwd, isAttachWorktree, selectedWorktreePath, checkout?.isGit]) + const canOpenExplorer = draftExplorerCheckout !== null const openExplorerForDraftCheckout = useCallback(() => { if (!draftExplorerCheckout) { - return; + return } - activateExplorerTabForCheckout(draftExplorerCheckout); - openFileExplorer(); - }, [activateExplorerTabForCheckout, draftExplorerCheckout, openFileExplorer]); + activateExplorerTabForCheckout(draftExplorerCheckout) + openFileExplorer() + }, [activateExplorerTabForCheckout, draftExplorerCheckout, openFileExplorer]) const handleToggleExplorer = useCallback(() => { if (!canOpenExplorer) { - return; + return } if (isExplorerOpen) { - toggleFileExplorer(); - return; + toggleFileExplorer() + return } - openExplorerForDraftCheckout(); - }, [ - canOpenExplorer, - isExplorerOpen, - openExplorerForDraftCheckout, - toggleFileExplorer, - ]); + openExplorerForDraftCheckout() + }, [canOpenExplorer, isExplorerOpen, openExplorerForDraftCheckout, toggleFileExplorer]) const handleOpenExplorerFromGesture = useCallback(() => { if (!canOpenExplorer) { - return; + return } - openExplorerForDraftCheckout(); - }, [canOpenExplorer, openExplorerForDraftCheckout]); + openExplorerForDraftCheckout() + }, [canOpenExplorer, openExplorerForDraftCheckout]) const explorerOpenGesture = useMemo( () => Gesture.Pan() @@ -782,18 +726,18 @@ function DraftAgentScreenContent({ .activeOffsetX(-15) .failOffsetY([-10, 10]) .onEnd((event) => { - const shouldOpen = event.translationX < -80 || event.velocityX < -500; + const shouldOpen = event.translationX < -80 || event.velocityX < -500 if (shouldOpen) { - runOnJS(handleOpenExplorerFromGesture)(); + runOnJS(handleOpenExplorerFromGesture)() } }), [canOpenExplorer, handleOpenExplorerFromGesture, isExplorerOpen, isMobile] - ); - const hasWorkingDirectorySearch = debouncedWorkingDirSearchQuery.length > 0; + ) + const hasWorkingDirectorySearch = debouncedWorkingDirSearchQuery.length > 0 const workingDirSearchError = directorySuggestionsQuery.error instanceof Error ? directorySuggestionsQuery.error.message - : null; + : null const workingDirSuggestionPaths = useMemo( () => buildWorkingDirectorySuggestions({ @@ -807,46 +751,40 @@ function DraftAgentScreenContent({ hasWorkingDirectorySearch, workingDirSearchQuery, ] - ); + ) const workingDirComboOptions = useMemo( () => workingDirSuggestionPaths.map((path) => ({ id: path, label: shortenPath(path), - kind: "directory" as const, + kind: 'directory' as const, })), [workingDirSuggestionPaths] - ); + ) const workingDirEmptyText = useMemo(() => { if (hasWorkingDirectorySearch) { if (workingDirSearchError) { - return "Failed to search directories on this host."; + return 'Failed to search directories on this host.' } - return "No directories match your search."; + return 'No directories match your search.' } return agentWorkingDirSuggestions.length > 0 - ? "No agent directories match your search." - : "We'll suggest directories from agents on this host once they exist."; - }, [ - agentWorkingDirSuggestions.length, - hasWorkingDirectorySearch, - workingDirSearchError, - ]); - const displayWorkingDir = shortenPath(workingDir); + ? 'No agent directories match your search.' + : "We'll suggest directories from agents on this host once they exist." + }, [agentWorkingDirSuggestions.length, hasWorkingDirectorySearch, workingDirSearchError]) + const displayWorkingDir = shortenPath(workingDir) const worktreeTriggerValue = - worktreeMode === "create" - ? "Create new worktree" - : selectedWorktreeLabel || "Select worktree"; + worktreeMode === 'create' ? 'Create new worktree' : selectedWorktreeLabel || 'Select worktree' const worktreeComboOptions = useMemo( () => [ { - id: "__none__", - label: "None", + id: '__none__', + label: 'None', }, { - id: "__create_new__", - label: "Create new worktree", + id: '__create_new__', + label: 'Create new worktree', }, ...worktreeOptions.map((option) => ({ id: option.path, @@ -855,7 +793,7 @@ function DraftAgentScreenContent({ })), ], [worktreeOptions] - ); + ) const branchComboOptions = useMemo(() => { const options = buildBranchComboOptions({ @@ -864,51 +802,45 @@ function DraftAgentScreenContent({ baseRef: checkout?.isGit ? checkout.baseRef : null, typedBaseBranch: baseBranch, worktreeBranchLabels: worktreeOptions.map((option) => option.label), - }); + }) - const normalizedQuery = normalizeBranchOptionName(branchSearchQuery)?.toLowerCase() ?? ""; + const normalizedQuery = normalizeBranchOptionName(branchSearchQuery)?.toLowerCase() ?? '' if (!normalizedQuery) { - return options; + return options } return options.sort((a, b) => { - const aLower = a.label.toLowerCase(); - const bLower = b.label.toLowerCase(); - const aPrefix = aLower.startsWith(normalizedQuery); - const bPrefix = bLower.startsWith(normalizedQuery); + const aLower = a.label.toLowerCase() + const bLower = b.label.toLowerCase() + const aPrefix = aLower.startsWith(normalizedQuery) + const bPrefix = bLower.startsWith(normalizedQuery) if (aPrefix !== bPrefix) { - return aPrefix ? -1 : 1; + return aPrefix ? -1 : 1 } - return aLower.localeCompare(bLower); - }); - }, [ - baseBranch, - branchSearchQuery, - branchSuggestionsQuery.data, - checkout, - worktreeOptions, - ]); + return aLower.localeCompare(bLower) + }) + }, [baseBranch, branchSearchQuery, branchSuggestionsQuery.data, checkout, worktreeOptions]) const createAgentClient = useSessionStore((state) => - selectedServerId ? state.sessions[selectedServerId]?.client ?? null : null - ); + selectedServerId ? (state.sessions[selectedServerId]?.client ?? null) : null + ) const draftCommandConfig = useMemo(() => { const cwd = ( isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir - ).trim(); + ).trim() if (!cwd) { - return undefined; + return undefined } return { provider: selectedProvider, cwd, - ...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}), + ...(modeOptions.length > 0 && selectedMode !== '' ? { modeId: selectedMode } : {}), ...(selectedModel.trim() ? { model: selectedModel.trim() } : {}), ...(selectedThinkingOptionId.trim() ? { thinkingOptionId: selectedThinkingOptionId.trim() } : {}), - }; + } }, [ isAttachWorktree, modeOptions.length, @@ -918,19 +850,19 @@ function DraftAgentScreenContent({ selectedThinkingOptionId, selectedWorktreePath, workingDir, - ]); + ]) - const promptValue = machine.tag === "draft" ? machine.promptText : ""; - const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : ""; - const isSubmitting = machine.tag === "creating"; + const promptValue = machine.tag === 'draft' ? machine.promptText : '' + const formErrorMessage = machine.tag === 'draft' ? machine.errorMessage : '' + const isSubmitting = machine.tag === 'creating' const optimisticStreamItems = useMemo(() => { - if (machine.tag !== "creating") { - return EMPTY_STREAM_ITEMS; + if (machine.tag !== 'creating') { + return EMPTY_STREAM_ITEMS } return [ { - kind: "user_message", + kind: 'user_message', id: machine.attempt.messageId, text: machine.attempt.text, timestamp: machine.attempt.timestamp, @@ -938,26 +870,27 @@ function DraftAgentScreenContent({ ? { images: machine.attempt.images } : {}), }, - ]; - }, [machine]); + ] + }, [machine]) const draftAgent = useMemo(() => { - if (machine.tag !== "creating") { - return null; + if (machine.tag !== 'creating') { + return null } - const serverId = selectedServerId ?? ""; - const now = machine.attempt.timestamp; - const cwd = (isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() || "."; - const provider = selectedProvider; - const model = selectedModel.trim() || null; - const thinkingOptionId = selectedThinkingOptionId.trim() || null; - const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null; + const serverId = selectedServerId ?? '' + const now = machine.attempt.timestamp + const cwd = + (isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() || '.' + const provider = selectedProvider + const model = selectedModel.trim() || null + const thinkingOptionId = selectedThinkingOptionId.trim() || null + const modeId = modeOptions.length > 0 && selectedMode !== '' ? selectedMode : null return { serverId, id: DRAFT_AGENT_ID, provider, - status: "running", + status: 'running', createdAt: now, updatedAt: now, lastUserMessageAt: now, @@ -973,15 +906,15 @@ function DraftAgentScreenContent({ model, modeId, }, - title: "New agent", + title: 'New agent', cwd, model, thinkingOptionId, labels: {}, - }; + } }, [ machine.tag, - machine.tag === "creating" ? machine.attempt.timestamp : null, + machine.tag === 'creating' ? machine.attempt.timestamp : null, isAttachWorktree, modeOptions.length, selectedMode, @@ -991,123 +924,110 @@ function DraftAgentScreenContent({ selectedServerId, selectedWorktreePath, workingDir, - ]); + ]) useEffect(() => { - setActiveExplorerCheckout(draftExplorerCheckout); - }, [draftExplorerCheckout, setActiveExplorerCheckout]); + setActiveExplorerCheckout(draftExplorerCheckout) + }, [draftExplorerCheckout, setActiveExplorerCheckout]) useEffect(() => { if (!draftExplorerCheckout) { - return; + return } - activateExplorerTabForCheckout(draftExplorerCheckout); - }, [activateExplorerTabForCheckout, draftExplorerCheckout]); + activateExplorerTabForCheckout(draftExplorerCheckout) + }, [activateExplorerTabForCheckout, draftExplorerCheckout]) useEffect(() => { if (canOpenExplorer || !isExplorerOpen) { - return; + return } - closeFileExplorer(); - }, [canOpenExplorer, closeFileExplorer, isExplorerOpen]); + closeFileExplorer() + }, [canOpenExplorer, closeFileExplorer, isExplorerOpen]) useEffect(() => { return () => { - setActiveExplorerCheckout(null); - }; - }, [setActiveExplorerCheckout]); + setActiveExplorerCheckout(null) + } + }, [setActiveExplorerCheckout]) const handleCreateFromInput = useCallback( - async ({ - text, - images, - }: { - text: string; - images?: UserMessageImageAttachment[]; - }) => { + async ({ text, images }: { text: string; images?: UserMessageImageAttachment[] }) => { if (isSubmitting) { - throw new Error("Already loading"); + throw new Error('Already loading') } - dispatch({ type: "DRAFT_SET_ERROR", message: "" }); - const trimmedPath = workingDir.trim(); - const trimmedPrompt = text.trim(); + dispatch({ type: 'DRAFT_SET_ERROR', message: '' }) + const trimmedPath = workingDir.trim() + const trimmedPrompt = text.trim() const resolvedWorkingDir = - isAttachWorktree && selectedWorktreePath - ? selectedWorktreePath - : trimmedPath; + isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : trimmedPath if (!trimmedPath) { - dispatch({ type: "DRAFT_SET_ERROR", message: "Working directory is required" }); - throw new Error("Working directory is required"); + dispatch({ type: 'DRAFT_SET_ERROR', message: 'Working directory is required' }) + throw new Error('Working directory is required') } if (isDirectoryNotExists) { dispatch({ - type: "DRAFT_SET_ERROR", - message: "Working directory does not exist on the selected host", - }); - throw new Error("Working directory does not exist on the selected host"); + type: 'DRAFT_SET_ERROR', + message: 'Working directory does not exist on the selected host', + }) + throw new Error('Working directory does not exist on the selected host') } if (!trimmedPrompt) { - dispatch({ type: "DRAFT_SET_ERROR", message: "Initial prompt is required" }); - throw new Error("Initial prompt is required"); + dispatch({ type: 'DRAFT_SET_ERROR', message: 'Initial prompt is required' }) + throw new Error('Initial prompt is required') } if (!selectedServerId) { - dispatch({ type: "DRAFT_SET_ERROR", message: "No host selected" }); - throw new Error("No host selected"); + dispatch({ type: 'DRAFT_SET_ERROR', message: 'No host selected' }) + throw new Error('No host selected') } if (providerDefinitions.length === 0) { dispatch({ - type: "DRAFT_SET_ERROR", - message: "No available providers on the selected host", - }); - throw new Error("No available providers on the selected host"); + type: 'DRAFT_SET_ERROR', + message: 'No available providers on the selected host', + }) + throw new Error('No available providers on the selected host') } if (gitBlockingError) { - dispatch({ type: "DRAFT_SET_ERROR", message: gitBlockingError }); - throw new Error(gitBlockingError); + dispatch({ type: 'DRAFT_SET_ERROR', message: gitBlockingError }) + throw new Error(gitBlockingError) } if (isAttachWorktree && !selectedWorktreePath) { - const message = "Select a worktree to attach"; - dispatch({ type: "DRAFT_SET_ERROR", message }); - throw new Error(message); + const message = 'Select a worktree to attach' + dispatch({ type: 'DRAFT_SET_ERROR', message }) + throw new Error(message) } if (baseBranchError) { - dispatch({ type: "DRAFT_SET_ERROR", message: baseBranchError }); - throw new Error(baseBranchError); + dispatch({ type: 'DRAFT_SET_ERROR', message: baseBranchError }) + throw new Error(baseBranchError) } if (!createAgentClient) { - dispatch({ type: "DRAFT_SET_ERROR", message: "Host is not connected" }); - throw new Error("Host is not connected"); + dispatch({ type: 'DRAFT_SET_ERROR', message: 'Host is not connected' }) + throw new Error('Host is not connected') } const attempt = { messageId: generateMessageId(), text: trimmedPrompt, timestamp: new Date(), ...(images && images.length > 0 ? { images } : {}), - }; + } setPendingCreateAttempt({ serverId: selectedServerId, agentId: null, messageId: attempt.messageId, text: attempt.text, timestamp: attempt.timestamp.getTime(), - ...(attempt.images && attempt.images.length > 0 - ? { images: attempt.images } - : {}), - }); - const modeId = - modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined; - const trimmedModel = selectedModel.trim(); - const trimmedThinkingOptionId = selectedThinkingOptionId.trim(); + ...(attempt.images && attempt.images.length > 0 ? { images: attempt.images } : {}), + }) + const modeId = modeOptions.length > 0 && selectedMode !== '' ? selectedMode : undefined + const trimmedModel = selectedModel.trim() + const trimmedThinkingOptionId = selectedThinkingOptionId.trim() const config: AgentSessionConfig = { provider: selectedProvider, cwd: resolvedWorkingDir, ...(modeId ? { modeId } : {}), ...(trimmedModel ? { model: trimmedModel } : {}), - ...(trimmedThinkingOptionId - ? { thinkingOptionId: trimmedThinkingOptionId } - : {}), - }; - const effectiveBaseBranch = baseBranch.trim(); + ...(trimmedThinkingOptionId ? { thinkingOptionId: trimmedThinkingOptionId } : {}), + } + const effectiveBaseBranch = baseBranch.trim() const effectiveWorktreeSlug = - isCreateWorktree && !worktreeSlug ? createNameId() : worktreeSlug; + isCreateWorktree && !worktreeSlug ? createNameId() : worktreeSlug if (isCreateWorktree && !worktreeSlug && effectiveWorktreeSlug) { - setWorktreeSlug(effectiveWorktreeSlug); + setWorktreeSlug(effectiveWorktreeSlug) } const gitOptions = isCreateWorktree && !isNonGitDirectory && effectiveWorktreeSlug @@ -1118,48 +1038,46 @@ function DraftAgentScreenContent({ worktreeSlug: effectiveWorktreeSlug, baseBranch: effectiveBaseBranch, } - : undefined; + : undefined - void persistFormPreferences(); - if (Platform.OS === "web") { - (document.activeElement as HTMLElement | null)?.blur?.(); + void persistFormPreferences() + if (Platform.OS === 'web') { + ;(document.activeElement as HTMLElement | null)?.blur?.() } - Keyboard.dismiss(); - dispatch({ type: "SUBMIT", attempt }); - onCreateFlowActiveChange?.(true); + Keyboard.dismiss() + dispatch({ type: 'SUBMIT', attempt }) + onCreateFlowActiveChange?.(true) try { - const imagesData = await encodeImages(images); + const imagesData = await encodeImages(images) const result = await createAgentClient.createAgent({ config, - labels: { ui: "true" }, + labels: { ui: 'true' }, initialPrompt: trimmedPrompt, ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), git: gitOptions, - }); + }) - const agentId = (result as { id?: string })?.id; + const agentId = (result as { id?: string })?.id if (agentId && selectedServerId) { - updatePendingAgentId(agentId); - router.replace( - buildHostAgentDetailRoute(selectedServerId, agentId) as any - ); - return; + updatePendingAgentId(agentId) + router.replace(buildHostAgentDetailRoute(selectedServerId, agentId) as any) + return } dispatch({ - type: "CREATE_FAILED", - message: "Failed to create agent", - }); - onCreateFlowActiveChange?.(false); - clearPendingCreateAttempt(); - throw new Error("Failed to create agent"); + type: 'CREATE_FAILED', + message: 'Failed to create agent', + }) + onCreateFlowActiveChange?.(false) + clearPendingCreateAttempt() + throw new Error('Failed to create agent') } catch (error) { - const message = error instanceof Error ? error.message : "Failed to create agent"; - dispatch({ type: "CREATE_FAILED", message }); - onCreateFlowActiveChange?.(false); - clearPendingCreateAttempt(); - throw error; // Re-throw so AgentInputArea knows it failed + const message = error instanceof Error ? error.message : 'Failed to create agent' + dispatch({ type: 'CREATE_FAILED', message }) + onCreateFlowActiveChange?.(false) + clearPendingCreateAttempt() + throw error // Re-throw so AgentInputArea knows it failed } }, [ @@ -1189,20 +1107,20 @@ function DraftAgentScreenContent({ isSubmitting, onCreateFlowActiveChange, ] - ); + ) if (daemons.length === 0) { return ( { - setSelectedServerIdFromUser(profile.serverId); + setSelectedServerIdFromUser(profile.serverId) }} /> - ); + ) } - const explorerServerId = draftExplorerCheckout?.serverId ?? null; - const explorerIsGit = draftExplorerCheckout?.isGit ?? false; + const explorerServerId = draftExplorerCheckout?.serverId ?? null + const explorerIsGit = draftExplorerCheckout?.isGit ?? false const mainContent = ( @@ -1219,21 +1137,17 @@ function DraftAgentScreenContent({ ) : null} @@ -1241,7 +1155,7 @@ function DraftAgentScreenContent({ - {machine.tag === "creating" && draftAgent && selectedServerId ? ( + {machine.tag === 'creating' && draftAgent && selectedServerId ? ( - {worktreeMode === "create" ? ( + {worktreeMode === 'create' ? ( setIsBranchOpen(true)} - disabled={repoInfoStatus === "loading"} + disabled={repoInfoStatus === 'loading'} icon={} showLabel={false} /> ) : null} ) : null} - {baseBranchError ? {baseBranchError} : null} - {repoInfoError ? {repoInfoError} : null} - {gitBlockingError ? {gitBlockingError} : null} - {attachWorktreeError ? {attachWorktreeError} : null} - {worktreeOptionsError ? {worktreeOptionsError} : null} + {baseBranchError ? ( + {baseBranchError} + ) : null} + {repoInfoError ? ( + {repoInfoError} + ) : null} + {gitBlockingError ? ( + {gitBlockingError} + ) : null} + {attachWorktreeError ? ( + {attachWorktreeError} + ) : null} + {worktreeOptionsError ? ( + {worktreeOptionsError} + ) : null} { - if (id === "__create_new__") { - setWorktreeMode("create"); + if (id === '__create_new__') { + setWorktreeMode('create') if (!worktreeSlug) { - setWorktreeSlug(createNameId()); + setWorktreeSlug(createNameId()) } - setSelectedWorktreePath(""); - return; + setSelectedWorktreePath('') + return } - if (id === "__none__") { - setWorktreeMode("none"); - setSelectedWorktreePath(""); - return; + if (id === '__none__') { + setWorktreeMode('none') + setSelectedWorktreePath('') + return } - handleSelectWorktreePath(id); - setWorktreeMode("attach"); + handleSelectWorktreePath(id) + setWorktreeMode('attach') }} title="Select worktree" searchPlaceholder="Search worktrees..." @@ -1400,9 +1324,9 @@ function DraftAgentScreenContent({ title="Select base branch" open={isBranchOpen} onOpenChange={(nextOpen) => { - setIsBranchOpen(nextOpen); + setIsBranchOpen(nextOpen) if (!nextOpen) { - setBranchSearchQuery(""); + setBranchSearchQuery('') } }} anchorRef={branchAnchorRef} @@ -1419,13 +1343,13 @@ function DraftAgentScreenContent({ dispatch({ type: "DRAFT_SET_PROMPT", text: next })} - autoFocus={machine.tag === "draft"} + onChangeText={(next) => dispatch({ type: 'DRAFT_SET_PROMPT', text: next })} + autoFocus={machine.tag === 'draft'} onAddImages={handleAddImagesCallback} commandDraftConfig={draftCommandConfig} /> @@ -1442,7 +1366,7 @@ function DraftAgentScreenContent({ ) : null} - ); + ) return ( @@ -1465,7 +1389,7 @@ function DraftAgentScreenContent({ ) : null} - ); + ) } const styles = StyleSheet.create((theme) => ({ @@ -1475,7 +1399,7 @@ const styles = StyleSheet.create((theme) => ({ }, outerContainer: { flex: 1, - flexDirection: "row", + flexDirection: 'row', }, agentPanel: { flex: 1, @@ -1485,17 +1409,17 @@ const styles = StyleSheet.create((theme) => ({ paddingTop: theme.spacing[2], }, menuToggleRow: { - width: "100%", - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", + width: '100%', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', }, menuButton: { marginLeft: theme.spacing[2], }, contentContainer: { flex: 1, - overflow: "hidden", + overflow: 'hidden', }, scrollView: { flex: 1, @@ -1508,7 +1432,7 @@ const styles = StyleSheet.create((theme) => ({ }, configScrollContent: { flexGrow: 1, - justifyContent: "flex-end", + justifyContent: 'flex-end', }, configSection: { paddingHorizontal: { @@ -1519,19 +1443,19 @@ const styles = StyleSheet.create((theme) => ({ paddingBottom: theme.spacing[4], gap: theme.spacing[2], maxWidth: MAX_CONTENT_WIDTH, - alignSelf: "center", - width: "100%", + alignSelf: 'center', + width: '100%', }, topSelectorRow: { - flexDirection: "row", - alignItems: "center", + flexDirection: 'row', + alignItems: 'center', gap: theme.spacing[2], }, stackedSelectorGroup: { gap: theme.spacing[2], }, fullSelector: { - width: "100%", + width: '100%', }, formSeparator: { height: theme.borderWidth[1], @@ -1571,7 +1495,7 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.palette.yellow[400], }, warningText: { - color: "#000000", + color: '#000000', fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.semibold, }, @@ -1579,4 +1503,4 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.palette.red[500], fontSize: theme.fontSize.base, }, -})); +})) diff --git a/packages/app/src/utils/file-mention-autocomplete.test.ts b/packages/app/src/utils/file-mention-autocomplete.test.ts new file mode 100644 index 000000000..7db20836a --- /dev/null +++ b/packages/app/src/utils/file-mention-autocomplete.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { applyFileMentionReplacement, findActiveFileMention } from './file-mention-autocomplete' + +describe('findActiveFileMention', () => { + it('detects mentions at the start of input', () => { + const mention = findActiveFileMention({ + text: '@src/components', + cursorIndex: '@src/components'.length, + }) + expect(mention).toEqual({ + start: 0, + end: '@src/components'.length, + query: 'src/components', + }) + }) + + it('detects mentions in the middle of input using cursor position', () => { + const text = 'read "@src/com" before merging' + const cursorIndex = text.indexOf('"') + 9 + const mention = findActiveFileMention({ + text, + cursorIndex, + }) + expect(mention).toEqual({ + start: text.indexOf('@'), + end: cursorIndex, + query: 'src/com', + }) + }) + + it('returns null when cursor is outside the mention token', () => { + const text = 'please review @src/components now' + const mention = findActiveFileMention({ + text, + cursorIndex: text.length, + }) + expect(mention).toBeNull() + }) +}) + +describe('applyFileMentionReplacement', () => { + it('replaces only the active @query segment with a quoted relative path', () => { + const text = 'open @src/com next' + const next = applyFileMentionReplacement({ + text, + mention: { start: 5, end: 13, query: 'src/com' }, + relativePath: 'src/components/chat.tsx', + }) + expect(next).toBe('open "src/components/chat.tsx" next') + }) + + it('escapes double quotes in replacement path', () => { + const text = '@foo' + const next = applyFileMentionReplacement({ + text, + mention: { start: 0, end: 4, query: 'foo' }, + relativePath: 'src/"quoted".ts', + }) + expect(next).toBe('"src/\\"quoted\\".ts"') + }) +}) diff --git a/packages/app/src/utils/file-mention-autocomplete.ts b/packages/app/src/utils/file-mention-autocomplete.ts new file mode 100644 index 000000000..9c1edcc9a --- /dev/null +++ b/packages/app/src/utils/file-mention-autocomplete.ts @@ -0,0 +1,48 @@ +export interface FileMentionRange { + start: number + end: number + query: string +} + +interface FindActiveFileMentionInput { + text: string + cursorIndex: number +} + +interface ApplyFileMentionReplacementInput { + text: string + mention: FileMentionRange + relativePath: string +} + +const INVALID_MENTION_QUERY_CHARS = /[\s\n\r\t"']/ + +export function findActiveFileMention(input: FindActiveFileMentionInput): FileMentionRange | null { + const clampedCursor = Math.max(0, Math.min(input.cursorIndex, input.text.length)) + const beforeCursor = input.text.slice(0, clampedCursor) + + for ( + let atIndex = beforeCursor.lastIndexOf('@'); + atIndex >= 0; + atIndex = beforeCursor.lastIndexOf('@', atIndex - 1) + ) { + const query = beforeCursor.slice(atIndex + 1) + if (INVALID_MENTION_QUERY_CHARS.test(query)) { + continue + } + return { + start: atIndex, + end: clampedCursor, + query, + } + } + + return null +} + +export function applyFileMentionReplacement(input: ApplyFileMentionReplacementInput): string { + const safePath = input.relativePath.replace(/"/g, '\\"') + const before = input.text.slice(0, input.mention.start) + const after = input.text.slice(input.mention.end) + return `${before}"${safePath}"${after}` +} diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index 6c934683d..3f070a17c 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -1,15 +1,17 @@ -import { afterEach, describe, expect, expectTypeOf, test, vi } from "vitest"; -import { DaemonClient, type DaemonTransport } from "./daemon-client"; +import { afterEach, describe, expect, expectTypeOf, test, vi } from 'vitest' +import { DaemonClient, type DaemonTransport } from './daemon-client' import { BinaryMuxChannel, TerminalBinaryMessageType, asUint8Array, decodeBinaryMuxFrame, encodeBinaryMuxFrame, -} from "../shared/binary-mux.js"; +} from '../shared/binary-mux.js' -expectTypeOf<"getGitDiff" extends keyof DaemonClient ? true : false>().toEqualTypeOf(); -expectTypeOf<"getHighlightedDiff" extends keyof DaemonClient ? true : false>().toEqualTypeOf(); +expectTypeOf<'getGitDiff' extends keyof DaemonClient ? true : false>().toEqualTypeOf() +expectTypeOf< + 'getHighlightedDiff' extends keyof DaemonClient ? true : false +>().toEqualTypeOf() function createMockLogger() { return { @@ -17,37 +19,37 @@ function createMockLogger() { info: vi.fn(), warn: vi.fn(), error: vi.fn(), - }; + } } function createMockTransport() { - const sent: Array = []; + const sent: Array = [] - let onMessage: (data: unknown) => void = () => {}; - let onOpen: () => void = () => {}; - let onClose: (_event?: unknown) => void = () => {}; - let onError: (_event?: unknown) => void = () => {}; + let onMessage: (data: unknown) => void = () => {} + let onOpen: () => void = () => {} + let onClose: (_event?: unknown) => void = () => {} + let onError: (_event?: unknown) => void = () => {} const transport: DaemonTransport = { send: (data) => sent.push(data), close: () => {}, onMessage: (handler) => { - onMessage = handler; - return () => {}; + onMessage = handler + return () => {} }, onOpen: (handler) => { - onOpen = handler; - return () => {}; + onOpen = handler + return () => {} }, onClose: (handler) => { - onClose = handler; - return () => {}; + onClose = handler + return () => {} }, onError: (handler) => { - onError = handler; - return () => {}; + onError = handler + return () => {} }, - }; + } return { transport, @@ -56,58 +58,58 @@ function createMockTransport() { triggerClose: (event?: unknown) => onClose(event), triggerError: (event?: unknown) => onError(event), triggerMessage: (data: unknown) => onMessage(data), - }; + } } function wrapSessionMessage(message: unknown): string { return JSON.stringify({ - type: "session", + type: 'session', message, - }); + }) } -describe("DaemonClient", () => { - const clients: DaemonClient[] = []; +describe('DaemonClient', () => { + const clients: DaemonClient[] = [] afterEach(async () => { for (const client of clients) { - await client.close(); + await client.close() } - clients.length = 0; - }); + clients.length = 0 + }) - test("dedupes in-flight checkout status requests per agentId", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); + test('dedupes in-flight checkout status requests per agentId', async () => { + const logger = createMockLogger() + const mock = createMockTransport() const client = new DaemonClient({ - url: "ws://test", + url: 'ws://test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, - }); - clients.push(client); + }) + clients.push(client) - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; + const connectPromise = client.connect() + mock.triggerOpen() + await connectPromise - const p1 = client.getCheckoutStatus("/tmp/project"); - const p2 = client.getCheckoutStatus("/tmp/project"); + const p1 = client.getCheckoutStatus('/tmp/project') + const p2 = client.getCheckoutStatus('/tmp/project') - expect(mock.sent).toHaveLength(1); + expect(mock.sent).toHaveLength(1) const request = JSON.parse(mock.sent[0]) as { - type: "session"; - message: { type: "checkout_status_request"; cwd: string; requestId: string }; - }; + type: 'session' + message: { type: 'checkout_status_request'; cwd: string; requestId: string } + } const response = { - type: "session", + type: 'session', message: { - type: "checkout_status_response", + type: 'checkout_status_response', payload: { - cwd: "/tmp/project", + cwd: '/tmp/project', error: null, requestId: request.message.requestId, isGit: false, @@ -122,21 +124,29 @@ describe("DaemonClient", () => { remoteUrl: null, }, }, - }; + } - mock.triggerMessage(JSON.stringify(response)); - const [r1, r2] = await Promise.all([p1, p2]); - expect(r1).toMatchObject({ cwd: "/tmp/project", requestId: request.message.requestId, isGit: false }); - expect(r2).toMatchObject({ cwd: "/tmp/project", requestId: request.message.requestId, isGit: false }); + mock.triggerMessage(JSON.stringify(response)) + const [r1, r2] = await Promise.all([p1, p2]) + expect(r1).toMatchObject({ + cwd: '/tmp/project', + requestId: request.message.requestId, + isGit: false, + }) + expect(r2).toMatchObject({ + cwd: '/tmp/project', + requestId: request.message.requestId, + isGit: false, + }) // After completion, a new call should issue a new request. - const p3 = client.getCheckoutStatus("/tmp/project"); - expect(mock.sent).toHaveLength(2); + const p3 = client.getCheckoutStatus('/tmp/project') + expect(mock.sent).toHaveLength(2) const request2 = JSON.parse(mock.sent[1]) as { - type: "session"; - message: { type: "checkout_status_request"; cwd: string; requestId: string }; - }; + type: 'session' + message: { type: 'checkout_status_request'; cwd: string; requestId: string } + } mock.triggerMessage( JSON.stringify({ @@ -146,785 +156,795 @@ describe("DaemonClient", () => { payload: { ...response.message.payload, requestId: request2.message.requestId }, }, }) - ); + ) await expect(p3).resolves.toMatchObject({ - cwd: "/tmp/project", + cwd: '/tmp/project', requestId: request2.message.requestId, isGit: false, - }); - }); + }) + }) - test("subscribes to checkout diff updates via RPC handshake", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); + test('subscribes to checkout diff updates via RPC handshake', async () => { + const logger = createMockLogger() + const mock = createMockTransport() const client = new DaemonClient({ - url: "ws://test", + url: 'ws://test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, - }); - clients.push(client); + }) + clients.push(client) - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; + const connectPromise = client.connect() + mock.triggerOpen() + await connectPromise const promise = client.subscribeCheckoutDiff( - "/tmp/project", - { mode: "uncommitted" }, - { subscriptionId: "checkout-sub-1" } - ); + '/tmp/project', + { mode: 'uncommitted' }, + { subscriptionId: 'checkout-sub-1' } + ) - expect(mock.sent).toHaveLength(1); + expect(mock.sent).toHaveLength(1) const request = JSON.parse(mock.sent[0]) as { - type: "session"; + type: 'session' message: { - type: "subscribe_checkout_diff_request"; - subscriptionId: string; - cwd: string; - compare: { mode: "uncommitted" | "base"; baseRef?: string }; - requestId: string; - }; - }; - expect(request.message.type).toBe("subscribe_checkout_diff_request"); - expect(request.message.subscriptionId).toBe("checkout-sub-1"); - expect(request.message.cwd).toBe("/tmp/project"); - expect(request.message.compare).toEqual({ mode: "uncommitted" }); + type: 'subscribe_checkout_diff_request' + subscriptionId: string + cwd: string + compare: { mode: 'uncommitted' | 'base'; baseRef?: string } + requestId: string + } + } + expect(request.message.type).toBe('subscribe_checkout_diff_request') + expect(request.message.subscriptionId).toBe('checkout-sub-1') + expect(request.message.cwd).toBe('/tmp/project') + expect(request.message.compare).toEqual({ mode: 'uncommitted' }) mock.triggerMessage( JSON.stringify({ - type: "session", + type: 'session', message: { - type: "subscribe_checkout_diff_response", + type: 'subscribe_checkout_diff_response', payload: { - subscriptionId: "checkout-sub-1", - cwd: "/tmp/project", + subscriptionId: 'checkout-sub-1', + cwd: '/tmp/project', files: [], error: null, requestId: request.message.requestId, }, }, }) - ); + ) await expect(promise).resolves.toEqual({ - subscriptionId: "checkout-sub-1", - cwd: "/tmp/project", + subscriptionId: 'checkout-sub-1', + cwd: '/tmp/project', files: [], error: null, requestId: request.message.requestId, - }); - }); + }) + }) - test("getCheckoutDiff uses one-shot subscription protocol", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); + test('getCheckoutDiff uses one-shot subscription protocol', async () => { + const logger = createMockLogger() + const mock = createMockTransport() const client = new DaemonClient({ - url: "ws://test", + url: 'ws://test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, - }); - clients.push(client); + }) + clients.push(client) - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; + const connectPromise = client.connect() + mock.triggerOpen() + await connectPromise - const promise = client.getCheckoutDiff("/tmp/project", { mode: "base", baseRef: "main" }); + const promise = client.getCheckoutDiff('/tmp/project', { mode: 'base', baseRef: 'main' }) - expect(mock.sent).toHaveLength(1); + expect(mock.sent).toHaveLength(1) const subscribeRequest = JSON.parse(mock.sent[0]) as { - type: "session"; + type: 'session' message: { - type: "subscribe_checkout_diff_request"; - subscriptionId: string; - cwd: string; - compare: { mode: "uncommitted" | "base"; baseRef?: string }; - requestId: string; - }; - }; - expect(subscribeRequest.message.type).toBe("subscribe_checkout_diff_request"); - expect(subscribeRequest.message.cwd).toBe("/tmp/project"); - expect(subscribeRequest.message.compare).toEqual({ mode: "base", baseRef: "main" }); + type: 'subscribe_checkout_diff_request' + subscriptionId: string + cwd: string + compare: { mode: 'uncommitted' | 'base'; baseRef?: string } + requestId: string + } + } + expect(subscribeRequest.message.type).toBe('subscribe_checkout_diff_request') + expect(subscribeRequest.message.cwd).toBe('/tmp/project') + expect(subscribeRequest.message.compare).toEqual({ mode: 'base', baseRef: 'main' }) mock.triggerMessage( JSON.stringify({ - type: "session", + type: 'session', message: { - type: "subscribe_checkout_diff_response", + type: 'subscribe_checkout_diff_response', payload: { subscriptionId: subscribeRequest.message.subscriptionId, - cwd: "/tmp/project", + cwd: '/tmp/project', files: [], error: null, requestId: subscribeRequest.message.requestId, }, }, }) - ); + ) await expect(promise).resolves.toEqual({ - cwd: "/tmp/project", + cwd: '/tmp/project', files: [], error: null, requestId: subscribeRequest.message.requestId, - }); + }) - expect(mock.sent).toHaveLength(2); + expect(mock.sent).toHaveLength(2) const unsubscribeRequest = JSON.parse(mock.sent[1]) as { - type: "session"; + type: 'session' message: { - type: "unsubscribe_checkout_diff_request"; - subscriptionId: string; - }; - }; - expect(unsubscribeRequest.message.type).toBe("unsubscribe_checkout_diff_request"); - expect(unsubscribeRequest.message.subscriptionId).toBe( - subscribeRequest.message.subscriptionId - ); - }); + type: 'unsubscribe_checkout_diff_request' + subscriptionId: string + } + } + expect(unsubscribeRequest.message.type).toBe('unsubscribe_checkout_diff_request') + expect(unsubscribeRequest.message.subscriptionId).toBe(subscribeRequest.message.subscriptionId) + }) - test("requests branch suggestions via RPC", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); + test('requests branch suggestions via RPC', async () => { + const logger = createMockLogger() + const mock = createMockTransport() const client = new DaemonClient({ - url: "ws://test", + url: 'ws://test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, - }); - clients.push(client); + }) + clients.push(client) - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; + const connectPromise = client.connect() + mock.triggerOpen() + await connectPromise const promise = client.getBranchSuggestions( - { cwd: "/tmp/project", query: "mai", limit: 5 }, - "req-branches" - ); + { cwd: '/tmp/project', query: 'mai', limit: 5 }, + 'req-branches' + ) - expect(mock.sent).toHaveLength(1); + expect(mock.sent).toHaveLength(1) const request = JSON.parse(mock.sent[0]) as { - type: "session"; + type: 'session' message: { - type: "branch_suggestions_request"; - cwd: string; - query?: string; - limit?: number; - requestId: string; - }; - }; - expect(request.message.type).toBe("branch_suggestions_request"); - expect(request.message.cwd).toBe("/tmp/project"); - expect(request.message.query).toBe("mai"); - expect(request.message.limit).toBe(5); - expect(request.message.requestId).toBe("req-branches"); + type: 'branch_suggestions_request' + cwd: string + query?: string + limit?: number + requestId: string + } + } + expect(request.message.type).toBe('branch_suggestions_request') + expect(request.message.cwd).toBe('/tmp/project') + expect(request.message.query).toBe('mai') + expect(request.message.limit).toBe(5) + expect(request.message.requestId).toBe('req-branches') mock.triggerMessage( JSON.stringify({ - type: "session", + type: 'session', message: { - type: "branch_suggestions_response", + type: 'branch_suggestions_response', payload: { - branches: ["main"], + branches: ['main'], error: null, - requestId: "req-branches", + requestId: 'req-branches', }, }, }) - ); + ) await expect(promise).resolves.toEqual({ - branches: ["main"], + branches: ['main'], error: null, - requestId: "req-branches", - }); - }); + requestId: 'req-branches', + }) + }) - test("requests directory suggestions via RPC", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); + test('requests directory suggestions via RPC', async () => { + const logger = createMockLogger() + const mock = createMockTransport() const client = new DaemonClient({ - url: "ws://test", + url: 'ws://test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, - }); - clients.push(client); + }) + clients.push(client) - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; + const connectPromise = client.connect() + mock.triggerOpen() + await connectPromise const promise = client.getDirectorySuggestions( - { query: "proj", limit: 10 }, - "req-directories" - ); + { + query: 'proj', + limit: 10, + cwd: '/tmp/project', + includeFiles: true, + includeDirectories: true, + }, + 'req-directories' + ) - expect(mock.sent).toHaveLength(1); + expect(mock.sent).toHaveLength(1) const request = JSON.parse(mock.sent[0]) as { - type: "session"; + type: 'session' message: { - type: "directory_suggestions_request"; - query: string; - limit?: number; - requestId: string; - }; - }; - expect(request.message.type).toBe("directory_suggestions_request"); - expect(request.message.query).toBe("proj"); - expect(request.message.limit).toBe(10); - expect(request.message.requestId).toBe("req-directories"); + type: 'directory_suggestions_request' + query: string + cwd?: string + includeFiles?: boolean + includeDirectories?: boolean + limit?: number + requestId: string + } + } + expect(request.message.type).toBe('directory_suggestions_request') + expect(request.message.query).toBe('proj') + expect(request.message.cwd).toBe('/tmp/project') + expect(request.message.includeFiles).toBe(true) + expect(request.message.includeDirectories).toBe(true) + expect(request.message.limit).toBe(10) + expect(request.message.requestId).toBe('req-directories') mock.triggerMessage( JSON.stringify({ - type: "session", + type: 'session', message: { - type: "directory_suggestions_response", + type: 'directory_suggestions_response', payload: { - directories: ["/Users/test/projects/paseo"], + directories: ['/Users/test/projects/paseo'], + entries: [{ path: 'README.md', kind: 'file' }], error: null, - requestId: "req-directories", + requestId: 'req-directories', }, }, }) - ); + ) await expect(promise).resolves.toEqual({ - directories: ["/Users/test/projects/paseo"], + directories: ['/Users/test/projects/paseo'], + entries: [{ path: 'README.md', kind: 'file' }], error: null, - requestId: "req-directories", - }); - }); + requestId: 'req-directories', + }) + }) - test("requests checkout merge from base via RPC", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); + test('requests checkout merge from base via RPC', async () => { + const logger = createMockLogger() + const mock = createMockTransport() const client = new DaemonClient({ - url: "ws://test", + url: 'ws://test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, - }); - clients.push(client); + }) + clients.push(client) - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; + const connectPromise = client.connect() + mock.triggerOpen() + await connectPromise const promise = client.checkoutMergeFromBase( - "/tmp/project", - { baseRef: "main", requireCleanTarget: true }, - "req-merge-from-base" - ); + '/tmp/project', + { baseRef: 'main', requireCleanTarget: true }, + 'req-merge-from-base' + ) - expect(mock.sent).toHaveLength(1); + expect(mock.sent).toHaveLength(1) const request = JSON.parse(mock.sent[0]) as { - type: "session"; + type: 'session' message: { - type: "checkout_merge_from_base_request"; - cwd: string; - baseRef?: string; - requireCleanTarget?: boolean; - requestId: string; - }; - }; - expect(request.message.type).toBe("checkout_merge_from_base_request"); - expect(request.message.cwd).toBe("/tmp/project"); - expect(request.message.baseRef).toBe("main"); - expect(request.message.requireCleanTarget).toBe(true); - expect(request.message.requestId).toBe("req-merge-from-base"); + type: 'checkout_merge_from_base_request' + cwd: string + baseRef?: string + requireCleanTarget?: boolean + requestId: string + } + } + expect(request.message.type).toBe('checkout_merge_from_base_request') + expect(request.message.cwd).toBe('/tmp/project') + expect(request.message.baseRef).toBe('main') + expect(request.message.requireCleanTarget).toBe(true) + expect(request.message.requestId).toBe('req-merge-from-base') mock.triggerMessage( JSON.stringify({ - type: "session", + type: 'session', message: { - type: "checkout_merge_from_base_response", + type: 'checkout_merge_from_base_response', payload: { - cwd: "/tmp/project", - requestId: "req-merge-from-base", + cwd: '/tmp/project', + requestId: 'req-merge-from-base', success: true, error: null, }, }, }) - ); + ) await expect(promise).resolves.toEqual({ - cwd: "/tmp/project", - requestId: "req-merge-from-base", + cwd: '/tmp/project', + requestId: 'req-merge-from-base', success: true, error: null, - }); - }); + }) + }) - test("resubscribes checkout diff streams after reconnect", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); + test('resubscribes checkout diff streams after reconnect', async () => { + const logger = createMockLogger() + const mock = createMockTransport() const client = new DaemonClient({ - url: "ws://test", + url: 'ws://test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, - }); - clients.push(client); + }) + clients.push(client) const internal = client as unknown as { checkoutDiffSubscriptions: Map< string, - { cwd: string; compare: { mode: "uncommitted" | "base"; baseRef?: string } } - >; - }; - internal.checkoutDiffSubscriptions.set("checkout-sub-1", { - cwd: "/tmp/project", - compare: { mode: "base", baseRef: "main" }, - }); + { cwd: string; compare: { mode: 'uncommitted' | 'base'; baseRef?: string } } + > + } + internal.checkoutDiffSubscriptions.set('checkout-sub-1', { + cwd: '/tmp/project', + compare: { mode: 'base', baseRef: 'main' }, + }) - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; + const connectPromise = client.connect() + mock.triggerOpen() + await connectPromise - expect(mock.sent).toHaveLength(1); + expect(mock.sent).toHaveLength(1) const request = JSON.parse(mock.sent[0]) as { - type: "session"; + type: 'session' message: { - type: "subscribe_checkout_diff_request"; - subscriptionId: string; - cwd: string; - compare: { mode: "uncommitted" | "base"; baseRef?: string }; - requestId: string; - }; - }; - expect(request.message.type).toBe("subscribe_checkout_diff_request"); - expect(request.message.subscriptionId).toBe("checkout-sub-1"); - expect(request.message.cwd).toBe("/tmp/project"); - expect(request.message.compare).toEqual({ mode: "base", baseRef: "main" }); - expect(typeof request.message.requestId).toBe("string"); - expect(request.message.requestId.length).toBeGreaterThan(0); - }); + type: 'subscribe_checkout_diff_request' + subscriptionId: string + cwd: string + compare: { mode: 'uncommitted' | 'base'; baseRef?: string } + requestId: string + } + } + expect(request.message.type).toBe('subscribe_checkout_diff_request') + expect(request.message.subscriptionId).toBe('checkout-sub-1') + expect(request.message.cwd).toBe('/tmp/project') + expect(request.message.compare).toEqual({ mode: 'base', baseRef: 'main' }) + expect(typeof request.message.requestId).toBe('string') + expect(request.message.requestId.length).toBeGreaterThan(0) + }) - test("fetches agents via RPC with filters, sort, and pagination", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); + test('fetches agents via RPC with filters, sort, and pagination', async () => { + const logger = createMockLogger() + const mock = createMockTransport() const client = new DaemonClient({ - url: "ws://test", + url: 'ws://test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, - }); - clients.push(client); + }) + clients.push(client) - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; + const connectPromise = client.connect() + mock.triggerOpen() + await connectPromise const promise = client.fetchAgents({ - filter: { labels: { ui: "true" } }, + filter: { labels: { ui: 'true' } }, sort: [ - { key: "status_priority", direction: "asc" }, - { key: "created_at", direction: "desc" }, + { key: 'status_priority', direction: 'asc' }, + { key: 'created_at', direction: 'desc' }, ], - page: { limit: 25, cursor: "cursor-1" }, - subscribe: { subscriptionId: "sub-1" }, - }); + page: { limit: 25, cursor: 'cursor-1' }, + subscribe: { subscriptionId: 'sub-1' }, + }) - expect(mock.sent).toHaveLength(1); + expect(mock.sent).toHaveLength(1) const request = JSON.parse(mock.sent[0]) as { - type: "session"; + type: 'session' message: { - type: "fetch_agents_request"; - requestId: string; - filter?: { labels?: Record }; + type: 'fetch_agents_request' + requestId: string + filter?: { labels?: Record } sort?: Array<{ - key: "status_priority" | "created_at" | "updated_at" | "title"; - direction: "asc" | "desc"; - }>; - page?: { limit: number; cursor?: string }; - subscribe?: { subscriptionId?: string }; - }; - }; - expect(request.message.type).toBe("fetch_agents_request"); + key: 'status_priority' | 'created_at' | 'updated_at' | 'title' + direction: 'asc' | 'desc' + }> + page?: { limit: number; cursor?: string } + subscribe?: { subscriptionId?: string } + } + } + expect(request.message.type).toBe('fetch_agents_request') expect(request.message.sort).toEqual([ - { key: "status_priority", direction: "asc" }, - { key: "created_at", direction: "desc" }, - ]); - expect(request.message.page).toEqual({ limit: 25, cursor: "cursor-1" }); - expect(request.message.subscribe).toEqual({ subscriptionId: "sub-1" }); + { key: 'status_priority', direction: 'asc' }, + { key: 'created_at', direction: 'desc' }, + ]) + expect(request.message.page).toEqual({ limit: 25, cursor: 'cursor-1' }) + expect(request.message.subscribe).toEqual({ subscriptionId: 'sub-1' }) mock.triggerMessage( JSON.stringify({ - type: "session", + type: 'session', message: { - type: "fetch_agents_response", + type: 'fetch_agents_response', payload: { requestId: request.message.requestId, - subscriptionId: "sub-1", + subscriptionId: 'sub-1', entries: [], pageInfo: { nextCursor: null, - prevCursor: "cursor-1", + prevCursor: 'cursor-1', hasMore: false, }, }, }, }) - ); + ) await expect(promise).resolves.toEqual({ requestId: request.message.requestId, - subscriptionId: "sub-1", + subscriptionId: 'sub-1', entries: [], pageInfo: { nextCursor: null, - prevCursor: "cursor-1", + prevCursor: 'cursor-1', hasMore: false, }, - }); - }); + }) + }) - test("uses server-provided dictation finish timeout budget", async () => { - vi.useFakeTimers(); - const logger = createMockLogger(); - const mock = createMockTransport(); + test('uses server-provided dictation finish timeout budget', async () => { + vi.useFakeTimers() + const logger = createMockLogger() + const mock = createMockTransport() const client = new DaemonClient({ - url: "ws://test", + url: 'ws://test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, - }); - clients.push(client); + }) + clients.push(client) - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; + const connectPromise = client.connect() + mock.triggerOpen() + await connectPromise - const finishPromise = client.finishDictationStream("dict-1", 0); + const finishPromise = client.finishDictationStream('dict-1', 0) const finishError = finishPromise.then( () => null, (error) => error - ); + ) - expect(mock.sent).toHaveLength(1); + expect(mock.sent).toHaveLength(1) mock.triggerMessage( wrapSessionMessage({ - type: "dictation_stream_finish_accepted", + type: 'dictation_stream_finish_accepted', payload: { - dictationId: "dict-1", + dictationId: 'dict-1', timeoutMs: 100, }, }) - ); + ) - await vi.advanceTimersByTimeAsync(5_101); - const error = await finishError; - expect(error).toBeInstanceOf(Error); + await vi.advanceTimersByTimeAsync(5_101) + const error = await finishError + expect(error).toBeInstanceOf(Error) expect((error as Error).message).toContain( - "Timeout waiting for dictation finalization (5100ms)" - ); + 'Timeout waiting for dictation finalization (5100ms)' + ) - vi.useRealTimers(); - }); + vi.useRealTimers() + }) - test("resolves dictation finish when final arrives after finish accepted", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); + test('resolves dictation finish when final arrives after finish accepted', async () => { + const logger = createMockLogger() + const mock = createMockTransport() const client = new DaemonClient({ - url: "ws://test", + url: 'ws://test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, - }); - clients.push(client); + }) + clients.push(client) - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; + const connectPromise = client.connect() + mock.triggerOpen() + await connectPromise - const finishPromise = client.finishDictationStream("dict-2", 1); - expect(mock.sent).toHaveLength(1); + const finishPromise = client.finishDictationStream('dict-2', 1) + expect(mock.sent).toHaveLength(1) mock.triggerMessage( wrapSessionMessage({ - type: "dictation_stream_finish_accepted", + type: 'dictation_stream_finish_accepted', payload: { - dictationId: "dict-2", + dictationId: 'dict-2', timeoutMs: 1000, }, }) - ); + ) mock.triggerMessage( wrapSessionMessage({ - type: "dictation_stream_final", + type: 'dictation_stream_final', payload: { - dictationId: "dict-2", - text: "hello", + dictationId: 'dict-2', + text: 'hello', }, }) - ); + ) await expect(finishPromise).resolves.toEqual({ - dictationId: "dict-2", - text: "hello", - }); - }); + dictationId: 'dict-2', + text: 'hello', + }) + }) - test("cancels waiters when send fails (no leaked timeouts)", async () => { - vi.useFakeTimers(); - const logger = createMockLogger(); - const mock = createMockTransport(); + test('cancels waiters when send fails (no leaked timeouts)', async () => { + vi.useFakeTimers() + const logger = createMockLogger() + const mock = createMockTransport() const transportFactory = () => ({ ...mock.transport, send: () => { - throw new Error("boom"); + throw new Error('boom') }, - }); + }) const client = new DaemonClient({ - url: "ws://test", + url: 'ws://test', logger, reconnect: { enabled: false }, transportFactory, - }); - clients.push(client); + }) + clients.push(client) - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; + const connectPromise = client.connect() + mock.triggerOpen() + await connectPromise - const promise = client.getCheckoutStatus("/tmp/project"); - await expect(promise).rejects.toThrow("boom"); + const promise = client.getCheckoutStatus('/tmp/project') + await expect(promise).rejects.toThrow('boom') // Ensure we didn't leave a waiter behind that will reject later. - const internal = client as unknown as { waiters: Set }; - expect(internal.waiters.size).toBe(0); + const internal = client as unknown as { waiters: Set } + expect(internal.waiters.size).toBe(0) - vi.runOnlyPendingTimers(); - vi.useRealTimers(); - }); + vi.runOnlyPendingTimers() + vi.useRealTimers() + }) - test("lists available providers via RPC", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); + test('lists available providers via RPC', async () => { + const logger = createMockLogger() + const mock = createMockTransport() const client = new DaemonClient({ - url: "ws://test", + url: 'ws://test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, - }); - clients.push(client); + }) + clients.push(client) - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; + const connectPromise = client.connect() + mock.triggerOpen() + await connectPromise - const promise = client.listAvailableProviders(); - expect(mock.sent).toHaveLength(1); + const promise = client.listAvailableProviders() + expect(mock.sent).toHaveLength(1) const request = JSON.parse(mock.sent[0]) as { - type: "session"; - message: { type: "list_available_providers_request"; requestId: string }; - }; - expect(request.message.type).toBe("list_available_providers_request"); + type: 'session' + message: { type: 'list_available_providers_request'; requestId: string } + } + expect(request.message.type).toBe('list_available_providers_request') mock.triggerMessage( JSON.stringify({ - type: "session", + type: 'session', message: { - type: "list_available_providers_response", + type: 'list_available_providers_response', payload: { providers: [ - { provider: "claude", available: true, error: null }, - { provider: "codex", available: false, error: "Missing binary" }, + { provider: 'claude', available: true, error: null }, + { provider: 'codex', available: false, error: 'Missing binary' }, ], error: null, - fetchedAt: "2026-02-12T00:00:00.000Z", + fetchedAt: '2026-02-12T00:00:00.000Z', requestId: request.message.requestId, }, }, }) - ); + ) await expect(promise).resolves.toEqual({ providers: [ - { provider: "claude", available: true, error: null }, - { provider: "codex", available: false, error: "Missing binary" }, + { provider: 'claude', available: true, error: null }, + { provider: 'codex', available: false, error: 'Missing binary' }, ], error: null, - fetchedAt: "2026-02-12T00:00:00.000Z", + fetchedAt: '2026-02-12T00:00:00.000Z', requestId: request.message.requestId, - }); - }); + }) + }) - test("lists commands with draft config via RPC", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); + test('lists commands with draft config via RPC', async () => { + const logger = createMockLogger() + const mock = createMockTransport() const client = new DaemonClient({ - url: "ws://test", + url: 'ws://test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, - }); - clients.push(client); + }) + clients.push(client) - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; + const connectPromise = client.connect() + mock.triggerOpen() + await connectPromise - const promise = client.listCommands("__new_agent__", { + const promise = client.listCommands('__new_agent__', { draftConfig: { - provider: "codex", - cwd: "/tmp/project", - modeId: "bypassPermissions", - model: "gpt-5", - thinkingOptionId: "off", + provider: 'codex', + cwd: '/tmp/project', + modeId: 'bypassPermissions', + model: 'gpt-5', + thinkingOptionId: 'off', }, - }); - expect(mock.sent).toHaveLength(1); + }) + expect(mock.sent).toHaveLength(1) const request = JSON.parse(mock.sent[0]) as { - type: "session"; + type: 'session' message: { - type: "list_commands_request"; - agentId: string; + type: 'list_commands_request' + agentId: string draftConfig?: { - provider: string; - cwd: string; - modeId?: string; - model?: string; - thinkingOptionId?: string; - }; - requestId: string; - }; - }; - expect(request.message.type).toBe("list_commands_request"); - expect(request.message.agentId).toBe("__new_agent__"); + provider: string + cwd: string + modeId?: string + model?: string + thinkingOptionId?: string + } + requestId: string + } + } + expect(request.message.type).toBe('list_commands_request') + expect(request.message.agentId).toBe('__new_agent__') expect(request.message.draftConfig).toEqual({ - provider: "codex", - cwd: "/tmp/project", - modeId: "bypassPermissions", - model: "gpt-5", - thinkingOptionId: "off", - }); + provider: 'codex', + cwd: '/tmp/project', + modeId: 'bypassPermissions', + model: 'gpt-5', + thinkingOptionId: 'off', + }) mock.triggerMessage( JSON.stringify({ - type: "session", + type: 'session', message: { - type: "list_commands_response", + type: 'list_commands_response', payload: { - agentId: "__new_agent__", - commands: [ - { name: "help", description: "Show help", argumentHint: "" }, - ], + agentId: '__new_agent__', + commands: [{ name: 'help', description: 'Show help', argumentHint: '' }], error: null, requestId: request.message.requestId, }, }, }) - ); + ) await expect(promise).resolves.toEqual({ - agentId: "__new_agent__", - commands: [{ name: "help", description: "Show help", argumentHint: "" }], + agentId: '__new_agent__', + commands: [{ name: 'help', description: 'Show help', argumentHint: '' }], error: null, requestId: request.message.requestId, - }); - }); + }) + }) - test("lists commands with legacy requestId signature via RPC", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); + test('lists commands with legacy requestId signature via RPC', async () => { + const logger = createMockLogger() + const mock = createMockTransport() const client = new DaemonClient({ - url: "ws://test", + url: 'ws://test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, - }); - clients.push(client); + }) + clients.push(client) - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; + const connectPromise = client.connect() + mock.triggerOpen() + await connectPromise - const promise = client.listCommands("agent-1", "req-legacy"); - expect(mock.sent).toHaveLength(1); + const promise = client.listCommands('agent-1', 'req-legacy') + expect(mock.sent).toHaveLength(1) const request = JSON.parse(mock.sent[0]) as { - type: "session"; + type: 'session' message: { - type: "list_commands_request"; - agentId: string; - draftConfig?: unknown; - requestId: string; - }; - }; - expect(request.message.type).toBe("list_commands_request"); - expect(request.message.agentId).toBe("agent-1"); - expect(request.message.requestId).toBe("req-legacy"); - expect(request.message.draftConfig).toBeUndefined(); + type: 'list_commands_request' + agentId: string + draftConfig?: unknown + requestId: string + } + } + expect(request.message.type).toBe('list_commands_request') + expect(request.message.agentId).toBe('agent-1') + expect(request.message.requestId).toBe('req-legacy') + expect(request.message.draftConfig).toBeUndefined() mock.triggerMessage( JSON.stringify({ - type: "session", + type: 'session', message: { - type: "list_commands_response", + type: 'list_commands_response', payload: { - agentId: "agent-1", + agentId: 'agent-1', commands: [], error: null, - requestId: "req-legacy", + requestId: 'req-legacy', }, }, }) - ); + ) await expect(promise).resolves.toEqual({ - agentId: "agent-1", + agentId: 'agent-1', commands: [], error: null, - requestId: "req-legacy", - }); - }); + requestId: 'req-legacy', + }) + }) - test("auto-acks terminal stream chunks after delivery", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); + test('auto-acks terminal stream chunks after delivery', async () => { + const logger = createMockLogger() + const mock = createMockTransport() const client = new DaemonClient({ - url: "ws://test", + url: 'ws://test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, - }); - clients.push(client); + }) + clients.push(client) - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; + const connectPromise = client.connect() + mock.triggerOpen() + await connectPromise - const seen: string[] = []; - const decoder = new TextDecoder(); + const seen: string[] = [] + const decoder = new TextDecoder() const unsubscribe = client.onTerminalStreamData(7, (chunk) => { - seen.push(decoder.decode(chunk.data)); - }); + seen.push(decoder.decode(chunk.data)) + }) - const payload = new TextEncoder().encode("hello"); + const payload = new TextEncoder().encode('hello') mock.triggerMessage( encodeBinaryMuxFrame({ channel: BinaryMuxChannel.Terminal, @@ -933,78 +953,78 @@ describe("DaemonClient", () => { offset: 10, payload, }) - ); + ) - expect(seen).toEqual(["hello"]); - expect(mock.sent).toHaveLength(1); - const ackBytes = asUint8Array(mock.sent[0]); - expect(ackBytes).not.toBeNull(); - const ackFrame = decodeBinaryMuxFrame(ackBytes!); - expect(ackFrame).not.toBeNull(); - expect(ackFrame!.channel).toBe(BinaryMuxChannel.Terminal); - expect(ackFrame!.messageType).toBe(TerminalBinaryMessageType.Ack); - expect(ackFrame!.streamId).toBe(7); - expect(ackFrame!.offset).toBe(15); - unsubscribe(); - }); + expect(seen).toEqual(['hello']) + expect(mock.sent).toHaveLength(1) + const ackBytes = asUint8Array(mock.sent[0]) + expect(ackBytes).not.toBeNull() + const ackFrame = decodeBinaryMuxFrame(ackBytes!) + expect(ackFrame).not.toBeNull() + expect(ackFrame!.channel).toBe(BinaryMuxChannel.Terminal) + expect(ackFrame!.messageType).toBe(TerminalBinaryMessageType.Ack) + expect(ackFrame!.streamId).toBe(7) + expect(ackFrame!.offset).toBe(15) + unsubscribe() + }) - test("handles terminal binary frames delivered as UTF-8 strings", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); + test('handles terminal binary frames delivered as UTF-8 strings', async () => { + const logger = createMockLogger() + const mock = createMockTransport() const client = new DaemonClient({ - url: "ws://test", + url: 'ws://test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, - }); - clients.push(client); + }) + clients.push(client) - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; + const connectPromise = client.connect() + mock.triggerOpen() + await connectPromise - const seen: string[] = []; + const seen: string[] = [] client.onTerminalStreamData(11, (chunk) => { - seen.push(new TextDecoder().decode(chunk.data)); - }); + seen.push(new TextDecoder().decode(chunk.data)) + }) - const payload = new TextEncoder().encode("ls\r\n"); + const payload = new TextEncoder().encode('ls\r\n') const frame = encodeBinaryMuxFrame({ channel: BinaryMuxChannel.Terminal, messageType: TerminalBinaryMessageType.OutputUtf8, streamId: 11, offset: 0, payload, - }); - const frameAsString = new TextDecoder("utf-8", { fatal: true }).decode(frame); + }) + const frameAsString = new TextDecoder('utf-8', { fatal: true }).decode(frame) - mock.triggerMessage(frameAsString); + mock.triggerMessage(frameAsString) - expect(seen).toEqual(["ls\r\n"]); - expect(mock.sent).toHaveLength(1); - const ackFrame = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!); - expect(ackFrame?.channel).toBe(BinaryMuxChannel.Terminal); - expect(ackFrame?.messageType).toBe(TerminalBinaryMessageType.Ack); - expect(ackFrame?.streamId).toBe(11); - expect(ackFrame?.offset).toBe(4); - }); + expect(seen).toEqual(['ls\r\n']) + expect(mock.sent).toHaveLength(1) + const ackFrame = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!) + expect(ackFrame?.channel).toBe(BinaryMuxChannel.Terminal) + expect(ackFrame?.messageType).toBe(TerminalBinaryMessageType.Ack) + expect(ackFrame?.streamId).toBe(11) + expect(ackFrame?.offset).toBe(4) + }) - test("acks buffered terminal chunks when handler is attached", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); + test('acks buffered terminal chunks when handler is attached', async () => { + const logger = createMockLogger() + const mock = createMockTransport() const client = new DaemonClient({ - url: "ws://test", + url: 'ws://test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, - }); - clients.push(client); + }) + clients.push(client) - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; + const connectPromise = client.connect() + mock.triggerOpen() + await connectPromise mock.triggerMessage( encodeBinaryMuxFrame({ @@ -1012,67 +1032,67 @@ describe("DaemonClient", () => { messageType: TerminalBinaryMessageType.OutputUtf8, streamId: 19, offset: 0, - payload: new TextEncoder().encode("buffered"), + payload: new TextEncoder().encode('buffered'), }) - ); + ) - expect(mock.sent).toHaveLength(0); + expect(mock.sent).toHaveLength(0) - const seen: string[] = []; + const seen: string[] = [] client.onTerminalStreamData(19, (chunk) => { - seen.push(new TextDecoder().decode(chunk.data)); - }); + seen.push(new TextDecoder().decode(chunk.data)) + }) - expect(seen.join("")).toContain("buffered"); - expect(mock.sent).toHaveLength(1); - const ackFrame = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!); - expect(ackFrame?.messageType).toBe(TerminalBinaryMessageType.Ack); - expect(ackFrame?.streamId).toBe(19); - expect(ackFrame?.offset).toBe(8); - }); + expect(seen.join('')).toContain('buffered') + expect(mock.sent).toHaveLength(1) + const ackFrame = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!) + expect(ackFrame?.messageType).toBe(TerminalBinaryMessageType.Ack) + expect(ackFrame?.streamId).toBe(19) + expect(ackFrame?.offset).toBe(8) + }) - test("stops delivering and acking after terminal_stream_exit", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); + test('stops delivering and acking after terminal_stream_exit', async () => { + const logger = createMockLogger() + const mock = createMockTransport() const client = new DaemonClient({ - url: "ws://test", + url: 'ws://test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, - }); - clients.push(client); + }) + clients.push(client) - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; + const connectPromise = client.connect() + mock.triggerOpen() + await connectPromise - const seen: string[] = []; + const seen: string[] = [] const unsubscribe = client.onTerminalStreamData(23, (chunk) => { - seen.push(new TextDecoder().decode(chunk.data)); - }); + seen.push(new TextDecoder().decode(chunk.data)) + }) mock.triggerMessage( encodeBinaryMuxFrame({ channel: BinaryMuxChannel.Terminal, messageType: TerminalBinaryMessageType.OutputUtf8, streamId: 23, offset: 0, - payload: new TextEncoder().encode("before-exit"), + payload: new TextEncoder().encode('before-exit'), }) - ); - expect(seen).toEqual(["before-exit"]); - expect(mock.sent).toHaveLength(1); - mock.sent.length = 0; + ) + expect(seen).toEqual(['before-exit']) + expect(mock.sent).toHaveLength(1) + mock.sent.length = 0 mock.triggerMessage( wrapSessionMessage({ - type: "terminal_stream_exit", + type: 'terminal_stream_exit', payload: { streamId: 23, - terminalId: "term-1", + terminalId: 'term-1', }, }) - ); + ) mock.triggerMessage( encodeBinaryMuxFrame({ @@ -1080,250 +1100,250 @@ describe("DaemonClient", () => { messageType: TerminalBinaryMessageType.OutputUtf8, streamId: 23, offset: 12, - payload: new TextEncoder().encode("after-exit"), + payload: new TextEncoder().encode('after-exit'), }) - ); + ) - expect(seen).toEqual(["before-exit"]); - expect(mock.sent).toHaveLength(0); - unsubscribe(); - }); + expect(seen).toEqual(['before-exit']) + expect(mock.sent).toHaveLength(0) + unsubscribe() + }) - test("cleans local stream state even when detach reports success=false", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); + test('cleans local stream state even when detach reports success=false', async () => { + const logger = createMockLogger() + const mock = createMockTransport() const client = new DaemonClient({ - url: "ws://test", + url: 'ws://test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, - }); - clients.push(client); + }) + clients.push(client) - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; + const connectPromise = client.connect() + mock.triggerOpen() + await connectPromise - const seen: string[] = []; + const seen: string[] = [] client.onTerminalStreamData(31, (chunk) => { - seen.push(new TextDecoder().decode(chunk.data)); - }); + seen.push(new TextDecoder().decode(chunk.data)) + }) - const detachPromise = client.detachTerminalStream(31, "detach-31"); + const detachPromise = client.detachTerminalStream(31, 'detach-31') const detachRequest = JSON.parse(String(mock.sent[0])) as { - type: "session"; - message: { type: string; streamId: number; requestId: string }; - }; - expect(detachRequest.message.type).toBe("detach_terminal_stream_request"); - expect(detachRequest.message.streamId).toBe(31); + type: 'session' + message: { type: string; streamId: number; requestId: string } + } + expect(detachRequest.message.type).toBe('detach_terminal_stream_request') + expect(detachRequest.message.streamId).toBe(31) mock.triggerMessage( wrapSessionMessage({ - type: "detach_terminal_stream_response", + type: 'detach_terminal_stream_response', payload: { streamId: 31, success: false, - requestId: "detach-31", + requestId: 'detach-31', }, }) - ); + ) - const payload = await detachPromise; - expect(payload.success).toBe(false); + const payload = await detachPromise + expect(payload.success).toBe(false) - mock.sent.length = 0; + mock.sent.length = 0 mock.triggerMessage( encodeBinaryMuxFrame({ channel: BinaryMuxChannel.Terminal, messageType: TerminalBinaryMessageType.OutputUtf8, streamId: 31, offset: 0, - payload: new TextEncoder().encode("after-detach"), + payload: new TextEncoder().encode('after-detach'), }) - ); + ) - expect(seen).toEqual([]); - expect(mock.sent).toHaveLength(0); - }); + expect(seen).toEqual([]) + expect(mock.sent).toHaveLength(0) + }) - test("parses canonical agent_stream tool_call payloads without crashing", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); + test('parses canonical agent_stream tool_call payloads without crashing', async () => { + const logger = createMockLogger() + const mock = createMockTransport() const client = new DaemonClient({ - url: "ws://test", + url: 'ws://test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, - }); - clients.push(client); + }) + clients.push(client) - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; + const connectPromise = client.connect() + mock.triggerOpen() + await connectPromise - const received: unknown[] = []; - const unsubscribe = client.on("agent_stream", (msg) => { - received.push(msg); - }); + const received: unknown[] = [] + const unsubscribe = client.on('agent_stream', (msg) => { + received.push(msg) + }) mock.triggerMessage( wrapSessionMessage({ - type: "agent_stream", + type: 'agent_stream', payload: { - agentId: "agent_cli", - timestamp: "2026-02-08T20:20:00.000Z", + agentId: 'agent_cli', + timestamp: '2026-02-08T20:20:00.000Z', event: { - type: "timeline", - provider: "codex", + type: 'timeline', + provider: 'codex', item: { - type: "tool_call", - callId: "call_cli_stream", - name: "shell", - status: "running", + type: 'tool_call', + callId: 'call_cli_stream', + name: 'shell', + status: 'running', detail: { - type: "shell", - command: "pwd", + type: 'shell', + command: 'pwd', }, error: null, }, }, }, }) - ); + ) - unsubscribe(); + unsubscribe() - expect(received).toHaveLength(1); + expect(received).toHaveLength(1) const streamMsg = received[0] as { payload: { event: { - type: "timeline"; + type: 'timeline' item: { - type: "tool_call"; - status: string; - error: unknown; + type: 'tool_call' + status: string + error: unknown detail: { - type: string; - }; - }; - }; - }; - }; + type: string + } + } + } + } + } - expect(streamMsg.payload.event.item.status).toBe("running"); - expect(streamMsg.payload.event.item.error).toBeNull(); - expect(streamMsg.payload.event.item.detail.type).toBe("shell"); - expect(logger.warn).not.toHaveBeenCalled(); - }); + expect(streamMsg.payload.event.item.status).toBe('running') + expect(streamMsg.payload.event.item.error).toBeNull() + expect(streamMsg.payload.event.item.detail.type).toBe('shell') + expect(logger.warn).not.toHaveBeenCalled() + }) - test("drops legacy agent_stream tool_call payloads and logs validation warning", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); + test('drops legacy agent_stream tool_call payloads and logs validation warning', async () => { + const logger = createMockLogger() + const mock = createMockTransport() const client = new DaemonClient({ - url: "ws://test", + url: 'ws://test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, - }); - clients.push(client); + }) + clients.push(client) - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; + const connectPromise = client.connect() + mock.triggerOpen() + await connectPromise - const received: unknown[] = []; - const unsubscribe = client.on("agent_stream", (msg) => { - received.push(msg); - }); + const received: unknown[] = [] + const unsubscribe = client.on('agent_stream', (msg) => { + received.push(msg) + }) mock.triggerMessage( wrapSessionMessage({ - type: "agent_stream", + type: 'agent_stream', payload: { - agentId: "agent_cli", - timestamp: "2026-02-08T20:20:00.000Z", + agentId: 'agent_cli', + timestamp: '2026-02-08T20:20:00.000Z', event: { - type: "timeline", - provider: "codex", + type: 'timeline', + provider: 'codex', item: { - type: "tool_call", - callId: "call_cli_stream_legacy", - name: "shell", - status: "inProgress", + type: 'tool_call', + callId: 'call_cli_stream_legacy', + name: 'shell', + status: 'inProgress', detail: { - type: "unknown", - input: { command: "pwd" }, + type: 'unknown', + input: { command: 'pwd' }, output: null, }, }, }, }, }) - ); + ) - unsubscribe(); + unsubscribe() - expect(received).toHaveLength(0); - expect(logger.warn).toHaveBeenCalled(); - }); + expect(received).toHaveLength(0) + expect(logger.warn).toHaveBeenCalled() + }) - test("parses canonical fetch_agent_timeline_response payloads without crashing", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); + test('parses canonical fetch_agent_timeline_response payloads without crashing', async () => { + const logger = createMockLogger() + const mock = createMockTransport() const client = new DaemonClient({ - url: "ws://test", + url: 'ws://test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, - }); - clients.push(client); + }) + clients.push(client) - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; + const connectPromise = client.connect() + mock.triggerOpen() + await connectPromise - const received: unknown[] = []; - const unsubscribe = client.on("fetch_agent_timeline_response", (msg) => { - received.push(msg); - }); + const received: unknown[] = [] + const unsubscribe = client.on('fetch_agent_timeline_response', (msg) => { + received.push(msg) + }) mock.triggerMessage( wrapSessionMessage({ - type: "fetch_agent_timeline_response", + type: 'fetch_agent_timeline_response', payload: { - requestId: "req-1", - agentId: "agent_cli", - direction: "tail", - projection: "projected", - epoch: "epoch-1", + requestId: 'req-1', + agentId: 'agent_cli', + direction: 'tail', + projection: 'projected', + epoch: 'epoch-1', reset: false, staleCursor: false, gap: false, window: { minSeq: 1, maxSeq: 1, nextSeq: 2 }, - startCursor: { epoch: "epoch-1", seq: 1 }, - endCursor: { epoch: "epoch-1", seq: 1 }, + startCursor: { epoch: 'epoch-1', seq: 1 }, + endCursor: { epoch: 'epoch-1', seq: 1 }, hasOlder: false, hasNewer: false, entries: [ { - timestamp: "2026-02-08T20:20:00.000Z", - provider: "codex", + timestamp: '2026-02-08T20:20:00.000Z', + provider: 'codex', seqStart: 1, seqEnd: 1, sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }], collapsed: [], item: { - type: "tool_call", - callId: "call_cli_snapshot", - name: "shell", - status: "running", + type: 'tool_call', + callId: 'call_cli_snapshot', + name: 'shell', + status: 'running', detail: { - type: "shell", - command: "pwd", + type: 'shell', + command: 'pwd', }, error: null, }, @@ -1332,90 +1352,90 @@ describe("DaemonClient", () => { error: null, }, }) - ); + ) - unsubscribe(); + unsubscribe() - expect(received).toHaveLength(1); + expect(received).toHaveLength(1) const timelineMsg = received[0] as { payload: { entries: Array<{ item: { - type: "tool_call"; - status: string; - error: unknown; + type: 'tool_call' + status: string + error: unknown detail: { - type: string; - }; - }; - }>; - }; - }; - - const firstEntry = timelineMsg.payload.entries[0]; - expect(firstEntry?.item.type).toBe("tool_call"); - if (firstEntry?.item.type === "tool_call") { - expect(firstEntry.item.status).toBe("running"); - expect(firstEntry.item.error).toBeNull(); - expect(firstEntry.item.detail.type).toBe("shell"); + type: string + } + } + }> + } } - expect(logger.warn).not.toHaveBeenCalled(); - }); - test("drops invalid fetch_agent_timeline_response tool_call payloads and logs validation warning", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); + const firstEntry = timelineMsg.payload.entries[0] + expect(firstEntry?.item.type).toBe('tool_call') + if (firstEntry?.item.type === 'tool_call') { + expect(firstEntry.item.status).toBe('running') + expect(firstEntry.item.error).toBeNull() + expect(firstEntry.item.detail.type).toBe('shell') + } + expect(logger.warn).not.toHaveBeenCalled() + }) + + test('drops invalid fetch_agent_timeline_response tool_call payloads and logs validation warning', async () => { + const logger = createMockLogger() + const mock = createMockTransport() const client = new DaemonClient({ - url: "ws://test", + url: 'ws://test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, - }); - clients.push(client); + }) + clients.push(client) - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; + const connectPromise = client.connect() + mock.triggerOpen() + await connectPromise - const received: unknown[] = []; - const unsubscribe = client.on("fetch_agent_timeline_response", (msg) => { - received.push(msg); - }); + const received: unknown[] = [] + const unsubscribe = client.on('fetch_agent_timeline_response', (msg) => { + received.push(msg) + }) mock.triggerMessage( wrapSessionMessage({ - type: "fetch_agent_timeline_response", + type: 'fetch_agent_timeline_response', payload: { - requestId: "req-invalid", - agentId: "agent_cli", - direction: "tail", - projection: "projected", - epoch: "epoch-1", + requestId: 'req-invalid', + agentId: 'agent_cli', + direction: 'tail', + projection: 'projected', + epoch: 'epoch-1', reset: false, staleCursor: false, gap: false, window: { minSeq: 1, maxSeq: 1, nextSeq: 2 }, - startCursor: { epoch: "epoch-1", seq: 1 }, - endCursor: { epoch: "epoch-1", seq: 1 }, + startCursor: { epoch: 'epoch-1', seq: 1 }, + endCursor: { epoch: 'epoch-1', seq: 1 }, hasOlder: false, hasNewer: false, entries: [ { - timestamp: "2026-02-08T20:20:00.000Z", - provider: "codex", + timestamp: '2026-02-08T20:20:00.000Z', + provider: 'codex', seqStart: 1, seqEnd: 1, sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }], collapsed: [], item: { - type: "tool_call", - callId: "call_cli_invalid", - name: "shell", - status: "inProgress", + type: 'tool_call', + callId: 'call_cli_invalid', + name: 'shell', + status: 'inProgress', detail: { - type: "unknown", - input: { command: "pwd" }, + type: 'unknown', + input: { command: 'pwd' }, output: null, }, }, @@ -1424,96 +1444,96 @@ describe("DaemonClient", () => { error: null, }, }) - ); + ) - unsubscribe(); + unsubscribe() - expect(received).toHaveLength(0); - expect(logger.warn).toHaveBeenCalled(); - }); + expect(received).toHaveLength(0) + expect(logger.warn).toHaveBeenCalled() + }) - test("sends subscribe/unsubscribe terminals messages", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); + test('sends subscribe/unsubscribe terminals messages', async () => { + const logger = createMockLogger() + const mock = createMockTransport() const client = new DaemonClient({ - url: "ws://test", + url: 'ws://test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, - }); - clients.push(client); + }) + clients.push(client) - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; + const connectPromise = client.connect() + mock.triggerOpen() + await connectPromise - client.subscribeTerminals({ cwd: "/tmp/project" }); - client.unsubscribeTerminals({ cwd: "/tmp/project" }); + client.subscribeTerminals({ cwd: '/tmp/project' }) + client.unsubscribeTerminals({ cwd: '/tmp/project' }) - expect(mock.sent).toHaveLength(2); + expect(mock.sent).toHaveLength(2) expect(JSON.parse(String(mock.sent[0]))).toEqual({ - type: "session", + type: 'session', message: { - type: "subscribe_terminals_request", - cwd: "/tmp/project", + type: 'subscribe_terminals_request', + cwd: '/tmp/project', }, - }); + }) expect(JSON.parse(String(mock.sent[1]))).toEqual({ - type: "session", + type: 'session', message: { - type: "unsubscribe_terminals_request", - cwd: "/tmp/project", + type: 'unsubscribe_terminals_request', + cwd: '/tmp/project', }, - }); - }); + }) + }) - test("dispatches terminals_changed events to typed listeners", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); + test('dispatches terminals_changed events to typed listeners', async () => { + const logger = createMockLogger() + const mock = createMockTransport() const client = new DaemonClient({ - url: "ws://test", + url: 'ws://test', logger, reconnect: { enabled: false }, transportFactory: () => mock.transport, - }); - clients.push(client); + }) + clients.push(client) - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; + const connectPromise = client.connect() + mock.triggerOpen() + await connectPromise - const received: Array<{ cwd: string; names: string[] }> = []; - const unsubscribe = client.on("terminals_changed", (message) => { + const received: Array<{ cwd: string; names: string[] }> = [] + const unsubscribe = client.on('terminals_changed', (message) => { received.push({ cwd: message.payload.cwd, names: message.payload.terminals.map((terminal) => terminal.name), - }); - }); + }) + }) mock.triggerMessage( wrapSessionMessage({ - type: "terminals_changed", + type: 'terminals_changed', payload: { - cwd: "/tmp/project", + cwd: '/tmp/project', terminals: [ { - id: "term-1", - name: "Dev Server", + id: 'term-1', + name: 'Dev Server', }, ], }, }) - ); + ) - unsubscribe(); + unsubscribe() expect(received).toEqual([ { - cwd: "/tmp/project", - names: ["Dev Server"], + cwd: '/tmp/project', + names: ['Dev Server'], }, - ]); - }); -}); + ]) + }) +}) diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index dd1fdcb8d..4db7299fb 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -1,4 +1,4 @@ -import type { z } from "zod"; +import type { z } from 'zod' import { AgentCreateFailedStatusPayloadSchema, AgentCreatedStatusPayloadSchema, @@ -7,7 +7,7 @@ import { RestartRequestedStatusPayloadSchema, SessionInboundMessageSchema, WSOutboundMessageSchema, -} from "../shared/messages.js"; +} from '../shared/messages.js' import type { AgentStreamEventPayload, AgentSnapshotPayload, @@ -45,16 +45,16 @@ import type { TerminalInput, SessionInboundMessage, SessionOutboundMessage, -} from "../shared/messages.js"; +} from '../shared/messages.js' import type { AgentPermissionRequest, AgentPermissionResponse, AgentPersistenceHandle, AgentProvider, AgentSessionConfig, -} from "../server/agent/agent-sdk-types.js"; -import { getAgentProviderDefinition } from "../server/agent/provider-manifest.js"; -import { isRelayClientWebSocketUrl } from "../shared/daemon-endpoints.js"; +} from '../server/agent/agent-sdk-types.js' +import { getAgentProviderDefinition } from '../server/agent/provider-manifest.js' +import { isRelayClientWebSocketUrl } from '../shared/daemon-endpoints.js' import { asUint8Array, decodeBinaryMuxFrame, @@ -63,15 +63,12 @@ import { TerminalBinaryFlags, TerminalBinaryMessageType, type BinaryMuxFrame, -} from "../shared/binary-mux.js"; -import { - encodeTerminalKeyInput, - type TerminalKeyInput, -} from "../shared/terminal-key-input.js"; +} from '../shared/binary-mux.js' +import { encodeTerminalKeyInput, type TerminalKeyInput } from '../shared/terminal-key-input.js' import { TerminalStreamManager, type TerminalStreamChunk, -} from "./daemon-client-terminal-stream-manager.js"; +} from './daemon-client-terminal-stream-manager.js' import { createRelayE2eeTransportFactory, createWebSocketTransportFactory, @@ -84,13 +81,13 @@ import { type DaemonTransport, type DaemonTransportFactory, type WebSocketFactory, -} from "./daemon-client-transport.js"; +} from './daemon-client-transport.js' 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; + 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 = { @@ -98,276 +95,259 @@ const consoleLogger: Logger = { info: (obj, msg) => console.info(msg, obj), warn: (obj, msg) => console.warn(msg, obj), error: (obj, msg) => console.error(msg, obj), -}; +} export type { DaemonTransport, DaemonTransportFactory, WebSocketFactory, WebSocketLike, -} from "./daemon-client-transport.js"; -export type { TerminalStreamChunk } from "./daemon-client-terminal-stream-manager.js"; +} from './daemon-client-transport.js' +export type { TerminalStreamChunk } from './daemon-client-terminal-stream-manager.js' export type ConnectionState = - | { status: "idle" } - | { status: "connecting"; attempt: number } - | { status: "connected" } - | { status: "disconnected"; reason?: string }; + | { status: 'idle' } + | { status: 'connecting'; attempt: number } + | { status: 'connected' } + | { status: 'disconnected'; reason?: string } export type DaemonEvent = | { - type: "agent_update"; - agentId: string; - payload: Extract["payload"]; + type: 'agent_update' + agentId: string + payload: Extract['payload'] } | { - type: "agent_stream"; - agentId: string; - event: AgentStreamEventPayload; - timestamp: string; - seq?: number; - epoch?: string; + type: 'agent_stream' + agentId: string + event: AgentStreamEventPayload + timestamp: string + seq?: number + epoch?: string } - | { type: "status"; payload: { status: string } & Record } - | { type: "agent_deleted"; agentId: string } + | { type: 'status'; payload: { status: string } & Record } + | { type: 'agent_deleted'; agentId: string } | { - type: "agent_permission_request"; - agentId: string; - request: AgentPermissionRequest; + type: 'agent_permission_request' + agentId: string + request: AgentPermissionRequest } | { - type: "agent_permission_resolved"; - agentId: string; - requestId: string; - resolution: AgentPermissionResponse; + type: 'agent_permission_resolved' + agentId: string + requestId: string + resolution: AgentPermissionResponse } - | { type: "error"; message: string }; + | { type: 'error'; message: string } -export type DaemonEventHandler = (event: DaemonEvent) => void; +export type DaemonEventHandler = (event: DaemonEvent) => void export type DaemonClientConfig = { - url: string; - authHeader?: string; - suppressSendErrors?: boolean; - transportFactory?: DaemonTransportFactory; - webSocketFactory?: WebSocketFactory; - logger?: Logger; + url: string + authHeader?: string + suppressSendErrors?: boolean + transportFactory?: DaemonTransportFactory + webSocketFactory?: WebSocketFactory + logger?: Logger e2ee?: { - enabled?: boolean; - daemonPublicKeyB64?: string; - }; + enabled?: boolean + daemonPublicKeyB64?: string + } reconnect?: { - enabled?: boolean; - baseDelayMs?: number; - maxDelayMs?: number; - }; -}; - -export type SendMessageOptions = { - messageId?: string; - images?: Array<{ data: string; mimeType: string }>; -}; - -type AgentConfigOverrides = Partial>; - -export type CreateAgentRequestOptions = { - config?: AgentSessionConfig; - provider?: AgentProvider; - cwd?: string; - initialPrompt?: string; - outputSchema?: Record; - images?: CreateAgentRequestMessage["images"]; - git?: GitSetupOptions; - worktreeName?: string; - requestId?: string; - labels?: Record; -} & AgentConfigOverrides; - -type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; -type SubscribeCheckoutDiffPayload = Extract< - SessionOutboundMessage, - { type: "subscribe_checkout_diff_response" } ->["payload"]; -type CheckoutDiffPayload = Omit; -type CheckoutCommitPayload = CheckoutCommitResponse["payload"]; -type CheckoutMergePayload = CheckoutMergeResponse["payload"]; -type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"]; -type CheckoutPushPayload = CheckoutPushResponse["payload"]; -type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"]; -type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"]; -type ValidateBranchPayload = ValidateBranchResponse["payload"]; -type BranchSuggestionsPayload = BranchSuggestionsResponse["payload"]; -type DirectorySuggestionsPayload = DirectorySuggestionsResponse["payload"]; -type PaseoWorktreeListPayload = PaseoWorktreeListResponse["payload"]; -type PaseoWorktreeArchivePayload = PaseoWorktreeArchiveResponse["payload"]; -type FileExplorerPayload = FileExplorerResponse["payload"]; -type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"]; -type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"]; -type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"]; -type SpeechModelsListPayload = SpeechModelsListResponse["payload"]; -type SpeechModelsDownloadPayload = SpeechModelsDownloadResponse["payload"]; -type ListCommandsPayload = ListCommandsResponse["payload"]; -type ListCommandsDraftConfig = Pick< - AgentSessionConfig, - "provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" ->; -type ListCommandsOptions = { - requestId?: string; - draftConfig?: ListCommandsDraftConfig; -}; -type SetVoiceModePayload = Extract< - SessionOutboundMessage, - { type: "set_voice_mode_response" } ->["payload"]; -type DictationFinishAcceptedPayload = Extract< - SessionOutboundMessage, - { type: "dictation_stream_finish_accepted" } ->["payload"]; -type AgentPermissionResolvedPayload = AgentPermissionResolvedMessage["payload"]; -type ListTerminalsPayload = ListTerminalsResponse["payload"]; -type CreateTerminalPayload = CreateTerminalResponse["payload"]; -type SubscribeTerminalPayload = SubscribeTerminalResponse["payload"]; -type TerminalOutputPayload = TerminalOutput["payload"]; -type KillTerminalPayload = KillTerminalResponse["payload"]; -type AttachTerminalStreamPayload = AttachTerminalStreamResponse["payload"]; -type DetachTerminalStreamPayload = DetachTerminalStreamResponse["payload"]; -export type FetchAgentTimelinePayload = FetchAgentTimelineResponseMessage["payload"]; - -export type FetchAgentTimelineDirection = FetchAgentTimelinePayload["direction"]; -export type FetchAgentTimelineProjection = FetchAgentTimelinePayload["projection"]; -export type FetchAgentTimelineCursor = NonNullable< - FetchAgentTimelinePayload["startCursor"] ->; -export type FetchAgentTimelineOptions = { - direction?: FetchAgentTimelineDirection; - cursor?: FetchAgentTimelineCursor; - limit?: number; - projection?: FetchAgentTimelineProjection; - requestId?: string; -}; - -type AgentRefreshedStatusPayload = z.infer< - typeof AgentRefreshedStatusPayloadSchema ->; -type RestartRequestedStatusPayload = z.infer< - typeof RestartRequestedStatusPayloadSchema ->; -type FetchAgentsPayload = Extract< - SessionOutboundMessage, - { type: "fetch_agents_response" } ->["payload"]; -type FetchAgentsRequest = Extract< - SessionInboundMessage, - { type: "fetch_agents_request" } ->; -export type FetchAgentsOptions = Omit & { - requestId?: string; -}; -export type FetchAgentsEntry = FetchAgentsPayload["entries"][number]; -export type FetchAgentsPageInfo = FetchAgentsPayload["pageInfo"]; - -export type WaitForFinishResult = { - status: "idle" | "error" | "permission" | "timeout"; - final: AgentSnapshotPayload | null; - error: string | null; - lastMessage: string | null; -}; - -type Waiter = { - predicate: (msg: SessionOutboundMessage) => T | null; - resolve: (value: T) => void; - reject: (error: Error) => void; - timeoutHandle: ReturnType | null; -}; - -type WaitHandle = { - promise: Promise; - cancel: (error: Error) => void; -}; - -type RpcWaitResult = - | { kind: "ok"; value: T } - | { kind: "error"; error: DaemonRpcError }; -type CorrelatedResponseMessage = Extract< - SessionOutboundMessage, - { payload: { requestId: string } } ->; -type CorrelatedResponseType = CorrelatedResponseMessage["type"]; -type CorrelatedResponsePayload = Extract< - CorrelatedResponseMessage, - { type: TType } ->["payload"]; - -class DaemonRpcError extends Error { - readonly requestId: string; - readonly requestType?: string; - readonly code?: string; - - constructor(params: { requestId: string; error: string; requestType?: string; code?: string }) { - super(params.error); - this.name = "DaemonRpcError"; - this.requestId = params.requestId; - this.requestType = params.requestType; - this.code = params.code; + enabled?: boolean + baseDelayMs?: number + maxDelayMs?: number } } -const DEFAULT_RECONNECT_BASE_DELAY_MS = 1500; -const DEFAULT_RECONNECT_MAX_DELAY_MS = 30000; +export type SendMessageOptions = { + messageId?: string + images?: Array<{ data: string; mimeType: string }> +} + +type AgentConfigOverrides = Partial> + +export type CreateAgentRequestOptions = { + config?: AgentSessionConfig + provider?: AgentProvider + cwd?: string + initialPrompt?: string + outputSchema?: Record + images?: CreateAgentRequestMessage['images'] + git?: GitSetupOptions + worktreeName?: string + requestId?: string + labels?: Record +} & AgentConfigOverrides + +type CheckoutStatusPayload = CheckoutStatusResponse['payload'] +type SubscribeCheckoutDiffPayload = Extract< + SessionOutboundMessage, + { type: 'subscribe_checkout_diff_response' } +>['payload'] +type CheckoutDiffPayload = Omit +type CheckoutCommitPayload = CheckoutCommitResponse['payload'] +type CheckoutMergePayload = CheckoutMergeResponse['payload'] +type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse['payload'] +type CheckoutPushPayload = CheckoutPushResponse['payload'] +type CheckoutPrCreatePayload = CheckoutPrCreateResponse['payload'] +type CheckoutPrStatusPayload = CheckoutPrStatusResponse['payload'] +type ValidateBranchPayload = ValidateBranchResponse['payload'] +type BranchSuggestionsPayload = BranchSuggestionsResponse['payload'] +type DirectorySuggestionsPayload = DirectorySuggestionsResponse['payload'] +type PaseoWorktreeListPayload = PaseoWorktreeListResponse['payload'] +type PaseoWorktreeArchivePayload = PaseoWorktreeArchiveResponse['payload'] +type FileExplorerPayload = FileExplorerResponse['payload'] +type FileDownloadTokenPayload = FileDownloadTokenResponse['payload'] +type ListProviderModelsPayload = ListProviderModelsResponseMessage['payload'] +type ListAvailableProvidersPayload = ListAvailableProvidersResponse['payload'] +type SpeechModelsListPayload = SpeechModelsListResponse['payload'] +type SpeechModelsDownloadPayload = SpeechModelsDownloadResponse['payload'] +type ListCommandsPayload = ListCommandsResponse['payload'] +type ListCommandsDraftConfig = Pick< + AgentSessionConfig, + 'provider' | 'cwd' | 'modeId' | 'model' | 'thinkingOptionId' +> +type ListCommandsOptions = { + requestId?: string + draftConfig?: ListCommandsDraftConfig +} +type SetVoiceModePayload = Extract< + SessionOutboundMessage, + { type: 'set_voice_mode_response' } +>['payload'] +type DictationFinishAcceptedPayload = Extract< + SessionOutboundMessage, + { type: 'dictation_stream_finish_accepted' } +>['payload'] +type AgentPermissionResolvedPayload = AgentPermissionResolvedMessage['payload'] +type ListTerminalsPayload = ListTerminalsResponse['payload'] +type CreateTerminalPayload = CreateTerminalResponse['payload'] +type SubscribeTerminalPayload = SubscribeTerminalResponse['payload'] +type TerminalOutputPayload = TerminalOutput['payload'] +type KillTerminalPayload = KillTerminalResponse['payload'] +type AttachTerminalStreamPayload = AttachTerminalStreamResponse['payload'] +type DetachTerminalStreamPayload = DetachTerminalStreamResponse['payload'] +export type FetchAgentTimelinePayload = FetchAgentTimelineResponseMessage['payload'] + +export type FetchAgentTimelineDirection = FetchAgentTimelinePayload['direction'] +export type FetchAgentTimelineProjection = FetchAgentTimelinePayload['projection'] +export type FetchAgentTimelineCursor = NonNullable +export type FetchAgentTimelineOptions = { + direction?: FetchAgentTimelineDirection + cursor?: FetchAgentTimelineCursor + limit?: number + projection?: FetchAgentTimelineProjection + requestId?: string +} + +type AgentRefreshedStatusPayload = z.infer +type RestartRequestedStatusPayload = z.infer +type FetchAgentsPayload = Extract< + SessionOutboundMessage, + { type: 'fetch_agents_response' } +>['payload'] +type FetchAgentsRequest = Extract +export type FetchAgentsOptions = Omit & { + requestId?: string +} +export type FetchAgentsEntry = FetchAgentsPayload['entries'][number] +export type FetchAgentsPageInfo = FetchAgentsPayload['pageInfo'] + +export type WaitForFinishResult = { + status: 'idle' | 'error' | 'permission' | 'timeout' + final: AgentSnapshotPayload | null + error: string | null + lastMessage: string | null +} + +type Waiter = { + predicate: (msg: SessionOutboundMessage) => T | null + resolve: (value: T) => void + reject: (error: Error) => void + timeoutHandle: ReturnType | null +} + +type WaitHandle = { + promise: Promise + cancel: (error: Error) => void +} + +type RpcWaitResult = { kind: 'ok'; value: T } | { kind: 'error'; error: DaemonRpcError } +type CorrelatedResponseMessage = Extract +type CorrelatedResponseType = CorrelatedResponseMessage['type'] +type CorrelatedResponsePayload = Extract< + CorrelatedResponseMessage, + { type: TType } +>['payload'] + +class DaemonRpcError extends Error { + readonly requestId: string + readonly requestType?: string + readonly code?: string + + constructor(params: { requestId: string; error: string; requestType?: string; code?: string }) { + super(params.error) + this.name = 'DaemonRpcError' + this.requestId = params.requestId + this.requestType = params.requestType + this.code = params.code + } +} + +const DEFAULT_RECONNECT_BASE_DELAY_MS = 1500 +const DEFAULT_RECONNECT_MAX_DELAY_MS = 30000 /** Default timeout for waiting for connection before sending queued messages */ -const DEFAULT_SEND_QUEUE_TIMEOUT_MS = 10000; -const DEFAULT_DICTATION_FINISH_ACCEPT_TIMEOUT_MS = 15000; -const DEFAULT_DICTATION_FINISH_FALLBACK_TIMEOUT_MS = 5 * 60 * 1000; -const DEFAULT_DICTATION_FINISH_TIMEOUT_GRACE_MS = 5000; +const DEFAULT_SEND_QUEUE_TIMEOUT_MS = 10000 +const DEFAULT_DICTATION_FINISH_ACCEPT_TIMEOUT_MS = 15000 +const DEFAULT_DICTATION_FINISH_FALLBACK_TIMEOUT_MS = 5 * 60 * 1000 +const DEFAULT_DICTATION_FINISH_TIMEOUT_GRACE_MS = 5000 function isWaiterTimeoutError(error: unknown): boolean { - return ( - error instanceof Error && error.message.startsWith("Timeout waiting for message") - ); + return error instanceof Error && error.message.startsWith('Timeout waiting for message') } interface PendingSend { - message: SessionInboundMessage; - resolve: () => void; - reject: (error: Error) => void; - timeoutHandle: ReturnType; + message: SessionInboundMessage + resolve: () => void + reject: (error: Error) => void + timeoutHandle: ReturnType } export class DaemonClient { - private transport: DaemonTransport | null = null; - private transportCleanup: Array<() => void> = []; - private rawMessageListeners: Set<(message: SessionOutboundMessage) => void> = new Set(); + private transport: DaemonTransport | null = null + private transportCleanup: Array<() => void> = [] + private rawMessageListeners: Set<(message: SessionOutboundMessage) => void> = new Set() private messageHandlers: Map< - SessionOutboundMessage["type"], + SessionOutboundMessage['type'], Set<(message: SessionOutboundMessage) => void> - > = new Map(); - private eventListeners: Set = new Set(); - private waiters: Set> = new Set(); - private checkoutStatusInFlight: Map> = new Map(); - private connectionListeners: Set<(status: ConnectionState) => void> = - new Set(); - private reconnectTimeout: ReturnType | null = null; - private pendingGenericTransportErrorTimeout: ReturnType | null = null; - private reconnectAttempt = 0; - private shouldReconnect = true; - private connectPromise: Promise | null = null; - private connectResolve: (() => void) | null = null; - private connectReject: ((error: Error) => void) | null = null; - private lastErrorValue: string | null = null; - private connectionState: ConnectionState = { status: "idle" }; + > = new Map() + private eventListeners: Set = new Set() + private waiters: Set> = new Set() + private checkoutStatusInFlight: Map> = new Map() + private connectionListeners: Set<(status: ConnectionState) => void> = new Set() + private reconnectTimeout: ReturnType | null = null + private pendingGenericTransportErrorTimeout: ReturnType | null = null + private reconnectAttempt = 0 + private shouldReconnect = true + private connectPromise: Promise | null = null + private connectResolve: (() => void) | null = null + private connectReject: ((error: Error) => void) | null = null + private lastErrorValue: string | null = null + private connectionState: ConnectionState = { status: 'idle' } private checkoutDiffSubscriptions = new Map< string, - { cwd: string; compare: { mode: "uncommitted" | "base"; baseRef?: string } } - >(); - private terminalDirectorySubscriptions = new Set(); - private logger: Logger; - private pendingSendQueue: PendingSend[] = []; - private relayClientId: string | null = null; - private terminalStreams: TerminalStreamManager; + { cwd: string; compare: { mode: 'uncommitted' | 'base'; baseRef?: string } } + >() + private terminalDirectorySubscriptions = new Set() + private logger: Logger + private pendingSendQueue: PendingSend[] = [] + private relayClientId: string | null = null + private terminalStreams: TerminalStreamManager constructor(private config: DaemonClientConfig) { - this.logger = config.logger ?? consoleLogger; + this.logger = config.logger ?? consoleLogger this.terminalStreams = new TerminalStreamManager({ sendAck: (ack) => { this.sendBinaryFrame({ @@ -376,19 +356,19 @@ export class DaemonClient { streamId: ack.streamId, offset: ack.offset, payload: new Uint8Array(0), - }); + }) }, - }); + }) // Relay requires a clientId so the daemon can create an independent // socket + E2EE channel per connected client. Generate one per DaemonClient // instance (stable across reconnects in this tab/app session). if (isRelayClientWebSocketUrl(this.config.url)) { try { - const parsed = new URL(this.config.url); - if (!parsed.searchParams.get("clientId")) { - this.relayClientId = `clt_${safeRandomId()}`; - parsed.searchParams.set("clientId", this.relayClientId); - this.config.url = parsed.toString(); + const parsed = new URL(this.config.url) + if (!parsed.searchParams.get('clientId')) { + this.relayClientId = `clt_${safeRandomId()}` + parsed.searchParams.set('clientId', this.relayClientId) + this.config.url = parsed.toString() } } catch { // ignore - invalid URL will be handled on connect @@ -401,36 +381,36 @@ export class DaemonClient { // ============================================================================ async connect(): Promise { - if (this.connectionState.status === "connected") { - return; + if (this.connectionState.status === 'connected') { + return } if (this.connectPromise) { - return this.connectPromise; + return this.connectPromise } - this.shouldReconnect = true; + this.shouldReconnect = true this.connectPromise = new Promise((resolve, reject) => { - this.connectResolve = resolve; - this.connectReject = reject; - this.attemptConnect(); - }); + this.connectResolve = resolve + this.connectReject = reject + this.attemptConnect() + }) - return this.connectPromise; + return this.connectPromise } private attemptConnect(): void { if (!this.shouldReconnect) { - this.rejectConnect(new Error("Daemon client is closed")); - return; + this.rejectConnect(new Error('Daemon client is closed')) + return } - if (this.connectionState.status === "connecting") { - return; + if (this.connectionState.status === 'connecting') { + return } - const headers: Record = {}; + const headers: Record = {} if (this.config.authHeader) { - headers["Authorization"] = this.config.authHeader; + headers['Authorization'] = this.config.authHeader } try { @@ -438,72 +418,69 @@ export class DaemonClient { // where `onerror` may fire before `onclose`), we can end up with multiple // concurrent relay sockets. Cloudflare then closes the old one with // "Replaced by new connection", causing a disconnect loop. - this.disposeTransport(); + this.disposeTransport() const baseTransportFactory = this.config.transportFactory ?? - createWebSocketTransportFactory( - this.config.webSocketFactory ?? defaultWebSocketFactory - ); + createWebSocketTransportFactory(this.config.webSocketFactory ?? defaultWebSocketFactory) const shouldUseRelayE2ee = - this.config.e2ee?.enabled === true && - isRelayClientWebSocketUrl(this.config.url); + this.config.e2ee?.enabled === true && isRelayClientWebSocketUrl(this.config.url) - let transportFactory = baseTransportFactory; + let transportFactory = baseTransportFactory if (shouldUseRelayE2ee) { - const daemonPublicKeyB64 = this.config.e2ee?.daemonPublicKeyB64; + const daemonPublicKeyB64 = this.config.e2ee?.daemonPublicKeyB64 if (!daemonPublicKeyB64) { - throw new Error("daemonPublicKeyB64 is required for relay E2EE"); + throw new Error('daemonPublicKeyB64 is required for relay E2EE') } transportFactory = createRelayE2eeTransportFactory({ baseFactory: baseTransportFactory, daemonPublicKeyB64, logger: this.logger, - }); + }) } - const transport = transportFactory({ url: this.config.url, headers }); - this.transport = transport; + const transport = transportFactory({ url: this.config.url, headers }) + this.transport = transport this.updateConnectionState({ - status: "connecting", + status: 'connecting', attempt: this.reconnectAttempt, - }); + }) this.transportCleanup = [ transport.onOpen(() => { if (this.pendingGenericTransportErrorTimeout) { - clearTimeout(this.pendingGenericTransportErrorTimeout); - this.pendingGenericTransportErrorTimeout = null; + clearTimeout(this.pendingGenericTransportErrorTimeout) + this.pendingGenericTransportErrorTimeout = null } - this.lastErrorValue = null; - this.reconnectAttempt = 0; - this.updateConnectionState({ status: "connected" }); - this.resubscribeCheckoutDiffSubscriptions(); - this.resubscribeTerminalDirectorySubscriptions(); - this.flushPendingSendQueue(); - this.resolveConnect(); + this.lastErrorValue = null + this.reconnectAttempt = 0 + this.updateConnectionState({ status: 'connected' }) + this.resubscribeCheckoutDiffSubscriptions() + this.resubscribeTerminalDirectorySubscriptions() + this.flushPendingSendQueue() + this.resolveConnect() }), transport.onClose((event) => { if (this.pendingGenericTransportErrorTimeout) { - clearTimeout(this.pendingGenericTransportErrorTimeout); - this.pendingGenericTransportErrorTimeout = null; + clearTimeout(this.pendingGenericTransportErrorTimeout) + this.pendingGenericTransportErrorTimeout = null } - const closeRecord = event as { code?: unknown; reason?: unknown } | null; + const closeRecord = event as { code?: unknown; reason?: unknown } | null const closeCode = - closeRecord && typeof closeRecord === "object" && typeof closeRecord.code === "number" + closeRecord && typeof closeRecord === 'object' && typeof closeRecord.code === 'number' ? closeRecord.code - : null; + : null const closeReason = - closeRecord && typeof closeRecord === "object" && typeof closeRecord.reason === "string" + closeRecord && typeof closeRecord === 'object' && typeof closeRecord.reason === 'string' ? closeRecord.reason - : null; - const reason = describeTransportClose(event); + : null + const reason = describeTransportClose(event) if (reason) { - this.lastErrorValue = reason; + this.lastErrorValue = reason } this.updateConnectionState({ - status: "disconnected", + status: 'disconnected', ...(reason ? { reason } : {}), - }); + }) // When connecting over the relay, only one client connection is allowed at a time. // If another device/tab takes over, we should not auto-reconnect and "fight" the new @@ -511,132 +488,129 @@ export class DaemonClient { if ( isRelayClientWebSocketUrl(this.config.url) && closeCode === 1008 && - (closeReason ?? reason) === "Replaced by new connection" + (closeReason ?? reason) === 'Replaced by new connection' ) { - this.shouldReconnect = false; - this.clearWaiters(new Error(reason ?? "Replaced by new connection")); - this.rejectPendingSendQueue(new Error(reason ?? "Replaced by new connection")); - this.rejectConnect(new Error(reason ?? "Replaced by new connection")); - return; + this.shouldReconnect = false + this.clearWaiters(new Error(reason ?? 'Replaced by new connection')) + this.rejectPendingSendQueue(new Error(reason ?? 'Replaced by new connection')) + this.rejectConnect(new Error(reason ?? 'Replaced by new connection')) + return } - this.scheduleReconnect(reason); + this.scheduleReconnect(reason) }), transport.onError((event) => { - const reason = describeTransportError(event); - const isGeneric = reason === "Transport error"; + const reason = describeTransportError(event) + const isGeneric = reason === 'Transport error' // Browser WebSocket.onerror often provides no useful details and is followed // by a close event (often with code 1006). Prefer surfacing the close details // instead of immediately disconnecting with a generic "Transport error". if (isGeneric) { - this.lastErrorValue ??= reason; + this.lastErrorValue ??= reason if (!this.pendingGenericTransportErrorTimeout) { this.pendingGenericTransportErrorTimeout = setTimeout(() => { - this.pendingGenericTransportErrorTimeout = null; + this.pendingGenericTransportErrorTimeout = null if ( - this.connectionState.status === "connected" || - this.connectionState.status === "connecting" + this.connectionState.status === 'connected' || + this.connectionState.status === 'connecting' ) { - this.lastErrorValue = reason; - this.updateConnectionState({ status: "disconnected", reason }); - this.scheduleReconnect(reason); + this.lastErrorValue = reason + this.updateConnectionState({ status: 'disconnected', reason }) + this.scheduleReconnect(reason) } - }, 250); + }, 250) } - return; + return } if (this.pendingGenericTransportErrorTimeout) { - clearTimeout(this.pendingGenericTransportErrorTimeout); - this.pendingGenericTransportErrorTimeout = null; + clearTimeout(this.pendingGenericTransportErrorTimeout) + this.pendingGenericTransportErrorTimeout = null } - this.lastErrorValue = reason; - this.updateConnectionState({ status: "disconnected", reason }); - this.scheduleReconnect(reason); + this.lastErrorValue = reason + this.updateConnectionState({ status: 'disconnected', reason }) + this.scheduleReconnect(reason) }), transport.onMessage((data) => this.handleTransportMessage(data)), - ]; + ] } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to connect"; - this.lastErrorValue = message; - this.scheduleReconnect(message); - this.rejectConnect(error instanceof Error ? error : new Error(message)); + const message = error instanceof Error ? error.message : 'Failed to connect' + this.lastErrorValue = message + this.scheduleReconnect(message) + this.rejectConnect(error instanceof Error ? error : new Error(message)) } } private resolveConnect(): void { if (this.connectResolve) { - this.connectResolve(); + this.connectResolve() } - this.connectPromise = null; - this.connectResolve = null; - this.connectReject = null; + this.connectPromise = null + this.connectResolve = null + this.connectReject = null } private rejectConnect(error: Error): void { if (this.connectReject) { - this.connectReject(error); + this.connectReject(error) } - this.connectPromise = null; - this.connectResolve = null; - this.connectReject = null; + this.connectPromise = null + this.connectResolve = null + this.connectReject = null } async close(): Promise { - this.shouldReconnect = false; - this.connectPromise = null; - this.connectResolve = null; - this.connectReject = null; + this.shouldReconnect = false + this.connectPromise = null + this.connectResolve = null + this.connectReject = null if (this.reconnectTimeout) { - clearTimeout(this.reconnectTimeout); - this.reconnectTimeout = null; + clearTimeout(this.reconnectTimeout) + this.reconnectTimeout = null } - this.disposeTransport(1000, "Client closed"); - this.clearWaiters(new Error("Daemon client closed")); - this.terminalStreams.clearAll(); + this.disposeTransport(1000, 'Client closed') + this.clearWaiters(new Error('Daemon client closed')) + this.terminalStreams.clearAll() this.updateConnectionState({ - status: "disconnected", - reason: "client_closed", - }); + status: 'disconnected', + reason: 'client_closed', + }) } ensureConnected(): void { if (!this.shouldReconnect) { - this.shouldReconnect = true; + this.shouldReconnect = true } if ( - this.connectionState.status === "connected" || - this.connectionState.status === "connecting" + this.connectionState.status === 'connected' || + this.connectionState.status === 'connecting' ) { - return; + return } - void this.connect(); + void this.connect() } getConnectionState(): ConnectionState { - return this.connectionState; + return this.connectionState } - subscribeConnectionStatus( - listener: (status: ConnectionState) => void - ): () => void { - this.connectionListeners.add(listener); - listener(this.connectionState); + subscribeConnectionStatus(listener: (status: ConnectionState) => void): () => void { + this.connectionListeners.add(listener) + listener(this.connectionState) return () => { - this.connectionListeners.delete(listener); - }; + this.connectionListeners.delete(listener) + } } get isConnected(): boolean { - return this.connectionState.status === "connected"; + return this.connectionState.status === 'connected' } get isConnecting(): boolean { - return this.connectionState.status === "connecting"; + return this.connectionState.status === 'connecting' } get lastError(): string | null { - return this.lastErrorValue; + return this.lastErrorValue } // ============================================================================ @@ -644,45 +618,48 @@ export class DaemonClient { // ============================================================================ subscribe(handler: DaemonEventHandler): () => void { - this.eventListeners.add(handler); - return () => this.eventListeners.delete(handler); + this.eventListeners.add(handler) + return () => this.eventListeners.delete(handler) } subscribeRawMessages(handler: (message: SessionOutboundMessage) => void): () => void { - this.rawMessageListeners.add(handler); + this.rawMessageListeners.add(handler) return () => { - this.rawMessageListeners.delete(handler); - }; + this.rawMessageListeners.delete(handler) + } } - on(type: SessionOutboundMessage["type"], handler: (message: SessionOutboundMessage) => void): () => void; - on(handler: DaemonEventHandler): () => void; on( - arg1: SessionOutboundMessage["type"] | DaemonEventHandler, + type: SessionOutboundMessage['type'], + handler: (message: SessionOutboundMessage) => void + ): () => void + on(handler: DaemonEventHandler): () => void + on( + arg1: SessionOutboundMessage['type'] | DaemonEventHandler, arg2?: (message: SessionOutboundMessage) => void ): () => void { - if (typeof arg1 === "function") { - return this.subscribe(arg1); + if (typeof arg1 === 'function') { + return this.subscribe(arg1) } - const type = arg1 as SessionOutboundMessage["type"]; - const handler = arg2 as (message: SessionOutboundMessage) => void; + const type = arg1 as SessionOutboundMessage['type'] + const handler = arg2 as (message: SessionOutboundMessage) => void if (!this.messageHandlers.has(type)) { - this.messageHandlers.set(type, new Set()); + this.messageHandlers.set(type, new Set()) } - this.messageHandlers.get(type)!.add(handler); + this.messageHandlers.get(type)!.add(handler) return () => { - const handlers = this.messageHandlers.get(type); + const handlers = this.messageHandlers.get(type) if (!handlers) { - return; + return } - handlers.delete(handler); + handlers.delete(handler) if (handlers.size === 0) { - this.messageHandlers.delete(type); + this.messageHandlers.delete(type) } - }; + } } // ============================================================================ @@ -695,37 +672,37 @@ export class DaemonClient { * For RPC methods that wait for responses, use `sendSessionMessageOrThrow` instead. */ private sendSessionMessage(message: SessionInboundMessage): void { - if (!this.transport || this.connectionState.status !== "connected") { + if (!this.transport || this.connectionState.status !== 'connected') { if (this.config.suppressSendErrors) { - return; + return } - throw new Error(`Transport not connected (status: ${this.connectionState.status})`); + throw new Error(`Transport not connected (status: ${this.connectionState.status})`) } - const payload = SessionInboundMessageSchema.parse(message); + const payload = SessionInboundMessageSchema.parse(message) try { - this.transport.send(JSON.stringify({ type: "session", message: payload })); + this.transport.send(JSON.stringify({ type: 'session', message: payload })) } catch (error) { if (this.config.suppressSendErrors) { - return; + return } - throw error instanceof Error ? error : new Error(String(error)); + throw error instanceof Error ? error : new Error(String(error)) } } private sendBinaryFrame(frame: BinaryMuxFrame): void { - if (!this.transport || this.connectionState.status !== "connected") { + if (!this.transport || this.connectionState.status !== 'connected') { if (this.config.suppressSendErrors) { - return; + return } - throw new Error(`Transport not connected (status: ${this.connectionState.status})`); + throw new Error(`Transport not connected (status: ${this.connectionState.status})`) } try { - this.transport.send(encodeBinaryMuxFrame(frame)); + this.transport.send(encodeBinaryMuxFrame(frame)) } catch (error) { if (this.config.suppressSendErrors) { - return; + return } - throw error instanceof Error ? error : new Error(String(error)); + throw error instanceof Error ? error : new Error(String(error)) } } @@ -736,54 +713,54 @@ export class DaemonClient { * This prevents waiters from hanging forever when called during connection. */ private sendSessionMessageOrThrow(message: SessionInboundMessage): Promise { - const status = this.connectionState.status; + const status = this.connectionState.status // If connected, send immediately - if (this.transport && status === "connected") { - const payload = SessionInboundMessageSchema.parse(message); - this.transport.send(JSON.stringify({ type: "session", message: payload })); - return Promise.resolve(); + if (this.transport && status === 'connected') { + const payload = SessionInboundMessageSchema.parse(message) + this.transport.send(JSON.stringify({ type: 'session', message: payload })) + return Promise.resolve() } // If connecting, queue the message to be sent once connected - if (status === "connecting") { + if (status === 'connecting') { return new Promise((resolve, reject) => { const timeoutHandle = setTimeout(() => { // Remove from queue - const idx = this.pendingSendQueue.findIndex((p) => p.resolve === resolve); + const idx = this.pendingSendQueue.findIndex((p) => p.resolve === resolve) if (idx !== -1) { - this.pendingSendQueue.splice(idx, 1); + this.pendingSendQueue.splice(idx, 1) } - reject(new Error(`Timed out waiting for connection to send message`)); - }, DEFAULT_SEND_QUEUE_TIMEOUT_MS); + reject(new Error(`Timed out waiting for connection to send message`)) + }, DEFAULT_SEND_QUEUE_TIMEOUT_MS) - this.pendingSendQueue.push({ message, resolve, reject, timeoutHandle }); - }); + this.pendingSendQueue.push({ message, resolve, reject, timeoutHandle }) + }) } // Not connected and not connecting - fail immediately - return Promise.reject(new Error(`Transport not connected (status: ${status})`)); + return Promise.reject(new Error(`Transport not connected (status: ${status})`)) } /** * Flush pending send queue - called when connection is established. */ private flushPendingSendQueue(): void { - const queue = this.pendingSendQueue; - this.pendingSendQueue = []; + const queue = this.pendingSendQueue + this.pendingSendQueue = [] for (const pending of queue) { - clearTimeout(pending.timeoutHandle); + clearTimeout(pending.timeoutHandle) try { - if (this.transport && this.connectionState.status === "connected") { - const payload = SessionInboundMessageSchema.parse(pending.message); - this.transport.send(JSON.stringify({ type: "session", message: payload })); - pending.resolve(); + if (this.transport && this.connectionState.status === 'connected') { + const payload = SessionInboundMessageSchema.parse(pending.message) + this.transport.send(JSON.stringify({ type: 'session', message: payload })) + pending.resolve() } else { - pending.reject(new Error("Connection lost before message could be sent")); + pending.reject(new Error('Connection lost before message could be sent')) } } catch (error) { - pending.reject(error instanceof Error ? error : new Error(String(error))); + pending.reject(error instanceof Error ? error : new Error(String(error))) } } } @@ -792,116 +769,109 @@ export class DaemonClient { * Reject all pending sends - called when connection fails or is closed. */ private rejectPendingSendQueue(error: Error): void { - const queue = this.pendingSendQueue; - this.pendingSendQueue = []; + const queue = this.pendingSendQueue + this.pendingSendQueue = [] for (const pending of queue) { - clearTimeout(pending.timeoutHandle); - pending.reject(error); + clearTimeout(pending.timeoutHandle) + pending.reject(error) } } - private async sendRequest( - params: { - requestId: string; - message: SessionInboundMessage; - timeout: number; - select: (msg: SessionOutboundMessage) => T | null; - options?: { skipQueue?: boolean }; - } - ): Promise { + private async sendRequest(params: { + requestId: string + message: SessionInboundMessage + timeout: number + select: (msg: SessionOutboundMessage) => T | null + options?: { skipQueue?: boolean } + }): Promise { const { promise, cancel } = this.waitForWithCancel>( (msg) => { - if (msg.type === "rpc_error" && msg.payload.requestId === params.requestId) { + if (msg.type === 'rpc_error' && msg.payload.requestId === params.requestId) { return { - kind: "error", + kind: 'error', error: new DaemonRpcError({ requestId: msg.payload.requestId, error: msg.payload.error, requestType: msg.payload.requestType, code: msg.payload.code, }), - }; + } } - const value = params.select(msg); + const value = params.select(msg) if (value === null) { - return null; + return null } - return { kind: "ok", value }; + return { kind: 'ok', value } }, params.timeout, params.options - ); + ) try { - await this.sendSessionMessageOrThrow(params.message); + await this.sendSessionMessageOrThrow(params.message) } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - cancel(err); - void promise.catch(() => undefined); - throw err; + const err = error instanceof Error ? error : new Error(String(error)) + cancel(err) + void promise.catch(() => undefined) + throw err } - const result = await promise; - if (result.kind === "error") { - throw result.error; + const result = await promise + if (result.kind === 'error') { + throw result.error } - return result.value; + return result.value } private async sendCorrelatedRequest< TResponseType extends CorrelatedResponseType, TResult = CorrelatedResponsePayload, - >( - params: { - requestId: string; - message: SessionInboundMessage; - timeout: number; - responseType: TResponseType; - options?: { skipQueue?: boolean }; - selectPayload?: (payload: CorrelatedResponsePayload) => TResult | null; - } - ): Promise { + >(params: { + requestId: string + message: SessionInboundMessage + timeout: number + responseType: TResponseType + options?: { skipQueue?: boolean } + selectPayload?: (payload: CorrelatedResponsePayload) => TResult | null + }): Promise { return this.sendRequest({ requestId: params.requestId, message: params.message, timeout: params.timeout, options: params.options, select: (msg) => { - const correlated = msg as CorrelatedResponseMessage; + const correlated = msg as CorrelatedResponseMessage if (correlated.type !== params.responseType) { - return null; + return null } - const payload = - correlated.payload as unknown as CorrelatedResponsePayload; + const payload = correlated.payload as unknown as CorrelatedResponsePayload if (payload.requestId !== params.requestId) { - return null; + return null } if (!params.selectPayload) { - return payload as TResult; + return payload as TResult } - return params.selectPayload(payload); + return params.selectPayload(payload) }, - }); + }) } private sendCorrelatedSessionRequest< TResponseType extends CorrelatedResponseType, TResult = CorrelatedResponsePayload, - >( - params: { - requestId?: string; - message: { type: SessionInboundMessage["type"] } & Record; - responseType: TResponseType; - timeout: number; - selectPayload?: (payload: CorrelatedResponsePayload) => TResult | null; - } - ): Promise { - const resolvedRequestId = this.createRequestId(params.requestId); + >(params: { + requestId?: string + message: { type: SessionInboundMessage['type'] } & Record + responseType: TResponseType + timeout: number + selectPayload?: (payload: CorrelatedResponsePayload) => TResult | null + }): Promise { + const resolvedRequestId = this.createRequestId(params.requestId) const message = SessionInboundMessageSchema.parse({ ...params.message, requestId: resolvedRequestId, - }); + }) return this.sendCorrelatedRequest({ requestId: resolvedRequestId, message, @@ -909,73 +879,72 @@ export class DaemonClient { timeout: params.timeout, options: { skipQueue: true }, ...(params.selectPayload ? { selectPayload: params.selectPayload } : {}), - }); + }) } private sendSessionMessageStrict(message: SessionInboundMessage): void { - if (!this.transport || this.connectionState.status !== "connected") { - throw new Error("Transport not connected"); + if (!this.transport || this.connectionState.status !== 'connected') { + throw new Error('Transport not connected') } - const payload = SessionInboundMessageSchema.parse(message); + const payload = SessionInboundMessageSchema.parse(message) try { - this.transport.send(JSON.stringify({ type: "session", message: payload })); + this.transport.send(JSON.stringify({ type: 'session', message: payload })) } catch (error) { - throw error instanceof Error ? error : new Error(String(error)); + throw error instanceof Error ? error : new Error(String(error)) } } clearAgentAttention(agentId: string | string[]): void { - this.sendSessionMessage({ type: "clear_agent_attention", agentId }); + this.sendSessionMessage({ type: 'clear_agent_attention', agentId }) } sendHeartbeat(params: { - deviceType: "web" | "mobile"; - focusedAgentId: string | null; - lastActivityAt: string; - appVisible: boolean; - appVisibilityChangedAt?: string; + deviceType: 'web' | 'mobile' + focusedAgentId: string | null + lastActivityAt: string + appVisible: boolean + appVisibilityChangedAt?: string }): void { this.sendSessionMessage({ - type: "client_heartbeat", + type: 'client_heartbeat', deviceType: params.deviceType, focusedAgentId: params.focusedAgentId, lastActivityAt: params.lastActivityAt, appVisible: params.appVisible, appVisibilityChangedAt: params.appVisibilityChangedAt, - }); + }) } registerPushToken(token: string): void { this.sendSessionMessage({ - type: "register_push_token", + type: 'register_push_token', token, - }); + }) } async ping(params?: { requestId?: string; timeoutMs?: number }): Promise<{ - requestId: string; - clientSentAt: number; - serverReceivedAt: number; - serverSentAt: number; - rttMs: number; + requestId: string + clientSentAt: number + serverReceivedAt: number + serverSentAt: number + rttMs: number }> { const requestId = - params?.requestId ?? - `ping-${Date.now()}-${Math.random().toString(36).slice(2)}`; - const clientSentAt = Date.now(); + params?.requestId ?? `ping-${Date.now()}-${Math.random().toString(36).slice(2)}` + const clientSentAt = Date.now() const payload = await this.sendRequest({ requestId, - message: { type: "ping", requestId, clientSentAt }, + message: { type: 'ping', requestId, clientSentAt }, timeout: params?.timeoutMs ?? 5000, select: (msg) => { - if (msg.type !== "pong") return null; - if (msg.payload.requestId !== requestId) return null; - if (typeof msg.payload.serverReceivedAt !== "number") return null; - if (typeof msg.payload.serverSentAt !== "number") return null; - return msg.payload; + if (msg.type !== 'pong') return null + if (msg.payload.requestId !== requestId) return null + if (typeof msg.payload.serverReceivedAt !== 'number') return null + if (typeof msg.payload.serverSentAt !== 'number') return null + return msg.payload }, - }); + }) return { requestId, @@ -983,7 +952,7 @@ export class DaemonClient { serverReceivedAt: payload.serverReceivedAt, serverSentAt: payload.serverSentAt, rttMs: Date.now() - clientSentAt, - }; + } } // ============================================================================ @@ -991,85 +960,85 @@ export class DaemonClient { // ============================================================================ async fetchAgents(options?: FetchAgentsOptions): Promise { - const resolvedRequestId = this.createRequestId(options?.requestId); + const resolvedRequestId = this.createRequestId(options?.requestId) const message = SessionInboundMessageSchema.parse({ - type: "fetch_agents_request", + type: 'fetch_agents_request', requestId: resolvedRequestId, ...(options?.filter ? { filter: options.filter } : {}), ...(options?.sort ? { sort: options.sort } : {}), ...(options?.page ? { page: options.page } : {}), ...(options?.subscribe ? { subscribe: options.subscribe } : {}), - }); + }) return this.sendRequest({ requestId: resolvedRequestId, message, timeout: 10000, options: { skipQueue: true }, select: (msg) => { - if (msg.type !== "fetch_agents_response") { - return null; + if (msg.type !== 'fetch_agents_response') { + return null } if (msg.payload.requestId !== resolvedRequestId) { - return null; + return null } - return msg.payload; + return msg.payload }, - }); + }) } async fetchAgent(agentId: string, requestId?: string): Promise { - const resolvedRequestId = this.createRequestId(requestId); + const resolvedRequestId = this.createRequestId(requestId) const message = SessionInboundMessageSchema.parse({ - type: "fetch_agent_request", + type: 'fetch_agent_request', requestId: resolvedRequestId, agentId, - }); + }) const payload = await this.sendRequest({ requestId: resolvedRequestId, message, timeout: 10000, options: { skipQueue: true }, select: (msg) => { - if (msg.type !== "fetch_agent_response") { - return null; + if (msg.type !== 'fetch_agent_response') { + return null } if (msg.payload.requestId !== resolvedRequestId) { - return null; + return null } - return msg.payload; + return msg.payload }, - }); + }) if (payload.error) { - throw new Error(payload.error); + throw new Error(payload.error) } - return payload.agent; + return payload.agent } private resubscribeCheckoutDiffSubscriptions(): void { if (this.checkoutDiffSubscriptions.size === 0) { - return; + return } for (const [subscriptionId, subscription] of this.checkoutDiffSubscriptions) { const message = SessionInboundMessageSchema.parse({ - type: "subscribe_checkout_diff_request", + type: 'subscribe_checkout_diff_request', subscriptionId, cwd: subscription.cwd, compare: subscription.compare, requestId: this.createRequestId(), - }); - this.sendSessionMessage(message); + }) + this.sendSessionMessage(message) } } private resubscribeTerminalDirectorySubscriptions(): void { if (this.terminalDirectorySubscriptions.size === 0) { - return; + return } for (const cwd of this.terminalDirectorySubscriptions) { this.sendSessionMessage({ - type: "subscribe_terminals_request", + type: 'subscribe_terminals_request', cwd, - }); + }) } } @@ -1078,24 +1047,22 @@ export class DaemonClient { // ============================================================================ async createAgent(options: CreateAgentRequestOptions): Promise { - const requestId = this.createRequestId(options.requestId); - const config = resolveAgentConfig(options); + const requestId = this.createRequestId(options.requestId) + const config = resolveAgentConfig(options) const message = SessionInboundMessageSchema.parse({ - type: "create_agent_request", + type: 'create_agent_request', requestId, config, ...(options.initialPrompt ? { initialPrompt: options.initialPrompt } : {}), ...(options.outputSchema ? { outputSchema: options.outputSchema } : {}), - ...(options.images && options.images.length > 0 - ? { images: options.images } - : {}), + ...(options.images && options.images.length > 0 ? { images: options.images } : {}), ...(options.git ? { git: options.git } : {}), ...(options.worktreeName ? { worktreeName: options.worktreeName } : {}), ...(options.labels && Object.keys(options.labels).length > 0 ? { labels: options.labels } : {}), - }); + }) const status = await this.sendRequest({ requestId, @@ -1103,107 +1070,107 @@ export class DaemonClient { timeout: 15000, options: { skipQueue: true }, select: (msg) => { - if (msg.type !== "status") { - return null; + if (msg.type !== 'status') { + return null } - const created = AgentCreatedStatusPayloadSchema.safeParse(msg.payload); + const created = AgentCreatedStatusPayloadSchema.safeParse(msg.payload) if (created.success && created.data.requestId === requestId) { - return created.data; + return created.data } - const failed = AgentCreateFailedStatusPayloadSchema.safeParse(msg.payload); + const failed = AgentCreateFailedStatusPayloadSchema.safeParse(msg.payload) if (failed.success && failed.data.requestId === requestId) { - return failed.data; + return failed.data } - return null; + return null }, - }); - if (status.status === "agent_create_failed") { - throw new Error(status.error); + }) + if (status.status === 'agent_create_failed') { + throw new Error(status.error) } - return status.agent; + return status.agent } async deleteAgent(agentId: string): Promise { - const requestId = this.createRequestId(); + const requestId = this.createRequestId() const message = SessionInboundMessageSchema.parse({ - type: "delete_agent_request", + type: 'delete_agent_request', agentId, requestId, - }); + }) await this.sendRequest({ requestId, message, timeout: 10000, options: { skipQueue: true }, select: (msg) => { - if (msg.type !== "agent_deleted") { - return null; + if (msg.type !== 'agent_deleted') { + return null } if (msg.payload.requestId !== requestId) { - return null; + return null } - return msg.payload; + return msg.payload }, - }); + }) } async archiveAgent(agentId: string): Promise<{ archivedAt: string }> { - const requestId = this.createRequestId(); + const requestId = this.createRequestId() const message = SessionInboundMessageSchema.parse({ - type: "archive_agent_request", + type: 'archive_agent_request', agentId, requestId, - }); + }) const result = await this.sendRequest({ requestId, message, timeout: 10000, options: { skipQueue: true }, select: (msg) => { - if (msg.type !== "agent_archived") { - return null; + if (msg.type !== 'agent_archived') { + return null } if (msg.payload.requestId !== requestId) { - return null; + return null } - return msg.payload; + return msg.payload }, - }); - return { archivedAt: result.archivedAt }; + }) + return { archivedAt: result.archivedAt } } async updateAgent( agentId: string, updates: { name?: string; labels?: Record } ): Promise { - const requestId = this.createRequestId(); + const requestId = this.createRequestId() const message = SessionInboundMessageSchema.parse({ - type: "update_agent_request", + type: 'update_agent_request', agentId, ...(updates.name !== undefined ? { name: updates.name } : {}), ...(updates.labels && Object.keys(updates.labels).length > 0 ? { labels: updates.labels } : {}), requestId, - }); + }) const payload = await this.sendRequest({ requestId, message, timeout: 10000, options: { skipQueue: true }, select: (msg) => { - if (msg.type !== "update_agent_response") { - return null; + if (msg.type !== 'update_agent_response') { + return null } if (msg.payload.requestId !== requestId) { - return null; + return null } - return msg.payload; + return msg.payload }, - }); + }) if (!payload.accepted) { - throw new Error(payload.error ?? "updateAgent rejected"); + throw new Error(payload.error ?? 'updateAgent rejected') } } @@ -1211,13 +1178,13 @@ export class DaemonClient { handle: AgentPersistenceHandle, overrides?: Partial ): Promise { - const requestId = this.createRequestId(); + const requestId = this.createRequestId() const message = SessionInboundMessageSchema.parse({ - type: "resume_agent_request", + type: 'resume_agent_request', requestId, handle, ...(overrides ? { overrides } : {}), - }); + }) const status = await this.sendRequest({ requestId, @@ -1225,62 +1192,59 @@ export class DaemonClient { timeout: 15000, options: { skipQueue: true }, select: (msg) => { - if (msg.type !== "status") { - return null; + if (msg.type !== 'status') { + return null } - const resumed = AgentResumedStatusPayloadSchema.safeParse(msg.payload); + const resumed = AgentResumedStatusPayloadSchema.safeParse(msg.payload) if (resumed.success && resumed.data.requestId === requestId) { - return resumed.data; + return resumed.data } - return null; + return null }, - }); + }) - return status.agent; + return status.agent } - async refreshAgent( - agentId: string, - requestId?: string - ): Promise { - const resolvedRequestId = this.createRequestId(requestId); + async refreshAgent(agentId: string, requestId?: string): Promise { + const resolvedRequestId = this.createRequestId(requestId) const message = SessionInboundMessageSchema.parse({ - type: "refresh_agent_request", + type: 'refresh_agent_request', agentId, requestId: resolvedRequestId, - }); + }) return this.sendRequest({ requestId: resolvedRequestId, message, timeout: 15000, options: { skipQueue: true }, select: (msg) => { - if (msg.type !== "status") { - return null; + if (msg.type !== 'status') { + return null } - const refreshed = AgentRefreshedStatusPayloadSchema.safeParse(msg.payload); + const refreshed = AgentRefreshedStatusPayloadSchema.safeParse(msg.payload) if (refreshed.success && refreshed.data.requestId === resolvedRequestId) { - return refreshed.data; + return refreshed.data } - return null; + return null }, - }); + }) } async fetchAgentTimeline( agentId: string, options: FetchAgentTimelineOptions = {} ): Promise { - const resolvedRequestId = this.createRequestId(options.requestId); + const resolvedRequestId = this.createRequestId(options.requestId) const message = SessionInboundMessageSchema.parse({ - type: "fetch_agent_timeline_request", + type: 'fetch_agent_timeline_request', agentId, requestId: resolvedRequestId, ...(options.direction ? { direction: options.direction } : {}), ...(options.cursor ? { cursor: options.cursor } : {}), - ...(typeof options.limit === "number" ? { limit: options.limit } : {}), + ...(typeof options.limit === 'number' ? { limit: options.limit } : {}), ...(options.projection ? { projection: options.projection } : {}), - }); + }) const payload = await this.sendRequest({ requestId: resolvedRequestId, @@ -1288,21 +1252,21 @@ export class DaemonClient { timeout: 15000, options: { skipQueue: true }, select: (msg) => { - if (msg.type !== "fetch_agent_timeline_response") { - return null; + if (msg.type !== 'fetch_agent_timeline_response') { + return null } if (msg.payload.requestId !== resolvedRequestId) { - return null; + return null } - return msg.payload; + return msg.payload }, - }); + }) if (payload.error) { - throw new Error(payload.error); + throw new Error(payload.error) } - return payload; + return payload } // ============================================================================ @@ -1314,265 +1278,252 @@ export class DaemonClient { text: string, options?: SendMessageOptions ): Promise { - const requestId = this.createRequestId(); - const messageId = options?.messageId ?? crypto.randomUUID(); + const requestId = this.createRequestId() + const messageId = options?.messageId ?? crypto.randomUUID() const message = SessionInboundMessageSchema.parse({ - type: "send_agent_message_request", + type: 'send_agent_message_request', requestId, agentId, text, ...(messageId ? { messageId } : {}), ...(options?.images ? { images: options.images } : {}), - }); + }) const payload = await this.sendRequest({ requestId, message, timeout: 15000, options: { skipQueue: true }, select: (msg) => { - if (msg.type !== "send_agent_message_response") { - return null; + if (msg.type !== 'send_agent_message_response') { + return null } if (msg.payload.requestId !== requestId) { - return null; + return null } - return msg.payload; + return msg.payload }, - }); + }) if (!payload.accepted) { - throw new Error(payload.error ?? "sendAgentMessage rejected"); + throw new Error(payload.error ?? 'sendAgentMessage rejected') } } - async sendMessage( - agentId: string, - text: string, - options?: SendMessageOptions - ): Promise { - await this.sendAgentMessage(agentId, text, options); + async sendMessage(agentId: string, text: string, options?: SendMessageOptions): Promise { + await this.sendAgentMessage(agentId, text, options) } async cancelAgent(agentId: string): Promise { - this.sendSessionMessage({ type: "cancel_agent_request", agentId }); + this.sendSessionMessage({ type: 'cancel_agent_request', agentId }) } async setAgentMode(agentId: string, modeId: string): Promise { - const requestId = this.createRequestId(); + const requestId = this.createRequestId() const message = SessionInboundMessageSchema.parse({ - type: "set_agent_mode_request", + type: 'set_agent_mode_request', agentId, modeId, requestId, - }); + }) const payload = await this.sendRequest({ requestId, message, timeout: 15000, options: { skipQueue: true }, select: (msg) => { - if (msg.type !== "set_agent_mode_response") { - return null; + if (msg.type !== 'set_agent_mode_response') { + return null } if (msg.payload.requestId !== requestId) { - return null; + return null } - return msg.payload; + return msg.payload }, - }); + }) if (!payload.accepted) { - throw new Error(payload.error ?? "setAgentMode rejected"); + throw new Error(payload.error ?? 'setAgentMode rejected') } } async setAgentModel(agentId: string, modelId: string | null): Promise { - const requestId = this.createRequestId(); + const requestId = this.createRequestId() const message = SessionInboundMessageSchema.parse({ - type: "set_agent_model_request", + type: 'set_agent_model_request', agentId, modelId, requestId, - }); + }) const payload = await this.sendRequest({ requestId, message, timeout: 15000, options: { skipQueue: true }, select: (msg) => { - if (msg.type !== "set_agent_model_response") { - return null; + if (msg.type !== 'set_agent_model_response') { + return null } if (msg.payload.requestId !== requestId) { - return null; + return null } - return msg.payload; + return msg.payload }, - }); + }) if (!payload.accepted) { - throw new Error(payload.error ?? "setAgentModel rejected"); + throw new Error(payload.error ?? 'setAgentModel rejected') } } - async setAgentThinkingOption( - agentId: string, - thinkingOptionId: string | null - ): Promise { - const requestId = this.createRequestId(); + async setAgentThinkingOption(agentId: string, thinkingOptionId: string | null): Promise { + const requestId = this.createRequestId() const message = SessionInboundMessageSchema.parse({ - type: "set_agent_thinking_request", + type: 'set_agent_thinking_request', agentId, thinkingOptionId, requestId, - }); + }) const payload = await this.sendRequest({ requestId, message, timeout: 15000, options: { skipQueue: true }, select: (msg) => { - if (msg.type !== "set_agent_thinking_response") { - return null; + if (msg.type !== 'set_agent_thinking_response') { + return null } if (msg.payload.requestId !== requestId) { - return null; + return null } - return msg.payload; + return msg.payload }, - }); + }) if (!payload.accepted) { - throw new Error(payload.error ?? "setAgentThinkingOption rejected"); + throw new Error(payload.error ?? 'setAgentThinkingOption rejected') } } - async restartServer( - reason?: string, - requestId?: string - ): Promise { - const resolvedRequestId = this.createRequestId(requestId); + async restartServer(reason?: string, requestId?: string): Promise { + const resolvedRequestId = this.createRequestId(requestId) const message = SessionInboundMessageSchema.parse({ - type: "restart_server_request", + type: 'restart_server_request', ...(reason && reason.trim().length > 0 ? { reason } : {}), requestId: resolvedRequestId, - }); + }) return this.sendRequest({ requestId: resolvedRequestId, message, timeout: 10000, options: { skipQueue: true }, select: (msg) => { - if (msg.type !== "status") { - return null; + if (msg.type !== 'status') { + return null } - const restarted = RestartRequestedStatusPayloadSchema.safeParse( - msg.payload - ); + const restarted = RestartRequestedStatusPayloadSchema.safeParse(msg.payload) if (!restarted.success) { - return null; + return null } if (restarted.data.requestId !== resolvedRequestId) { - return null; + return null } - return restarted.data; + return restarted.data }, - }); + }) } // ============================================================================ // Audio / Voice // ============================================================================ - async setVoiceMode( - enabled: boolean, - agentId?: string - ): Promise { - const requestId = this.createRequestId(); + async setVoiceMode(enabled: boolean, agentId?: string): Promise { + const requestId = this.createRequestId() const message = SessionInboundMessageSchema.parse({ - type: "set_voice_mode", + type: 'set_voice_mode', enabled, ...(agentId ? { agentId } : {}), requestId, - }); + }) const response = await this.sendRequest({ requestId, message, timeout: 10000, select: (msg) => { - if (msg.type !== "set_voice_mode_response") { - return null; + if (msg.type !== 'set_voice_mode_response') { + return null } if (msg.payload.requestId !== requestId) { - return null; + return null } - return msg.payload; + return msg.payload }, - }); + }) if (!response.accepted) { const codeSuffix = - typeof response.reasonCode === "string" && response.reasonCode.trim().length > 0 + typeof response.reasonCode === 'string' && response.reasonCode.trim().length > 0 ? ` (${response.reasonCode})` - : ""; - throw new Error((response.error ?? "Failed to set voice mode") + codeSuffix); + : '' + throw new Error((response.error ?? 'Failed to set voice mode') + codeSuffix) } - return response; + return response } - async sendVoiceAudioChunk( - audio: string, - format: string, - isLast: boolean - ): Promise { - this.sendSessionMessage({ type: "voice_audio_chunk", audio, format, isLast }); + async sendVoiceAudioChunk(audio: string, format: string, isLast: boolean): Promise { + this.sendSessionMessage({ type: 'voice_audio_chunk', audio, format, isLast }) } async startDictationStream(dictationId: string, format: string): Promise { const ack = this.waitForWithCancel( (msg) => { - if (msg.type !== "dictation_stream_ack") { - return null; + if (msg.type !== 'dictation_stream_ack') { + return null } if (msg.payload.dictationId !== dictationId) { - return null; + return null } if (msg.payload.ackSeq !== -1) { - return null; + return null } - return msg.payload; + return msg.payload }, 30000, { skipQueue: true } - ); - const ackPromise = ack.promise.then(() => undefined); + ) + const ackPromise = ack.promise.then(() => undefined) const streamError = this.waitForWithCancel( (msg) => { - if (msg.type !== "dictation_stream_error") { - return null; + if (msg.type !== 'dictation_stream_error') { + return null } if (msg.payload.dictationId !== dictationId) { - return null; + return null } - return msg.payload; + return msg.payload }, 30000, { skipQueue: true } - ); + ) const errorPromise = streamError.promise.then((payload) => { - throw new Error(payload.error); - }); + throw new Error(payload.error) + }) - const cleanupError = new Error("Cancelled dictation start waiter"); + const cleanupError = new Error('Cancelled dictation start waiter') try { - this.sendSessionMessageStrict({ type: "dictation_stream_start", dictationId, format }); - await Promise.race([ackPromise, errorPromise]); + this.sendSessionMessageStrict({ type: 'dictation_stream_start', dictationId, format }) + await Promise.race([ackPromise, errorPromise]) } finally { - ack.cancel(cleanupError); - streamError.cancel(cleanupError); - void ackPromise.catch(() => undefined); - void errorPromise.catch(() => undefined); + ack.cancel(cleanupError) + streamError.cancel(cleanupError) + void ackPromise.catch(() => undefined) + void errorPromise.catch(() => undefined) } } sendDictationStreamChunk(dictationId: string, seq: number, audio: string, format: string): void { - this.sendSessionMessageStrict({ type: "dictation_stream_chunk", dictationId, seq, audio, format }); + this.sendSessionMessageStrict({ + type: 'dictation_stream_chunk', + dictationId, + seq, + audio, + format, + }) } async finishDictationStream( @@ -1581,157 +1532,153 @@ export class DaemonClient { ): Promise<{ dictationId: string; text: string }> { const final = this.waitForWithCancel( (msg) => { - if (msg.type !== "dictation_stream_final") { - return null; + if (msg.type !== 'dictation_stream_final') { + return null } if (msg.payload.dictationId !== dictationId) { - return null; + return null } - return msg.payload; + return msg.payload }, 0, { skipQueue: true } - ); + ) const streamError = this.waitForWithCancel( (msg) => { - if (msg.type !== "dictation_stream_error") { - return null; + if (msg.type !== 'dictation_stream_error') { + return null } if (msg.payload.dictationId !== dictationId) { - return null; + return null } - return msg.payload; + return msg.payload }, 0, { skipQueue: true } - ); + ) const finishAccepted = this.waitForWithCancel( (msg) => { - if (msg.type !== "dictation_stream_finish_accepted") { - return null; + if (msg.type !== 'dictation_stream_finish_accepted') { + return null } if (msg.payload.dictationId !== dictationId) { - return null; + return null } - return msg.payload; + return msg.payload }, DEFAULT_DICTATION_FINISH_ACCEPT_TIMEOUT_MS, { skipQueue: true } - ); + ) - const finalPromise = final.promise; + const finalPromise = final.promise const errorPromise = streamError.promise.then((payload) => { - throw new Error(payload.error); - }); - const finishAcceptedPromise = finishAccepted.promise; + throw new Error(payload.error) + }) + const finishAcceptedPromise = finishAccepted.promise const finalOutcomePromise = finalPromise.then((payload) => ({ - kind: "final" as const, + kind: 'final' as const, payload, - })); + })) const errorOutcomePromise = errorPromise.then( () => ({ - kind: "error" as const, - error: new Error("Unexpected dictation stream error state"), + kind: 'error' as const, + error: new Error('Unexpected dictation stream error state'), }), (error) => ({ - kind: "error" as const, + kind: 'error' as const, error: error instanceof Error ? error : new Error(String(error)), }) - ); + ) const finishAcceptedOutcomePromise = finishAcceptedPromise.then( - (payload) => ({ kind: "accepted" as const, payload }), + (payload) => ({ kind: 'accepted' as const, payload }), (error) => { if (isWaiterTimeoutError(error)) { - return { kind: "accepted_timeout" as const }; + return { kind: 'accepted_timeout' as const } } return { - kind: "accepted_error" as const, + kind: 'accepted_error' as const, error: error instanceof Error ? error : new Error(String(error)), - }; + } } - ); + ) const waitForFinalResult = async ( timeoutMs: number ): Promise<{ dictationId: string; text: string }> => { if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { - const outcome = await Promise.race([finalOutcomePromise, errorOutcomePromise]); - if (outcome.kind === "error") { - throw outcome.error; + const outcome = await Promise.race([finalOutcomePromise, errorOutcomePromise]) + if (outcome.kind === 'error') { + throw outcome.error } - return outcome.payload; + return outcome.payload } - let timeoutHandle: ReturnType | null = null; - const timeoutPromise = new Promise<{ kind: "timeout" }>((resolve) => { - timeoutHandle = setTimeout(() => resolve({ kind: "timeout" }), timeoutMs); - }); + let timeoutHandle: ReturnType | null = null + const timeoutPromise = new Promise<{ kind: 'timeout' }>((resolve) => { + timeoutHandle = setTimeout(() => resolve({ kind: 'timeout' }), timeoutMs) + }) - const outcome = await Promise.race([ - finalOutcomePromise, - errorOutcomePromise, - timeoutPromise, - ]); + const outcome = await Promise.race([finalOutcomePromise, errorOutcomePromise, timeoutPromise]) if (timeoutHandle) { - clearTimeout(timeoutHandle); + clearTimeout(timeoutHandle) } - if (outcome.kind === "timeout") { - throw new Error(`Timeout waiting for dictation finalization (${timeoutMs}ms)`); + if (outcome.kind === 'timeout') { + throw new Error(`Timeout waiting for dictation finalization (${timeoutMs}ms)`) } - if (outcome.kind === "error") { - throw outcome.error; + if (outcome.kind === 'error') { + throw outcome.error } - return outcome.payload; - }; + return outcome.payload + } - const cleanupError = new Error("Cancelled dictation finish waiter"); + const cleanupError = new Error('Cancelled dictation finish waiter') try { - this.sendSessionMessageStrict({ type: "dictation_stream_finish", dictationId, finalSeq }); + this.sendSessionMessageStrict({ type: 'dictation_stream_finish', dictationId, finalSeq }) const firstOutcome = await Promise.race([ finalOutcomePromise, errorOutcomePromise, finishAcceptedOutcomePromise, - ]); + ]) - if (firstOutcome.kind === "final") { - return firstOutcome.payload; + if (firstOutcome.kind === 'final') { + return firstOutcome.payload } - if (firstOutcome.kind === "error") { - throw firstOutcome.error; + if (firstOutcome.kind === 'error') { + throw firstOutcome.error } - if (firstOutcome.kind === "accepted") { + if (firstOutcome.kind === 'accepted') { return await waitForFinalResult( firstOutcome.payload.timeoutMs + DEFAULT_DICTATION_FINISH_TIMEOUT_GRACE_MS - ); + ) } - return await waitForFinalResult(DEFAULT_DICTATION_FINISH_FALLBACK_TIMEOUT_MS); + return await waitForFinalResult(DEFAULT_DICTATION_FINISH_FALLBACK_TIMEOUT_MS) } finally { - final.cancel(cleanupError); - streamError.cancel(cleanupError); - finishAccepted.cancel(cleanupError); - void finalPromise.catch(() => undefined); - void errorPromise.catch(() => undefined); - void finishAcceptedPromise.catch(() => undefined); + final.cancel(cleanupError) + streamError.cancel(cleanupError) + finishAccepted.cancel(cleanupError) + void finalPromise.catch(() => undefined) + void errorPromise.catch(() => undefined) + void finishAcceptedPromise.catch(() => undefined) } } cancelDictationStream(dictationId: string): void { - this.sendSessionMessageStrict({ type: "dictation_stream_cancel", dictationId }); + this.sendSessionMessageStrict({ type: 'dictation_stream_cancel', dictationId }) } async abortRequest(): Promise { - this.sendSessionMessage({ type: "abort_request" }); + this.sendSessionMessage({ type: 'abort_request' }) } async audioPlayed(id: string): Promise { - this.sendSessionMessage({ type: "audio_played", id }); + this.sendSessionMessage({ type: 'audio_played', id }) } // ============================================================================ @@ -1742,21 +1689,21 @@ export class DaemonClient { cwd: string, options?: { requestId?: string } ): Promise { - const requestId = options?.requestId; + const requestId = options?.requestId if (!requestId) { - const existing = this.checkoutStatusInFlight.get(cwd); + const existing = this.checkoutStatusInFlight.get(cwd) if (existing) { - return existing; + return existing } } - const resolvedRequestId = this.createRequestId(requestId); + const resolvedRequestId = this.createRequestId(requestId) const message = SessionInboundMessageSchema.parse({ - type: "checkout_status_request", + type: 'checkout_status_request', cwd, requestId: resolvedRequestId, - }); + }) const responsePromise = this.sendRequest({ requestId: resolvedRequestId, @@ -1764,63 +1711,64 @@ export class DaemonClient { timeout: 60000, options: { skipQueue: true }, select: (msg) => { - if (msg.type !== "checkout_status_response") { - return null; + if (msg.type !== 'checkout_status_response') { + return null } if (msg.payload.requestId !== resolvedRequestId) { - return null; + return null } - return msg.payload; + return msg.payload }, - }); + }) if (!requestId) { - this.checkoutStatusInFlight.set(cwd, responsePromise); + this.checkoutStatusInFlight.set(cwd, responsePromise) void responsePromise .finally(() => { if (this.checkoutStatusInFlight.get(cwd) === responsePromise) { - this.checkoutStatusInFlight.delete(cwd); + this.checkoutStatusInFlight.delete(cwd) } }) - .catch(() => undefined); + .catch(() => undefined) } - return responsePromise; + return responsePromise } - private normalizeCheckoutDiffCompare( - compare: { mode: "uncommitted" | "base"; baseRef?: string } - ): { mode: "uncommitted" | "base"; baseRef?: string } { - if (compare.mode === "uncommitted") { - return { mode: "uncommitted" }; + private normalizeCheckoutDiffCompare(compare: { + mode: 'uncommitted' | 'base' + baseRef?: string + }): { mode: 'uncommitted' | 'base'; baseRef?: string } { + if (compare.mode === 'uncommitted') { + return { mode: 'uncommitted' } } - const trimmedBaseRef = compare.baseRef?.trim(); + const trimmedBaseRef = compare.baseRef?.trim() if (!trimmedBaseRef) { - return { mode: "base" }; + return { mode: 'base' } } - return { mode: "base", baseRef: trimmedBaseRef }; + return { mode: 'base', baseRef: trimmedBaseRef } } async getCheckoutDiff( cwd: string, - compare: { mode: "uncommitted" | "base"; baseRef?: string }, + compare: { mode: 'uncommitted' | 'base'; baseRef?: string }, requestId?: string ): Promise { - const oneShotSubscriptionId = `oneshot-checkout-diff:${crypto.randomUUID()}`; + const oneShotSubscriptionId = `oneshot-checkout-diff:${crypto.randomUUID()}` try { const payload = await this.subscribeCheckoutDiff(cwd, compare, { subscriptionId: oneShotSubscriptionId, requestId, - }); + }) return { cwd: payload.cwd, files: payload.files, error: payload.error, requestId: payload.requestId, - }; + } } finally { try { - this.unsubscribeCheckoutDiff(oneShotSubscriptionId); + this.unsubscribeCheckoutDiff(oneShotSubscriptionId) } catch { // Ignore disconnect races during one-shot cleanup. } @@ -1829,56 +1777,56 @@ export class DaemonClient { async subscribeCheckoutDiff( cwd: string, - compare: { mode: "uncommitted" | "base"; baseRef?: string }, + compare: { mode: 'uncommitted' | 'base'; baseRef?: string }, options?: { subscriptionId?: string; requestId?: string } ): Promise { - const subscriptionId = options?.subscriptionId ?? crypto.randomUUID(); - const normalizedCompare = this.normalizeCheckoutDiffCompare(compare); - const previousSubscription = this.checkoutDiffSubscriptions.get(subscriptionId) ?? null; + const subscriptionId = options?.subscriptionId ?? crypto.randomUUID() + const normalizedCompare = this.normalizeCheckoutDiffCompare(compare) + const previousSubscription = this.checkoutDiffSubscriptions.get(subscriptionId) ?? null this.checkoutDiffSubscriptions.set(subscriptionId, { cwd, compare: normalizedCompare, - }); + }) - const resolvedRequestId = this.createRequestId(options?.requestId); + const resolvedRequestId = this.createRequestId(options?.requestId) const message = SessionInboundMessageSchema.parse({ - type: "subscribe_checkout_diff_request", + type: 'subscribe_checkout_diff_request', subscriptionId, cwd, compare: normalizedCompare, requestId: resolvedRequestId, - }); + }) try { return await this.sendCorrelatedRequest({ requestId: resolvedRequestId, message, - responseType: "subscribe_checkout_diff_response", + responseType: 'subscribe_checkout_diff_response', timeout: 60000, options: { skipQueue: true }, selectPayload: (payload) => { if (payload.subscriptionId !== subscriptionId) { - return null; + return null } - return payload; + return payload }, - }); + }) } catch (error) { if (previousSubscription) { - this.checkoutDiffSubscriptions.set(subscriptionId, previousSubscription); + this.checkoutDiffSubscriptions.set(subscriptionId, previousSubscription) } else { - this.checkoutDiffSubscriptions.delete(subscriptionId); + this.checkoutDiffSubscriptions.delete(subscriptionId) } - throw error; + throw error } } unsubscribeCheckoutDiff(subscriptionId: string): void { - this.checkoutDiffSubscriptions.delete(subscriptionId); + this.checkoutDiffSubscriptions.delete(subscriptionId) this.sendSessionMessage({ - type: "unsubscribe_checkout_diff_request", + type: 'unsubscribe_checkout_diff_request', subscriptionId, - }); + }) } async checkoutCommit( @@ -1889,33 +1837,33 @@ export class DaemonClient { return this.sendCorrelatedSessionRequest({ requestId, message: { - type: "checkout_commit_request", + type: 'checkout_commit_request', cwd, message: input.message, addAll: input.addAll, }, - responseType: "checkout_commit_response", + responseType: 'checkout_commit_response', timeout: 60000, - }); + }) } async checkoutMerge( cwd: string, - input: { baseRef?: string; strategy?: "merge" | "squash"; requireCleanTarget?: boolean }, + input: { baseRef?: string; strategy?: 'merge' | 'squash'; requireCleanTarget?: boolean }, requestId?: string ): Promise { return this.sendCorrelatedSessionRequest({ requestId, message: { - type: "checkout_merge_request", + type: 'checkout_merge_request', cwd, baseRef: input.baseRef, strategy: input.strategy, requireCleanTarget: input.requireCleanTarget, }, - responseType: "checkout_merge_response", + responseType: 'checkout_merge_response', timeout: 60000, - }); + }) } async checkoutMergeFromBase( @@ -1926,26 +1874,26 @@ export class DaemonClient { return this.sendCorrelatedSessionRequest({ requestId, message: { - type: "checkout_merge_from_base_request", + type: 'checkout_merge_from_base_request', cwd, baseRef: input.baseRef, requireCleanTarget: input.requireCleanTarget, }, - responseType: "checkout_merge_from_base_response", + responseType: 'checkout_merge_from_base_response', timeout: 60000, - }); + }) } async checkoutPush(cwd: string, requestId?: string): Promise { return this.sendCorrelatedSessionRequest({ requestId, message: { - type: "checkout_push_request", + type: 'checkout_push_request', cwd, }, - responseType: "checkout_push_response", + responseType: 'checkout_push_response', timeout: 60000, - }); + }) } async checkoutPrCreate( @@ -1956,30 +1904,27 @@ export class DaemonClient { return this.sendCorrelatedSessionRequest({ requestId, message: { - type: "checkout_pr_create_request", + type: 'checkout_pr_create_request', cwd, title: input.title, body: input.body, baseRef: input.baseRef, }, - responseType: "checkout_pr_create_response", + responseType: 'checkout_pr_create_response', timeout: 60000, - }); + }) } - async checkoutPrStatus( - cwd: string, - requestId?: string - ): Promise { + async checkoutPrStatus(cwd: string, requestId?: string): Promise { return this.sendCorrelatedSessionRequest({ requestId, message: { - type: "checkout_pr_status_request", + type: 'checkout_pr_status_request', cwd, }, - responseType: "checkout_pr_status_response", + responseType: 'checkout_pr_status_response', timeout: 60000, - }); + }) } async getPaseoWorktreeList( @@ -1989,13 +1934,13 @@ export class DaemonClient { return this.sendCorrelatedSessionRequest({ requestId, message: { - type: "paseo_worktree_list_request", + type: 'paseo_worktree_list_request', cwd: input.cwd, repoRoot: input.repoRoot, }, - responseType: "paseo_worktree_list_response", + responseType: 'paseo_worktree_list_response', timeout: 60000, - }); + }) } async archivePaseoWorktree( @@ -2005,14 +1950,14 @@ export class DaemonClient { return this.sendCorrelatedSessionRequest({ requestId, message: { - type: "paseo_worktree_archive_request", + type: 'paseo_worktree_archive_request', worktreePath: input.worktreePath, repoRoot: input.repoRoot, branchName: input.branchName, }, - responseType: "paseo_worktree_archive_response", + responseType: 'paseo_worktree_archive_response', timeout: 20000, - }); + }) } async validateBranch( @@ -2022,13 +1967,13 @@ export class DaemonClient { return this.sendCorrelatedSessionRequest({ requestId, message: { - type: "validate_branch_request", + type: 'validate_branch_request', cwd: options.cwd, branchName: options.branchName, }, - responseType: "validate_branch_response", + responseType: 'validate_branch_response', timeout: 10000, - }); + }) } async getBranchSuggestions( @@ -2038,30 +1983,39 @@ export class DaemonClient { return this.sendCorrelatedSessionRequest({ requestId, message: { - type: "branch_suggestions_request", + type: 'branch_suggestions_request', cwd: options.cwd, query: options.query, limit: options.limit, }, - responseType: "branch_suggestions_response", + responseType: 'branch_suggestions_response', timeout: 10000, - }); + }) } async getDirectorySuggestions( - options: { query: string; limit?: number }, + options: { + query: string + limit?: number + cwd?: string + includeFiles?: boolean + includeDirectories?: boolean + }, requestId?: string ): Promise { return this.sendCorrelatedSessionRequest({ requestId, message: { - type: "directory_suggestions_request", + type: 'directory_suggestions_request', query: options.query, + cwd: options.cwd, + includeFiles: options.includeFiles, + includeDirectories: options.includeDirectories, limit: options.limit, }, - responseType: "directory_suggestions_response", + responseType: 'directory_suggestions_response', timeout: 10000, - }); + }) } // ============================================================================ @@ -2071,20 +2025,20 @@ export class DaemonClient { async exploreFileSystem( agentId: string, path: string, - mode: "list" | "file" = "list", + mode: 'list' | 'file' = 'list', requestId?: string ): Promise { return this.sendCorrelatedSessionRequest({ requestId, message: { - type: "file_explorer_request", + type: 'file_explorer_request', agentId, path, mode, }, - responseType: "file_explorer_response", + responseType: 'file_explorer_response', timeout: 10000, - }); + }) } async requestDownloadToken( @@ -2095,28 +2049,28 @@ export class DaemonClient { return this.sendCorrelatedSessionRequest({ requestId, message: { - type: "file_download_token_request", + type: 'file_download_token_request', agentId, path, }, - responseType: "file_download_token_response", + responseType: 'file_download_token_response', timeout: 10000, - }); + }) } async requestProjectIcon( cwd: string, requestId?: string - ): Promise { + ): Promise { return this.sendCorrelatedSessionRequest({ requestId, message: { - type: "project_icon_request", + type: 'project_icon_request', cwd, }, - responseType: "project_icon_response", + responseType: 'project_icon_response', timeout: 10000, - }); + }) } // ============================================================================ @@ -2130,81 +2084,75 @@ export class DaemonClient { return this.sendCorrelatedSessionRequest({ requestId: options?.requestId, message: { - type: "list_provider_models_request", + type: 'list_provider_models_request', provider, cwd: options?.cwd, }, - responseType: "list_provider_models_response", + responseType: 'list_provider_models_response', timeout: 30000, - }); + }) } async listAvailableProviders(options?: { - requestId?: string; + requestId?: string }): Promise { return this.sendCorrelatedSessionRequest({ requestId: options?.requestId, message: { - type: "list_available_providers_request", + type: 'list_available_providers_request', }, - responseType: "list_available_providers_response", + responseType: 'list_available_providers_response', timeout: 30000, - }); + }) } async listSpeechModels(requestId?: string): Promise { return this.sendCorrelatedSessionRequest({ requestId, message: { - type: "speech_models_list_request", + type: 'speech_models_list_request', }, - responseType: "speech_models_list_response", + responseType: 'speech_models_list_response', timeout: 30000, - }); + }) } - async downloadSpeechModels( - options?: { modelIds?: string[]; requestId?: string } - ): Promise { + async downloadSpeechModels(options?: { + modelIds?: string[] + requestId?: string + }): Promise { return this.sendCorrelatedSessionRequest({ requestId: options?.requestId, message: { - type: "speech_models_download_request", + type: 'speech_models_download_request', modelIds: options?.modelIds, }, - responseType: "speech_models_download_response", + responseType: 'speech_models_download_response', timeout: 30 * 60 * 1000, - }); + }) } - async listCommands(agentId: string, requestId?: string): Promise; - async listCommands( - agentId: string, - options?: ListCommandsOptions - ): Promise; + async listCommands(agentId: string, requestId?: string): Promise + async listCommands(agentId: string, options?: ListCommandsOptions): Promise async listCommands( agentId: string, requestIdOrOptions?: string | ListCommandsOptions ): Promise { const requestId = - typeof requestIdOrOptions === "string" - ? requestIdOrOptions - : requestIdOrOptions?.requestId; + typeof requestIdOrOptions === 'string' ? requestIdOrOptions : requestIdOrOptions?.requestId const draftConfig = - typeof requestIdOrOptions === "string" - ? undefined - : requestIdOrOptions?.draftConfig; + typeof requestIdOrOptions === 'string' ? undefined : requestIdOrOptions?.draftConfig return this.sendCorrelatedSessionRequest({ requestId, message: { - type: "list_commands_request", + type: 'list_commands_request', agentId, ...(draftConfig ? { draftConfig } : {}), }, - responseType: "list_commands_response", + responseType: 'list_commands_response', timeout: 30000, - }); + }) } // ============================================================================ @@ -2217,11 +2165,11 @@ export class DaemonClient { response: AgentPermissionResponse ): Promise { this.sendSessionMessage({ - type: "agent_permission_response", + type: 'agent_permission_response', agentId, requestId, response, - }); + }) } async respondToPermissionAndWait( @@ -2231,29 +2179,29 @@ export class DaemonClient { timeout = 15000 ): Promise { const message = SessionInboundMessageSchema.parse({ - type: "agent_permission_response", + type: 'agent_permission_response', agentId, requestId, response, - }); + }) return this.sendRequest({ requestId, message, timeout, options: { skipQueue: true }, select: (msg) => { - if (msg.type !== "agent_permission_resolved") { - return null; + if (msg.type !== 'agent_permission_resolved') { + return null } if (msg.payload.requestId !== requestId) { - return null; + return null } if (msg.payload.agentId !== agentId) { - return null; + return null } - return msg.payload; + return msg.payload }, - }); + }) } // ============================================================================ @@ -2265,41 +2213,38 @@ export class DaemonClient { predicate: (snapshot: AgentSnapshotPayload) => boolean, timeout = 60000 ): Promise { - const startedAt = Date.now(); + const startedAt = Date.now() while (Date.now() - startedAt < timeout) { - const snapshot = await this.fetchAgent(agentId).catch(() => null); + const snapshot = await this.fetchAgent(agentId).catch(() => null) if (snapshot && predicate(snapshot)) { - return snapshot; + return snapshot } - await new Promise((resolve) => setTimeout(resolve, 250)); + await new Promise((resolve) => setTimeout(resolve, 250)) } - throw new Error(`Timed out waiting for agent ${agentId}`); + throw new Error(`Timed out waiting for agent ${agentId}`) } - async waitForFinish( - agentId: string, - timeout = 60000 - ): Promise { - const requestId = this.createRequestId(); + async waitForFinish(agentId: string, timeout = 60000): Promise { + const requestId = this.createRequestId() const message = SessionInboundMessageSchema.parse({ - type: "wait_for_finish_request", + type: 'wait_for_finish_request', requestId, agentId, timeoutMs: timeout, - }); + }) const payload = await this.sendCorrelatedRequest({ requestId, message, - responseType: "wait_for_finish_response", + responseType: 'wait_for_finish_response', timeout: timeout + 5000, options: { skipQueue: true }, - }); + }) return { status: payload.status, final: payload.final, error: payload.error, lastMessage: payload.lastMessage, - }; + } } // ============================================================================ @@ -2307,44 +2252,41 @@ export class DaemonClient { // ============================================================================ subscribeTerminals(input: { cwd: string }): void { - this.terminalDirectorySubscriptions.add(input.cwd); - if (!this.transport || this.connectionState.status !== "connected") { - return; + this.terminalDirectorySubscriptions.add(input.cwd) + if (!this.transport || this.connectionState.status !== 'connected') { + return } this.sendSessionMessage({ - type: "subscribe_terminals_request", + type: 'subscribe_terminals_request', cwd: input.cwd, - }); + }) } unsubscribeTerminals(input: { cwd: string }): void { - this.terminalDirectorySubscriptions.delete(input.cwd); - if (!this.transport || this.connectionState.status !== "connected") { - return; + this.terminalDirectorySubscriptions.delete(input.cwd) + if (!this.transport || this.connectionState.status !== 'connected') { + return } this.sendSessionMessage({ - type: "unsubscribe_terminals_request", + type: 'unsubscribe_terminals_request', cwd: input.cwd, - }); + }) } - async listTerminals( - cwd: string, - requestId?: string - ): Promise { - const resolvedRequestId = this.createRequestId(requestId); + async listTerminals(cwd: string, requestId?: string): Promise { + const resolvedRequestId = this.createRequestId(requestId) const message = SessionInboundMessageSchema.parse({ - type: "list_terminals_request", + type: 'list_terminals_request', cwd, requestId: resolvedRequestId, - }); + }) return this.sendCorrelatedRequest({ requestId: resolvedRequestId, message, - responseType: "list_terminals_response", + responseType: 'list_terminals_response', timeout: 10000, options: { skipQueue: true }, - }); + }) } async createTerminal( @@ -2352,131 +2294,125 @@ export class DaemonClient { name?: string, requestId?: string ): Promise { - const resolvedRequestId = this.createRequestId(requestId); + const resolvedRequestId = this.createRequestId(requestId) const message = SessionInboundMessageSchema.parse({ - type: "create_terminal_request", + type: 'create_terminal_request', cwd, name, requestId: resolvedRequestId, - }); + }) return this.sendCorrelatedRequest({ requestId: resolvedRequestId, message, - responseType: "create_terminal_response", + responseType: 'create_terminal_response', timeout: 10000, options: { skipQueue: true }, - }); + }) } async subscribeTerminal( terminalId: string, requestId?: string ): Promise { - const resolvedRequestId = this.createRequestId(requestId); + const resolvedRequestId = this.createRequestId(requestId) const message = SessionInboundMessageSchema.parse({ - type: "subscribe_terminal_request", + type: 'subscribe_terminal_request', terminalId, requestId: resolvedRequestId, - }); + }) return this.sendCorrelatedRequest({ requestId: resolvedRequestId, message, - responseType: "subscribe_terminal_response", + responseType: 'subscribe_terminal_response', timeout: 10000, options: { skipQueue: true }, - }); + }) } unsubscribeTerminal(terminalId: string): void { this.sendSessionMessage({ - type: "unsubscribe_terminal_request", + type: 'unsubscribe_terminal_request', terminalId, - }); + }) } - sendTerminalInput( - terminalId: string, - message: TerminalInput["message"] - ): void { + sendTerminalInput(terminalId: string, message: TerminalInput['message']): void { this.sendSessionMessage({ - type: "terminal_input", + type: 'terminal_input', terminalId, message, - }); + }) } - async killTerminal( - terminalId: string, - requestId?: string - ): Promise { - const resolvedRequestId = this.createRequestId(requestId); + async killTerminal(terminalId: string, requestId?: string): Promise { + const resolvedRequestId = this.createRequestId(requestId) const message = SessionInboundMessageSchema.parse({ - type: "kill_terminal_request", + type: 'kill_terminal_request', terminalId, requestId: resolvedRequestId, - }); + }) return this.sendCorrelatedRequest({ requestId: resolvedRequestId, message, - responseType: "kill_terminal_response", + responseType: 'kill_terminal_response', timeout: 10000, options: { skipQueue: true }, - }); + }) } async attachTerminalStream( terminalId: string, options?: { - resumeOffset?: number; - rows?: number; - cols?: number; + resumeOffset?: number + rows?: number + cols?: number }, requestId?: string ): Promise { - const resolvedRequestId = this.createRequestId(requestId); + const resolvedRequestId = this.createRequestId(requestId) const message = SessionInboundMessageSchema.parse({ - type: "attach_terminal_stream_request", + type: 'attach_terminal_stream_request', terminalId, requestId: resolvedRequestId, ...(options?.resumeOffset !== undefined ? { resumeOffset: options.resumeOffset } : {}), ...(options?.rows !== undefined ? { rows: options.rows } : {}), ...(options?.cols !== undefined ? { cols: options.cols } : {}), - }); + }) return this.sendCorrelatedRequest({ requestId: resolvedRequestId, message, - responseType: "attach_terminal_stream_response", + responseType: 'attach_terminal_stream_response', timeout: 10000, options: { skipQueue: true }, - }); + }) } async detachTerminalStream( streamId: number, requestId?: string ): Promise { - const resolvedRequestId = this.createRequestId(requestId); + const resolvedRequestId = this.createRequestId(requestId) const message = SessionInboundMessageSchema.parse({ - type: "detach_terminal_stream_request", + type: 'detach_terminal_stream_request', streamId, requestId: resolvedRequestId, - }); + }) const payload = await this.sendCorrelatedRequest({ requestId: resolvedRequestId, message, - responseType: "detach_terminal_stream_response", + responseType: 'detach_terminal_stream_response', timeout: 10000, options: { skipQueue: true }, - }); - this.terminalStreams.clearStream({ streamId }); - return payload; + }) + this.terminalStreams.clearStream({ streamId }) + return payload } onTerminalStreamData( streamId: number, handler: (chunk: TerminalStreamChunk) => void ): () => void { - return this.terminalStreams.subscribe({ streamId, handler }); + return this.terminalStreams.subscribe({ streamId, handler }) } async waitForTerminalStreamData( @@ -2486,69 +2422,66 @@ export class DaemonClient { ): Promise { return new Promise((resolve, reject) => { const timeoutHandle = setTimeout(() => { - unsubscribe(); - reject(new Error(`Timeout waiting for terminal stream data (${timeout}ms)`)); - }, timeout); + unsubscribe() + reject(new Error(`Timeout waiting for terminal stream data (${timeout}ms)`)) + }, timeout) const unsubscribe = this.onTerminalStreamData(streamId, (chunk) => { if (!predicate(chunk)) { - return; + return } - clearTimeout(timeoutHandle); - unsubscribe(); - resolve(chunk); - }); - }); + clearTimeout(timeoutHandle) + unsubscribe() + resolve(chunk) + }) + }) } sendTerminalStreamInput(streamId: number, data: string | Uint8Array): void { - const payload = typeof data === "string" ? encodeUtf8String(data) : data; + const payload = typeof data === 'string' ? encodeUtf8String(data) : data this.sendBinaryFrame({ channel: BinaryMuxChannel.Terminal, messageType: TerminalBinaryMessageType.InputUtf8, streamId, offset: 0, payload, - }); + }) } sendTerminalStreamKey(streamId: number, input: TerminalKeyInput): void { - const encoded = encodeTerminalKeyInput(input); + const encoded = encodeTerminalKeyInput(input) if (!encoded) { - return; + return } - this.sendTerminalStreamInput(streamId, encoded); + this.sendTerminalStreamInput(streamId, encoded) } sendTerminalStreamAck(streamId: number, offset: number): void { - const normalizedOffset = Math.max(0, Math.floor(offset)); - this.terminalStreams.noteAck({ streamId, offset: normalizedOffset }); + const normalizedOffset = Math.max(0, Math.floor(offset)) + this.terminalStreams.noteAck({ streamId, offset: normalizedOffset }) this.sendBinaryFrame({ channel: BinaryMuxChannel.Terminal, messageType: TerminalBinaryMessageType.Ack, streamId, offset: normalizedOffset, payload: new Uint8Array(0), - }); + }) } - async waitForTerminalOutput( - terminalId: string, - timeout = 5000 - ): Promise { + async waitForTerminalOutput(terminalId: string, timeout = 5000): Promise { return this.waitFor( (msg) => { - if (msg.type !== "terminal_output") { - return null; + if (msg.type !== 'terminal_output') { + return null } if (msg.payload.terminalId !== terminalId) { - return null; + return null } - return msg.payload; + return msg.payload }, timeout, { skipQueue: true } - ); + ) } // ============================================================================ @@ -2556,73 +2489,71 @@ export class DaemonClient { // ============================================================================ private createRequestId(requestId?: string): string { - return requestId ?? crypto.randomUUID(); + return requestId ?? crypto.randomUUID() } - private disposeTransport(code = 1001, reason = "Reconnecting"): void { - this.cleanupTransport(); + private disposeTransport(code = 1001, reason = 'Reconnecting'): void { + this.cleanupTransport() if (this.transport) { try { - this.transport.close(code, reason); + this.transport.close(code, reason) } catch { // no-op } - this.transport = null; + this.transport = null } } private cleanupTransport(): void { if (this.pendingGenericTransportErrorTimeout) { - clearTimeout(this.pendingGenericTransportErrorTimeout); - this.pendingGenericTransportErrorTimeout = null; + clearTimeout(this.pendingGenericTransportErrorTimeout) + this.pendingGenericTransportErrorTimeout = null } for (const cleanup of this.transportCleanup) { try { - cleanup(); + cleanup() } catch { // no-op } } - this.transportCleanup = []; + this.transportCleanup = [] } private handleTransportMessage(data: unknown): void { const rawData = - data && typeof data === "object" && "data" in data - ? (data as { data: unknown }).data - : data; - const rawBytes = asUint8Array(rawData); + data && typeof data === 'object' && 'data' in data ? (data as { data: unknown }).data : data + const rawBytes = asUint8Array(rawData) if (rawBytes) { - const frame = decodeBinaryMuxFrame(rawBytes); + const frame = decodeBinaryMuxFrame(rawBytes) if (frame) { - this.handleBinaryFrame(frame); - return; + this.handleBinaryFrame(frame) + return } } - const payload = decodeMessageData(rawData); + const payload = decodeMessageData(rawData) if (!payload) { - return; + return } - let parsedJson: unknown; + let parsedJson: unknown try { - parsedJson = JSON.parse(payload); + parsedJson = JSON.parse(payload) } catch { - return; + return } - const parsed = WSOutboundMessageSchema.safeParse(parsedJson); + const parsed = WSOutboundMessageSchema.safeParse(parsedJson) if (!parsed.success) { - const msgType = (parsedJson as { message?: { type?: string } })?.message?.type ?? "unknown"; - this.logger.warn({ msgType, error: parsed.error.message }, "Message validation failed"); - return; + const msgType = (parsedJson as { message?: { type?: string } })?.message?.type ?? 'unknown' + this.logger.warn({ msgType, error: parsed.error.message }, 'Message validation failed') + return } - if (parsed.data.type === "pong") { - return; + if (parsed.data.type === 'pong') { + return } - this.handleSessionMessage(parsed.data.message); + this.handleSessionMessage(parsed.data.message) } private handleBinaryFrame(frame: BinaryMuxFrame): void { @@ -2636,17 +2567,17 @@ export class DaemonClient { endOffset: frame.offset + (frame.payload?.byteLength ?? 0), replay: Boolean((frame.flags ?? 0) & TerminalBinaryFlags.Replay), data: frame.payload ?? new Uint8Array(0), - }; - this.terminalStreams.receiveChunk({ chunk }); - return; + } + this.terminalStreams.receiveChunk({ chunk }) + return } } private updateConnectionState(next: ConnectionState): void { - this.connectionState = next; + this.connectionState = next for (const listener of this.connectionListeners) { try { - listener(next); + listener(next) } catch { // no-op } @@ -2655,93 +2586,89 @@ export class DaemonClient { private scheduleReconnect(reason?: string): void { if (this.reconnectTimeout) { - clearTimeout(this.reconnectTimeout); - this.reconnectTimeout = null; + clearTimeout(this.reconnectTimeout) + this.reconnectTimeout = null } if (!this.shouldReconnect || this.config.reconnect?.enabled === false) { - this.rejectConnect( - new Error(reason ?? "Transport disconnected before connect") - ); - return; + this.rejectConnect(new Error(reason ?? 'Transport disconnected before connect')) + return } - const attempt = this.reconnectAttempt; - const baseDelay = - this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS; - const maxDelay = - this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS; - const delay = Math.min(baseDelay * 2 ** attempt, maxDelay); - this.reconnectAttempt = attempt + 1; + const attempt = this.reconnectAttempt + const baseDelay = this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS + const maxDelay = this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS + const delay = Math.min(baseDelay * 2 ** attempt, maxDelay) + this.reconnectAttempt = attempt + 1 - if (typeof reason === "string" && reason.trim().length > 0) { - this.lastErrorValue = reason.trim(); + if (typeof reason === 'string' && reason.trim().length > 0) { + this.lastErrorValue = reason.trim() } // Clear all pending waiters and queued sends since the connection was lost // and responses from the previous connection will never arrive. - this.clearWaiters(new Error(reason ?? "Connection lost")); - this.rejectPendingSendQueue(new Error(reason ?? "Connection lost")); - this.terminalStreams.clearAll(); + this.clearWaiters(new Error(reason ?? 'Connection lost')) + this.rejectPendingSendQueue(new Error(reason ?? 'Connection lost')) + this.terminalStreams.clearAll() this.updateConnectionState({ - status: "disconnected", + status: 'disconnected', ...(reason ? { reason } : {}), - }); + }) this.reconnectTimeout = setTimeout(() => { - this.reconnectTimeout = null; + this.reconnectTimeout = null if (!this.shouldReconnect) { - return; + return } - this.attemptConnect(); - }, delay); + this.attemptConnect() + }, delay) } private handleSessionMessage(msg: SessionOutboundMessage): void { - if (msg.type === "terminal_stream_exit") { - this.terminalStreams.clearStream({ streamId: msg.payload.streamId }); + if (msg.type === 'terminal_stream_exit') { + this.terminalStreams.clearStream({ streamId: msg.payload.streamId }) } if (this.rawMessageListeners.size > 0) { for (const handler of this.rawMessageListeners) { try { - handler(msg); + handler(msg) } catch { // no-op } } } - const handlers = this.messageHandlers.get(msg.type); + const handlers = this.messageHandlers.get(msg.type) if (handlers) { for (const handler of handlers) { try { - handler(msg); + handler(msg) } catch { // no-op } } } - const event = this.toEvent(msg); + const event = this.toEvent(msg) if (event) { for (const handler of this.eventListeners) { - handler(event); + handler(event) } } - this.resolveWaiters(msg); + this.resolveWaiters(msg) } private resolveWaiters(msg: SessionOutboundMessage): void { for (const waiter of Array.from(this.waiters)) { - const result = waiter.predicate(msg); + const result = waiter.predicate(msg) if (result !== null) { - this.waiters.delete(waiter); + this.waiters.delete(waiter) if (waiter.timeoutHandle) { - clearTimeout(waiter.timeoutHandle); + clearTimeout(waiter.timeoutHandle) } - waiter.resolve(result); + waiter.resolve(result) } } } @@ -2749,52 +2676,49 @@ export class DaemonClient { private clearWaiters(error: Error): void { for (const waiter of Array.from(this.waiters)) { if (waiter.timeoutHandle) { - clearTimeout(waiter.timeoutHandle); + clearTimeout(waiter.timeoutHandle) } - waiter.reject(error); + waiter.reject(error) } - this.waiters.clear(); + this.waiters.clear() } private toEvent(msg: SessionOutboundMessage): DaemonEvent | null { switch (msg.type) { - case "agent_update": + case 'agent_update': return { - type: "agent_update", - agentId: - msg.payload.kind === "upsert" - ? msg.payload.agent.id - : msg.payload.agentId, + type: 'agent_update', + agentId: msg.payload.kind === 'upsert' ? msg.payload.agent.id : msg.payload.agentId, payload: msg.payload, - }; - case "agent_stream": + } + case 'agent_stream': return { - type: "agent_stream", + type: 'agent_stream', agentId: msg.payload.agentId, event: msg.payload.event, timestamp: msg.payload.timestamp, - ...(typeof msg.payload.seq === "number" ? { seq: msg.payload.seq } : {}), - ...(typeof msg.payload.epoch === "string" ? { epoch: msg.payload.epoch } : {}), - }; - case "status": - return { type: "status", payload: msg.payload }; - case "agent_deleted": - return { type: "agent_deleted", agentId: msg.payload.agentId }; - case "agent_permission_request": + ...(typeof msg.payload.seq === 'number' ? { seq: msg.payload.seq } : {}), + ...(typeof msg.payload.epoch === 'string' ? { epoch: msg.payload.epoch } : {}), + } + case 'status': + return { type: 'status', payload: msg.payload } + case 'agent_deleted': + return { type: 'agent_deleted', agentId: msg.payload.agentId } + case 'agent_permission_request': return { - type: "agent_permission_request", + type: 'agent_permission_request', agentId: msg.payload.agentId, request: msg.payload.request, - }; - case "agent_permission_resolved": + } + case 'agent_permission_resolved': return { - type: "agent_permission_resolved", + type: 'agent_permission_resolved', agentId: msg.payload.agentId, requestId: msg.payload.requestId, resolution: msg.payload.resolution, - }; + } default: - return null; + return null } } @@ -2803,7 +2727,7 @@ export class DaemonClient { timeout = 30000, _options?: { skipQueue?: boolean } ): Promise { - return this.waitForWithCancel(predicate, timeout, _options).promise; + return this.waitForWithCancel(predicate, timeout, _options).promise } private waitForWithCancel( @@ -2812,70 +2736,70 @@ export class DaemonClient { _options?: { skipQueue?: boolean } ): WaitHandle { // Capture stack trace at call site, not inside setTimeout - const timeoutError = new Error(`Timeout waiting for message (${timeout}ms)`); + const timeoutError = new Error(`Timeout waiting for message (${timeout}ms)`) - let waiter: Waiter | null = null; - let settled = false; - let rejectFn: ((error: Error) => void) | null = null; + let waiter: Waiter | null = null + let settled = false + let rejectFn: ((error: Error) => void) | null = null const promise = new Promise((resolve, reject) => { const wrappedResolve = (value: T) => { - if (settled) return; - settled = true; - resolve(value); - }; + if (settled) return + settled = true + resolve(value) + } const wrappedReject = (error: Error) => { - if (settled) return; - settled = true; - reject(error); - }; - rejectFn = wrappedReject; + if (settled) return + settled = true + reject(error) + } + rejectFn = wrappedReject const timeoutHandle = timeout > 0 ? setTimeout(() => { if (waiter) { - this.waiters.delete(waiter); + this.waiters.delete(waiter) } - wrappedReject(timeoutError); + wrappedReject(timeoutError) }, timeout) - : null; + : null waiter = { predicate, resolve: wrappedResolve, reject: wrappedReject, timeoutHandle, - }; - this.waiters.add(waiter); - }); + } + this.waiters.add(waiter) + }) const cancel = (error: Error) => { if (settled) { - return; + return } if (waiter) { - this.waiters.delete(waiter); + this.waiters.delete(waiter) if (waiter.timeoutHandle) { - clearTimeout(waiter.timeoutHandle); + clearTimeout(waiter.timeoutHandle) } } if (rejectFn) { - rejectFn(error); - return; + rejectFn(error) + return } // Extremely unlikely: cancel called before the Promise executor ran. queueMicrotask(() => { if (!settled && rejectFn) { - rejectFn(error); + rejectFn(error) } - }); - }; + }) + } - return { promise, cancel }; + return { promise, cancel } } } @@ -2891,27 +2815,27 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon requestId: _requestId, labels: _labels, ...overrides - } = options; + } = options const baseConfig: Partial = { ...(provider ? { provider } : {}), ...(cwd ? { cwd } : {}), ...overrides, - }; + } - const merged = config ? { ...baseConfig, ...config } : baseConfig; + const merged = config ? { ...baseConfig, ...config } : baseConfig if (!merged.provider || !merged.cwd) { - throw new Error("createAgent requires provider and cwd"); + throw new Error('createAgent requires provider and cwd') } if (!merged.modeId) { - merged.modeId = getAgentProviderDefinition(merged.provider).defaultModeId ?? undefined; + merged.modeId = getAgentProviderDefinition(merged.provider).defaultModeId ?? undefined } return { ...merged, provider: merged.provider, cwd: merged.cwd, - }; + } } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index eaf5183ef..161be4e7c 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,12 +1,12 @@ -import { v4 as uuidv4 } from "uuid"; -import { watch, type FSWatcher } from "node:fs"; -import { stat } from "fs/promises"; -import { exec } from "child_process"; -import { promisify } from "util"; -import { join, resolve, sep } from "path"; -import { homedir } from "node:os"; -import { z } from "zod"; -import type { ToolSet } from "ai"; +import { v4 as uuidv4 } from 'uuid' +import { watch, type FSWatcher } from 'node:fs' +import { stat } from 'fs/promises' +import { exec } from 'child_process' +import { promisify } from 'util' +import { join, resolve, sep } from 'path' +import { homedir } from 'node:os' +import { z } from 'zod' +import type { ToolSet } from 'ai' import { serializeAgentStreamEvent, type AgentSnapshotPayload, @@ -30,62 +30,51 @@ import { type DirectorySuggestionsRequest, type ProjectCheckoutLitePayload, type ProjectPlacementPayload, -} from "./messages.js"; -import type { - TerminalManager, - TerminalsChangedEvent, -} from "../terminal/terminal-manager.js"; -import type { TerminalSession } from "../terminal/terminal.js"; +} from './messages.js' +import type { TerminalManager, TerminalsChangedEvent } from '../terminal/terminal-manager.js' +import type { TerminalSession } from '../terminal/terminal.js' import { BinaryMuxChannel, TerminalBinaryFlags, TerminalBinaryMessageType, type BinaryMuxFrame, -} from "../shared/binary-mux.js"; -import { TTSManager } from "./agent/tts-manager.js"; -import { STTManager } from "./agent/stt-manager.js"; -import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js"; -import { maybePersistTtsDebugAudio } from "./agent/tts-debug.js"; -import { isPaseoDictationDebugEnabled } from "./agent/recordings-debug.js"; +} from '../shared/binary-mux.js' +import { TTSManager } from './agent/tts-manager.js' +import { STTManager } from './agent/stt-manager.js' +import type { SpeechToTextProvider, TextToSpeechProvider } from './speech/speech-provider.js' +import { maybePersistTtsDebugAudio } from './agent/tts-debug.js' +import { isPaseoDictationDebugEnabled } from './agent/recordings-debug.js' import { DictationStreamManager, type DictationStreamOutboundMessage, -} from "./dictation/dictation-stream-manager.js"; -import { - buildConfigOverrides, - buildSessionConfig, - extractTimestamps, -} from "./persistence-hooks.js"; -import { experimental_createMCPClient } from "ai"; -import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; -import type { - VoiceCallerContext, - VoiceMcpStdioConfig, - VoiceSpeakHandler, -} from "./voice-types.js"; +} from './dictation/dictation-stream-manager.js' +import { buildConfigOverrides, buildSessionConfig, extractTimestamps } from './persistence-hooks.js' +import { experimental_createMCPClient } from 'ai' +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' +import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from './voice-types.js' -export type AgentMcpTransportFactory = () => Promise; -import { buildProviderRegistry } from "./agent/provider-registry.js"; -import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; -import { AgentManager } from "./agent/agent-manager.js"; +export type AgentMcpTransportFactory = () => Promise +import { buildProviderRegistry } from './agent/provider-registry.js' +import type { AgentProviderRuntimeSettingsMap } from './agent/provider-launch-config.js' +import { AgentManager } from './agent/agent-manager.js' import type { AgentTimelineCursor, AgentTimelineFetchDirection, ManagedAgent, -} from "./agent/agent-manager.js"; -import { scheduleAgentMetadataGeneration } from "./agent/agent-metadata-generator.js"; -import { toAgentPayload } from "./agent/agent-projections.js"; +} from './agent/agent-manager.js' +import { scheduleAgentMetadataGeneration } from './agent/agent-metadata-generator.js' +import { toAgentPayload } from './agent/agent-projections.js' import { appendTimelineItemIfAgentKnown, emitLiveTimelineItemIfAgentKnown, -} from "./agent/timeline-append.js"; -import { projectTimelineRows, type TimelineProjectionMode } from "./agent/timeline-projection.js"; +} from './agent/timeline-append.js' +import { projectTimelineRows, type TimelineProjectionMode } from './agent/timeline-projection.js' import { DEFAULT_STRUCTURED_GENERATION_PROVIDERS, StructuredAgentFallbackError, StructuredAgentResponseError, generateStructuredAgentResponseWithFallback, -} from "./agent/agent-response-loop.js"; +} from './agent/agent-response-loop.js' import type { AgentPermissionResponse, AgentPromptContentBlock, @@ -96,22 +85,22 @@ import type { AgentStreamEvent, AgentProvider, AgentPersistenceHandle, -} from "./agent/agent-sdk-types.js"; -import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js"; -import { isValidAgentProvider, AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js"; +} from './agent/agent-sdk-types.js' +import { AgentStorage, type StoredAgentRecord } from './agent/agent-storage.js' +import { isValidAgentProvider, AGENT_PROVIDER_IDS } from './agent/provider-manifest.js' import { buildVoiceAgentMcpServerConfig, buildVoiceModeSystemPrompt, stripVoiceModeSystemPrompt, -} from "./voice-config.js"; -import { isVoicePermissionAllowed } from "./voice-permission-policy.js"; +} from './voice-config.js' +import { isVoicePermissionAllowed } from './voice-permission-policy.js' import { listDirectoryEntries, readExplorerFile, getDownloadableFileInfo, -} from "./file-explorer/service.js"; -import { DownloadTokenStore } from "./file-download/token-store.js"; -import { PushTokenStore } from "./push/token-store.js"; +} from './file-explorer/service.js' +import { DownloadTokenStore } from './file-download/token-store.js' +import { PushTokenStore } from './push/token-store.js' import { type WorktreeConfig, slugify, @@ -120,11 +109,8 @@ import { deletePaseoWorktree, isPaseoOwnedWorktreeCwd, resolvePaseoWorktreeRootForCwd, -} from "../utils/worktree.js"; -import { - createAgentWorktree, - runAsyncWorktreeBootstrap, -} from "./worktree-bootstrap.js"; +} from '../utils/worktree.js' +import { createAgentWorktree, runAsyncWorktreeBootstrap } from './worktree-bootstrap.js' import { getCheckoutDiff, getCheckoutStatus, @@ -139,292 +125,283 @@ import { pushCurrentBranch, createPullRequest, getPullRequestStatus, -} from "../utils/checkout-git.js"; -import { getProjectIcon } from "../utils/project-icon.js"; -import { expandTilde } from "../utils/path.js"; -import { searchHomeDirectories } from "../utils/directory-suggestions.js"; +} from '../utils/checkout-git.js' +import { getProjectIcon } from '../utils/project-icon.js' +import { expandTilde } from '../utils/path.js' +import { searchHomeDirectories, searchWorkspaceEntries } from '../utils/directory-suggestions.js' import { ensureLocalSpeechModels, getLocalSpeechModelDir, listLocalSpeechModels, type LocalSpeechModelId, -} from "./speech/providers/local/models.js"; -import type { Resolvable } from "./speech/provider-resolver.js"; -import type { SpeechReadinessSnapshot, SpeechReadinessState } from "./speech/speech-runtime.js"; -import type pino from "pino"; +} from './speech/providers/local/models.js' +import type { Resolvable } from './speech/provider-resolver.js' +import type { SpeechReadinessSnapshot, SpeechReadinessState } from './speech/speech-runtime.js' +import type pino from 'pino' -const execAsync = promisify(exec); +const execAsync = promisify(exec) const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = { ...process.env, - GIT_OPTIONAL_LOCKS: "0", -}; -const pendingAgentInitializations = new Map>(); -let restartRequested = false; -const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0]; -const RESTART_EXIT_DELAY_MS = 250; -const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150; -const CHECKOUT_DIFF_FALLBACK_REFRESH_MS = 5_000; -const TERMINAL_STREAM_WINDOW_BYTES = 256 * 1024; -const TERMINAL_STREAM_MAX_PENDING_BYTES = 2 * 1024 * 1024; -const TERMINAL_STREAM_MAX_PENDING_CHUNKS = 2048; + GIT_OPTIONAL_LOCKS: '0', +} +const pendingAgentInitializations = new Map>() +let restartRequested = false +const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0] +const RESTART_EXIT_DELAY_MS = 250 +const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150 +const CHECKOUT_DIFF_FALLBACK_REFRESH_MS = 5_000 +const TERMINAL_STREAM_WINDOW_BYTES = 256 * 1024 +const TERMINAL_STREAM_MAX_PENDING_BYTES = 2 * 1024 * 1024 +const TERMINAL_STREAM_MAX_PENDING_CHUNKS = 2048 function deriveRemoteProjectKey(remoteUrl: string | null): string | null { if (!remoteUrl) { - return null; + return null } - const trimmed = remoteUrl.trim(); + const trimmed = remoteUrl.trim() if (!trimmed) { - return null; + return null } - let host: string | null = null; - let path: string | null = null; + let host: string | null = null + let path: string | null = null - const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/); + const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/) if (scpLike) { - host = scpLike[1] ?? null; - path = scpLike[2] ?? null; - } else if (trimmed.includes("://")) { + host = scpLike[1] ?? null + path = scpLike[2] ?? null + } else if (trimmed.includes('://')) { try { - const parsed = new URL(trimmed); - host = parsed.hostname || null; - path = parsed.pathname ? parsed.pathname.replace(/^\//, "") : null; + const parsed = new URL(trimmed) + host = parsed.hostname || null + path = parsed.pathname ? parsed.pathname.replace(/^\//, '') : null } catch { - return null; + return null } } if (!host || !path) { - return null; + return null } - let cleanedPath = path.trim().replace(/^\/+/, "").replace(/\/+$/, ""); - if (cleanedPath.endsWith(".git")) { - cleanedPath = cleanedPath.slice(0, -4); + let cleanedPath = path.trim().replace(/^\/+/, '').replace(/\/+$/, '') + if (cleanedPath.endsWith('.git')) { + cleanedPath = cleanedPath.slice(0, -4) } - if (!cleanedPath.includes("/")) { - return null; + if (!cleanedPath.includes('/')) { + return null } - const cleanedHost = host.toLowerCase(); - if (cleanedHost === "github.com") { - return `remote:github.com/${cleanedPath}`; + const cleanedHost = host.toLowerCase() + if (cleanedHost === 'github.com') { + return `remote:github.com/${cleanedPath}` } - return `remote:${cleanedHost}/${cleanedPath}`; + return `remote:${cleanedHost}/${cleanedPath}` } -function deriveProjectGroupingKey(options: { - cwd: string; - remoteUrl: string | null; -}): string { - const remoteKey = deriveRemoteProjectKey(options.remoteUrl); +function deriveProjectGroupingKey(options: { cwd: string; remoteUrl: string | null }): string { + const remoteKey = deriveRemoteProjectKey(options.remoteUrl) if (remoteKey) { - return remoteKey; + return remoteKey } - const worktreeMarker = ".paseo/worktrees/"; - const idx = options.cwd.indexOf(worktreeMarker); + const worktreeMarker = '.paseo/worktrees/' + const idx = options.cwd.indexOf(worktreeMarker) if (idx !== -1) { - return options.cwd.slice(0, idx).replace(/\/$/, ""); + return options.cwd.slice(0, idx).replace(/\/$/, '') } - return options.cwd; + return options.cwd } function deriveProjectGroupingName(projectKey: string): string { - const githubRemotePrefix = "remote:github.com/"; + const githubRemotePrefix = 'remote:github.com/' if (projectKey.startsWith(githubRemotePrefix)) { - return projectKey.slice(githubRemotePrefix.length) || projectKey; + return projectKey.slice(githubRemotePrefix.length) || projectKey } - const segments = projectKey.split(/[\\/]/).filter(Boolean); - return segments[segments.length - 1] || projectKey; + const segments = projectKey.split(/[\\/]/).filter(Boolean) + return segments[segments.length - 1] || projectKey } -type ProcessingPhase = "idle" | "transcribing"; +type ProcessingPhase = 'idle' | 'transcribing' -type CheckoutDiffCompareInput = SubscribeCheckoutDiffRequest["compare"]; +type CheckoutDiffCompareInput = SubscribeCheckoutDiffRequest['compare'] type CheckoutDiffSnapshotPayload = Omit< - Extract["payload"], - "subscriptionId" ->; + Extract['payload'], + 'subscriptionId' +> type CheckoutDiffWatchTarget = { - key: string; - cwd: string; - diffCwd: string; - compare: CheckoutDiffCompareInput; - subscriptions: Set; - watchers: FSWatcher[]; - fallbackRefreshInterval: NodeJS.Timeout | null; - debounceTimer: NodeJS.Timeout | null; - refreshPromise: Promise | null; - refreshQueued: boolean; - latestPayload: CheckoutDiffSnapshotPayload | null; - latestFingerprint: string | null; -}; + key: string + cwd: string + diffCwd: string + compare: CheckoutDiffCompareInput + subscriptions: Set + watchers: FSWatcher[] + fallbackRefreshInterval: NodeJS.Timeout | null + debounceTimer: NodeJS.Timeout | null + refreshPromise: Promise | null + refreshQueued: boolean + latestPayload: CheckoutDiffSnapshotPayload | null + latestFingerprint: string | null +} type NormalizedGitOptions = { - baseBranch?: string; - createNewBranch: boolean; - newBranchName?: string; - createWorktree: boolean; - worktreeSlug?: string; -}; + baseBranch?: string + createNewBranch: boolean + newBranchName?: string + createWorktree: boolean + worktreeSlug?: string +} -type CheckoutErrorCode = "NOT_GIT_REPO" | "NOT_ALLOWED" | "MERGE_CONFLICT" | "UNKNOWN"; +type CheckoutErrorCode = 'NOT_GIT_REPO' | 'NOT_ALLOWED' | 'MERGE_CONFLICT' | 'UNKNOWN' type CheckoutErrorPayload = { - code: CheckoutErrorCode; - message: string; -}; + code: CheckoutErrorCode + message: string +} type TerminalStreamPendingChunk = { - data: string; - startOffset: number; - endOffset: number; - replay: boolean; -}; + data: string + startOffset: number + endOffset: number + replay: boolean +} -type FetchAgentsRequestMessage = Extract< - SessionInboundMessage, - { type: "fetch_agents_request" } ->; -type FetchAgentsRequestFilter = NonNullable; -type FetchAgentsRequestSort = NonNullable[number]; +type FetchAgentsRequestMessage = Extract +type FetchAgentsRequestFilter = NonNullable +type FetchAgentsRequestSort = NonNullable[number] type FetchAgentsResponsePayload = Extract< SessionOutboundMessage, - { type: "fetch_agents_response" } ->["payload"]; -type FetchAgentsResponseEntry = FetchAgentsResponsePayload["entries"][number]; -type FetchAgentsResponsePageInfo = FetchAgentsResponsePayload["pageInfo"]; -type AgentUpdatePayload = Extract< - SessionOutboundMessage, - { type: "agent_update" } ->["payload"]; -type AgentUpdatesFilter = FetchAgentsRequestFilter; + { type: 'fetch_agents_response' } +>['payload'] +type FetchAgentsResponseEntry = FetchAgentsResponsePayload['entries'][number] +type FetchAgentsResponsePageInfo = FetchAgentsResponsePayload['pageInfo'] +type AgentUpdatePayload = Extract['payload'] +type AgentUpdatesFilter = FetchAgentsRequestFilter type AgentUpdatesSubscriptionState = { - subscriptionId: string; - filter?: AgentUpdatesFilter; - isBootstrapping: boolean; - pendingUpdatesByAgentId: Map; -}; + subscriptionId: string + filter?: AgentUpdatesFilter + isBootstrapping: boolean + pendingUpdatesByAgentId: Map +} type FetchAgentsCursor = { - sort: FetchAgentsRequestSort[]; - values: Record; - id: string; -}; + sort: FetchAgentsRequestSort[] + values: Record + id: string +} class SessionRequestError extends Error { - constructor(readonly code: string, message: string) { - super(message); - this.name = "SessionRequestError"; + constructor( + readonly code: string, + message: string + ) { + super(message) + this.name = 'SessionRequestError' } } -const PCM_SAMPLE_RATE = 16000; -const PCM_CHANNELS = 1; -const PCM_BITS_PER_SAMPLE = 16; -const PCM_BYTES_PER_MS = - (PCM_SAMPLE_RATE * PCM_CHANNELS * (PCM_BITS_PER_SAMPLE / 8)) / 1000; -const MIN_STREAMING_SEGMENT_DURATION_MS = 1000; -const MIN_STREAMING_SEGMENT_BYTES = Math.round( - PCM_BYTES_PER_MS * MIN_STREAMING_SEGMENT_DURATION_MS -); -const VOICE_MODE_INACTIVITY_FLUSH_MS = 4500; -const VOICE_INTERNAL_DICTATION_ID_PREFIX = "__voice_turn__:"; -const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/; -const AgentIdSchema = z.string().uuid(); -const VOICE_MCP_SERVER_NAME = "paseo_voice"; +const PCM_SAMPLE_RATE = 16000 +const PCM_CHANNELS = 1 +const PCM_BITS_PER_SAMPLE = 16 +const PCM_BYTES_PER_MS = (PCM_SAMPLE_RATE * PCM_CHANNELS * (PCM_BITS_PER_SAMPLE / 8)) / 1000 +const MIN_STREAMING_SEGMENT_DURATION_MS = 1000 +const MIN_STREAMING_SEGMENT_BYTES = Math.round(PCM_BYTES_PER_MS * MIN_STREAMING_SEGMENT_DURATION_MS) +const VOICE_MODE_INACTIVITY_FLUSH_MS = 4500 +const VOICE_INTERNAL_DICTATION_ID_PREFIX = '__voice_turn__:' +const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/ +const AgentIdSchema = z.string().uuid() +const VOICE_MCP_SERVER_NAME = 'paseo_voice' type VoiceModeBaseConfig = { - systemPrompt?: string; - mcpServers?: Record; -}; + systemPrompt?: string + mcpServers?: Record +} interface AudioBufferState { - chunks: Buffer[]; - format: string; - isPCM: boolean; - totalPCMBytes: number; + chunks: Buffer[] + format: string + isPCM: boolean + totalPCMBytes: number } type VoiceTranscriptionResultPayload = { - text: string; - requestId: string; - language?: string; - duration?: number; - avgLogprob?: number; - isLowConfidence?: boolean; - byteLength?: number; - format?: string; - debugRecordingPath?: string; -}; + text: string + requestId: string + language?: string + duration?: number + avgLogprob?: number + isLowConfidence?: boolean + byteLength?: number + format?: string + debugRecordingPath?: string +} export type SessionOptions = { - clientId: string; - onMessage: (msg: SessionOutboundMessage) => void; - onBinaryMessage?: (frame: BinaryMuxFrame) => void; - logger: pino.Logger; - downloadTokenStore: DownloadTokenStore; - pushTokenStore: PushTokenStore; - paseoHome: string; - agentManager: AgentManager; - agentStorage: AgentStorage; - createAgentMcpTransport: AgentMcpTransportFactory; - stt: Resolvable; - tts: Resolvable; - terminalManager: TerminalManager | null; + clientId: string + onMessage: (msg: SessionOutboundMessage) => void + onBinaryMessage?: (frame: BinaryMuxFrame) => void + logger: pino.Logger + downloadTokenStore: DownloadTokenStore + pushTokenStore: PushTokenStore + paseoHome: string + agentManager: AgentManager + agentStorage: AgentStorage + createAgentMcpTransport: AgentMcpTransportFactory + stt: Resolvable + tts: Resolvable + terminalManager: TerminalManager | null voice?: { - voiceAgentMcpStdio?: VoiceMcpStdioConfig | null; - }; + voiceAgentMcpStdio?: VoiceMcpStdioConfig | null + } voiceBridge?: { - registerVoiceSpeakHandler?: (agentId: string, handler: VoiceSpeakHandler) => void; - unregisterVoiceSpeakHandler?: (agentId: string) => void; - registerVoiceCallerContext?: (agentId: string, context: VoiceCallerContext) => void; - unregisterVoiceCallerContext?: (agentId: string) => void; - ensureVoiceMcpSocketForAgent?: (agentId: string) => Promise; - removeVoiceMcpSocketForAgent?: (agentId: string) => Promise; - }; + registerVoiceSpeakHandler?: (agentId: string, handler: VoiceSpeakHandler) => void + unregisterVoiceSpeakHandler?: (agentId: string) => void + registerVoiceCallerContext?: (agentId: string, context: VoiceCallerContext) => void + unregisterVoiceCallerContext?: (agentId: string) => void + ensureVoiceMcpSocketForAgent?: (agentId: string) => Promise + removeVoiceMcpSocketForAgent?: (agentId: string) => Promise + } dictation?: { - finalTimeoutMs?: number; - stt?: Resolvable; + finalTimeoutMs?: number + stt?: Resolvable localModels?: { - modelsDir: string; - defaultModelIds: LocalSpeechModelId[]; - }; - getSpeechReadiness?: () => SpeechReadinessSnapshot; - }; - agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap; -}; + modelsDir: string + defaultModelIds: LocalSpeechModelId[] + } + getSpeechReadiness?: () => SpeechReadinessSnapshot + } + agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap +} type VoiceFeatureUnavailableContext = { - reasonCode: SpeechReadinessSnapshot["voiceFeature"]["reasonCode"]; - message: string; - retryable: boolean; - missingModelIds: LocalSpeechModelId[]; -}; + reasonCode: SpeechReadinessSnapshot['voiceFeature']['reasonCode'] + message: string + retryable: boolean + missingModelIds: LocalSpeechModelId[] +} type VoiceFeatureUnavailableResponseMetadata = { - reasonCode?: SpeechReadinessSnapshot["voiceFeature"]["reasonCode"]; - retryable?: boolean; - missingModelIds?: LocalSpeechModelId[]; -}; + reasonCode?: SpeechReadinessSnapshot['voiceFeature']['reasonCode'] + retryable?: boolean + missingModelIds?: LocalSpeechModelId[] +} class VoiceFeatureUnavailableError extends Error { - readonly reasonCode: SpeechReadinessSnapshot["voiceFeature"]["reasonCode"]; - readonly retryable: boolean; - readonly missingModelIds: LocalSpeechModelId[]; + readonly reasonCode: SpeechReadinessSnapshot['voiceFeature']['reasonCode'] + readonly retryable: boolean + readonly missingModelIds: LocalSpeechModelId[] constructor(context: VoiceFeatureUnavailableContext) { - super(context.message); - this.name = "VoiceFeatureUnavailableError"; - this.reasonCode = context.reasonCode; - this.retryable = context.retryable; - this.missingModelIds = [...context.missingModelIds]; + super(context.message) + this.name = 'VoiceFeatureUnavailableError' + this.reasonCode = context.reasonCode + this.retryable = context.retryable + this.missingModelIds = [...context.missingModelIds] } } @@ -434,69 +411,62 @@ function convertPCMToWavBuffer( channels: number, bitsPerSample: number ): Buffer { - const headerSize = 44; - const wavBuffer = Buffer.alloc(headerSize + pcmBuffer.length); - const byteRate = (sampleRate * channels * bitsPerSample) / 8; - const blockAlign = (channels * bitsPerSample) / 8; + const headerSize = 44 + const wavBuffer = Buffer.alloc(headerSize + pcmBuffer.length) + const byteRate = (sampleRate * channels * bitsPerSample) / 8 + const blockAlign = (channels * bitsPerSample) / 8 - wavBuffer.write("RIFF", 0); - wavBuffer.writeUInt32LE(36 + pcmBuffer.length, 4); - wavBuffer.write("WAVE", 8); - wavBuffer.write("fmt ", 12); - wavBuffer.writeUInt32LE(16, 16); - wavBuffer.writeUInt16LE(1, 20); - wavBuffer.writeUInt16LE(channels, 22); - wavBuffer.writeUInt32LE(sampleRate, 24); - wavBuffer.writeUInt32LE(byteRate, 28); - wavBuffer.writeUInt16LE(blockAlign, 32); - wavBuffer.writeUInt16LE(bitsPerSample, 34); - wavBuffer.write("data", 36); - wavBuffer.writeUInt32LE(pcmBuffer.length, 40); - pcmBuffer.copy(wavBuffer, 44); + wavBuffer.write('RIFF', 0) + wavBuffer.writeUInt32LE(36 + pcmBuffer.length, 4) + wavBuffer.write('WAVE', 8) + wavBuffer.write('fmt ', 12) + wavBuffer.writeUInt32LE(16, 16) + wavBuffer.writeUInt16LE(1, 20) + wavBuffer.writeUInt16LE(channels, 22) + wavBuffer.writeUInt32LE(sampleRate, 24) + wavBuffer.writeUInt32LE(byteRate, 28) + wavBuffer.writeUInt16LE(blockAlign, 32) + wavBuffer.writeUInt16LE(bitsPerSample, 34) + wavBuffer.write('data', 36) + wavBuffer.writeUInt32LE(pcmBuffer.length, 40) + pcmBuffer.copy(wavBuffer, 44) - return wavBuffer; + return wavBuffer } -function coerceAgentProvider( - logger: pino.Logger, - value: string, - agentId?: string -): AgentProvider { +function coerceAgentProvider(logger: pino.Logger, value: string, agentId?: string): AgentProvider { if (isValidAgentProvider(value)) { - return value; + return value } logger.warn( { value, agentId, defaultProvider: DEFAULT_AGENT_PROVIDER }, - `Unknown provider '${value}' for agent ${agentId ?? "unknown"}; defaulting to '${DEFAULT_AGENT_PROVIDER}'` - ); - return DEFAULT_AGENT_PROVIDER; + `Unknown provider '${value}' for agent ${agentId ?? 'unknown'}; defaulting to '${DEFAULT_AGENT_PROVIDER}'` + ) + return DEFAULT_AGENT_PROVIDER } function toAgentPersistenceHandle( logger: pino.Logger, - handle: StoredAgentRecord["persistence"] + handle: StoredAgentRecord['persistence'] ): AgentPersistenceHandle | null { if (!handle) { - return null; + return null } - const provider = handle.provider; + const provider = handle.provider if (!isValidAgentProvider(provider)) { - logger.warn( - { provider }, - `Ignoring persistence handle with unknown provider '${provider}'` - ); - return null; + logger.warn({ provider }, `Ignoring persistence handle with unknown provider '${provider}'`) + return null } if (!handle.sessionId) { - logger.warn("Ignoring persistence handle missing sessionId"); - return null; + logger.warn('Ignoring persistence handle missing sessionId') + return null } return { provider, sessionId: handle.sessionId, nativeHandle: handle.nativeHandle, metadata: handle.metadata, - } satisfies AgentPersistenceHandle; + } satisfies AgentPersistenceHandle } /** @@ -505,112 +475,105 @@ function toAgentPersistenceHandle( * Session has no knowledge of WebSockets - it only emits and receives messages. */ export class Session { - private readonly clientId: string; - private readonly sessionId: string; - private readonly onMessage: (msg: SessionOutboundMessage) => void; - private readonly onBinaryMessage: ((frame: BinaryMuxFrame) => void) | null; - private readonly sessionLogger: pino.Logger; - private readonly paseoHome: string; + private readonly clientId: string + private readonly sessionId: string + private readonly onMessage: (msg: SessionOutboundMessage) => void + private readonly onBinaryMessage: ((frame: BinaryMuxFrame) => void) | null + private readonly sessionLogger: pino.Logger + private readonly paseoHome: string // State machine - private abortController: AbortController; - private processingPhase: ProcessingPhase = "idle"; + private abortController: AbortController + private processingPhase: ProcessingPhase = 'idle' // Voice mode state - private isVoiceMode = false; - private speechInProgress = false; + private isVoiceMode = false + private speechInProgress = false - private readonly dictationStreamManager: DictationStreamManager; - private readonly voiceStreamManager: DictationStreamManager; + private readonly dictationStreamManager: DictationStreamManager + private readonly voiceStreamManager: DictationStreamManager // Audio buffering for interruption handling - private pendingAudioSegments: Array<{ audio: Buffer; format: string }> = []; - private bufferTimeout: NodeJS.Timeout | null = null; - private voiceModeInactivityTimeout: NodeJS.Timeout | null = null; - private audioBuffer: AudioBufferState | null = null; - private activeVoiceDictationId: string | null = null; - private activeVoiceDictationFormat: string | null = null; - private activeVoiceDictationNextSeq = 0; - private activeVoiceDictationStartPromise: Promise | null = null; - private activeVoiceDictationFinalizePromise: Promise | null = null; - private activeVoiceDictationResultPromise: - | Promise<{ text: string; debugRecordingPath?: string }> - | null = null; + private pendingAudioSegments: Array<{ audio: Buffer; format: string }> = [] + private bufferTimeout: NodeJS.Timeout | null = null + private voiceModeInactivityTimeout: NodeJS.Timeout | null = null + private audioBuffer: AudioBufferState | null = null + private activeVoiceDictationId: string | null = null + private activeVoiceDictationFormat: string | null = null + private activeVoiceDictationNextSeq = 0 + private activeVoiceDictationStartPromise: Promise | null = null + private activeVoiceDictationFinalizePromise: Promise | null = null + private activeVoiceDictationResultPromise: Promise<{ + text: string + debugRecordingPath?: string + }> | null = null private activeVoiceDictationResolve: | ((value: { text: string; debugRecordingPath?: string }) => void) - | null = null; - private activeVoiceDictationReject: ((error: Error) => void) | null = null; + | null = null + private activeVoiceDictationReject: ((error: Error) => void) | null = null // Optional TTS debug capture (persisted per utterance) - private readonly ttsDebugStreams = new Map< - string, - { format: string; chunks: Buffer[] } - >(); + private readonly ttsDebugStreams = new Map() // Per-session managers - private readonly ttsManager: TTSManager; - private readonly sttManager: STTManager; + private readonly ttsManager: TTSManager + private readonly sttManager: STTManager // Per-session MCP client and tools - private agentMcpClient: Awaited< - ReturnType - > | null = null; - private agentTools: ToolSet | null = null; - private agentManager: AgentManager; - private readonly agentStorage: AgentStorage; - private readonly createAgentMcpTransport: AgentMcpTransportFactory; - private readonly downloadTokenStore: DownloadTokenStore; - private readonly pushTokenStore: PushTokenStore; - private readonly providerRegistry: ReturnType; - private unsubscribeAgentEvents: (() => void) | null = null; - private agentUpdatesSubscription: AgentUpdatesSubscriptionState | null = null; + private agentMcpClient: Awaited> | null = null + private agentTools: ToolSet | null = null + private agentManager: AgentManager + private readonly agentStorage: AgentStorage + private readonly createAgentMcpTransport: AgentMcpTransportFactory + private readonly downloadTokenStore: DownloadTokenStore + private readonly pushTokenStore: PushTokenStore + private readonly providerRegistry: ReturnType + private unsubscribeAgentEvents: (() => void) | null = null + private agentUpdatesSubscription: AgentUpdatesSubscriptionState | null = null private clientActivity: { - deviceType: "web" | "mobile"; - focusedAgentId: string | null; - lastActivityAt: Date; - appVisible: boolean; - appVisibilityChangedAt: Date; - } | null = null; - private readonly MOBILE_BACKGROUND_STREAM_GRACE_MS = 60_000; - private readonly terminalManager: TerminalManager | null; - private readonly subscribedTerminalDirectories = new Set(); - private unsubscribeTerminalsChanged: (() => void) | null = null; - private terminalSubscriptions: Map void> = new Map(); - private terminalExitSubscriptions: Map void> = new Map(); + deviceType: 'web' | 'mobile' + focusedAgentId: string | null + lastActivityAt: Date + appVisible: boolean + appVisibilityChangedAt: Date + } | null = null + private readonly MOBILE_BACKGROUND_STREAM_GRACE_MS = 60_000 + private readonly terminalManager: TerminalManager | null + private readonly subscribedTerminalDirectories = new Set() + private unsubscribeTerminalsChanged: (() => void) | null = null + private terminalSubscriptions: Map void> = new Map() + private terminalExitSubscriptions: Map void> = new Map() private readonly terminalStreams = new Map< number, { - terminalId: string; - unsubscribe: () => void; - lastOutputOffset: number; - lastAckOffset: number; - pendingChunks: TerminalStreamPendingChunk[]; - pendingBytes: number; + terminalId: string + unsubscribe: () => void + lastOutputOffset: number + lastAckOffset: number + pendingChunks: TerminalStreamPendingChunk[] + pendingBytes: number } - >(); - private readonly terminalStreamByTerminalId = new Map(); - private nextTerminalStreamId = 1; - private readonly checkoutDiffSubscriptions = new Map(); - private readonly checkoutDiffTargets = new Map(); - private readonly voiceAgentMcpStdio: VoiceMcpStdioConfig | null; - private readonly localSpeechModelsDir: string; - private readonly defaultLocalSpeechModelIds: LocalSpeechModelId[]; - private readonly registerVoiceSpeakHandler?: ( - agentId: string, - handler: VoiceSpeakHandler - ) => void; - private readonly unregisterVoiceSpeakHandler?: (agentId: string) => void; + >() + private readonly terminalStreamByTerminalId = new Map() + private nextTerminalStreamId = 1 + private readonly checkoutDiffSubscriptions = new Map() + private readonly checkoutDiffTargets = new Map() + private readonly voiceAgentMcpStdio: VoiceMcpStdioConfig | null + private readonly localSpeechModelsDir: string + private readonly defaultLocalSpeechModelIds: LocalSpeechModelId[] + private readonly registerVoiceSpeakHandler?: (agentId: string, handler: VoiceSpeakHandler) => void + private readonly unregisterVoiceSpeakHandler?: (agentId: string) => void private readonly registerVoiceCallerContext?: ( agentId: string, context: VoiceCallerContext - ) => void; - private readonly unregisterVoiceCallerContext?: (agentId: string) => void; - private readonly ensureVoiceMcpSocketForAgent?: (agentId: string) => Promise; - private readonly removeVoiceMcpSocketForAgent?: (agentId: string) => Promise; - private readonly getSpeechReadiness?: () => SpeechReadinessSnapshot; - private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined; - private voiceModeAgentId: string | null = null; - private voiceModeBaseConfig: VoiceModeBaseConfig | null = null; + ) => void + private readonly unregisterVoiceCallerContext?: (agentId: string) => void + private readonly ensureVoiceMcpSocketForAgent?: (agentId: string) => Promise + private readonly removeVoiceMcpSocketForAgent?: (agentId: string) => Promise + private readonly getSpeechReadiness?: () => SpeechReadinessSnapshot + private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined + private voiceModeAgentId: string | null = null + private voiceModeBaseConfig: VoiceModeBaseConfig | null = null constructor(options: SessionOptions) { const { @@ -631,87 +594,87 @@ export class Session { voiceBridge, dictation, agentProviderRuntimeSettings, - } = options; - this.clientId = clientId; - this.sessionId = uuidv4(); - this.onMessage = onMessage; - this.onBinaryMessage = onBinaryMessage ?? null; - this.downloadTokenStore = downloadTokenStore; - this.pushTokenStore = pushTokenStore; - this.paseoHome = paseoHome; - this.agentManager = agentManager; - this.agentStorage = agentStorage; - this.createAgentMcpTransport = createAgentMcpTransport; - this.terminalManager = terminalManager; + } = options + this.clientId = clientId + this.sessionId = uuidv4() + this.onMessage = onMessage + this.onBinaryMessage = onBinaryMessage ?? null + this.downloadTokenStore = downloadTokenStore + this.pushTokenStore = pushTokenStore + this.paseoHome = paseoHome + this.agentManager = agentManager + this.agentStorage = agentStorage + this.createAgentMcpTransport = createAgentMcpTransport + this.terminalManager = terminalManager if (this.terminalManager) { - this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged( - (event) => this.handleTerminalsChanged(event) - ); + this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) => + this.handleTerminalsChanged(event) + ) } - this.voiceAgentMcpStdio = voice?.voiceAgentMcpStdio ?? null; - const configuredModelsDir = dictation?.localModels?.modelsDir?.trim(); + this.voiceAgentMcpStdio = voice?.voiceAgentMcpStdio ?? null + const configuredModelsDir = dictation?.localModels?.modelsDir?.trim() this.localSpeechModelsDir = configuredModelsDir && configuredModelsDir.length > 0 ? configuredModelsDir - : join(this.paseoHome, "models", "local-speech"); + : join(this.paseoHome, 'models', 'local-speech') this.defaultLocalSpeechModelIds = dictation?.localModels?.defaultModelIds && dictation.localModels.defaultModelIds.length > 0 ? [...new Set(dictation.localModels.defaultModelIds)] - : ["parakeet-tdt-0.6b-v2-int8", "kokoro-en-v0_19"]; - this.registerVoiceSpeakHandler = voiceBridge?.registerVoiceSpeakHandler; - this.unregisterVoiceSpeakHandler = voiceBridge?.unregisterVoiceSpeakHandler; - this.registerVoiceCallerContext = voiceBridge?.registerVoiceCallerContext; - this.unregisterVoiceCallerContext = voiceBridge?.unregisterVoiceCallerContext; - this.ensureVoiceMcpSocketForAgent = voiceBridge?.ensureVoiceMcpSocketForAgent; - this.removeVoiceMcpSocketForAgent = voiceBridge?.removeVoiceMcpSocketForAgent; - this.getSpeechReadiness = dictation?.getSpeechReadiness; - this.agentProviderRuntimeSettings = agentProviderRuntimeSettings; - this.abortController = new AbortController(); + : ['parakeet-tdt-0.6b-v2-int8', 'kokoro-en-v0_19'] + this.registerVoiceSpeakHandler = voiceBridge?.registerVoiceSpeakHandler + this.unregisterVoiceSpeakHandler = voiceBridge?.unregisterVoiceSpeakHandler + this.registerVoiceCallerContext = voiceBridge?.registerVoiceCallerContext + this.unregisterVoiceCallerContext = voiceBridge?.unregisterVoiceCallerContext + this.ensureVoiceMcpSocketForAgent = voiceBridge?.ensureVoiceMcpSocketForAgent + this.removeVoiceMcpSocketForAgent = voiceBridge?.removeVoiceMcpSocketForAgent + this.getSpeechReadiness = dictation?.getSpeechReadiness + this.agentProviderRuntimeSettings = agentProviderRuntimeSettings + this.abortController = new AbortController() this.sessionLogger = logger.child({ - module: "session", + module: 'session', clientId: this.clientId, sessionId: this.sessionId, - }); + }) this.providerRegistry = buildProviderRegistry(this.sessionLogger, { runtimeSettings: this.agentProviderRuntimeSettings, - }); + }) // Initialize per-session managers - this.ttsManager = new TTSManager(this.sessionId, this.sessionLogger, tts); - this.sttManager = new STTManager(this.sessionId, this.sessionLogger, stt); + this.ttsManager = new TTSManager(this.sessionId, this.sessionLogger, tts) + this.sttManager = new STTManager(this.sessionId, this.sessionLogger, stt) this.dictationStreamManager = new DictationStreamManager({ logger: this.sessionLogger, sessionId: this.sessionId, emit: (msg) => this.handleDictationManagerMessage(msg), stt: dictation?.stt ?? null, finalTimeoutMs: dictation?.finalTimeoutMs, - }); + }) this.voiceStreamManager = new DictationStreamManager({ - logger: this.sessionLogger.child({ stream: "voice-internal" }), + logger: this.sessionLogger.child({ stream: 'voice-internal' }), sessionId: this.sessionId, emit: (msg) => this.handleDictationManagerMessage(msg), stt: stt, finalTimeoutMs: dictation?.finalTimeoutMs, - }); + }) // Initialize agent MCP client asynchronously - void this.initializeAgentMcp(); - this.subscribeToAgentEvents(); + void this.initializeAgentMcp() + this.subscribeToAgentEvents() - this.sessionLogger.trace("Session created"); + this.sessionLogger.trace('Session created') } /** * Get the client's current activity state */ public getClientActivity(): { - deviceType: "web" | "mobile"; - focusedAgentId: string | null; - lastActivityAt: Date; - appVisible: boolean; - appVisibilityChangedAt: Date; + deviceType: 'web' | 'mobile' + focusedAgentId: string | null + lastActivityAt: Date + appVisible: boolean + appVisibilityChangedAt: Date } | null { - return this.clientActivity; + return this.clientActivity } /** @@ -728,18 +691,18 @@ export class Session { text: string, images?: Array<{ data: string; mimeType: string }> ): AgentPromptInput { - const normalized = text?.trim() ?? ""; + const normalized = text?.trim() ?? '' if (!images || images.length === 0) { - return normalized; + return normalized } - const blocks: AgentPromptContentBlock[] = []; + const blocks: AgentPromptContentBlock[] = [] if (normalized.length > 0) { - blocks.push({ type: "text", text: normalized }); + blocks.push({ type: 'text', text: normalized }) } for (const image of images) { - blocks.push({ type: "image", data: image.data, mimeType: image.mimeType }); + blocks.push({ type: 'image', data: image.data, mimeType: image.mimeType }) } - return blocks; + return blocks } /** @@ -747,53 +710,53 @@ export class Session { * Returns once the manager confirms the stream has been cancelled. */ private async interruptAgentIfRunning(agentId: string): Promise { - const snapshot = this.agentManager.getAgent(agentId); + const snapshot = this.agentManager.getAgent(agentId) if (!snapshot) { - throw new Error(`Agent ${agentId} not found`); + throw new Error(`Agent ${agentId} not found`) } - if (snapshot.lifecycle !== "running" && !snapshot.pendingRun) { + if (snapshot.lifecycle !== 'running' && !snapshot.pendingRun) { this.sessionLogger.debug( { agentId, lifecycle: snapshot.lifecycle, pendingRun: Boolean(snapshot.pendingRun) }, - "interruptAgentIfRunning: not running, skipping" - ); - return; + 'interruptAgentIfRunning: not running, skipping' + ) + return } this.sessionLogger.debug( { agentId, lifecycle: snapshot.lifecycle, pendingRun: Boolean(snapshot.pendingRun) }, - "interruptAgentIfRunning: interrupting" - ); + 'interruptAgentIfRunning: interrupting' + ) try { - const t0 = Date.now(); - const cancelled = await this.agentManager.cancelAgentRun(agentId); + const t0 = Date.now() + const cancelled = await this.agentManager.cancelAgentRun(agentId) this.sessionLogger.debug( { agentId, cancelled, durationMs: Date.now() - t0 }, - "interruptAgentIfRunning: cancelAgentRun completed" - ); + 'interruptAgentIfRunning: cancelAgentRun completed' + ) if (!cancelled) { this.sessionLogger.warn( { agentId }, - "interruptAgentIfRunning: reported running but no active run was cancelled" - ); + 'interruptAgentIfRunning: reported running but no active run was cancelled' + ) } } catch (error) { - throw error; + throw error } } private hasActiveAgentRun(agentId: string | null): boolean { if (!agentId) { - return false; + return false } - const snapshot = this.agentManager.getAgent(agentId); + const snapshot = this.agentManager.getAgent(agentId) if (!snapshot) { - return false; + return false } - return snapshot.lifecycle === "running" || Boolean(snapshot.pendingRun); + return snapshot.lifecycle === 'running' || Boolean(snapshot.pendingRun) } /** @@ -804,19 +767,16 @@ export class Session { prompt: AgentPromptInput, runOptions?: AgentRunOptions ): { ok: true } | { ok: false; error: string } { - this.sessionLogger.info( - { agentId }, - `Starting agent stream for ${agentId}` - ); + this.sessionLogger.info({ agentId }, `Starting agent stream for ${agentId}`) - let iterator: AsyncGenerator; + let iterator: AsyncGenerator try { - iterator = this.agentManager.streamAgent(agentId, prompt, runOptions); + iterator = this.agentManager.streamAgent(agentId, prompt, runOptions) } catch (error) { - this.handleAgentRunError(agentId, error, "Failed to start agent run"); + this.handleAgentRunError(agentId, error, 'Failed to start agent run') const message = - error instanceof Error ? error.message : typeof error === "string" ? error : "Unknown error"; - return { ok: false, error: message }; + error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error' + return { ok: false, error: message } } void (async () => { @@ -825,33 +785,26 @@ export class Session { // Events are forwarded via the session's AgentManager subscription. } } catch (error) { - this.handleAgentRunError(agentId, error, "Agent stream failed"); + this.handleAgentRunError(agentId, error, 'Agent stream failed') } - })(); + })() - return { ok: true }; + return { ok: true } } - private handleAgentRunError( - agentId: string, - error: unknown, - context: string - ): void { + private handleAgentRunError(agentId: string, error: unknown, context: string): void { const message = - error instanceof Error ? error.message : typeof error === "string" ? error : "Unknown error"; - this.sessionLogger.error( - { err: error, agentId, context }, - `${context} for agent ${agentId}` - ); + error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error' + this.sessionLogger.error({ err: error, agentId, context }, `${context} for agent ${agentId}`) this.emit({ - type: "activity_log", + type: 'activity_log', payload: { id: uuidv4(), timestamp: new Date(), - type: "error", + type: 'error', content: `${context}: ${message}`, }, - }); + }) } /** @@ -860,23 +813,20 @@ export class Session { private async initializeAgentMcp(): Promise { try { // Create an in-memory transport connected to the Agent MCP server - const transport = await this.createAgentMcpTransport(); + const transport = await this.createAgentMcpTransport() this.agentMcpClient = await experimental_createMCPClient({ transport, - }); + }) - this.agentTools = (await this.agentMcpClient.tools()) as ToolSet; - const agentToolCount = Object.keys(this.agentTools ?? {}).length; + this.agentTools = (await this.agentMcpClient.tools()) as ToolSet + const agentToolCount = Object.keys(this.agentTools ?? {}).length this.sessionLogger.trace( { agentToolCount }, `Agent MCP initialized with ${agentToolCount} tools` - ); + ) } catch (error) { - this.sessionLogger.error( - { err: error }, - "Failed to initialize Agent MCP" - ); + this.sessionLogger.error({ err: error }, 'Failed to initialize Agent MCP') } } @@ -885,26 +835,26 @@ export class Session { */ private subscribeToAgentEvents(): void { if (this.unsubscribeAgentEvents) { - this.unsubscribeAgentEvents(); + this.unsubscribeAgentEvents() } this.unsubscribeAgentEvents = this.agentManager.subscribe( (event) => { - if (event.type === "agent_state") { - void this.forwardAgentUpdate(event.agent); - return; + if (event.type === 'agent_state') { + void this.forwardAgentUpdate(event.agent) + return } if ( this.isVoiceMode && this.voiceModeAgentId === event.agentId && - event.event.type === "permission_requested" && + event.event.type === 'permission_requested' && isVoicePermissionAllowed(event.event.request) ) { - const requestId = event.event.request.id; + const requestId = event.event.request.id void this.agentManager .respondToPermission(event.agentId, requestId, { - behavior: "allow", + behavior: 'allow', }) .catch((error) => { this.sessionLogger.warn( @@ -913,81 +863,79 @@ export class Session { agentId: event.agentId, requestId, }, - "Failed to auto-allow speak tool permission in voice mode" - ); - }); + 'Failed to auto-allow speak tool permission in voice mode' + ) + }) } // Reduce bandwidth/CPU on mobile: only forward high-frequency agent stream events // for the focused agent, with a short grace window while backgrounded. // History catch-up is handled via pull-based `fetch_agent_timeline_request`. - const activity = this.clientActivity; - if (activity?.deviceType === "mobile") { + const activity = this.clientActivity + if (activity?.deviceType === 'mobile') { if (!activity.focusedAgentId) { - return; + return } if (activity.focusedAgentId !== event.agentId) { - return; + return } if (!activity.appVisible) { - const hiddenForMs = Date.now() - activity.appVisibilityChangedAt.getTime(); + const hiddenForMs = Date.now() - activity.appVisibilityChangedAt.getTime() if (hiddenForMs >= this.MOBILE_BACKGROUND_STREAM_GRACE_MS) { - return; + return } } } - const serializedEvent = serializeAgentStreamEvent(event.event); + const serializedEvent = serializeAgentStreamEvent(event.event) if (!serializedEvent) { - return; + return } const payload = { agentId: event.agentId, event: serializedEvent, timestamp: new Date().toISOString(), - ...(typeof event.seq === "number" ? { seq: event.seq } : {}), - ...(typeof event.epoch === "string" ? { epoch: event.epoch } : {}), - } as const; + ...(typeof event.seq === 'number' ? { seq: event.seq } : {}), + ...(typeof event.epoch === 'string' ? { epoch: event.epoch } : {}), + } as const this.emit({ - type: "agent_stream", + type: 'agent_stream', payload, - }); + }) - if (event.event.type === "permission_requested") { + if (event.event.type === 'permission_requested') { this.emit({ - type: "agent_permission_request", + type: 'agent_permission_request', payload: { agentId: event.agentId, request: event.event.request, }, - }); - } else if (event.event.type === "permission_resolved") { + }) + } else if (event.event.type === 'permission_resolved') { this.emit({ - type: "agent_permission_resolved", + type: 'agent_permission_resolved', payload: { agentId: event.agentId, requestId: event.event.requestId, resolution: event.event.resolution, }, - }); + }) } // Title updates may be applied asynchronously after agent creation. }, { replayState: false } - ); + ) } - private async buildAgentPayload( - agent: ManagedAgent - ): Promise { - const storedRecord = await this.agentStorage.get(agent.id); - const title = storedRecord?.title ?? null; - const payload = toAgentPayload(agent, { title }); - payload.archivedAt = storedRecord?.archivedAt ?? null; - return payload; + private async buildAgentPayload(agent: ManagedAgent): Promise { + const storedRecord = await this.agentStorage.get(agent.id) + const title = storedRecord?.title ?? null + const payload = toAgentPayload(agent, { title }) + payload.archivedAt = storedRecord?.archivedAt ?? null + return payload } private buildStoredAgentPayload(record: StoredAgentRecord): AgentSnapshotPayload { @@ -998,15 +946,13 @@ export class Session { supportsMcpServers: false, supportsReasoningStream: false, supportsToolInvocations: true, - } as const; + } as const - const createdAt = new Date(record.createdAt); - const updatedAt = new Date(record.lastActivityAt ?? record.updatedAt); - const lastUserMessageAt = record.lastUserMessageAt - ? new Date(record.lastUserMessageAt) - : null; + const createdAt = new Date(record.createdAt) + const updatedAt = new Date(record.lastActivityAt ?? record.updatedAt) + const lastUserMessageAt = record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null - const provider = coerceAgentProvider(this.sessionLogger, record.provider, record.id); + const provider = coerceAgentProvider(this.sessionLogger, record.provider, record.id) return { id: record.id, provider, @@ -1030,113 +976,111 @@ export class Session { attentionTimestamp: record.attentionTimestamp ?? null, archivedAt: record.archivedAt ?? null, labels: record.labels, - }; + } } private async ensureAgentLoaded(agentId: string): Promise { - const existing = this.agentManager.getAgent(agentId); + const existing = this.agentManager.getAgent(agentId) if (existing) { - return existing; + return existing } - const inflight = pendingAgentInitializations.get(agentId); + const inflight = pendingAgentInitializations.get(agentId) if (inflight) { - return inflight; + return inflight } const initPromise = (async () => { - const record = await this.agentStorage.get(agentId); + const record = await this.agentStorage.get(agentId) if (!record) { - throw new Error(`Agent not found: ${agentId}`); + throw new Error(`Agent not found: ${agentId}`) } - const handle = toAgentPersistenceHandle(this.sessionLogger, record.persistence); - let snapshot: ManagedAgent; + const handle = toAgentPersistenceHandle(this.sessionLogger, record.persistence) + let snapshot: ManagedAgent if (handle) { snapshot = await this.agentManager.resumeAgentFromPersistence( handle, buildConfigOverrides(record), agentId, extractTimestamps(record) - ); + ) this.sessionLogger.info( { agentId, provider: record.provider }, - "Agent resumed from persistence" - ); + 'Agent resumed from persistence' + ) } else { - const config = buildSessionConfig(record); - snapshot = await this.agentManager.createAgent(config, agentId, { labels: record.labels }); + const config = buildSessionConfig(record) + snapshot = await this.agentManager.createAgent(config, agentId, { labels: record.labels }) this.sessionLogger.info( { agentId, provider: record.provider }, - "Agent created from stored config" - ); + 'Agent created from stored config' + ) } - await this.agentManager.hydrateTimelineFromProvider(agentId); - return this.agentManager.getAgent(agentId) ?? snapshot; - })(); + await this.agentManager.hydrateTimelineFromProvider(agentId) + return this.agentManager.getAgent(agentId) ?? snapshot + })() - pendingAgentInitializations.set(agentId, initPromise); + pendingAgentInitializations.set(agentId, initPromise) try { - return await initPromise; + return await initPromise } finally { - const current = pendingAgentInitializations.get(agentId); + const current = pendingAgentInitializations.get(agentId) if (current === initPromise) { - pendingAgentInitializations.delete(agentId); + pendingAgentInitializations.delete(agentId) } } } private matchesAgentFilter(options: { - agent: AgentSnapshotPayload; - project: ProjectPlacementPayload; - filter?: AgentUpdatesFilter; + agent: AgentSnapshotPayload + project: ProjectPlacementPayload + filter?: AgentUpdatesFilter }): boolean { - const { agent, project, filter } = options; + const { agent, project, filter } = options if (filter?.labels) { const matchesLabels = Object.entries(filter.labels).every( ([key, value]) => agent.labels[key] === value - ); + ) if (!matchesLabels) { - return false; + return false } } - const includeArchived = filter?.includeArchived ?? false; + const includeArchived = filter?.includeArchived ?? false if (!includeArchived && agent.archivedAt) { - return false; + return false } if (filter?.statuses && filter.statuses.length > 0) { - const statuses = new Set(filter.statuses); + const statuses = new Set(filter.statuses) if (!statuses.has(agent.status)) { - return false; + return false } } - if (typeof filter?.requiresAttention === "boolean") { - const requiresAttention = agent.requiresAttention ?? false; + if (typeof filter?.requiresAttention === 'boolean') { + const requiresAttention = agent.requiresAttention ?? false if (requiresAttention !== filter.requiresAttention) { - return false; + return false } } if (filter?.projectKeys && filter.projectKeys.length > 0) { - const projectKeys = new Set( - filter.projectKeys.filter((item) => item.trim().length > 0) - ); + const projectKeys = new Set(filter.projectKeys.filter((item) => item.trim().length > 0)) if (projectKeys.size > 0 && !projectKeys.has(project.projectKey)) { - return false; + return false } } - return true; + return true } private getAgentUpdateTargetId(update: AgentUpdatePayload): string { - return update.kind === "remove" ? update.agentId : update.agent.id; + return update.kind === 'remove' ? update.agentId : update.agent.id } private bufferOrEmitAgentUpdate( @@ -1144,48 +1088,43 @@ export class Session { payload: AgentUpdatePayload ): void { if (subscription.isBootstrapping) { - subscription.pendingUpdatesByAgentId.set( - this.getAgentUpdateTargetId(payload), - payload - ); - return; + subscription.pendingUpdatesByAgentId.set(this.getAgentUpdateTargetId(payload), payload) + return } this.emit({ - type: "agent_update", + type: 'agent_update', payload, - }); + }) } private flushBootstrappedAgentUpdates(options?: { - snapshotUpdatedAtByAgentId?: Map; + snapshotUpdatedAtByAgentId?: Map }): void { - const subscription = this.agentUpdatesSubscription; + const subscription = this.agentUpdatesSubscription if (!subscription || !subscription.isBootstrapping) { - return; + return } - subscription.isBootstrapping = false; - const pending = Array.from(subscription.pendingUpdatesByAgentId.values()); - subscription.pendingUpdatesByAgentId.clear(); + subscription.isBootstrapping = false + const pending = Array.from(subscription.pendingUpdatesByAgentId.values()) + subscription.pendingUpdatesByAgentId.clear() for (const payload of pending) { - if (payload.kind === "upsert") { - const snapshotUpdatedAt = options?.snapshotUpdatedAtByAgentId?.get( - payload.agent.id - ); - if (typeof snapshotUpdatedAt === "number") { - const updateUpdatedAt = Date.parse(payload.agent.updatedAt); + if (payload.kind === 'upsert') { + const snapshotUpdatedAt = options?.snapshotUpdatedAtByAgentId?.get(payload.agent.id) + if (typeof snapshotUpdatedAt === 'number') { + const updateUpdatedAt = Date.parse(payload.agent.updatedAt) if (!Number.isNaN(updateUpdatedAt) && updateUpdatedAt <= snapshotUpdatedAt) { - continue; + continue } } } this.emit({ - type: "agent_update", + type: 'agent_update', payload, - }); + }) } } @@ -1197,7 +1136,7 @@ export class Session { remoteUrl: null, isPaseoOwnedWorktree: false, mainRepoRoot: null, - }; + } } private toProjectCheckoutLite( @@ -1205,7 +1144,7 @@ export class Session { status: Awaited> ): ProjectCheckoutLitePayload { if (!status.isGit) { - return this.buildFallbackProjectCheckout(cwd); + return this.buildFallbackProjectCheckout(cwd) } if (status.isPaseoOwnedWorktree) { @@ -1216,7 +1155,7 @@ export class Session { remoteUrl: status.remoteUrl, isPaseoOwnedWorktree: true, mainRepoRoot: status.mainRepoRoot, - }; + } } return { @@ -1226,54 +1165,54 @@ export class Session { remoteUrl: status.remoteUrl, isPaseoOwnedWorktree: false, mainRepoRoot: null, - }; + } } private async buildProjectPlacement(cwd: string): Promise { const checkout = await getCheckoutStatusLite(cwd, { paseoHome: this.paseoHome }) .then((status) => this.toProjectCheckoutLite(cwd, status)) - .catch(() => this.buildFallbackProjectCheckout(cwd)); + .catch(() => this.buildFallbackProjectCheckout(cwd)) const projectKey = deriveProjectGroupingKey({ cwd, remoteUrl: checkout.remoteUrl, - }); + }) return { projectKey, projectName: deriveProjectGroupingName(projectKey), checkout, - }; + } } private async forwardAgentUpdate(agent: ManagedAgent): Promise { try { - const subscription = this.agentUpdatesSubscription; + const subscription = this.agentUpdatesSubscription if (!subscription) { - return; + return } - const payload = await this.buildAgentPayload(agent); - const project = await this.buildProjectPlacement(payload.cwd); + const payload = await this.buildAgentPayload(agent) + const project = await this.buildProjectPlacement(payload.cwd) const matches = this.matchesAgentFilter({ agent: payload, project, filter: subscription.filter, - }); + }) if (matches) { this.bufferOrEmitAgentUpdate(subscription, { - kind: "upsert", + kind: 'upsert', agent: payload, project, - }); - return; + }) + return } this.bufferOrEmitAgentUpdate(subscription, { - kind: "remove", + kind: 'remove', agentId: payload.id, - }); + }) } catch (error) { - this.sessionLogger.error({ err: error }, "Failed to emit agent update"); + this.sessionLogger.error({ err: error }, 'Failed to emit agent update') } } @@ -1283,61 +1222,56 @@ export class Session { public async handleMessage(msg: SessionInboundMessage): Promise { try { switch (msg.type) { - case "voice_audio_chunk": - await this.handleAudioChunk(msg); - break; + case 'voice_audio_chunk': + await this.handleAudioChunk(msg) + break - case "abort_request": - await this.handleAbort(); - break; + case 'abort_request': + await this.handleAbort() + break - case "audio_played": - this.handleAudioPlayed(msg.id); - break; + case 'audio_played': + this.handleAudioPlayed(msg.id) + break - case "fetch_agents_request": - await this.handleFetchAgents(msg); - break; + case 'fetch_agents_request': + await this.handleFetchAgents(msg) + break - case "fetch_agent_request": - await this.handleFetchAgent(msg.agentId, msg.requestId); - break; + case 'fetch_agent_request': + await this.handleFetchAgent(msg.agentId, msg.requestId) + break - case "delete_agent_request": - await this.handleDeleteAgentRequest(msg.agentId, msg.requestId); - break; + case 'delete_agent_request': + await this.handleDeleteAgentRequest(msg.agentId, msg.requestId) + break - case "archive_agent_request": - await this.handleArchiveAgentRequest(msg.agentId, msg.requestId); - break; + case 'archive_agent_request': + await this.handleArchiveAgentRequest(msg.agentId, msg.requestId) + break - case "update_agent_request": - await this.handleUpdateAgentRequest( - msg.agentId, - msg.name, - msg.labels, - msg.requestId - ); - break; + case 'update_agent_request': + await this.handleUpdateAgentRequest(msg.agentId, msg.name, msg.labels, msg.requestId) + break - case "set_voice_mode": - await this.handleSetVoiceMode(msg.enabled, msg.agentId, msg.requestId); - break; + case 'set_voice_mode': + await this.handleSetVoiceMode(msg.enabled, msg.agentId, msg.requestId) + break - case "send_agent_message_request": - await this.handleSendAgentMessageRequest(msg); - break; + case 'send_agent_message_request': + await this.handleSendAgentMessageRequest(msg) + break - case "wait_for_finish_request": - await this.handleWaitForFinish(msg.agentId, msg.requestId, msg.timeoutMs); - break; + case 'wait_for_finish_request': + await this.handleWaitForFinish(msg.agentId, msg.requestId, msg.timeoutMs) + break - case "dictation_stream_start": + case 'dictation_stream_start': { - const unavailable = this.resolveVoiceFeatureUnavailableContext("dictation"); + const unavailable = this.resolveVoiceFeatureUnavailableContext('dictation') if (unavailable) { this.emit({ - type: "dictation_stream_error", + type: 'dictation_stream_error', payload: { dictationId: msg.dictationId, error: unavailable.message, @@ -1345,462 +1279,433 @@ export class Session { reasonCode: unavailable.reasonCode, missingModelIds: unavailable.missingModelIds, }, - }); - break; + }) + break } } - await this.dictationStreamManager.handleStart(msg.dictationId, msg.format); - break; + await this.dictationStreamManager.handleStart(msg.dictationId, msg.format) + break - case "dictation_stream_chunk": + case 'dictation_stream_chunk': await this.dictationStreamManager.handleChunk({ dictationId: msg.dictationId, seq: msg.seq, audioBase64: msg.audio, format: msg.format, - }); - break; + }) + break - case "dictation_stream_finish": - await this.dictationStreamManager.handleFinish(msg.dictationId, msg.finalSeq); - break; + case 'dictation_stream_finish': + await this.dictationStreamManager.handleFinish(msg.dictationId, msg.finalSeq) + break - case "dictation_stream_cancel": - this.dictationStreamManager.handleCancel(msg.dictationId); - break; + case 'dictation_stream_cancel': + this.dictationStreamManager.handleCancel(msg.dictationId) + break - case "create_agent_request": - await this.handleCreateAgentRequest(msg); - break; + case 'create_agent_request': + await this.handleCreateAgentRequest(msg) + break - case "resume_agent_request": - await this.handleResumeAgentRequest(msg); - break; + case 'resume_agent_request': + await this.handleResumeAgentRequest(msg) + break - case "refresh_agent_request": - await this.handleRefreshAgentRequest(msg); - break; + case 'refresh_agent_request': + await this.handleRefreshAgentRequest(msg) + break - case "cancel_agent_request": - await this.handleCancelAgentRequest(msg.agentId); - break; + case 'cancel_agent_request': + await this.handleCancelAgentRequest(msg.agentId) + break - case "restart_server_request": - await this.handleRestartServerRequest(msg.requestId, msg.reason); - break; + case 'restart_server_request': + await this.handleRestartServerRequest(msg.requestId, msg.reason) + break - case "fetch_agent_timeline_request": - await this.handleFetchAgentTimelineRequest(msg); - break; + case 'fetch_agent_timeline_request': + await this.handleFetchAgentTimelineRequest(msg) + break - case "set_agent_mode_request": - await this.handleSetAgentModeRequest(msg.agentId, msg.modeId, msg.requestId); - break; + case 'set_agent_mode_request': + await this.handleSetAgentModeRequest(msg.agentId, msg.modeId, msg.requestId) + break - case "set_agent_model_request": - await this.handleSetAgentModelRequest(msg.agentId, msg.modelId, msg.requestId); - break; + case 'set_agent_model_request': + await this.handleSetAgentModelRequest(msg.agentId, msg.modelId, msg.requestId) + break - case "set_agent_thinking_request": - await this.handleSetAgentThinkingRequest( - msg.agentId, - msg.thinkingOptionId, - msg.requestId - ); - break; + case 'set_agent_thinking_request': + await this.handleSetAgentThinkingRequest(msg.agentId, msg.thinkingOptionId, msg.requestId) + break - case "agent_permission_response": - await this.handleAgentPermissionResponse( - msg.agentId, - msg.requestId, - msg.response - ); - break; + case 'agent_permission_response': + await this.handleAgentPermissionResponse(msg.agentId, msg.requestId, msg.response) + break - case "checkout_status_request": - await this.handleCheckoutStatusRequest(msg); - break; + case 'checkout_status_request': + await this.handleCheckoutStatusRequest(msg) + break - case "validate_branch_request": - await this.handleValidateBranchRequest(msg); - break; + case 'validate_branch_request': + await this.handleValidateBranchRequest(msg) + break - case "branch_suggestions_request": - await this.handleBranchSuggestionsRequest(msg); - break; + case 'branch_suggestions_request': + await this.handleBranchSuggestionsRequest(msg) + break - case "directory_suggestions_request": - await this.handleDirectorySuggestionsRequest(msg); - break; + case 'directory_suggestions_request': + await this.handleDirectorySuggestionsRequest(msg) + break - case "subscribe_checkout_diff_request": - await this.handleSubscribeCheckoutDiffRequest(msg); - break; + case 'subscribe_checkout_diff_request': + await this.handleSubscribeCheckoutDiffRequest(msg) + break - case "unsubscribe_checkout_diff_request": - this.handleUnsubscribeCheckoutDiffRequest(msg); - break; + case 'unsubscribe_checkout_diff_request': + this.handleUnsubscribeCheckoutDiffRequest(msg) + break - case "checkout_commit_request": - await this.handleCheckoutCommitRequest(msg); - break; + case 'checkout_commit_request': + await this.handleCheckoutCommitRequest(msg) + break - case "checkout_merge_request": - await this.handleCheckoutMergeRequest(msg); - break; + case 'checkout_merge_request': + await this.handleCheckoutMergeRequest(msg) + break - case "checkout_merge_from_base_request": - await this.handleCheckoutMergeFromBaseRequest(msg); - break; + case 'checkout_merge_from_base_request': + await this.handleCheckoutMergeFromBaseRequest(msg) + break - case "checkout_push_request": - await this.handleCheckoutPushRequest(msg); - break; + case 'checkout_push_request': + await this.handleCheckoutPushRequest(msg) + break - case "checkout_pr_create_request": - await this.handleCheckoutPrCreateRequest(msg); - break; + case 'checkout_pr_create_request': + await this.handleCheckoutPrCreateRequest(msg) + break - case "checkout_pr_status_request": - await this.handleCheckoutPrStatusRequest(msg); - break; + case 'checkout_pr_status_request': + await this.handleCheckoutPrStatusRequest(msg) + break - case "paseo_worktree_list_request": - await this.handlePaseoWorktreeListRequest(msg); - break; + case 'paseo_worktree_list_request': + await this.handlePaseoWorktreeListRequest(msg) + break - case "paseo_worktree_archive_request": - await this.handlePaseoWorktreeArchiveRequest(msg); - break; + case 'paseo_worktree_archive_request': + await this.handlePaseoWorktreeArchiveRequest(msg) + break - case "file_explorer_request": - await this.handleFileExplorerRequest(msg); - break; + case 'file_explorer_request': + await this.handleFileExplorerRequest(msg) + break - case "project_icon_request": - await this.handleProjectIconRequest(msg); - break; + case 'project_icon_request': + await this.handleProjectIconRequest(msg) + break - case "file_download_token_request": - await this.handleFileDownloadTokenRequest(msg); - break; + case 'file_download_token_request': + await this.handleFileDownloadTokenRequest(msg) + break - case "list_provider_models_request": - await this.handleListProviderModelsRequest(msg); - break; + case 'list_provider_models_request': + await this.handleListProviderModelsRequest(msg) + break - case "list_available_providers_request": - await this.handleListAvailableProvidersRequest(msg); - break; + case 'list_available_providers_request': + await this.handleListAvailableProvidersRequest(msg) + break - case "speech_models_list_request": - await this.handleSpeechModelsListRequest(msg); - break; + case 'speech_models_list_request': + await this.handleSpeechModelsListRequest(msg) + break - case "speech_models_download_request": - await this.handleSpeechModelsDownloadRequest(msg); - break; + case 'speech_models_download_request': + await this.handleSpeechModelsDownloadRequest(msg) + break - case "clear_agent_attention": - await this.handleClearAgentAttention(msg.agentId); - break; + case 'clear_agent_attention': + await this.handleClearAgentAttention(msg.agentId) + break - case "client_heartbeat": - this.handleClientHeartbeat(msg); - break; + case 'client_heartbeat': + this.handleClientHeartbeat(msg) + break - case "ping": { - const now = Date.now(); + case 'ping': { + const now = Date.now() this.emit({ - type: "pong", + type: 'pong', payload: { requestId: msg.requestId, clientSentAt: msg.clientSentAt, serverReceivedAt: now, serverSentAt: now, }, - }); - break; + }) + break } - case "list_commands_request": - await this.handleListCommandsRequest(msg); - break; + case 'list_commands_request': + await this.handleListCommandsRequest(msg) + break - case "register_push_token": - this.handleRegisterPushToken(msg.token); - break; + case 'register_push_token': + this.handleRegisterPushToken(msg.token) + break - case "subscribe_terminals_request": - this.handleSubscribeTerminalsRequest(msg); - break; + case 'subscribe_terminals_request': + this.handleSubscribeTerminalsRequest(msg) + break - case "unsubscribe_terminals_request": - this.handleUnsubscribeTerminalsRequest(msg); - break; + case 'unsubscribe_terminals_request': + this.handleUnsubscribeTerminalsRequest(msg) + break - case "list_terminals_request": - await this.handleListTerminalsRequest(msg); - break; + case 'list_terminals_request': + await this.handleListTerminalsRequest(msg) + break - case "create_terminal_request": - await this.handleCreateTerminalRequest(msg); - break; + case 'create_terminal_request': + await this.handleCreateTerminalRequest(msg) + break - case "subscribe_terminal_request": - await this.handleSubscribeTerminalRequest(msg); - break; + case 'subscribe_terminal_request': + await this.handleSubscribeTerminalRequest(msg) + break - case "unsubscribe_terminal_request": - this.handleUnsubscribeTerminalRequest(msg); - break; + case 'unsubscribe_terminal_request': + this.handleUnsubscribeTerminalRequest(msg) + break - case "terminal_input": - this.handleTerminalInput(msg); - break; + case 'terminal_input': + this.handleTerminalInput(msg) + break - case "kill_terminal_request": - await this.handleKillTerminalRequest(msg); - break; + case 'kill_terminal_request': + await this.handleKillTerminalRequest(msg) + break - case "attach_terminal_stream_request": - await this.handleAttachTerminalStreamRequest(msg); - break; + case 'attach_terminal_stream_request': + await this.handleAttachTerminalStreamRequest(msg) + break - case "detach_terminal_stream_request": - this.handleDetachTerminalStreamRequest(msg); - break; + case 'detach_terminal_stream_request': + this.handleDetachTerminalStreamRequest(msg) + break } } catch (error: any) { - const err = error instanceof Error ? error : new Error(String(error)); - this.sessionLogger.error( - { err }, - "Error handling message" - ); + const err = error instanceof Error ? error : new Error(String(error)) + this.sessionLogger.error({ err }, 'Error handling message') - const requestId = (msg as { requestId?: unknown }).requestId; - if (typeof requestId === "string") { + const requestId = (msg as { requestId?: unknown }).requestId + if (typeof requestId === 'string') { try { this.emit({ - type: "rpc_error", + type: 'rpc_error', payload: { requestId, requestType: msg.type, - error: "Request failed", - code: "handler_error", + error: 'Request failed', + code: 'handler_error', }, - }); + }) } catch (emitError) { - this.sessionLogger.error({ err: emitError }, "Failed to emit rpc_error"); + this.sessionLogger.error({ err: emitError }, 'Failed to emit rpc_error') } } this.emit({ - type: "activity_log", + type: 'activity_log', payload: { id: uuidv4(), timestamp: new Date(), - type: "error", + type: 'error', content: `Error: ${err.message}`, }, - }); + }) } } public handleBinaryFrame(frame: BinaryMuxFrame): void { switch (frame.channel) { case BinaryMuxChannel.Terminal: - this.handleTerminalBinaryFrame(frame); - break; + this.handleTerminalBinaryFrame(frame) + break default: this.sessionLogger.warn( { channel: frame.channel, messageType: frame.messageType }, - "Unhandled binary mux channel" - ); - break; + 'Unhandled binary mux channel' + ) + break } } private handleTerminalBinaryFrame(frame: BinaryMuxFrame): void { if (frame.messageType === TerminalBinaryMessageType.InputUtf8) { - const binding = this.terminalStreams.get(frame.streamId); + const binding = this.terminalStreams.get(frame.streamId) if (!binding) { - this.sessionLogger.warn({ streamId: frame.streamId }, "Terminal stream not found for input"); - return; + this.sessionLogger.warn({ streamId: frame.streamId }, 'Terminal stream not found for input') + return } if (!this.terminalManager) { - return; + return } - const session = this.terminalManager.getTerminal(binding.terminalId); + const session = this.terminalManager.getTerminal(binding.terminalId) if (!session) { - this.detachTerminalStream(frame.streamId, { emitExit: true }); - return; + this.detachTerminalStream(frame.streamId, { emitExit: true }) + return } - const payload = frame.payload ?? new Uint8Array(0); + const payload = frame.payload ?? new Uint8Array(0) if (payload.byteLength === 0) { - return; + return } - const text = Buffer.from(payload).toString("utf8"); + const text = Buffer.from(payload).toString('utf8') if (!text) { - return; + return } - session.send({ type: "input", data: text }); - return; + session.send({ type: 'input', data: text }) + return } if (frame.messageType === TerminalBinaryMessageType.Ack) { - const binding = this.terminalStreams.get(frame.streamId); + const binding = this.terminalStreams.get(frame.streamId) if (binding) { if (!Number.isFinite(frame.offset) || frame.offset < 0) { - return; + return } const nextAckOffset = Math.max( binding.lastAckOffset, Math.min(Math.floor(frame.offset), binding.lastOutputOffset) - ); + ) if (nextAckOffset > binding.lastAckOffset) { - binding.lastAckOffset = nextAckOffset; - this.flushPendingTerminalStreamChunks(frame.streamId, binding); + binding.lastAckOffset = nextAckOffset + this.flushPendingTerminalStreamChunks(frame.streamId, binding) } } - return; + return } this.sessionLogger.warn( { streamId: frame.streamId, messageType: frame.messageType }, - "Unhandled terminal binary frame" - ); + 'Unhandled terminal binary frame' + ) } - private async handleRestartServerRequest( - requestId: string, - reason?: string - ): Promise { + private async handleRestartServerRequest(requestId: string, reason?: string): Promise { if (restartRequested) { - this.sessionLogger.debug("Restart already requested, ignoring duplicate"); - return; + this.sessionLogger.debug('Restart already requested, ignoring duplicate') + return } - restartRequested = true; + restartRequested = true const payload: { status: string } & Record = { - status: "restart_requested", + status: 'restart_requested', clientId: this.clientId, - }; - if (reason && reason.trim().length > 0) { - payload.reason = reason; } - payload.requestId = requestId; + if (reason && reason.trim().length > 0) { + payload.reason = reason + } + payload.requestId = requestId - this.sessionLogger.warn({ reason }, "Restart requested via websocket"); + this.sessionLogger.warn({ reason }, 'Restart requested via websocket') this.emit({ - type: "status", + type: 'status', payload, - }); + }) - if (typeof process.send === "function") { + if (typeof process.send === 'function') { process.send({ - type: "paseo:restart", + type: 'paseo:restart', ...(reason ? { reason } : {}), - }); - return; + }) + return } setTimeout(() => { - process.exit(0); - }, RESTART_EXIT_DELAY_MS); + process.exit(0) + }, RESTART_EXIT_DELAY_MS) } - private async handleDeleteAgentRequest( - agentId: string, - requestId: string - ): Promise { - this.sessionLogger.info( - { agentId }, - `Deleting agent ${agentId} from registry` - ); + private async handleDeleteAgentRequest(agentId: string, requestId: string): Promise { + this.sessionLogger.info({ agentId }, `Deleting agent ${agentId} from registry`) // Prevent the persistence hook from re-creating the record while we close/delete. - this.agentStorage.beginDelete(agentId); + this.agentStorage.beginDelete(agentId) try { - await this.agentManager.closeAgent(agentId); + await this.agentManager.closeAgent(agentId) } catch (error: any) { this.sessionLogger.warn( { err: error, agentId }, `Failed to close agent ${agentId} during delete` - ); + ) } try { - await this.agentStorage.remove(agentId); + await this.agentStorage.remove(agentId) } catch (error: any) { this.sessionLogger.error( { err: error, agentId }, `Failed to remove agent ${agentId} from registry` - ); + ) } this.emit({ - type: "agent_deleted", + type: 'agent_deleted', payload: { agentId, requestId, }, - }); + }) if (this.agentUpdatesSubscription) { this.bufferOrEmitAgentUpdate(this.agentUpdatesSubscription, { - kind: "remove", + kind: 'remove', agentId, - }); + }) } } - private async handleArchiveAgentRequest( - agentId: string, - requestId: string - ): Promise { - this.sessionLogger.info( - { agentId }, - `Archiving agent ${agentId}` - ); + private async handleArchiveAgentRequest(agentId: string, requestId: string): Promise { + this.sessionLogger.info({ agentId }, `Archiving agent ${agentId}`) - const archivedAt = new Date().toISOString(); - let archivedRecord: StoredAgentRecord | null = null; + const archivedAt = new Date().toISOString() + let archivedRecord: StoredAgentRecord | null = null try { - const existing = await this.agentStorage.get(agentId); + const existing = await this.agentStorage.get(agentId) if (existing) { archivedRecord = { ...existing, archivedAt, - }; - await this.agentStorage.upsert(archivedRecord); + } + await this.agentStorage.upsert(archivedRecord) } - this.agentManager.notifyAgentState(agentId); + this.agentManager.notifyAgentState(agentId) } catch (error: any) { - this.sessionLogger.error( - { err: error, agentId }, - `Failed to archive agent ${agentId}` - ); + this.sessionLogger.error({ err: error, agentId }, `Failed to archive agent ${agentId}`) } this.emit({ - type: "agent_archived", + type: 'agent_archived', payload: { agentId, archivedAt, requestId, }, - }); + }) if (archivedRecord) { await this.maybeArchiveWorktreeAfterLastAgentArchived({ archivedAgentId: agentId, archivedAgentCwd: archivedRecord.cwd, requestId, - }); + }) } } @@ -1811,78 +1716,80 @@ export class Session { requestId: string ): Promise { this.sessionLogger.info( - { agentId, requestId, hasName: typeof name === "string", labelCount: labels ? Object.keys(labels).length : 0 }, - "session: update_agent_request" - ); + { + agentId, + requestId, + hasName: typeof name === 'string', + labelCount: labels ? Object.keys(labels).length : 0, + }, + 'session: update_agent_request' + ) - const normalizedName = name?.trim(); - const normalizedLabels = - labels && Object.keys(labels).length > 0 ? labels : undefined; + const normalizedName = name?.trim() + const normalizedLabels = labels && Object.keys(labels).length > 0 ? labels : undefined if (!normalizedName && !normalizedLabels) { this.emit({ - type: "update_agent_response", + type: 'update_agent_response', payload: { requestId, agentId, accepted: false, - error: "Nothing to update (provide name and/or labels)", + error: 'Nothing to update (provide name and/or labels)', }, - }); - return; + }) + return } try { - const liveAgent = this.agentManager.getAgent(agentId); + const liveAgent = this.agentManager.getAgent(agentId) if (liveAgent) { if (normalizedName) { - await this.agentManager.setTitle(agentId, normalizedName); + await this.agentManager.setTitle(agentId, normalizedName) } if (normalizedLabels) { - await this.agentManager.setLabels(agentId, normalizedLabels); + await this.agentManager.setLabels(agentId, normalizedLabels) } } else { - const existing = await this.agentStorage.get(agentId); + const existing = await this.agentStorage.get(agentId) if (!existing) { - throw new Error(`Agent not found: ${agentId}`); + throw new Error(`Agent not found: ${agentId}`) } await this.agentStorage.upsert({ ...existing, ...(normalizedName ? { title: normalizedName } : {}), - ...(normalizedLabels - ? { labels: { ...existing.labels, ...normalizedLabels } } - : {}), - }); + ...(normalizedLabels ? { labels: { ...existing.labels, ...normalizedLabels } } : {}), + }) } this.emit({ - type: "update_agent_response", + type: 'update_agent_response', payload: { requestId, agentId, accepted: true, error: null }, - }); + }) } catch (error: any) { this.sessionLogger.error( { err: error, agentId, requestId }, - "session: update_agent_request error" - ); + 'session: update_agent_request error' + ) this.emit({ - type: "activity_log", + type: 'activity_log', payload: { id: uuidv4(), timestamp: new Date(), - type: "error", + type: 'error', content: `Failed to update agent: ${error.message}`, }, - }); + }) this.emit({ - type: "update_agent_response", + type: 'update_agent_response', payload: { requestId, agentId, accepted: false, - error: error?.message ? String(error.message) : "Failed to update agent", + error: error?.message ? String(error.message) : 'Failed to update agent', }, - }); + }) } } @@ -1894,51 +1801,51 @@ export class Session { message: state.message, retryable: state.retryable, missingModelIds: [...state.missingModelIds], - }; + } } private resolveModeReadinessState( readiness: SpeechReadinessSnapshot, - mode: "voice_mode" | "dictation" + mode: 'voice_mode' | 'dictation' ): SpeechReadinessState { - if (mode === "voice_mode") { - return readiness.realtimeVoice; + if (mode === 'voice_mode') { + return readiness.realtimeVoice } - return readiness.dictation; + return readiness.dictation } private getVoiceFeatureUnavailableResponseMetadata( error: unknown ): VoiceFeatureUnavailableResponseMetadata { if (!(error instanceof VoiceFeatureUnavailableError)) { - return {}; + return {} } return { reasonCode: error.reasonCode, retryable: error.retryable, missingModelIds: error.missingModelIds, - }; + } } private resolveVoiceFeatureUnavailableContext( - mode: "voice_mode" | "dictation" + mode: 'voice_mode' | 'dictation' ): VoiceFeatureUnavailableContext | null { - const readiness = this.getSpeechReadiness?.(); + const readiness = this.getSpeechReadiness?.() if (!readiness) { - return null; + return null } - const modeReadiness = this.resolveModeReadinessState(readiness, mode); + const modeReadiness = this.resolveModeReadinessState(readiness, mode) if (!modeReadiness.enabled) { - return this.toVoiceFeatureUnavailableContext(modeReadiness); + return this.toVoiceFeatureUnavailableContext(modeReadiness) } if (!readiness.voiceFeature.available) { - return this.toVoiceFeatureUnavailableContext(readiness.voiceFeature); + return this.toVoiceFeatureUnavailableContext(readiness.voiceFeature) } if (!modeReadiness.available) { - return this.toVoiceFeatureUnavailableContext(modeReadiness); + return this.toVoiceFeatureUnavailableContext(modeReadiness) } - return null; + return null } /** @@ -1951,39 +1858,36 @@ export class Session { ): Promise { try { if (enabled) { - const unavailable = this.resolveVoiceFeatureUnavailableContext("voice_mode"); + const unavailable = this.resolveVoiceFeatureUnavailableContext('voice_mode') if (unavailable) { - throw new VoiceFeatureUnavailableError(unavailable); + throw new VoiceFeatureUnavailableError(unavailable) } - const normalizedAgentId = this.parseVoiceTargetAgentId( - agentId ?? "", - "set_voice_mode" - ); + const normalizedAgentId = this.parseVoiceTargetAgentId(agentId ?? '', 'set_voice_mode') if ( this.isVoiceMode && this.voiceModeAgentId && this.voiceModeAgentId !== normalizedAgentId ) { - await this.disableVoiceModeForActiveAgent(true); + await this.disableVoiceModeForActiveAgent(true) } if (!this.isVoiceMode || this.voiceModeAgentId !== normalizedAgentId) { - const refreshedAgentId = await this.enableVoiceModeForAgent(normalizedAgentId); - this.voiceModeAgentId = refreshedAgentId; + const refreshedAgentId = await this.enableVoiceModeForAgent(normalizedAgentId) + this.voiceModeAgentId = refreshedAgentId } - this.isVoiceMode = true; + this.isVoiceMode = true this.sessionLogger.info( { agentId: this.voiceModeAgentId, }, - "Voice mode enabled for existing agent" - ); + 'Voice mode enabled for existing agent' + ) if (requestId) { this.emit({ - type: "set_voice_mode_response", + type: 'set_voice_mode_response', payload: { requestId, enabled: true, @@ -1991,17 +1895,17 @@ export class Session { accepted: true, error: null, }, - }); + }) } - return; + return } - await this.disableVoiceModeForActiveAgent(true); - this.isVoiceMode = false; - this.sessionLogger.info("Voice mode disabled"); + await this.disableVoiceModeForActiveAgent(true) + this.isVoiceMode = false + this.sessionLogger.info('Voice mode disabled') if (requestId) { this.emit({ - type: "set_voice_mode_response", + type: 'set_voice_mode_response', payload: { requestId, enabled: false, @@ -2009,23 +1913,22 @@ export class Session { accepted: true, error: null, }, - }); + }) } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : "Failed to set voice mode"; - const unavailable = this.getVoiceFeatureUnavailableResponseMetadata(error); + const errorMessage = error instanceof Error ? error.message : 'Failed to set voice mode' + const unavailable = this.getVoiceFeatureUnavailableResponseMetadata(error) this.sessionLogger.error( { err: error, enabled, requestedAgentId: agentId ?? null, }, - "set_voice_mode failed" - ); + 'set_voice_mode failed' + ) if (requestId) { this.emit({ - type: "set_voice_mode_response", + type: 'set_voice_mode_response', payload: { requestId, enabled: this.isVoiceMode, @@ -2034,37 +1937,37 @@ export class Session { error: errorMessage, ...unavailable, }, - }); - return; + }) + return } - throw error; + throw error } } private parseVoiceTargetAgentId(rawId: string, source: string): string { - const parsed = AgentIdSchema.safeParse(rawId.trim()); + const parsed = AgentIdSchema.safeParse(rawId.trim()) if (!parsed.success) { - throw new Error(`${source}: agentId must be a UUID`); + throw new Error(`${source}: agentId must be a UUID`) } - return parsed.data; + return parsed.data } private cloneMcpServers( servers: Record | undefined ): Record | undefined { if (!servers) { - return undefined; + return undefined } - return JSON.parse(JSON.stringify(servers)) as Record; + return JSON.parse(JSON.stringify(servers)) as Record } private buildVoiceModeMcpServers( existing: Record | undefined, socketPath: string ): Record { - const mcpStdio = this.voiceAgentMcpStdio; + const mcpStdio = this.voiceAgentMcpStdio if (!mcpStdio) { - throw new Error("Voice MCP stdio bridge is not configured"); + throw new Error('Voice MCP stdio bridge is not configured') } return { ...(existing ?? {}), @@ -2074,215 +1977,211 @@ export class Session { socketPath, env: mcpStdio.env, }), - }; + } } private async enableVoiceModeForAgent(agentId: string): Promise { - const ensureVoiceSocket = this.ensureVoiceMcpSocketForAgent; + const ensureVoiceSocket = this.ensureVoiceMcpSocketForAgent if (!ensureVoiceSocket) { - throw new Error("Voice MCP socket bridge is not configured"); + throw new Error('Voice MCP socket bridge is not configured') } - const existing = await this.ensureAgentLoaded(agentId); + const existing = await this.ensureAgentLoaded(agentId) - const socketPath = await ensureVoiceSocket(agentId); - this.registerVoiceBridgeForAgent(agentId); + const socketPath = await ensureVoiceSocket(agentId) + this.registerVoiceBridgeForAgent(agentId) const baseConfig: VoiceModeBaseConfig = { systemPrompt: stripVoiceModeSystemPrompt(existing.config.systemPrompt), mcpServers: this.cloneMcpServers(existing.config.mcpServers), - }; - this.voiceModeBaseConfig = baseConfig; + } + this.voiceModeBaseConfig = baseConfig const refreshOverrides: Partial = { systemPrompt: buildVoiceModeSystemPrompt(baseConfig.systemPrompt, true), mcpServers: this.buildVoiceModeMcpServers(baseConfig.mcpServers, socketPath), - }; + } try { - const refreshed = await this.agentManager.reloadAgentSession( - agentId, - refreshOverrides - ); - return refreshed.id; + const refreshed = await this.agentManager.reloadAgentSession(agentId, refreshOverrides) + return refreshed.id } catch (error) { - this.unregisterVoiceSpeakHandler?.(agentId); - this.unregisterVoiceCallerContext?.(agentId); - await this.removeVoiceMcpSocketForAgent?.(agentId).catch(() => undefined); - this.voiceModeBaseConfig = null; - throw error; + this.unregisterVoiceSpeakHandler?.(agentId) + this.unregisterVoiceCallerContext?.(agentId) + await this.removeVoiceMcpSocketForAgent?.(agentId).catch(() => undefined) + this.voiceModeBaseConfig = null + throw error } } - private async disableVoiceModeForActiveAgent( - restoreAgentConfig: boolean - ): Promise { - this.clearVoiceModeInactivityTimeout(); - this.cancelActiveVoiceDictationStream("voice mode disabled"); + private async disableVoiceModeForActiveAgent(restoreAgentConfig: boolean): Promise { + this.clearVoiceModeInactivityTimeout() + this.cancelActiveVoiceDictationStream('voice mode disabled') - const agentId = this.voiceModeAgentId; + const agentId = this.voiceModeAgentId if (!agentId) { - this.voiceModeBaseConfig = null; - return; + this.voiceModeBaseConfig = null + return } - this.unregisterVoiceSpeakHandler?.(agentId); - this.unregisterVoiceCallerContext?.(agentId); + this.unregisterVoiceSpeakHandler?.(agentId) + this.unregisterVoiceCallerContext?.(agentId) await this.removeVoiceMcpSocketForAgent?.(agentId).catch((error) => { this.sessionLogger.warn( { err: error, agentId }, - "Failed to remove voice MCP socket bridge on disable" - ); - }); + 'Failed to remove voice MCP socket bridge on disable' + ) + }) if (restoreAgentConfig && this.voiceModeBaseConfig) { - const baseConfig = this.voiceModeBaseConfig; + const baseConfig = this.voiceModeBaseConfig try { await this.agentManager.reloadAgentSession(agentId, { systemPrompt: buildVoiceModeSystemPrompt(baseConfig.systemPrompt, false), mcpServers: this.cloneMcpServers(baseConfig.mcpServers), - }); + }) } catch (error) { this.sessionLogger.warn( { err: error, agentId }, - "Failed to restore agent config while disabling voice mode" - ); + 'Failed to restore agent config while disabling voice mode' + ) } } - this.voiceModeBaseConfig = null; - this.voiceModeAgentId = null; + this.voiceModeBaseConfig = null + this.voiceModeAgentId = null } private isInternalVoiceDictationId(dictationId: string): boolean { - return dictationId.startsWith(VOICE_INTERNAL_DICTATION_ID_PREFIX); + return dictationId.startsWith(VOICE_INTERNAL_DICTATION_ID_PREFIX) } private handleDictationManagerMessage(msg: DictationStreamOutboundMessage): void { - if (msg.type === "activity_log") { - const metadata = msg.payload.metadata as { dictationId?: unknown } | undefined; + if (msg.type === 'activity_log') { + const metadata = msg.payload.metadata as { dictationId?: unknown } | undefined const dictationId = - metadata && typeof metadata.dictationId === "string" ? metadata.dictationId : null; + metadata && typeof metadata.dictationId === 'string' ? metadata.dictationId : null if (dictationId && this.isInternalVoiceDictationId(dictationId)) { - return; + return } - this.emit(msg as unknown as SessionOutboundMessage); - return; + this.emit(msg as unknown as SessionOutboundMessage) + return } - const payloadWithDictationId = msg.payload as { dictationId?: unknown }; + const payloadWithDictationId = msg.payload as { dictationId?: unknown } const dictationId = - payloadWithDictationId && typeof payloadWithDictationId.dictationId === "string" + payloadWithDictationId && typeof payloadWithDictationId.dictationId === 'string' ? payloadWithDictationId.dictationId - : null; + : null if (!dictationId || !this.isInternalVoiceDictationId(dictationId)) { - this.emit(msg as unknown as SessionOutboundMessage); - return; + this.emit(msg as unknown as SessionOutboundMessage) + return } - if (msg.type === "dictation_stream_final") { + if (msg.type === 'dictation_stream_final') { if (dictationId !== this.activeVoiceDictationId || !this.activeVoiceDictationResolve) { - return; + return } this.activeVoiceDictationResolve({ text: msg.payload.text, ...(msg.payload.debugRecordingPath ? { debugRecordingPath: msg.payload.debugRecordingPath } : {}), - }); - return; + }) + return } - if (msg.type === "dictation_stream_error") { + if (msg.type === 'dictation_stream_error') { if (dictationId !== this.activeVoiceDictationId || !this.activeVoiceDictationReject) { - return; + return } - this.activeVoiceDictationReject(new Error(msg.payload.error)); - return; + this.activeVoiceDictationReject(new Error(msg.payload.error)) + return } // Ack/partial messages for internal voice dictation are consumed server-side. } private resetActiveVoiceDictationState(): void { - this.activeVoiceDictationId = null; - this.activeVoiceDictationFormat = null; - this.activeVoiceDictationNextSeq = 0; - this.activeVoiceDictationStartPromise = null; - this.activeVoiceDictationFinalizePromise = null; - this.activeVoiceDictationResultPromise = null; - this.activeVoiceDictationResolve = null; - this.activeVoiceDictationReject = null; + this.activeVoiceDictationId = null + this.activeVoiceDictationFormat = null + this.activeVoiceDictationNextSeq = 0 + this.activeVoiceDictationStartPromise = null + this.activeVoiceDictationFinalizePromise = null + this.activeVoiceDictationResultPromise = null + this.activeVoiceDictationResolve = null + this.activeVoiceDictationReject = null } private cancelActiveVoiceDictationStream(reason: string): void { - const dictationId = this.activeVoiceDictationId; + const dictationId = this.activeVoiceDictationId if (!dictationId) { - return; + return } - this.sessionLogger.debug({ dictationId, reason }, "Cancelling active internal voice dictation stream"); + this.sessionLogger.debug( + { dictationId, reason }, + 'Cancelling active internal voice dictation stream' + ) if (this.activeVoiceDictationReject) { - this.activeVoiceDictationReject(new Error(`Voice dictation cancelled: ${reason}`)); + this.activeVoiceDictationReject(new Error(`Voice dictation cancelled: ${reason}`)) } - this.voiceStreamManager.handleCancel(dictationId); - this.resetActiveVoiceDictationState(); + this.voiceStreamManager.handleCancel(dictationId) + this.resetActiveVoiceDictationState() } private async ensureActiveVoiceDictationStream(format: string): Promise { if (this.activeVoiceDictationId && this.activeVoiceDictationFormat === format) { if (this.activeVoiceDictationStartPromise) { - await this.activeVoiceDictationStartPromise; + await this.activeVoiceDictationStartPromise } - return; + return } if (this.activeVoiceDictationId) { - await this.finalizeActiveVoiceDictationStream("voice format changed"); + await this.finalizeActiveVoiceDictationStream('voice format changed') } - const dictationId = `${VOICE_INTERNAL_DICTATION_ID_PREFIX}${uuidv4()}`; - let resolve: - | ((value: { text: string; debugRecordingPath?: string }) => void) - | null = null; - let reject: ((error: Error) => void) | null = null; + const dictationId = `${VOICE_INTERNAL_DICTATION_ID_PREFIX}${uuidv4()}` + let resolve: ((value: { text: string; debugRecordingPath?: string }) => void) | null = null + let reject: ((error: Error) => void) | null = null const resultPromise = new Promise<{ text: string; debugRecordingPath?: string }>( (resolveFn, rejectFn) => { - resolve = resolveFn; - reject = rejectFn; + resolve = resolveFn + reject = rejectFn } - ); + ) // Prevent process-level unhandled rejection warnings when cancellation races are resolved later. - void resultPromise.catch(() => undefined); + void resultPromise.catch(() => undefined) - this.activeVoiceDictationId = dictationId; - this.activeVoiceDictationFormat = format; - this.activeVoiceDictationNextSeq = 0; - this.activeVoiceDictationFinalizePromise = null; - this.activeVoiceDictationResultPromise = resultPromise; - this.activeVoiceDictationResolve = resolve; - this.activeVoiceDictationReject = reject; - this.setPhase("transcribing"); + this.activeVoiceDictationId = dictationId + this.activeVoiceDictationFormat = format + this.activeVoiceDictationNextSeq = 0 + this.activeVoiceDictationFinalizePromise = null + this.activeVoiceDictationResultPromise = resultPromise + this.activeVoiceDictationResolve = resolve + this.activeVoiceDictationReject = reject + this.setPhase('transcribing') this.emit({ - type: "activity_log", + type: 'activity_log', payload: { id: uuidv4(), timestamp: new Date(), - type: "system", - content: "Transcribing audio...", + type: 'system', + content: 'Transcribing audio...', }, - }); + }) - const startPromise = this.voiceStreamManager.handleStart(dictationId, format); - this.activeVoiceDictationStartPromise = startPromise; + const startPromise = this.voiceStreamManager.handleStart(dictationId, format) + this.activeVoiceDictationStartPromise = startPromise try { - await startPromise; + await startPromise } catch (error) { - this.resetActiveVoiceDictationState(); - throw error; + this.resetActiveVoiceDictationState() + throw error } finally { if (this.activeVoiceDictationId === dictationId) { - this.activeVoiceDictationStartPromise = null; + this.activeVoiceDictationStartPromise = null } } } @@ -2292,56 +2191,56 @@ export class Session { format: string ): Promise { if (this.activeVoiceDictationFinalizePromise) { - await this.activeVoiceDictationFinalizePromise.catch(() => undefined); + await this.activeVoiceDictationFinalizePromise.catch(() => undefined) } - await this.ensureActiveVoiceDictationStream(format); - const dictationId = this.activeVoiceDictationId; + await this.ensureActiveVoiceDictationStream(format) + const dictationId = this.activeVoiceDictationId if (!dictationId) { - throw new Error("Voice dictation stream did not initialize"); + throw new Error('Voice dictation stream did not initialize') } - const seq = this.activeVoiceDictationNextSeq; - this.activeVoiceDictationNextSeq += 1; + const seq = this.activeVoiceDictationNextSeq + this.activeVoiceDictationNextSeq += 1 await this.voiceStreamManager.handleChunk({ dictationId, seq, audioBase64, format, - }); + }) } private async finalizeActiveVoiceDictationStream(reason: string): Promise { - const dictationId = this.activeVoiceDictationId; + const dictationId = this.activeVoiceDictationId if (!dictationId) { - return; + return } - this.clearVoiceModeInactivityTimeout(); + this.clearVoiceModeInactivityTimeout() if (this.activeVoiceDictationStartPromise) { - await this.activeVoiceDictationStartPromise; + await this.activeVoiceDictationStartPromise } if (this.activeVoiceDictationFinalizePromise) { - await this.activeVoiceDictationFinalizePromise; - return; + await this.activeVoiceDictationFinalizePromise + return } - const finalSeq = this.activeVoiceDictationNextSeq - 1; - const resultPromise = this.activeVoiceDictationResultPromise; + const finalSeq = this.activeVoiceDictationNextSeq - 1 + const resultPromise = this.activeVoiceDictationResultPromise if (!resultPromise) { - this.resetActiveVoiceDictationState(); - return; + this.resetActiveVoiceDictationState() + return } this.activeVoiceDictationFinalizePromise = (async () => { this.sessionLogger.debug( { dictationId, finalSeq, reason }, - "Finalizing internal voice dictation stream" - ); - await this.voiceStreamManager.handleFinish(dictationId, finalSeq); - const result = await resultPromise; - this.resetActiveVoiceDictationState(); - const requestId = uuidv4(); - const transcriptText = result.text.trim(); + 'Finalizing internal voice dictation stream' + ) + await this.voiceStreamManager.handleFinish(dictationId, finalSeq) + const result = await resultPromise + this.resetActiveVoiceDictationState() + const requestId = uuidv4() + const transcriptText = result.text.trim() this.sessionLogger.info( { requestId, @@ -2349,35 +2248,33 @@ export class Session { transcriptLength: transcriptText.length, transcript: transcriptText, }, - "Transcription result" - ); + 'Transcription result' + ) await this.handleTranscriptionResultPayload({ text: result.text, requestId, ...(result.debugRecordingPath - ? { debugRecordingPath: result.debugRecordingPath, format: "audio/wav" } + ? { debugRecordingPath: result.debugRecordingPath, format: 'audio/wav' } : {}), - }); - })(); + }) + })() try { - await this.activeVoiceDictationFinalizePromise; + await this.activeVoiceDictationFinalizePromise } catch (error) { - this.resetActiveVoiceDictationState(); - this.setPhase("idle"); - this.clearSpeechInProgress("transcription error"); + this.resetActiveVoiceDictationState() + this.setPhase('idle') + this.clearSpeechInProgress('transcription error') this.emit({ - type: "activity_log", + type: 'activity_log', payload: { id: uuidv4(), timestamp: new Date(), - type: "error", - content: `Transcription error: ${ - error instanceof Error ? error.message : String(error) - }`, + type: 'error', + content: `Transcription error: ${error instanceof Error ? error.message : String(error)}`, }, - }); - throw error; + }) + throw error } } @@ -2394,60 +2291,57 @@ export class Session { this.sessionLogger.info( { agentId, textPreview: text.substring(0, 50), imageCount: images?.length ?? 0 }, `Sending text to agent ${agentId}${images && images.length > 0 ? ` with ${images.length} image attachment(s)` : ''}` - ); + ) try { - await this.ensureAgentLoaded(agentId); + await this.ensureAgentLoaded(agentId) + } catch (error) { + this.handleAgentRunError(agentId, error, 'Failed to initialize agent before sending prompt') + return + } + + try { + await this.interruptAgentIfRunning(agentId) } catch (error) { this.handleAgentRunError( agentId, error, - "Failed to initialize agent before sending prompt" - ); - return; + 'Failed to interrupt running agent before sending prompt' + ) + return } - try { - await this.interruptAgentIfRunning(agentId); - } catch (error) { - this.handleAgentRunError( - agentId, - error, - "Failed to interrupt running agent before sending prompt" - ); - return; - } - - const prompt = this.buildAgentPrompt(text, images); + const prompt = this.buildAgentPrompt(text, images) try { this.agentManager.recordUserMessage(agentId, text, { messageId, emitState: false, - }); + }) } catch (error) { this.sessionLogger.error( { err: error, agentId }, `Failed to record user message for agent ${agentId}` - ); + ) } - this.startAgentStream(agentId, prompt, runOptions); + this.startAgentStream(agentId, prompt, runOptions) } /** * Handle create agent request */ private async handleCreateAgentRequest( - msg: Extract + msg: Extract ): Promise { - const { config, worktreeName, requestId, initialPrompt, outputSchema, git, images, labels } = msg; + const { config, worktreeName, requestId, initialPrompt, outputSchema, git, images, labels } = + msg this.sessionLogger.info( { cwd: config.cwd, provider: config.provider, worktreeName }, `Creating agent in ${config.cwd} (${config.provider})${ - worktreeName ? ` with worktree ${worktreeName}` : "" + worktreeName ? ` with worktree ${worktreeName}` : '' }` - ); + ) try { const { sessionConfig, worktreeConfig } = await this.buildAgentSessionConfig( @@ -2455,15 +2349,11 @@ export class Session { git, worktreeName, labels - ); - const snapshot = await this.agentManager.createAgent( - sessionConfig, - undefined, - { labels } - ); - await this.forwardAgentUpdate(snapshot); + ) + const snapshot = await this.agentManager.createAgent(sessionConfig, undefined, { labels }) + await this.forwardAgentUpdate(snapshot) - const trimmedPrompt = initialPrompt?.trim(); + const trimmedPrompt = initialPrompt?.trim() if (trimmedPrompt) { scheduleAgentMetadataGeneration({ agentManager: this.agentManager, @@ -2473,7 +2363,7 @@ export class Session { explicitTitle: snapshot.config.title, paseoHome: this.paseoHome, logger: this.sessionLogger, - }); + }) try { await this.handleSendAgentMessage( @@ -2482,38 +2372,38 @@ export class Session { uuidv4(), images, outputSchema ? { outputSchema } : undefined - ); + ) } catch (promptError) { this.sessionLogger.error( { err: promptError, agentId: snapshot.id }, `Failed to run initial prompt for agent ${snapshot.id}` - ); + ) this.emit({ - type: "activity_log", + type: 'activity_log', payload: { id: uuidv4(), timestamp: new Date(), - type: "error", + type: 'error', content: `Initial prompt failed: ${(promptError as Error)?.message ?? promptError}`, }, - }); + }) } } if (requestId) { - const agentPayload = await this.getAgentPayloadById(snapshot.id); + const agentPayload = await this.getAgentPayloadById(snapshot.id) if (!agentPayload) { - throw new Error(`Agent ${snapshot.id} not found after creation`); + throw new Error(`Agent ${snapshot.id} not found after creation`) } this.emit({ - type: "status", + type: 'status', payload: { - status: "agent_created", + status: 'agent_created', agentId: snapshot.id, requestId, agent: agentPayload, }, - }); + }) } if (worktreeConfig) { @@ -2534,186 +2424,160 @@ export class Session { item, }), logger: this.sessionLogger, - }); + }) } this.sessionLogger.info( { agentId: snapshot.id, provider: snapshot.provider }, `Created agent ${snapshot.id} (${snapshot.provider})` - ); + ) } catch (error: any) { - this.sessionLogger.error( - { err: error }, - "Failed to create agent" - ); + this.sessionLogger.error({ err: error }, 'Failed to create agent') if (requestId) { this.emit({ - type: "status", + type: 'status', payload: { - status: "agent_create_failed", + status: 'agent_create_failed', requestId, error: (error as Error)?.message ?? String(error), }, - }); + }) } this.emit({ - type: "activity_log", + type: 'activity_log', payload: { id: uuidv4(), timestamp: new Date(), - type: "error", + type: 'error', content: `Failed to create agent: ${error.message}`, }, - }); + }) } } private async handleResumeAgentRequest( - msg: Extract + msg: Extract ): Promise { - const { handle, overrides, requestId } = msg; + const { handle, overrides, requestId } = msg if (!handle) { - this.sessionLogger.warn("Resume request missing persistence handle"); + this.sessionLogger.warn('Resume request missing persistence handle') this.emit({ - type: "activity_log", + type: 'activity_log', payload: { id: uuidv4(), timestamp: new Date(), - type: "error", - content: "Unable to resume agent: missing persistence handle", + type: 'error', + content: 'Unable to resume agent: missing persistence handle', }, - }); - return; + }) + return } this.sessionLogger.info( { sessionId: handle.sessionId, provider: handle.provider }, `Resuming agent ${handle.sessionId} (${handle.provider})` - ); + ) try { - const snapshot = await this.agentManager.resumeAgentFromPersistence( - handle, - overrides - ); - await this.agentManager.hydrateTimelineFromProvider(snapshot.id); - await this.forwardAgentUpdate(snapshot); - const timelineSize = this.agentManager.getTimeline(snapshot.id).length; + const snapshot = await this.agentManager.resumeAgentFromPersistence(handle, overrides) + await this.agentManager.hydrateTimelineFromProvider(snapshot.id) + await this.forwardAgentUpdate(snapshot) + const timelineSize = this.agentManager.getTimeline(snapshot.id).length if (requestId) { - const agentPayload = await this.getAgentPayloadById(snapshot.id); + const agentPayload = await this.getAgentPayloadById(snapshot.id) if (!agentPayload) { - throw new Error(`Agent ${snapshot.id} not found after resume`); + throw new Error(`Agent ${snapshot.id} not found after resume`) } this.emit({ - type: "status", + type: 'status', payload: { - status: "agent_resumed", + status: 'agent_resumed', agentId: snapshot.id, requestId, timelineSize, agent: agentPayload, }, - }); + }) } } catch (error: any) { - this.sessionLogger.error( - { err: error }, - "Failed to resume agent" - ); + this.sessionLogger.error({ err: error }, 'Failed to resume agent') this.emit({ - type: "activity_log", + type: 'activity_log', payload: { id: uuidv4(), timestamp: new Date(), - type: "error", + type: 'error', content: `Failed to resume agent: ${error.message}`, }, - }); + }) } } private async handleRefreshAgentRequest( - msg: Extract + msg: Extract ): Promise { - const { agentId, requestId } = msg; - this.sessionLogger.info( - { agentId }, - `Refreshing agent ${agentId} from persistence` - ); + const { agentId, requestId } = msg + this.sessionLogger.info({ agentId }, `Refreshing agent ${agentId} from persistence`) try { - let snapshot: ManagedAgent; - const existing = this.agentManager.getAgent(agentId); + let snapshot: ManagedAgent + const existing = this.agentManager.getAgent(agentId) if (existing) { - await this.interruptAgentIfRunning(agentId); + await this.interruptAgentIfRunning(agentId) if (existing.persistence) { - snapshot = await this.agentManager.reloadAgentSession( - agentId - ); + snapshot = await this.agentManager.reloadAgentSession(agentId) } else { - snapshot = existing; + snapshot = existing } } else { - const record = await this.agentStorage.get(agentId); + const record = await this.agentStorage.get(agentId) if (!record) { - throw new Error(`Agent not found: ${agentId}`); + throw new Error(`Agent not found: ${agentId}`) } - const handle = toAgentPersistenceHandle(this.sessionLogger, record.persistence); + const handle = toAgentPersistenceHandle(this.sessionLogger, record.persistence) if (!handle) { - throw new Error( - `Agent ${agentId} cannot be refreshed because it lacks persistence` - ); + throw new Error(`Agent ${agentId} cannot be refreshed because it lacks persistence`) } snapshot = await this.agentManager.resumeAgentFromPersistence( handle, buildConfigOverrides(record), agentId, extractTimestamps(record) - ); + ) } - await this.agentManager.hydrateTimelineFromProvider(agentId); - await this.forwardAgentUpdate(snapshot); - const timelineSize = this.agentManager.getTimeline(agentId).length; + await this.agentManager.hydrateTimelineFromProvider(agentId) + await this.forwardAgentUpdate(snapshot) + const timelineSize = this.agentManager.getTimeline(agentId).length if (requestId) { this.emit({ - type: "status", + type: 'status', payload: { - status: "agent_refreshed", + status: 'agent_refreshed', agentId, requestId, timelineSize, }, - }); + }) } } catch (error: any) { - this.sessionLogger.error( - { err: error, agentId }, - `Failed to refresh agent ${agentId}` - ); + this.sessionLogger.error({ err: error, agentId }, `Failed to refresh agent ${agentId}`) this.emit({ - type: "activity_log", + type: 'activity_log', payload: { id: uuidv4(), timestamp: new Date(), - type: "error", + type: 'error', content: `Failed to refresh agent: ${error.message}`, }, - }); + }) } } private async handleCancelAgentRequest(agentId: string): Promise { - this.sessionLogger.info( - { agentId }, - `Cancel request received for agent ${agentId}` - ); + this.sessionLogger.info({ agentId }, `Cancel request received for agent ${agentId}`) try { - await this.interruptAgentIfRunning(agentId); + await this.interruptAgentIfRunning(agentId) } catch (error) { - this.handleAgentRunError( - agentId, - error, - "Failed to cancel running agent on request" - ); + this.handleAgentRunError(agentId, error, 'Failed to cancel running agent on request') } } @@ -2723,9 +2587,9 @@ export class Session { legacyWorktreeName?: string, _labels?: Record ): Promise<{ sessionConfig: AgentSessionConfig; worktreeConfig?: WorktreeConfig }> { - let cwd = expandTilde(config.cwd); - const normalized = this.normalizeGitOptions(gitOptions, legacyWorktreeName); - let worktreeConfig: WorktreeConfig | undefined; + let cwd = expandTilde(config.cwd) + const normalized = this.normalizeGitOptions(gitOptions, legacyWorktreeName) + let worktreeConfig: WorktreeConfig | undefined if (!normalized) { return { @@ -2733,35 +2597,31 @@ export class Session { ...config, cwd, }, - }; + } } if (normalized.createWorktree) { - let targetBranch: string; + let targetBranch: string if (normalized.createNewBranch) { - targetBranch = normalized.newBranchName!; + targetBranch = normalized.newBranchName! } else { // Resolve current branch name from HEAD - const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { + const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd, env: READ_ONLY_GIT_ENV, - }); - targetBranch = stdout.trim(); + }) + targetBranch = stdout.trim() } if (!targetBranch) { - throw new Error( - "A branch name is required when creating a worktree." - ); + throw new Error('A branch name is required when creating a worktree.') } this.sessionLogger.info( { worktreeSlug: normalized.worktreeSlug ?? targetBranch, branch: targetBranch }, - `Creating worktree '${ - normalized.worktreeSlug ?? targetBranch - }' for branch ${targetBranch}` - ); + `Creating worktree '${normalized.worktreeSlug ?? targetBranch}' for branch ${targetBranch}` + ) const createdWorktree = await createAgentWorktree({ branchName: targetBranch, @@ -2769,17 +2629,17 @@ export class Session { baseBranch: normalized.baseBranch!, worktreeSlug: normalized.worktreeSlug ?? targetBranch, paseoHome: this.paseoHome, - }); - cwd = createdWorktree.worktreePath; - worktreeConfig = createdWorktree; + }) + cwd = createdWorktree.worktreePath + worktreeConfig = createdWorktree } else if (normalized.createNewBranch) { await this.createBranchFromBase({ cwd, baseBranch: normalized.baseBranch!, newBranchName: normalized.newBranchName!, - }); + }) } else if (normalized.baseBranch) { - await this.checkoutExistingBranch(cwd, normalized.baseBranch); + await this.checkoutExistingBranch(cwd, normalized.baseBranch) } return { @@ -2788,19 +2648,19 @@ export class Session { cwd, }, worktreeConfig, - }; + } } private async handleListProviderModelsRequest( - msg: Extract + msg: Extract ): Promise { - const fetchedAt = new Date().toISOString(); + const fetchedAt = new Date().toISOString() try { const models = await this.providerRegistry[msg.provider].fetchModels({ cwd: msg.cwd ? expandTilde(msg.cwd) : undefined, - }); + }) this.emit({ - type: "list_provider_models_response", + type: 'list_provider_models_response', payload: { provider: msg.provider, models, @@ -2808,77 +2668,74 @@ export class Session { fetchedAt, requestId: msg.requestId, }, - }); + }) } catch (error) { this.sessionLogger.error( { err: error, provider: msg.provider }, `Failed to list models for ${msg.provider}` - ); + ) this.emit({ - type: "list_provider_models_response", + type: 'list_provider_models_response', payload: { provider: msg.provider, error: (error as Error)?.message ?? String(error), fetchedAt, requestId: msg.requestId, }, - }); + }) } } private async handleListAvailableProvidersRequest( - msg: Extract + msg: Extract ): Promise { - const fetchedAt = new Date().toISOString(); + const fetchedAt = new Date().toISOString() try { - const providers = await this.agentManager.listProviderAvailability(); + const providers = await this.agentManager.listProviderAvailability() this.emit({ - type: "list_available_providers_response", + type: 'list_available_providers_response', payload: { providers, error: null, fetchedAt, requestId: msg.requestId, }, - }); + }) } catch (error) { - this.sessionLogger.error( - { err: error }, - "Failed to list provider availability" - ); + this.sessionLogger.error({ err: error }, 'Failed to list provider availability') this.emit({ - type: "list_available_providers_response", + type: 'list_available_providers_response', payload: { providers: [], error: (error as Error)?.message ?? String(error), fetchedAt, requestId: msg.requestId, }, - }); + }) } } private async handleSpeechModelsListRequest( - msg: Extract + msg: Extract ): Promise { - const modelsDir = this.localSpeechModelsDir; + const modelsDir = this.localSpeechModelsDir const models = await Promise.all( listLocalSpeechModels().map(async (model) => { - const modelDir = getLocalSpeechModelDir(modelsDir, model.id); - const missingFiles: string[] = []; + const modelDir = getLocalSpeechModelDir(modelsDir, model.id) + const missingFiles: string[] = [] for (const rel of model.requiredFiles) { - const filePath = join(modelDir, rel); + const filePath = join(modelDir, rel) try { - const fileStat = await stat(filePath); + const fileStat = await stat(filePath) if (fileStat.isDirectory()) { - continue; + continue } if (!fileStat.isFile() || fileStat.size <= 0) { - missingFiles.push(rel); + missingFiles.push(rel) } } catch { - missingFiles.push(rel); + missingFiles.push(rel) } } @@ -2889,72 +2746,70 @@ export class Session { modelDir, isDownloaded: missingFiles.length === 0, ...(missingFiles.length > 0 ? { missingFiles } : {}), - }; + } }) - ); + ) this.emit({ - type: "speech_models_list_response", + type: 'speech_models_list_response', payload: { modelsDir, models, requestId: msg.requestId, }, - }); + }) } private async handleSpeechModelsDownloadRequest( - msg: Extract + msg: Extract ): Promise { - const modelsDir = this.localSpeechModelsDir; + const modelsDir = this.localSpeechModelsDir const modelIdsRaw = - msg.modelIds && msg.modelIds.length > 0 - ? msg.modelIds - : this.defaultLocalSpeechModelIds; + msg.modelIds && msg.modelIds.length > 0 ? msg.modelIds : this.defaultLocalSpeechModelIds - const allModelIds = new Set(listLocalSpeechModels().map((m) => m.id)); - const invalid = modelIdsRaw.filter((id) => !allModelIds.has(id as LocalSpeechModelId)); + const allModelIds = new Set(listLocalSpeechModels().map((m) => m.id)) + const invalid = modelIdsRaw.filter((id) => !allModelIds.has(id as LocalSpeechModelId)) if (invalid.length > 0) { this.emit({ - type: "speech_models_download_response", + type: 'speech_models_download_response', payload: { modelsDir, downloadedModelIds: [], - error: `Unknown speech model id(s): ${invalid.join(", ")}`, + error: `Unknown speech model id(s): ${invalid.join(', ')}`, requestId: msg.requestId, }, - }); - return; + }) + return } - const modelIds = modelIdsRaw as LocalSpeechModelId[]; + const modelIds = modelIdsRaw as LocalSpeechModelId[] try { await ensureLocalSpeechModels({ modelsDir, modelIds, logger: this.sessionLogger, - }); + }) this.emit({ - type: "speech_models_download_response", + type: 'speech_models_download_response', payload: { modelsDir, downloadedModelIds: modelIds, error: null, requestId: msg.requestId, }, - }); + }) } catch (error) { - this.sessionLogger.error({ err: error, modelIds }, "Failed to download speech models"); + this.sessionLogger.error({ err: error, modelIds }, 'Failed to download speech models') this.emit({ - type: "speech_models_download_response", + type: 'speech_models_download_response', payload: { modelsDir, downloadedModelIds: [], error: error instanceof Error ? error.message : String(error), requestId: msg.requestId, }, - }); + }) } } @@ -2969,52 +2824,50 @@ export class Session { newBranchName: legacyWorktreeName, worktreeSlug: legacyWorktreeName, } - : undefined; + : undefined - const merged = gitOptions ?? fallbackOptions; + const merged = gitOptions ?? fallbackOptions if (!merged) { - return null; + return null } - const baseBranch = merged.baseBranch?.trim() || undefined; - const createWorktree = Boolean(merged.createWorktree); - const createNewBranch = Boolean(merged.createNewBranch); - const normalizedBranchName = merged.newBranchName - ? slugify(merged.newBranchName) - : undefined; + const baseBranch = merged.baseBranch?.trim() || undefined + const createWorktree = Boolean(merged.createWorktree) + const createNewBranch = Boolean(merged.createNewBranch) + const normalizedBranchName = merged.newBranchName ? slugify(merged.newBranchName) : undefined const normalizedWorktreeSlug = merged.worktreeSlug ? slugify(merged.worktreeSlug) - : normalizedBranchName; + : normalizedBranchName if (!createWorktree && !createNewBranch && !baseBranch) { - return null; + return null } if (baseBranch) { - this.assertSafeGitRef(baseBranch, "base branch"); + this.assertSafeGitRef(baseBranch, 'base branch') } if (createWorktree && !baseBranch) { - throw new Error("Base branch is required when creating a worktree"); + throw new Error('Base branch is required when creating a worktree') } if (createNewBranch && !baseBranch) { - throw new Error("Base branch is required when creating a new branch"); + throw new Error('Base branch is required when creating a new branch') } if (createNewBranch) { if (!normalizedBranchName) { - throw new Error("New branch name is required"); + throw new Error('New branch name is required') } - const validation = validateBranchSlug(normalizedBranchName); + const validation = validateBranchSlug(normalizedBranchName) if (!validation.valid) { - throw new Error(`Invalid branch name: ${validation.error}`); + throw new Error(`Invalid branch name: ${validation.error}`) } } if (normalizedWorktreeSlug) { - const validation = validateBranchSlug(normalizedWorktreeSlug); + const validation = validateBranchSlug(normalizedWorktreeSlug) if (!validation.valid) { - throw new Error(`Invalid worktree name: ${validation.error}`); + throw new Error(`Invalid worktree name: ${validation.error}`) } } @@ -3024,254 +2877,249 @@ export class Session { newBranchName: normalizedBranchName, createWorktree, worktreeSlug: normalizedWorktreeSlug, - }; + } } private assertSafeGitRef(ref: string, label: string): void { - if (!SAFE_GIT_REF_PATTERN.test(ref) || ref.includes("..") || ref.includes("@{")) { - throw new Error(`Invalid ${label}: ${ref}`); + if (!SAFE_GIT_REF_PATTERN.test(ref) || ref.includes('..') || ref.includes('@{')) { + throw new Error(`Invalid ${label}: ${ref}`) } } private toCheckoutError(error: unknown): CheckoutErrorPayload { if (error instanceof NotGitRepoError) { - return { code: "NOT_GIT_REPO", message: error.message }; + return { code: 'NOT_GIT_REPO', message: error.message } } if (error instanceof MergeConflictError) { - return { code: "MERGE_CONFLICT", message: error.message }; + return { code: 'MERGE_CONFLICT', message: error.message } } if (error instanceof MergeFromBaseConflictError) { - return { code: "MERGE_CONFLICT", message: error.message }; + return { code: 'MERGE_CONFLICT', message: error.message } } if (error instanceof Error) { - return { code: "UNKNOWN", message: error.message }; + return { code: 'UNKNOWN', message: error.message } } - return { code: "UNKNOWN", message: String(error) }; + return { code: 'UNKNOWN', message: String(error) } } private isPathWithinRoot(rootPath: string, candidatePath: string): boolean { - const resolvedRoot = resolve(rootPath); - const resolvedCandidate = resolve(candidatePath); + const resolvedRoot = resolve(rootPath) + const resolvedCandidate = resolve(candidatePath) if (resolvedCandidate === resolvedRoot) { - return true; + return true } - return resolvedCandidate.startsWith(resolvedRoot + sep); + return resolvedCandidate.startsWith(resolvedRoot + sep) } private async generateCommitMessage(cwd: string): Promise { const diff = await getCheckoutDiff( cwd, - { mode: "uncommitted", includeStructured: true }, + { mode: 'uncommitted', includeStructured: true }, { paseoHome: this.paseoHome } - ); + ) const schema = z.object({ message: z .string() .min(1) .max(72) - .describe("Concise git commit message, imperative mood, no trailing period."), - }); + .describe('Concise git commit message, imperative mood, no trailing period.'), + }) const fileList = diff.structured && diff.structured.length > 0 ? [ - "Files changed:", + 'Files changed:', ...diff.structured.map((file) => { - const changeType = file.isNew ? "A" : file.isDeleted ? "D" : "M"; - const status = file.status && file.status !== "ok" ? ` [${file.status}]` : ""; - return `${changeType}\t${file.path}\t(+${file.additions} -${file.deletions})${status}`; + const changeType = file.isNew ? 'A' : file.isDeleted ? 'D' : 'M' + const status = file.status && file.status !== 'ok' ? ` [${file.status}]` : '' + return `${changeType}\t${file.path}\t(+${file.additions} -${file.deletions})${status}` }), - ].join("\n") - : "Files changed: (unknown)"; - const maxPatchChars = 120_000; + ].join('\n') + : 'Files changed: (unknown)' + const maxPatchChars = 120_000 const patch = diff.diff.length > maxPatchChars ? `${diff.diff.slice(0, maxPatchChars)}\n\n... (diff truncated to ${maxPatchChars} chars)\n` - : diff.diff; + : diff.diff const prompt = [ - "Write a concise git commit message for the changes below.", + 'Write a concise git commit message for the changes below.', "Return JSON only with a single field 'message'.", - "", + '', fileList, - "", - patch.length > 0 ? patch : "(No diff available)", - ].join("\n"); + '', + patch.length > 0 ? patch : '(No diff available)', + ].join('\n') try { const result = await generateStructuredAgentResponseWithFallback({ manager: this.agentManager, cwd, prompt, schema, - schemaName: "CommitMessage", + schemaName: 'CommitMessage', maxRetries: 2, providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS, agentConfigOverrides: { - title: "Commit generator", + title: 'Commit generator', internal: true, }, - }); - return result.message; + }) + return result.message } catch (error) { if ( error instanceof StructuredAgentResponseError || error instanceof StructuredAgentFallbackError ) { - return "Update files"; + return 'Update files' } - throw error; + throw error } } - private async generatePullRequestText(cwd: string, baseRef?: string): Promise<{ - title: string; - body: string; + private async generatePullRequestText( + cwd: string, + baseRef?: string + ): Promise<{ + title: string + body: string }> { const diff = await getCheckoutDiff( cwd, { - mode: "base", + mode: 'base', baseRef, includeStructured: true, }, { paseoHome: this.paseoHome } - ); + ) const schema = z.object({ title: z.string().min(1).max(72), body: z.string().min(1), - }); + }) const fileList = diff.structured && diff.structured.length > 0 ? [ - "Files changed:", + 'Files changed:', ...diff.structured.map((file) => { - const changeType = file.isNew ? "A" : file.isDeleted ? "D" : "M"; - const status = file.status && file.status !== "ok" ? ` [${file.status}]` : ""; - return `${changeType}\t${file.path}\t(+${file.additions} -${file.deletions})${status}`; + const changeType = file.isNew ? 'A' : file.isDeleted ? 'D' : 'M' + const status = file.status && file.status !== 'ok' ? ` [${file.status}]` : '' + return `${changeType}\t${file.path}\t(+${file.additions} -${file.deletions})${status}` }), - ].join("\n") - : "Files changed: (unknown)"; - const maxPatchChars = 200_000; + ].join('\n') + : 'Files changed: (unknown)' + const maxPatchChars = 200_000 const patch = diff.diff.length > maxPatchChars ? `${diff.diff.slice(0, maxPatchChars)}\n\n... (diff truncated to ${maxPatchChars} chars)\n` - : diff.diff; + : diff.diff const prompt = [ - "Write a pull request title and body for the changes below.", + 'Write a pull request title and body for the changes below.', "Return JSON only with fields 'title' and 'body'.", - "", + '', fileList, - "", - patch.length > 0 ? patch : "(No diff available)", - ].join("\n"); + '', + patch.length > 0 ? patch : '(No diff available)', + ].join('\n') try { return await generateStructuredAgentResponseWithFallback({ manager: this.agentManager, cwd, prompt, schema, - schemaName: "PullRequest", + schemaName: 'PullRequest', maxRetries: 2, providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS, agentConfigOverrides: { - title: "PR generator", + title: 'PR generator', internal: true, }, - }); + }) } catch (error) { if ( error instanceof StructuredAgentResponseError || error instanceof StructuredAgentFallbackError ) { return { - title: "Update changes", - body: "Automated PR generated by Paseo.", - }; + title: 'Update changes', + body: 'Automated PR generated by Paseo.', + } } - throw error; + throw error } } private async ensureCleanWorkingTree(cwd: string): Promise { - const dirty = await this.isWorkingTreeDirty(cwd); + const dirty = await this.isWorkingTreeDirty(cwd) if (dirty) { throw new Error( - "Working directory has uncommitted changes. Commit or stash before switching branches." - ); + 'Working directory has uncommitted changes. Commit or stash before switching branches.' + ) } } private async isWorkingTreeDirty(cwd: string): Promise { try { - const { stdout } = await execAsync("git status --porcelain", { + const { stdout } = await execAsync('git status --porcelain', { cwd, env: READ_ONLY_GIT_ENV, - }); - return stdout.trim().length > 0; + }) + return stdout.trim().length > 0 } catch (error) { - throw new Error( - `Unable to inspect git status for ${cwd}: ${(error as Error).message}` - ); + throw new Error(`Unable to inspect git status for ${cwd}: ${(error as Error).message}`) } } - private async checkoutExistingBranch( - cwd: string, - branch: string - ): Promise { - this.assertSafeGitRef(branch, "branch"); + private async checkoutExistingBranch(cwd: string, branch: string): Promise { + this.assertSafeGitRef(branch, 'branch') try { - await execAsync(`git rev-parse --verify ${branch}`, { cwd }); + await execAsync(`git rev-parse --verify ${branch}`, { cwd }) } catch (error) { - throw new Error(`Branch not found: ${branch}`); + throw new Error(`Branch not found: ${branch}`) } - const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { + const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd, - }); - const current = stdout.trim(); + }) + const current = stdout.trim() if (current === branch) { - return; + return } - await this.ensureCleanWorkingTree(cwd); - await execAsync(`git checkout ${branch}`, { cwd }); + await this.ensureCleanWorkingTree(cwd) + await execAsync(`git checkout ${branch}`, { cwd }) } private async createBranchFromBase(params: { - cwd: string; - baseBranch: string; - newBranchName: string; + cwd: string + baseBranch: string + newBranchName: string }): Promise { - const { cwd, baseBranch, newBranchName } = params; - this.assertSafeGitRef(baseBranch, "base branch"); + const { cwd, baseBranch, newBranchName } = params + this.assertSafeGitRef(baseBranch, 'base branch') try { - await execAsync(`git rev-parse --verify ${baseBranch}`, { cwd }); + await execAsync(`git rev-parse --verify ${baseBranch}`, { cwd }) } catch (error) { - throw new Error(`Base branch not found: ${baseBranch}`); + throw new Error(`Base branch not found: ${baseBranch}`) } - const exists = await this.doesLocalBranchExist(cwd, newBranchName); + const exists = await this.doesLocalBranchExist(cwd, newBranchName) if (exists) { - throw new Error(`Branch already exists: ${newBranchName}`); + throw new Error(`Branch already exists: ${newBranchName}`) } - await this.ensureCleanWorkingTree(cwd); + await this.ensureCleanWorkingTree(cwd) await execAsync(`git checkout -b ${newBranchName} ${baseBranch}`, { cwd, - }); + }) } - private async doesLocalBranchExist( - cwd: string, - branch: string - ): Promise { + private async doesLocalBranchExist(cwd: string, branch: string): Promise { try { - await execAsync(`git show-ref --verify --quiet refs/heads/${branch}` , { + await execAsync(`git show-ref --verify --quiet refs/heads/${branch}`, { cwd, - }); - return true; + }) + return true } catch (error: any) { - return false; + return false } } @@ -3283,44 +3131,41 @@ export class Session { modeId: string, requestId: string ): Promise { - this.sessionLogger.info( - { agentId, modeId, requestId }, - "session: set_agent_mode_request" - ); + this.sessionLogger.info({ agentId, modeId, requestId }, 'session: set_agent_mode_request') try { - await this.agentManager.setAgentMode(agentId, modeId); + await this.agentManager.setAgentMode(agentId, modeId) this.sessionLogger.info( { agentId, modeId, requestId }, - "session: set_agent_mode_request success" - ); + 'session: set_agent_mode_request success' + ) this.emit({ - type: "set_agent_mode_response", + type: 'set_agent_mode_response', payload: { requestId, agentId, accepted: true, error: null }, - }); + }) } catch (error: any) { this.sessionLogger.error( { err: error, agentId, modeId, requestId }, - "session: set_agent_mode_request error" - ); + 'session: set_agent_mode_request error' + ) this.emit({ - type: "activity_log", + type: 'activity_log', payload: { id: uuidv4(), timestamp: new Date(), - type: "error", + type: 'error', content: `Failed to set agent mode: ${error.message}`, }, - }); + }) this.emit({ - type: "set_agent_mode_response", + type: 'set_agent_mode_response', payload: { requestId, agentId, accepted: false, - error: error?.message ? String(error.message) : "Failed to set agent mode", + error: error?.message ? String(error.message) : 'Failed to set agent mode', }, - }); + }) } } @@ -3329,44 +3174,41 @@ export class Session { modelId: string | null, requestId: string ): Promise { - this.sessionLogger.info( - { agentId, modelId, requestId }, - "session: set_agent_model_request" - ); + this.sessionLogger.info({ agentId, modelId, requestId }, 'session: set_agent_model_request') try { - await this.agentManager.setAgentModel(agentId, modelId); + await this.agentManager.setAgentModel(agentId, modelId) this.sessionLogger.info( { agentId, modelId, requestId }, - "session: set_agent_model_request success" - ); + 'session: set_agent_model_request success' + ) this.emit({ - type: "set_agent_model_response", + type: 'set_agent_model_response', payload: { requestId, agentId, accepted: true, error: null }, - }); + }) } catch (error: any) { this.sessionLogger.error( { err: error, agentId, modelId, requestId }, - "session: set_agent_model_request error" - ); + 'session: set_agent_model_request error' + ) this.emit({ - type: "activity_log", + type: 'activity_log', payload: { id: uuidv4(), timestamp: new Date(), - type: "error", + type: 'error', content: `Failed to set agent model: ${error.message}`, }, - }); + }) this.emit({ - type: "set_agent_model_response", + type: 'set_agent_model_response', payload: { requestId, agentId, accepted: false, - error: error?.message ? String(error.message) : "Failed to set agent model", + error: error?.message ? String(error.message) : 'Failed to set agent model', }, - }); + }) } } @@ -3377,44 +3219,42 @@ export class Session { ): Promise { this.sessionLogger.info( { agentId, thinkingOptionId, requestId }, - "session: set_agent_thinking_request" - ); + 'session: set_agent_thinking_request' + ) try { - await this.agentManager.setAgentThinkingOption(agentId, thinkingOptionId); + await this.agentManager.setAgentThinkingOption(agentId, thinkingOptionId) this.sessionLogger.info( { agentId, thinkingOptionId, requestId }, - "session: set_agent_thinking_request success" - ); + 'session: set_agent_thinking_request success' + ) this.emit({ - type: "set_agent_thinking_response", + type: 'set_agent_thinking_response', payload: { requestId, agentId, accepted: true, error: null }, - }); + }) } catch (error: any) { this.sessionLogger.error( { err: error, agentId, thinkingOptionId, requestId }, - "session: set_agent_thinking_request error" - ); + 'session: set_agent_thinking_request error' + ) this.emit({ - type: "activity_log", + type: 'activity_log', payload: { id: uuidv4(), timestamp: new Date(), - type: "error", + type: 'error', content: `Failed to set agent thinking option: ${error.message}`, }, - }); + }) this.emit({ - type: "set_agent_thinking_response", + type: 'set_agent_thinking_response', payload: { requestId, agentId, accepted: false, - error: error?.message - ? String(error.message) - : "Failed to set agent thinking option", + error: error?.message ? String(error.message) : 'Failed to set agent thinking option', }, - }); + }) } } @@ -3422,21 +3262,16 @@ export class Session { * Handle clearing agent attention flag */ private async handleClearAgentAttention(agentId: string | string[]): Promise { - const agentIds = Array.isArray(agentId) ? agentId : [agentId]; + const agentIds = Array.isArray(agentId) ? agentId : [agentId] this.sessionLogger.debug( { agentIds }, - `Clearing attention for ${agentIds.length} agent(s): ${agentIds.join(", ")}` - ); + `Clearing attention for ${agentIds.length} agent(s): ${agentIds.join(', ')}` + ) try { - await Promise.all( - agentIds.map((id) => this.agentManager.clearAgentAttention(id)) - ); + await Promise.all(agentIds.map((id) => this.agentManager.clearAgentAttention(id))) } catch (error: any) { - this.sessionLogger.error( - { err: error, agentIds }, - "Failed to clear agent attention" - ); + this.sessionLogger.error({ err: error, agentIds }, 'Failed to clear agent attention') // Don't throw - this is not critical } } @@ -3445,60 +3280,60 @@ export class Session { * Handle client heartbeat for activity tracking */ private handleClientHeartbeat(msg: { - deviceType: "web" | "mobile"; - focusedAgentId: string | null; - lastActivityAt: string; - appVisible: boolean; - appVisibilityChangedAt?: string; + deviceType: 'web' | 'mobile' + focusedAgentId: string | null + lastActivityAt: string + appVisible: boolean + appVisibilityChangedAt?: string }): void { const appVisibilityChangedAt = msg.appVisibilityChangedAt ? new Date(msg.appVisibilityChangedAt) - : new Date(msg.lastActivityAt); + : new Date(msg.lastActivityAt) this.clientActivity = { deviceType: msg.deviceType, focusedAgentId: msg.focusedAgentId, lastActivityAt: new Date(msg.lastActivityAt), appVisible: msg.appVisible, appVisibilityChangedAt, - }; + } } /** * Handle push token registration */ private handleRegisterPushToken(token: string): void { - this.pushTokenStore.addToken(token); - this.sessionLogger.info("Registered push token"); + this.pushTokenStore.addToken(token) + this.sessionLogger.info('Registered push token') } /** * Handle list commands request for an agent */ private async handleListCommandsRequest( - msg: Extract + msg: Extract ): Promise { - const { agentId, requestId, draftConfig } = msg; + const { agentId, requestId, draftConfig } = msg this.sessionLogger.debug( { agentId, draftConfig }, `Handling list commands request for agent ${agentId}` - ); + ) try { - const agents = this.agentManager.listAgents(); - const agent = agents.find((a) => a.id === agentId); + const agents = this.agentManager.listAgents() + const agent = agents.find((a) => a.id === agentId) if (agent?.session?.listCommands) { - const commands = await agent.session.listCommands(); + const commands = await agent.session.listCommands() this.emit({ - type: "list_commands_response", + type: 'list_commands_response', payload: { agentId, commands, error: null, requestId, }, - }); - return; + }) + return } if (!agent && draftConfig) { @@ -3510,47 +3345,41 @@ export class Session { ...(draftConfig.thinkingOptionId ? { thinkingOptionId: draftConfig.thinkingOptionId } : {}), - }; + } - const commands = await this.agentManager.listDraftCommands(sessionConfig); + const commands = await this.agentManager.listDraftCommands(sessionConfig) this.emit({ - type: "list_commands_response", + type: 'list_commands_response', payload: { agentId, commands, error: null, requestId, }, - }); - return; + }) + return } this.emit({ - type: "list_commands_response", + type: 'list_commands_response', payload: { agentId, commands: [], - error: agent - ? `Agent does not support listing commands` - : `Agent not found: ${agentId}`, + error: agent ? `Agent does not support listing commands` : `Agent not found: ${agentId}`, requestId, }, - }); - + }) } catch (error: any) { - this.sessionLogger.error( - { err: error, agentId, draftConfig }, - "Failed to list commands" - ); + this.sessionLogger.error({ err: error, agentId, draftConfig }, 'Failed to list commands') this.emit({ - type: "list_commands_response", + type: 'list_commands_response', payload: { agentId, commands: [], error: error.message, requestId, }, - }); + }) } } @@ -3565,43 +3394,40 @@ export class Session { this.sessionLogger.debug( { agentId, requestId }, `Handling permission response for agent ${agentId}, request ${requestId}` - ); + ) try { - await this.agentManager.respondToPermission(agentId, requestId, response); - this.sessionLogger.debug( - { agentId }, - `Permission response forwarded to agent ${agentId}` - ); + await this.agentManager.respondToPermission(agentId, requestId, response) + this.sessionLogger.debug({ agentId }, `Permission response forwarded to agent ${agentId}`) } catch (error: any) { this.sessionLogger.error( { err: error, agentId, requestId }, - "Failed to respond to permission" - ); + 'Failed to respond to permission' + ) this.emit({ - type: "activity_log", + type: 'activity_log', payload: { id: uuidv4(), timestamp: new Date(), - type: "error", + type: 'error', content: `Failed to respond to permission: ${error.message}`, }, - }); - throw error; + }) + throw error } } private async handleCheckoutStatusRequest( - msg: Extract + msg: Extract ): Promise { - const { cwd, requestId } = msg; - const resolvedCwd = expandTilde(cwd); + const { cwd, requestId } = msg + const resolvedCwd = expandTilde(cwd) try { - const status = await getCheckoutStatus(resolvedCwd, { paseoHome: this.paseoHome }); + const status = await getCheckoutStatus(resolvedCwd, { paseoHome: this.paseoHome }) if (!status.isGit) { this.emit({ - type: "checkout_status_response", + type: 'checkout_status_response', payload: { cwd, isGit: false, @@ -3617,13 +3443,13 @@ export class Session { error: null, requestId, }, - }); - return; + }) + return } if (status.isPaseoOwnedWorktree) { this.emit({ - type: "checkout_status_response", + type: 'checkout_status_response', payload: { cwd, isGit: true, @@ -3640,12 +3466,12 @@ export class Session { error: null, requestId, }, - }); - return; + }) + return } this.emit({ - type: "checkout_status_response", + type: 'checkout_status_response', payload: { cwd, isGit: true, @@ -3661,10 +3487,10 @@ export class Session { error: null, requestId, }, - }); + }) } catch (error) { this.emit({ - type: "checkout_status_response", + type: 'checkout_status_response', payload: { cwd, isGit: false, @@ -3680,26 +3506,26 @@ export class Session { error: this.toCheckoutError(error), requestId, }, - }); + }) } } private async handleValidateBranchRequest( - msg: Extract + msg: Extract ): Promise { - const { cwd, branchName, requestId } = msg; + const { cwd, branchName, requestId } = msg try { - const resolvedCwd = expandTilde(cwd); + const resolvedCwd = expandTilde(cwd) // Try local branch first try { await execAsync(`git rev-parse --verify ${branchName}`, { cwd: resolvedCwd, env: READ_ONLY_GIT_ENV, - }); + }) this.emit({ - type: "validate_branch_response", + type: 'validate_branch_response', payload: { exists: true, resolvedRef: branchName, @@ -3707,8 +3533,8 @@ export class Session { error: null, requestId, }, - }); - return; + }) + return } catch { // Local branch doesn't exist, try remote } @@ -3718,9 +3544,9 @@ export class Session { await execAsync(`git rev-parse --verify origin/${branchName}`, { cwd: resolvedCwd, env: READ_ONLY_GIT_ENV, - }); + }) this.emit({ - type: "validate_branch_response", + type: 'validate_branch_response', payload: { exists: true, resolvedRef: `origin/${branchName}`, @@ -3728,15 +3554,15 @@ export class Session { error: null, requestId, }, - }); - return; + }) + return } catch { // Remote branch doesn't exist either } // Branch not found anywhere this.emit({ - type: "validate_branch_response", + type: 'validate_branch_response', payload: { exists: false, resolvedRef: null, @@ -3744,10 +3570,10 @@ export class Session { error: null, requestId, }, - }); + }) } catch (error) { this.emit({ - type: "validate_branch_response", + type: 'validate_branch_response', payload: { exists: false, resolvedRef: null, @@ -3755,157 +3581,168 @@ export class Session { error: error instanceof Error ? error.message : String(error), requestId, }, - }); + }) } } private async handleBranchSuggestionsRequest( - msg: Extract + msg: Extract ): Promise { - const { cwd, query, limit, requestId } = msg; + const { cwd, query, limit, requestId } = msg try { - const resolvedCwd = expandTilde(cwd); - const branches = await listBranchSuggestions(resolvedCwd, { query, limit }); + const resolvedCwd = expandTilde(cwd) + const branches = await listBranchSuggestions(resolvedCwd, { query, limit }) this.emit({ - type: "branch_suggestions_response", + type: 'branch_suggestions_response', payload: { branches, error: null, requestId, }, - }); + }) } catch (error) { this.emit({ - type: "branch_suggestions_response", + type: 'branch_suggestions_response', payload: { branches: [], error: error instanceof Error ? error.message : String(error), requestId, }, - }); + }) } } - private async handleDirectorySuggestionsRequest( - msg: DirectorySuggestionsRequest - ): Promise { - const { query, limit, requestId } = msg; + private async handleDirectorySuggestionsRequest(msg: DirectorySuggestionsRequest): Promise { + const { query, limit, requestId, cwd, includeFiles, includeDirectories } = msg try { - const directories = await searchHomeDirectories({ - homeDir: process.env.HOME ?? homedir(), - query, - limit, - }); + const workspaceCwd = cwd?.trim() + const entries = workspaceCwd + ? await searchWorkspaceEntries({ + cwd: expandTilde(workspaceCwd), + query, + limit, + includeFiles, + includeDirectories, + }) + : ( + await searchHomeDirectories({ + homeDir: process.env.HOME ?? homedir(), + query, + limit, + }) + ).map((path) => ({ path, kind: 'directory' as const })) + const directories = entries + .filter((entry) => entry.kind === 'directory') + .map((entry) => entry.path) this.emit({ - type: "directory_suggestions_response", + type: 'directory_suggestions_response', payload: { directories, + entries, error: null, requestId, }, - }); + }) } catch (error) { this.emit({ - type: "directory_suggestions_response", + type: 'directory_suggestions_response', payload: { directories: [], + entries: [], error: error instanceof Error ? error.message : String(error), requestId, }, - }); + }) } } - private normalizeCheckoutDiffCompare(compare: CheckoutDiffCompareInput): CheckoutDiffCompareInput { - if (compare.mode === "uncommitted") { - return { mode: "uncommitted" }; + private normalizeCheckoutDiffCompare( + compare: CheckoutDiffCompareInput + ): CheckoutDiffCompareInput { + if (compare.mode === 'uncommitted') { + return { mode: 'uncommitted' } } - const trimmedBaseRef = compare.baseRef?.trim(); - return trimmedBaseRef - ? { mode: "base", baseRef: trimmedBaseRef } - : { mode: "base" }; + const trimmedBaseRef = compare.baseRef?.trim() + return trimmedBaseRef ? { mode: 'base', baseRef: trimmedBaseRef } : { mode: 'base' } } private buildCheckoutDiffTargetKey(cwd: string, compare: CheckoutDiffCompareInput): string { return JSON.stringify([ cwd, compare.mode, - compare.mode === "base" ? (compare.baseRef ?? "") : "", - ]); + compare.mode === 'base' ? (compare.baseRef ?? '') : '', + ]) } private closeCheckoutDiffWatchTarget(target: CheckoutDiffWatchTarget): void { if (target.debounceTimer) { - clearTimeout(target.debounceTimer); - target.debounceTimer = null; + clearTimeout(target.debounceTimer) + target.debounceTimer = null } if (target.fallbackRefreshInterval) { - clearInterval(target.fallbackRefreshInterval); - target.fallbackRefreshInterval = null; + clearInterval(target.fallbackRefreshInterval) + target.fallbackRefreshInterval = null } for (const watcher of target.watchers) { - watcher.close(); + watcher.close() } - target.watchers = []; + target.watchers = [] } private removeCheckoutDiffSubscription(subscriptionId: string): void { - const subscription = this.checkoutDiffSubscriptions.get(subscriptionId); + const subscription = this.checkoutDiffSubscriptions.get(subscriptionId) if (!subscription) { - return; + return } - this.checkoutDiffSubscriptions.delete(subscriptionId); + this.checkoutDiffSubscriptions.delete(subscriptionId) - const target = this.checkoutDiffTargets.get(subscription.targetKey); + const target = this.checkoutDiffTargets.get(subscription.targetKey) if (!target) { - return; + return } - target.subscriptions.delete(subscriptionId); + target.subscriptions.delete(subscriptionId) if (target.subscriptions.size === 0) { - this.closeCheckoutDiffWatchTarget(target); - this.checkoutDiffTargets.delete(subscription.targetKey); + this.closeCheckoutDiffWatchTarget(target) + this.checkoutDiffTargets.delete(subscription.targetKey) } } private async resolveCheckoutGitDir(cwd: string): Promise { try { - const { stdout } = await execAsync("git rev-parse --absolute-git-dir", { + const { stdout } = await execAsync('git rev-parse --absolute-git-dir', { cwd, env: READ_ONLY_GIT_ENV, - }); - const gitDir = stdout.trim(); - return gitDir.length > 0 ? gitDir : null; + }) + const gitDir = stdout.trim() + return gitDir.length > 0 ? gitDir : null } catch { - return null; + return null } } private async resolveCheckoutWatchRoot(cwd: string): Promise { try { - const { stdout } = await execAsync( - "git rev-parse --path-format=absolute --show-toplevel", - { - cwd, - env: READ_ONLY_GIT_ENV, - } - ); - const root = stdout.trim(); - return root.length > 0 ? root : null; + const { stdout } = await execAsync('git rev-parse --path-format=absolute --show-toplevel', { + cwd, + env: READ_ONLY_GIT_ENV, + }) + const root = stdout.trim() + return root.length > 0 ? root : null } catch { - return null; + return null } } private scheduleCheckoutDiffTargetRefresh(target: CheckoutDiffWatchTarget): void { if (target.debounceTimer) { - clearTimeout(target.debounceTimer); + clearTimeout(target.debounceTimer) } target.debounceTimer = setTimeout(() => { - target.debounceTimer = null; - void this.refreshCheckoutDiffTarget(target); - }, CHECKOUT_DIFF_WATCH_DEBOUNCE_MS); + target.debounceTimer = null + void this.refreshCheckoutDiffTarget(target) + }, CHECKOUT_DIFF_WATCH_DEBOUNCE_MS) } private emitCheckoutDiffUpdate( @@ -3913,21 +3750,21 @@ export class Session { snapshot: CheckoutDiffSnapshotPayload ): void { if (target.subscriptions.size === 0) { - return; + return } for (const subscriptionId of target.subscriptions) { this.emit({ - type: "checkout_diff_update", + type: 'checkout_diff_update', payload: { subscriptionId, ...snapshot, }, - }); + }) } } private checkoutDiffSnapshotFingerprint(snapshot: CheckoutDiffSnapshotPayload): string { - return JSON.stringify(snapshot); + return JSON.stringify(snapshot) } private async computeCheckoutDiffSnapshot( @@ -3935,7 +3772,7 @@ export class Session { compare: CheckoutDiffCompareInput, options?: { diffCwd?: string } ): Promise { - const diffCwd = options?.diffCwd ?? cwd; + const diffCwd = options?.diffCwd ?? cwd try { const diffResult = await getCheckoutDiff( diffCwd, @@ -3945,53 +3782,51 @@ export class Session { includeStructured: true, }, { paseoHome: this.paseoHome } - ); - const files = [...(diffResult.structured ?? [])]; + ) + const files = [...(diffResult.structured ?? [])] files.sort((a, b) => { - if (a.path === b.path) return 0; - return a.path < b.path ? -1 : 1; - }); + if (a.path === b.path) return 0 + return a.path < b.path ? -1 : 1 + }) return { cwd, files, error: null, - }; + } } catch (error) { return { cwd, files: [], error: this.toCheckoutError(error), - }; + } } } private async refreshCheckoutDiffTarget(target: CheckoutDiffWatchTarget): Promise { if (target.refreshPromise) { - target.refreshQueued = true; - return; + target.refreshQueued = true + return } target.refreshPromise = (async () => { do { - target.refreshQueued = false; - const snapshot = await this.computeCheckoutDiffSnapshot( - target.cwd, - target.compare, - { diffCwd: target.diffCwd } - ); - target.latestPayload = snapshot; - const fingerprint = this.checkoutDiffSnapshotFingerprint(snapshot); + target.refreshQueued = false + const snapshot = await this.computeCheckoutDiffSnapshot(target.cwd, target.compare, { + diffCwd: target.diffCwd, + }) + target.latestPayload = snapshot + const fingerprint = this.checkoutDiffSnapshotFingerprint(snapshot) if (fingerprint !== target.latestFingerprint) { - target.latestFingerprint = fingerprint; - this.emitCheckoutDiffUpdate(target, snapshot); + target.latestFingerprint = fingerprint + this.emitCheckoutDiffUpdate(target, snapshot) } - } while (target.refreshQueued); - })(); + } while (target.refreshQueued) + })() try { - await target.refreshPromise; + await target.refreshPromise } finally { - target.refreshPromise = null; + target.refreshPromise = null } } @@ -3999,13 +3834,13 @@ export class Session { cwd: string, compare: CheckoutDiffCompareInput ): Promise { - const targetKey = this.buildCheckoutDiffTargetKey(cwd, compare); - const existing = this.checkoutDiffTargets.get(targetKey); + const targetKey = this.buildCheckoutDiffTargetKey(cwd, compare) + const existing = this.checkoutDiffTargets.get(targetKey) if (existing) { - return existing; + return existing } - const watchRoot = await this.resolveCheckoutWatchRoot(cwd); + const watchRoot = await this.resolveCheckoutWatchRoot(cwd) const target: CheckoutDiffWatchTarget = { key: targetKey, cwd, @@ -4019,360 +3854,349 @@ export class Session { refreshQueued: false, latestPayload: null, latestFingerprint: null, - }; - - const repoWatchPath = watchRoot ?? cwd; - const watchPaths = new Set([repoWatchPath]); - const gitDir = await this.resolveCheckoutGitDir(cwd); - if (gitDir) { - watchPaths.add(gitDir); } - let hasRecursiveRepoCoverage = false; - const allowRecursiveRepoWatch = process.platform !== "linux"; - for (const watchPath of watchPaths) { - const shouldTryRecursive = watchPath === repoWatchPath && allowRecursiveRepoWatch; - const createWatcher = (recursive: boolean): FSWatcher => - watch( - watchPath, - { recursive }, - () => { - this.scheduleCheckoutDiffTargetRefresh(target); - } - ); + const repoWatchPath = watchRoot ?? cwd + const watchPaths = new Set([repoWatchPath]) + const gitDir = await this.resolveCheckoutGitDir(cwd) + if (gitDir) { + watchPaths.add(gitDir) + } - let watcher: FSWatcher | null = null; - let watcherIsRecursive = false; + let hasRecursiveRepoCoverage = false + const allowRecursiveRepoWatch = process.platform !== 'linux' + for (const watchPath of watchPaths) { + const shouldTryRecursive = watchPath === repoWatchPath && allowRecursiveRepoWatch + const createWatcher = (recursive: boolean): FSWatcher => + watch(watchPath, { recursive }, () => { + this.scheduleCheckoutDiffTargetRefresh(target) + }) + + let watcher: FSWatcher | null = null + let watcherIsRecursive = false try { if (shouldTryRecursive) { - watcher = createWatcher(true); - watcherIsRecursive = true; + watcher = createWatcher(true) + watcherIsRecursive = true } else { - watcher = createWatcher(false); + watcher = createWatcher(false) } } catch (error) { if (shouldTryRecursive) { try { - watcher = createWatcher(false); + watcher = createWatcher(false) this.sessionLogger.warn( { err: error, watchPath, cwd, compare }, - "Checkout diff recursive watch unavailable; using non-recursive fallback" - ); + 'Checkout diff recursive watch unavailable; using non-recursive fallback' + ) } catch (fallbackError) { this.sessionLogger.warn( { err: fallbackError, watchPath, cwd, compare }, - "Failed to start checkout diff watcher" - ); + 'Failed to start checkout diff watcher' + ) } } else { this.sessionLogger.warn( { err: error, watchPath, cwd, compare }, - "Failed to start checkout diff watcher" - ); + 'Failed to start checkout diff watcher' + ) } } if (!watcher) { - continue; + continue } - watcher.on("error", (error) => { + watcher.on('error', (error) => { this.sessionLogger.warn( { err: error, watchPath, cwd, compare }, - "Checkout diff watcher error" - ); - }); - target.watchers.push(watcher); + 'Checkout diff watcher error' + ) + }) + target.watchers.push(watcher) if (watchPath === repoWatchPath && watcherIsRecursive) { - hasRecursiveRepoCoverage = true; + hasRecursiveRepoCoverage = true } } - const missingRepoCoverage = !hasRecursiveRepoCoverage; + const missingRepoCoverage = !hasRecursiveRepoCoverage if (target.watchers.length === 0 || missingRepoCoverage) { target.fallbackRefreshInterval = setInterval(() => { - this.scheduleCheckoutDiffTargetRefresh(target); - }, CHECKOUT_DIFF_FALLBACK_REFRESH_MS); + this.scheduleCheckoutDiffTargetRefresh(target) + }, CHECKOUT_DIFF_FALLBACK_REFRESH_MS) this.sessionLogger.warn( { cwd, compare, intervalMs: CHECKOUT_DIFF_FALLBACK_REFRESH_MS, reason: - target.watchers.length === 0 - ? "no_watchers" - : "missing_recursive_repo_root_coverage", + target.watchers.length === 0 ? 'no_watchers' : 'missing_recursive_repo_root_coverage', }, - "Checkout diff watchers unavailable; using timed refresh fallback" - ); + 'Checkout diff watchers unavailable; using timed refresh fallback' + ) } - this.checkoutDiffTargets.set(targetKey, target); - return target; + this.checkoutDiffTargets.set(targetKey, target) + return target } private async handleSubscribeCheckoutDiffRequest( msg: SubscribeCheckoutDiffRequest ): Promise { - const cwd = expandTilde(msg.cwd); - const compare = this.normalizeCheckoutDiffCompare(msg.compare); + const cwd = expandTilde(msg.cwd) + const compare = this.normalizeCheckoutDiffCompare(msg.compare) - this.removeCheckoutDiffSubscription(msg.subscriptionId); - const target = await this.ensureCheckoutDiffWatchTarget(cwd, compare); - target.subscriptions.add(msg.subscriptionId); + this.removeCheckoutDiffSubscription(msg.subscriptionId) + const target = await this.ensureCheckoutDiffWatchTarget(cwd, compare) + target.subscriptions.add(msg.subscriptionId) this.checkoutDiffSubscriptions.set(msg.subscriptionId, { targetKey: target.key, - }); + }) const snapshot = target.latestPayload ?? (await this.computeCheckoutDiffSnapshot(cwd, compare, { diffCwd: target.diffCwd, - })); - target.latestPayload = snapshot; - target.latestFingerprint = this.checkoutDiffSnapshotFingerprint(snapshot); + })) + target.latestPayload = snapshot + target.latestFingerprint = this.checkoutDiffSnapshotFingerprint(snapshot) this.emit({ - type: "subscribe_checkout_diff_response", + type: 'subscribe_checkout_diff_response', payload: { subscriptionId: msg.subscriptionId, ...snapshot, requestId: msg.requestId, }, - }); + }) } - private handleUnsubscribeCheckoutDiffRequest( - msg: UnsubscribeCheckoutDiffRequest - ): void { - this.removeCheckoutDiffSubscription(msg.subscriptionId); + private handleUnsubscribeCheckoutDiffRequest(msg: UnsubscribeCheckoutDiffRequest): void { + this.removeCheckoutDiffSubscription(msg.subscriptionId) } private scheduleCheckoutDiffRefreshForCwd(cwd: string): void { - const resolvedCwd = expandTilde(cwd); + const resolvedCwd = expandTilde(cwd) for (const target of this.checkoutDiffTargets.values()) { if (target.cwd !== resolvedCwd && target.diffCwd !== resolvedCwd) { - continue; + continue } - this.scheduleCheckoutDiffTargetRefresh(target); + this.scheduleCheckoutDiffTargetRefresh(target) } } private async handleCheckoutCommitRequest( - msg: Extract + msg: Extract ): Promise { - const { cwd, requestId } = msg; + const { cwd, requestId } = msg try { - let message = msg.message?.trim() ?? ""; + let message = msg.message?.trim() ?? '' if (!message) { - message = await this.generateCommitMessage(cwd); + message = await this.generateCommitMessage(cwd) } if (!message) { - throw new Error("Commit message is required"); + throw new Error('Commit message is required') } await commitChanges(cwd, { message, addAll: msg.addAll ?? true, - }); - this.scheduleCheckoutDiffRefreshForCwd(cwd); + }) + this.scheduleCheckoutDiffRefreshForCwd(cwd) this.emit({ - type: "checkout_commit_response", + type: 'checkout_commit_response', payload: { cwd, success: true, error: null, requestId, }, - }); + }) } catch (error) { this.emit({ - type: "checkout_commit_response", + type: 'checkout_commit_response', payload: { cwd, success: false, error: this.toCheckoutError(error), requestId, }, - }); + }) } } private async handleCheckoutMergeRequest( - msg: Extract + msg: Extract ): Promise { - const { cwd, requestId } = msg; + const { cwd, requestId } = msg try { - const status = await getCheckoutStatus(cwd, { paseoHome: this.paseoHome }); + const status = await getCheckoutStatus(cwd, { paseoHome: this.paseoHome }) if (!status.isGit) { try { - await execAsync("git rev-parse --is-inside-work-tree", { + await execAsync('git rev-parse --is-inside-work-tree', { cwd, env: READ_ONLY_GIT_ENV, - }); + }) } catch (error) { const details = - typeof (error as any)?.stderr === "string" + typeof (error as any)?.stderr === 'string' ? String((error as any).stderr).trim() : error instanceof Error ? error.message - : String(error); - throw new Error(`Not a git repository: ${cwd}\n${details}`.trim()); + : String(error) + throw new Error(`Not a git repository: ${cwd}\n${details}`.trim()) } } if (msg.requireCleanTarget) { - const { stdout } = await execAsync("git status --porcelain", { + const { stdout } = await execAsync('git status --porcelain', { cwd, env: READ_ONLY_GIT_ENV, - }); + }) if (stdout.trim().length > 0) { - throw new Error("Working directory has uncommitted changes."); + throw new Error('Working directory has uncommitted changes.') } } - let baseRef = msg.baseRef ?? (status.isGit ? status.baseRef : null); + let baseRef = msg.baseRef ?? (status.isGit ? status.baseRef : null) if (!baseRef) { - throw new Error("Base branch is required for merge"); + throw new Error('Base branch is required for merge') } - if (baseRef.startsWith("origin/")) { - baseRef = baseRef.slice("origin/".length); + if (baseRef.startsWith('origin/')) { + baseRef = baseRef.slice('origin/'.length) } await mergeToBase( cwd, { baseRef, - mode: msg.strategy === "squash" ? "squash" : "merge", + mode: msg.strategy === 'squash' ? 'squash' : 'merge', }, { paseoHome: this.paseoHome } - ); - this.scheduleCheckoutDiffRefreshForCwd(cwd); + ) + this.scheduleCheckoutDiffRefreshForCwd(cwd) this.emit({ - type: "checkout_merge_response", + type: 'checkout_merge_response', payload: { cwd, success: true, error: null, requestId, }, - }); + }) } catch (error) { this.emit({ - type: "checkout_merge_response", + type: 'checkout_merge_response', payload: { cwd, success: false, error: this.toCheckoutError(error), requestId, }, - }); + }) } } private async handleCheckoutMergeFromBaseRequest( - msg: Extract + msg: Extract ): Promise { - const { cwd, requestId } = msg; + const { cwd, requestId } = msg try { if (msg.requireCleanTarget ?? true) { - const { stdout } = await execAsync("git status --porcelain", { + const { stdout } = await execAsync('git status --porcelain', { cwd, env: READ_ONLY_GIT_ENV, - }); + }) if (stdout.trim().length > 0) { - throw new Error("Working directory has uncommitted changes."); + throw new Error('Working directory has uncommitted changes.') } } - await mergeFromBase( - cwd, - { - baseRef: msg.baseRef, - requireCleanTarget: msg.requireCleanTarget ?? true, - }, - ); - this.scheduleCheckoutDiffRefreshForCwd(cwd); + await mergeFromBase(cwd, { + baseRef: msg.baseRef, + requireCleanTarget: msg.requireCleanTarget ?? true, + }) + this.scheduleCheckoutDiffRefreshForCwd(cwd) this.emit({ - type: "checkout_merge_from_base_response", + type: 'checkout_merge_from_base_response', payload: { cwd, success: true, error: null, requestId, }, - }); + }) } catch (error) { this.emit({ - type: "checkout_merge_from_base_response", + type: 'checkout_merge_from_base_response', payload: { cwd, success: false, error: this.toCheckoutError(error), requestId, }, - }); + }) } } private async handleCheckoutPushRequest( - msg: Extract + msg: Extract ): Promise { - const { cwd, requestId } = msg; + const { cwd, requestId } = msg try { - await pushCurrentBranch(cwd); + await pushCurrentBranch(cwd) this.emit({ - type: "checkout_push_response", + type: 'checkout_push_response', payload: { cwd, success: true, error: null, requestId, }, - }); + }) } catch (error) { this.emit({ - type: "checkout_push_response", + type: 'checkout_push_response', payload: { cwd, success: false, error: this.toCheckoutError(error), requestId, }, - }); + }) } } private async handleCheckoutPrCreateRequest( - msg: Extract + msg: Extract ): Promise { - const { cwd, requestId } = msg; + const { cwd, requestId } = msg try { - let title = msg.title?.trim() ?? ""; - let body = msg.body?.trim() ?? ""; + let title = msg.title?.trim() ?? '' + let body = msg.body?.trim() ?? '' if (!title || !body) { - const generated = await this.generatePullRequestText(cwd, msg.baseRef); - if (!title) title = generated.title; - if (!body) body = generated.body; + const generated = await this.generatePullRequestText(cwd, msg.baseRef) + if (!title) title = generated.title + if (!body) body = generated.body } const result = await createPullRequest(cwd, { title, body, base: msg.baseRef, - }); + }) this.emit({ - type: "checkout_pr_create_response", + type: 'checkout_pr_create_response', payload: { cwd, url: result.url ?? null, @@ -4380,10 +4204,10 @@ export class Session { error: null, requestId, }, - }); + }) } catch (error) { this.emit({ - type: "checkout_pr_create_response", + type: 'checkout_pr_create_response', payload: { cwd, url: null, @@ -4391,19 +4215,19 @@ export class Session { error: this.toCheckoutError(error), requestId, }, - }); + }) } } private async handleCheckoutPrStatusRequest( - msg: Extract + msg: Extract ): Promise { - const { cwd, requestId } = msg; + const { cwd, requestId } = msg try { - const prStatus = await getPullRequestStatus(cwd); + const prStatus = await getPullRequestStatus(cwd) this.emit({ - type: "checkout_pr_status_response", + type: 'checkout_pr_status_response', payload: { cwd, status: prStatus.status, @@ -4411,10 +4235,10 @@ export class Session { error: null, requestId, }, - }); + }) } catch (error) { this.emit({ - type: "checkout_pr_status_response", + type: 'checkout_pr_status_response', payload: { cwd, status: null, @@ -4422,31 +4246,31 @@ export class Session { error: this.toCheckoutError(error), requestId, }, - }); + }) } } private async handlePaseoWorktreeListRequest( - msg: Extract + msg: Extract ): Promise { - const { requestId } = msg; - const cwd = msg.repoRoot ?? msg.cwd; + const { requestId } = msg + const cwd = msg.repoRoot ?? msg.cwd if (!cwd) { this.emit({ - type: "paseo_worktree_list_response", + type: 'paseo_worktree_list_response', payload: { worktrees: [], - error: { code: "UNKNOWN", message: "cwd or repoRoot is required" }, + error: { code: 'UNKNOWN', message: 'cwd or repoRoot is required' }, requestId, }, - }); - return; + }) + return } try { - const worktrees = await listPaseoWorktrees({ cwd, paseoHome: this.paseoHome }); + const worktrees = await listPaseoWorktrees({ cwd, paseoHome: this.paseoHome }) this.emit({ - type: "paseo_worktree_list_response", + type: 'paseo_worktree_list_response', payload: { worktrees: worktrees.map((entry) => ({ worktreePath: entry.path, @@ -4456,243 +4280,241 @@ export class Session { error: null, requestId, }, - }); + }) } catch (error) { this.emit({ - type: "paseo_worktree_list_response", + type: 'paseo_worktree_list_response', payload: { worktrees: [], error: this.toCheckoutError(error), requestId, }, - }); + }) } } private async maybeArchiveWorktreeAfterLastAgentArchived(options: { - archivedAgentId: string; - archivedAgentCwd: string; - requestId: string; + archivedAgentId: string + archivedAgentCwd: string + requestId: string }): Promise { try { const ownership = await isPaseoOwnedWorktreeCwd(options.archivedAgentCwd, { paseoHome: this.paseoHome, - }); + }) if (!ownership.allowed) { - return; + return } const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(options.archivedAgentCwd, { paseoHome: this.paseoHome, - }); + }) if (!resolvedWorktree) { - return; + return } - const records = await this.agentStorage.list(); - const recordsById = new Map(records.map((record) => [record.id, record])); - const targetPath = resolvedWorktree.worktreePath; + const records = await this.agentStorage.list() + const recordsById = new Map(records.map((record) => [record.id, record])) + const targetPath = resolvedWorktree.worktreePath const hasRemainingNonArchivedRecord = records.some((record) => { if (record.id === options.archivedAgentId || record.archivedAt) { - return false; + return false } - return this.isPathWithinRoot(targetPath, record.cwd); - }); + return this.isPathWithinRoot(targetPath, record.cwd) + }) if (hasRemainingNonArchivedRecord) { - return; + return } const hasUnknownLiveAgent = this.agentManager.listAgents().some((agent) => { if (agent.id === options.archivedAgentId) { - return false; + return false } if (!this.isPathWithinRoot(targetPath, agent.cwd)) { - return false; + return false } - return !recordsById.has(agent.id); - }); + return !recordsById.has(agent.id) + }) if (hasUnknownLiveAgent) { - return; + return } - const repoRoot = ownership.repoRoot; + const repoRoot = ownership.repoRoot if (!repoRoot) { this.sessionLogger.warn( { agentId: options.archivedAgentId, worktreePath: targetPath }, - "Unable to resolve repo root for auto-archive after agent archive" - ); - return; + 'Unable to resolve repo root for auto-archive after agent archive' + ) + return } await this.archivePaseoWorktree({ targetPath, repoRoot, requestId: options.requestId, - }); + }) } catch (error: any) { this.sessionLogger.warn( { err: error, agentId: options.archivedAgentId, cwd: options.archivedAgentCwd }, - "Failed to auto-archive worktree after agent archive" - ); + 'Failed to auto-archive worktree after agent archive' + ) } } private async archivePaseoWorktree(options: { - targetPath: string; - repoRoot: string; - requestId: string; + targetPath: string + repoRoot: string + requestId: string }): Promise { - let targetPath = options.targetPath; + let targetPath = options.targetPath const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(targetPath, { paseoHome: this.paseoHome, - }); + }) if (resolvedWorktree) { - targetPath = resolvedWorktree.worktreePath; + targetPath = resolvedWorktree.worktreePath } - const removedAgents = new Set(); - const agents = this.agentManager.listAgents(); + const removedAgents = new Set() + const agents = this.agentManager.listAgents() for (const agent of agents) { if (this.isPathWithinRoot(targetPath, agent.cwd)) { - removedAgents.add(agent.id); + removedAgents.add(agent.id) try { - await this.agentManager.closeAgent(agent.id); + await this.agentManager.closeAgent(agent.id) } catch { // ignore cleanup errors } try { - await this.agentStorage.remove(agent.id); + await this.agentStorage.remove(agent.id) } catch { // ignore cleanup errors } } } - const registryRecords = await this.agentStorage.list(); + const registryRecords = await this.agentStorage.list() for (const record of registryRecords) { if (this.isPathWithinRoot(targetPath, record.cwd)) { - removedAgents.add(record.id); + removedAgents.add(record.id) try { - await this.agentStorage.remove(record.id); + await this.agentStorage.remove(record.id) } catch { // ignore cleanup errors } } } - await this.killTerminalsUnderPath(targetPath); + await this.killTerminalsUnderPath(targetPath) await deletePaseoWorktree({ cwd: options.repoRoot, worktreePath: targetPath, paseoHome: this.paseoHome, - }); + }) for (const agentId of removedAgents) { this.emit({ - type: "agent_deleted", + type: 'agent_deleted', payload: { agentId, requestId: options.requestId, }, - }); + }) } - return Array.from(removedAgents); + return Array.from(removedAgents) } private async handlePaseoWorktreeArchiveRequest( - msg: Extract + msg: Extract ): Promise { - const { requestId } = msg; - let targetPath = msg.worktreePath; - let repoRoot = msg.repoRoot ?? null; + const { requestId } = msg + let targetPath = msg.worktreePath + let repoRoot = msg.repoRoot ?? null try { if (!targetPath) { if (!repoRoot || !msg.branchName) { - throw new Error("worktreePath or repoRoot+branchName is required"); + throw new Error('worktreePath or repoRoot+branchName is required') } - const worktrees = await listPaseoWorktrees({ cwd: repoRoot, paseoHome: this.paseoHome }); - const match = worktrees.find((entry) => entry.branchName === msg.branchName); + const worktrees = await listPaseoWorktrees({ cwd: repoRoot, paseoHome: this.paseoHome }) + const match = worktrees.find((entry) => entry.branchName === msg.branchName) if (!match) { - throw new Error(`Paseo worktree not found for branch ${msg.branchName}`); + throw new Error(`Paseo worktree not found for branch ${msg.branchName}`) } - targetPath = match.path; + targetPath = match.path } const ownership = await isPaseoOwnedWorktreeCwd(targetPath, { paseoHome: this.paseoHome, - }); + }) if (!ownership.allowed) { this.emit({ - type: "paseo_worktree_archive_response", + type: 'paseo_worktree_archive_response', payload: { success: false, removedAgents: [], error: { - code: "NOT_ALLOWED", - message: "Worktree is not a Paseo-owned worktree", + code: 'NOT_ALLOWED', + message: 'Worktree is not a Paseo-owned worktree', }, requestId, }, - }); - return; + }) + return } - repoRoot = ownership.repoRoot ?? repoRoot ?? null; + repoRoot = ownership.repoRoot ?? repoRoot ?? null if (!repoRoot) { - throw new Error("Unable to resolve repo root for worktree"); + throw new Error('Unable to resolve repo root for worktree') } const removedAgents = await this.archivePaseoWorktree({ targetPath, repoRoot, requestId, - }); + }) this.emit({ - type: "paseo_worktree_archive_response", + type: 'paseo_worktree_archive_response', payload: { success: true, removedAgents, error: null, requestId, }, - }); + }) } catch (error) { this.emit({ - type: "paseo_worktree_archive_response", + type: 'paseo_worktree_archive_response', payload: { success: false, removedAgents: [], error: this.toCheckoutError(error), requestId, }, - }); + }) } } /** * Handle read-only file explorer requests scoped to an agent's cwd */ - private async handleFileExplorerRequest( - request: FileExplorerRequest - ): Promise { - const { agentId, path: requestedPath = ".", mode, requestId } = request; + private async handleFileExplorerRequest(request: FileExplorerRequest): Promise { + const { agentId, path: requestedPath = '.', mode, requestId } = request this.sessionLogger.debug( { agentId, mode, path: requestedPath }, `Handling file explorer request for agent ${agentId} (${mode} ${requestedPath})` - ); + ) try { - const agents = this.agentManager.listAgents(); - const agent = agents.find((a) => a.id === agentId); + const agents = this.agentManager.listAgents() + const agent = agents.find((a) => a.id === agentId) if (!agent) { this.emit({ - type: "file_explorer_response", + type: 'file_explorer_response', payload: { agentId, path: requestedPath, @@ -4702,18 +4524,18 @@ export class Session { error: `Agent not found: ${agentId}`, requestId, }, - }); - return; + }) + return } - if (mode === "list") { + if (mode === 'list') { const directory = await listDirectoryEntries({ root: agent.cwd, relativePath: requestedPath, - }); + }) this.emit({ - type: "file_explorer_response", + type: 'file_explorer_response', payload: { agentId, path: directory.path, @@ -4723,15 +4545,15 @@ export class Session { error: null, requestId, }, - }); + }) } else { const file = await readExplorerFile({ root: agent.cwd, relativePath: requestedPath, - }); + }) this.emit({ - type: "file_explorer_response", + type: 'file_explorer_response', payload: { agentId, path: file.path, @@ -4741,15 +4563,15 @@ export class Session { error: null, requestId, }, - }); + }) } } catch (error: any) { this.sessionLogger.error( { err: error, agentId, path: requestedPath }, `Failed to fulfill file explorer request for agent ${agentId}` - ); + ) this.emit({ - type: "file_explorer_response", + type: 'file_explorer_response', payload: { agentId, path: requestedPath, @@ -4759,7 +4581,7 @@ export class Session { error: error.message, requestId, }, - }); + }) } } @@ -4767,54 +4589,52 @@ export class Session { * Handle project icon request for a given cwd */ private async handleProjectIconRequest( - request: Extract + request: Extract ): Promise { - const { cwd, requestId } = request; + const { cwd, requestId } = request try { - const icon = await getProjectIcon(cwd); + const icon = await getProjectIcon(cwd) this.emit({ - type: "project_icon_response", + type: 'project_icon_response', payload: { cwd, icon, error: null, requestId, }, - }); + }) } catch (error: any) { this.emit({ - type: "project_icon_response", + type: 'project_icon_response', payload: { cwd, icon: null, error: error.message, requestId, }, - }); + }) } } /** * Handle file download token request scoped to an agent's cwd */ - private async handleFileDownloadTokenRequest( - request: FileDownloadTokenRequest - ): Promise { - const { agentId, path: requestedPath, requestId } = request; + private async handleFileDownloadTokenRequest(request: FileDownloadTokenRequest): Promise { + const { agentId, path: requestedPath, requestId } = request this.sessionLogger.debug( { agentId, path: requestedPath }, `Handling file download token request for agent ${agentId} (${requestedPath})` - ); + ) try { - const agents = this.agentManager.listAgents(); - const agent = agents.find((a) => a.id === agentId); + const agents = this.agentManager.listAgents() + const agent = agents.find((a) => a.id === agentId) if (!agent) { this.emit({ - type: "file_download_token_response", + type: 'file_download_token_response', payload: { agentId, path: requestedPath, @@ -4825,14 +4645,14 @@ export class Session { error: `Agent not found: ${agentId}`, requestId, }, - }); - return; + }) + return } const info = await getDownloadableFileInfo({ root: agent.cwd, relativePath: requestedPath, - }); + }) const entry = this.downloadTokenStore.issueToken({ agentId, @@ -4841,10 +4661,10 @@ export class Session { fileName: info.fileName, mimeType: info.mimeType, size: info.size, - }); + }) this.emit({ - type: "file_download_token_response", + type: 'file_download_token_response', payload: { agentId, path: info.path, @@ -4855,14 +4675,14 @@ export class Session { error: null, requestId, }, - }); + }) } catch (error: any) { this.sessionLogger.error( { err: error, agentId, path: requestedPath }, `Failed to issue download token for agent ${agentId}` - ); + ) this.emit({ - type: "file_download_token_response", + type: 'file_download_token_response', payload: { agentId, path: requestedPath, @@ -4873,66 +4693,68 @@ export class Session { error: error.message, requestId, }, - }); + }) } } /** * Build the current agent list payload (live + persisted), optionally filtered by labels. */ - private async listAgentPayloads(filter?: { labels?: Record }): Promise { + private async listAgentPayloads(filter?: { + labels?: Record + }): Promise { // Get live agents with session modes - const agentSnapshots = this.agentManager.listAgents(); + const agentSnapshots = this.agentManager.listAgents() const liveAgents = await Promise.all( agentSnapshots.map((agent) => this.buildAgentPayload(agent)) - ); + ) // Add persisted agents that have not been lazily initialized yet // (excluding internal agents which are for ephemeral system tasks) - const registryRecords = await this.agentStorage.list(); - const liveIds = new Set(agentSnapshots.map((a) => a.id)); + const registryRecords = await this.agentStorage.list() + const liveIds = new Set(agentSnapshots.map((a) => a.id)) const persistedAgents = registryRecords .filter((record) => !liveIds.has(record.id) && !record.internal) - .map((record) => this.buildStoredAgentPayload(record)); + .map((record) => this.buildStoredAgentPayload(record)) - let agents = [...liveAgents, ...persistedAgents]; + let agents = [...liveAgents, ...persistedAgents] // Filter by labels if filter provided if (filter?.labels) { - const filterLabels = filter.labels; + const filterLabels = filter.labels agents = agents.filter((agent) => Object.entries(filterLabels).every(([key, value]) => agent.labels[key] === value) - ); + ) } - return agents; + return agents } private async resolveAgentIdentifier( identifier: string ): Promise<{ ok: true; agentId: string } | { ok: false; error: string }> { - const trimmed = identifier.trim(); + const trimmed = identifier.trim() if (!trimmed) { - return { ok: false, error: "Agent identifier cannot be empty" }; + return { ok: false, error: 'Agent identifier cannot be empty' } } - const stored = await this.agentStorage.list(); - const storedRecords = stored.filter((record) => !record.internal); - const knownIds = new Set(); + const stored = await this.agentStorage.list() + const storedRecords = stored.filter((record) => !record.internal) + const knownIds = new Set() for (const record of storedRecords) { - knownIds.add(record.id); + knownIds.add(record.id) } for (const agent of this.agentManager.listAgents()) { - knownIds.add(agent.id); + knownIds.add(agent.id) } if (knownIds.has(trimmed)) { - return { ok: true, agentId: trimmed }; + return { ok: true, agentId: trimmed } } - const prefixMatches = Array.from(knownIds).filter((id) => id.startsWith(trimmed)); + const prefixMatches = Array.from(knownIds).filter((id) => id.startsWith(trimmed)) if (prefixMatches.length === 1) { - return { ok: true, agentId: prefixMatches[0] }; + return { ok: true, agentId: prefixMatches[0] } } if (prefixMatches.length > 1) { return { @@ -4940,13 +4762,13 @@ export class Session { error: `Agent identifier "${trimmed}" is ambiguous (${prefixMatches .slice(0, 5) .map((id) => id.slice(0, 8)) - .join(", ")}${prefixMatches.length > 5 ? ", …" : ""})`, - }; + .join(', ')}${prefixMatches.length > 5 ? ', …' : ''})`, + } } - const titleMatches = storedRecords.filter((record) => record.title === trimmed); + const titleMatches = storedRecords.filter((record) => record.title === trimmed) if (titleMatches.length === 1) { - return { ok: true, agentId: titleMatches[0].id }; + return { ok: true, agentId: titleMatches[0].id } } if (titleMatches.length > 1) { return { @@ -4954,99 +4776,94 @@ export class Session { error: `Agent title "${trimmed}" is ambiguous (${titleMatches .slice(0, 5) .map((r) => r.id.slice(0, 8)) - .join(", ")}${titleMatches.length > 5 ? ", …" : ""})`, - }; + .join(', ')}${titleMatches.length > 5 ? ', …' : ''})`, + } } - return { ok: false, error: `Agent not found: ${trimmed}` }; + return { ok: false, error: `Agent not found: ${trimmed}` } } private async getAgentPayloadById(agentId: string): Promise { - const live = this.agentManager.getAgent(agentId); + const live = this.agentManager.getAgent(agentId) if (live) { - return await this.buildAgentPayload(live); + return await this.buildAgentPayload(live) } - const record = await this.agentStorage.get(agentId); + const record = await this.agentStorage.get(agentId) if (!record || record.internal) { - return null; + return null } - return this.buildStoredAgentPayload(record); + return this.buildStoredAgentPayload(record) } private normalizeFetchAgentsSort( sort: FetchAgentsRequestSort[] | undefined ): FetchAgentsRequestSort[] { - const fallback: FetchAgentsRequestSort[] = [ - { key: "updated_at", direction: "desc" }, - ]; + const fallback: FetchAgentsRequestSort[] = [{ key: 'updated_at', direction: 'desc' }] if (!sort || sort.length === 0) { - return fallback; + return fallback } - const deduped: FetchAgentsRequestSort[] = []; - const seen = new Set(); + const deduped: FetchAgentsRequestSort[] = [] + const seen = new Set() for (const entry of sort) { if (seen.has(entry.key)) { - continue; + continue } - seen.add(entry.key); - deduped.push(entry); + seen.add(entry.key) + deduped.push(entry) } - return deduped.length > 0 ? deduped : fallback; + return deduped.length > 0 ? deduped : fallback } private getStatusPriority(agent: AgentSnapshotPayload): number { - const requiresAttention = agent.requiresAttention ?? false; - const attentionReason = agent.attentionReason ?? null; - if (requiresAttention && attentionReason === "permission") { - return 0; + const requiresAttention = agent.requiresAttention ?? false + const attentionReason = agent.attentionReason ?? null + if (requiresAttention && attentionReason === 'permission') { + return 0 } - if (agent.status === "error" || attentionReason === "error") { - return 1; + if (agent.status === 'error' || attentionReason === 'error') { + return 1 } - if (agent.status === "running") { - return 2; + if (agent.status === 'running') { + return 2 } - if (agent.status === "initializing") { - return 3; + if (agent.status === 'initializing') { + return 3 } - return 4; + return 4 } private getFetchAgentsSortValue( entry: FetchAgentsResponseEntry, - key: FetchAgentsRequestSort["key"] + key: FetchAgentsRequestSort['key'] ): string | number | null { switch (key) { - case "status_priority": - return this.getStatusPriority(entry.agent); - case "created_at": - return Date.parse(entry.agent.createdAt); - case "updated_at": - return Date.parse(entry.agent.updatedAt); - case "title": - return entry.agent.title?.toLocaleLowerCase() ?? ""; + case 'status_priority': + return this.getStatusPriority(entry.agent) + case 'created_at': + return Date.parse(entry.agent.createdAt) + case 'updated_at': + return Date.parse(entry.agent.updatedAt) + case 'title': + return entry.agent.title?.toLocaleLowerCase() ?? '' } } - private compareSortValues( - left: string | number | null, - right: string | number | null - ): number { + private compareSortValues(left: string | number | null, right: string | number | null): number { if (left === right) { - return 0; + return 0 } if (left === null) { - return -1; + return -1 } if (right === null) { - return 1; + return 1 } - if (typeof left === "number" && typeof right === "number") { - return left < right ? -1 : 1; + if (typeof left === 'number' && typeof right === 'number') { + return left < right ? -1 : 1 } - return String(left).localeCompare(String(right)); + return String(left).localeCompare(String(right)) } private compareFetchAgentsEntries( @@ -5055,24 +4872,24 @@ export class Session { sort: FetchAgentsRequestSort[] ): number { for (const spec of sort) { - const leftValue = this.getFetchAgentsSortValue(left, spec.key); - const rightValue = this.getFetchAgentsSortValue(right, spec.key); - const base = this.compareSortValues(leftValue, rightValue); + const leftValue = this.getFetchAgentsSortValue(left, spec.key) + const rightValue = this.getFetchAgentsSortValue(right, spec.key) + const base = this.compareSortValues(leftValue, rightValue) if (base === 0) { - continue; + continue } - return spec.direction === "asc" ? base : -base; + return spec.direction === 'asc' ? base : -base } - return left.agent.id.localeCompare(right.agent.id); + return left.agent.id.localeCompare(right.agent.id) } private encodeFetchAgentsCursor( entry: FetchAgentsResponseEntry, sort: FetchAgentsRequestSort[] ): string { - const values: Record = {}; + const values: Record = {} for (const spec of sort) { - values[spec.key] = this.getFetchAgentsSortValue(entry, spec.key); + values[spec.key] = this.getFetchAgentsSortValue(entry, spec.key) } return Buffer.from( JSON.stringify({ @@ -5080,82 +4897,81 @@ export class Session { values, id: entry.agent.id, }), - "utf8" - ).toString("base64url"); + 'utf8' + ).toString('base64url') } private decodeFetchAgentsCursor( cursor: string, sort: FetchAgentsRequestSort[] ): FetchAgentsCursor { - let parsed: unknown; + let parsed: unknown try { - parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")); + parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) } catch { - throw new SessionRequestError("invalid_cursor", "Invalid fetch_agents cursor"); + throw new SessionRequestError('invalid_cursor', 'Invalid fetch_agents cursor') } - if (!parsed || typeof parsed !== "object") { - throw new SessionRequestError("invalid_cursor", "Invalid fetch_agents cursor"); + if (!parsed || typeof parsed !== 'object') { + throw new SessionRequestError('invalid_cursor', 'Invalid fetch_agents cursor') } const payload = parsed as { - sort?: unknown; - values?: unknown; - id?: unknown; - }; - - if (!Array.isArray(payload.sort) || typeof payload.id !== "string") { - throw new SessionRequestError("invalid_cursor", "Invalid fetch_agents cursor"); - } - if (!payload.values || typeof payload.values !== "object") { - throw new SessionRequestError("invalid_cursor", "Invalid fetch_agents cursor"); + sort?: unknown + values?: unknown + id?: unknown } - const cursorSort: FetchAgentsRequestSort[] = []; + if (!Array.isArray(payload.sort) || typeof payload.id !== 'string') { + throw new SessionRequestError('invalid_cursor', 'Invalid fetch_agents cursor') + } + if (!payload.values || typeof payload.values !== 'object') { + throw new SessionRequestError('invalid_cursor', 'Invalid fetch_agents cursor') + } + + const cursorSort: FetchAgentsRequestSort[] = [] for (const item of payload.sort) { if ( !item || - typeof item !== "object" || - typeof (item as { key?: unknown }).key !== "string" || - typeof (item as { direction?: unknown }).direction !== "string" + typeof item !== 'object' || + typeof (item as { key?: unknown }).key !== 'string' || + typeof (item as { direction?: unknown }).direction !== 'string' ) { - throw new SessionRequestError("invalid_cursor", "Invalid fetch_agents cursor"); + throw new SessionRequestError('invalid_cursor', 'Invalid fetch_agents cursor') } - const key = (item as { key: string }).key; - const direction = (item as { direction: string }).direction; + const key = (item as { key: string }).key + const direction = (item as { direction: string }).direction if ( - (key !== "status_priority" && - key !== "created_at" && - key !== "updated_at" && - key !== "title") || - (direction !== "asc" && direction !== "desc") + (key !== 'status_priority' && + key !== 'created_at' && + key !== 'updated_at' && + key !== 'title') || + (direction !== 'asc' && direction !== 'desc') ) { - throw new SessionRequestError("invalid_cursor", "Invalid fetch_agents cursor"); + throw new SessionRequestError('invalid_cursor', 'Invalid fetch_agents cursor') } - cursorSort.push({ key, direction }); + cursorSort.push({ key, direction }) } if ( cursorSort.length !== sort.length || cursorSort.some( (entry, index) => - entry.key !== sort[index]?.key || - entry.direction !== sort[index]?.direction + entry.key !== sort[index]?.key || entry.direction !== sort[index]?.direction ) ) { throw new SessionRequestError( - "invalid_cursor", - "fetch_agents cursor does not match current sort" - ); + 'invalid_cursor', + 'fetch_agents cursor does not match current sort' + ) } return { sort: cursorSort, values: payload.values as Record, id: payload.id, - }; + } } private compareEntryWithCursor( @@ -5164,78 +4980,71 @@ export class Session { sort: FetchAgentsRequestSort[] ): number { for (const spec of sort) { - const leftValue = this.getFetchAgentsSortValue(entry, spec.key); + const leftValue = this.getFetchAgentsSortValue(entry, spec.key) const rightValue = - cursor.values[spec.key] !== undefined ? cursor.values[spec.key] ?? null : null; - const base = this.compareSortValues(leftValue, rightValue); + cursor.values[spec.key] !== undefined ? (cursor.values[spec.key] ?? null) : null + const base = this.compareSortValues(leftValue, rightValue) if (base === 0) { - continue; + continue } - return spec.direction === "asc" ? base : -base; + return spec.direction === 'asc' ? base : -base } - return entry.agent.id.localeCompare(cursor.id); + return entry.agent.id.localeCompare(cursor.id) } private async listFetchAgentsEntries( - request: Extract + request: Extract ): Promise<{ - entries: FetchAgentsResponseEntry[]; - pageInfo: FetchAgentsResponsePageInfo; + entries: FetchAgentsResponseEntry[] + pageInfo: FetchAgentsResponsePageInfo }> { - const filter = request.filter; - const sort = this.normalizeFetchAgentsSort(request.sort); + const filter = request.filter + const sort = this.normalizeFetchAgentsSort(request.sort) const agents = await this.listAgentPayloads({ labels: filter?.labels, - }); + }) - const placementByCwd = new Map>(); + const placementByCwd = new Map>() const getPlacement = (cwd: string): Promise => { - const existing = placementByCwd.get(cwd); + const existing = placementByCwd.get(cwd) if (existing) { - return existing; + return existing } - const placementPromise = this.buildProjectPlacement(cwd); - placementByCwd.set(cwd, placementPromise); - return placementPromise; - }; + const placementPromise = this.buildProjectPlacement(cwd) + placementByCwd.set(cwd, placementPromise) + return placementPromise + } let entries = await Promise.all( agents.map(async (agent) => ({ agent, project: await getPlacement(agent.cwd), })) - ); + ) entries = entries.filter((entry) => this.matchesAgentFilter({ agent: entry.agent, project: entry.project, filter, }) - ); + ) - entries.sort((left, right) => - this.compareFetchAgentsEntries(left, right, sort) - ); + entries.sort((left, right) => this.compareFetchAgentsEntries(left, right, sort)) - const cursorToken = request.page?.cursor; + const cursorToken = request.page?.cursor if (cursorToken) { - const cursor = this.decodeFetchAgentsCursor(cursorToken, sort); - entries = entries.filter( - (entry) => this.compareEntryWithCursor(entry, cursor, sort) > 0 - ); + const cursor = this.decodeFetchAgentsCursor(cursorToken, sort) + entries = entries.filter((entry) => this.compareEntryWithCursor(entry, cursor, sort) > 0) } - const limit = request.page?.limit ?? entries.length; - const pagedEntries = entries.slice(0, limit); - const hasMore = entries.length > limit; + const limit = request.page?.limit ?? entries.length + const pagedEntries = entries.slice(0, limit) + const hasMore = entries.length > limit const nextCursor = hasMore && pagedEntries.length > 0 - ? this.encodeFetchAgentsCursor( - pagedEntries[pagedEntries.length - 1], - sort - ) - : null; + ? this.encodeFetchAgentsCursor(pagedEntries[pagedEntries.length - 1], sort) + : null return { entries: pagedEntries, @@ -5244,19 +5053,18 @@ export class Session { prevCursor: request.page?.cursor ?? null, hasMore, }, - }; + } } private async handleFetchAgents( - request: Extract + request: Extract ): Promise { - const requestedSubscriptionId = request.subscribe?.subscriptionId?.trim(); - const subscriptionId = - request.subscribe - ? requestedSubscriptionId && requestedSubscriptionId.length > 0 - ? requestedSubscriptionId - : uuidv4() - : null; + const requestedSubscriptionId = request.subscribe?.subscriptionId?.trim() + const subscriptionId = request.subscribe + ? requestedSubscriptionId && requestedSubscriptionId.length > 0 + ? requestedSubscriptionId + : uuidv4() + : null try { if (subscriptionId) { @@ -5265,122 +5073,105 @@ export class Session { filter: request.filter, isBootstrapping: true, pendingUpdatesByAgentId: new Map(), - }; + } } - const payload = await this.listFetchAgentsEntries(request); - const snapshotUpdatedAtByAgentId = new Map(); + const payload = await this.listFetchAgentsEntries(request) + const snapshotUpdatedAtByAgentId = new Map() for (const entry of payload.entries) { - const parsedUpdatedAt = Date.parse(entry.agent.updatedAt); + const parsedUpdatedAt = Date.parse(entry.agent.updatedAt) if (!Number.isNaN(parsedUpdatedAt)) { - snapshotUpdatedAtByAgentId.set(entry.agent.id, parsedUpdatedAt); + snapshotUpdatedAtByAgentId.set(entry.agent.id, parsedUpdatedAt) } } this.emit({ - type: "fetch_agents_response", + type: 'fetch_agents_response', payload: { requestId: request.requestId, ...(subscriptionId ? { subscriptionId } : {}), ...payload, }, - }); + }) - if ( - subscriptionId && - this.agentUpdatesSubscription?.subscriptionId === subscriptionId - ) { - this.flushBootstrappedAgentUpdates({ snapshotUpdatedAtByAgentId }); + if (subscriptionId && this.agentUpdatesSubscription?.subscriptionId === subscriptionId) { + this.flushBootstrappedAgentUpdates({ snapshotUpdatedAtByAgentId }) } } catch (error) { - if ( - subscriptionId && - this.agentUpdatesSubscription?.subscriptionId === subscriptionId - ) { - this.agentUpdatesSubscription = null; + if (subscriptionId && this.agentUpdatesSubscription?.subscriptionId === subscriptionId) { + this.agentUpdatesSubscription = null } - const code = - error instanceof SessionRequestError ? error.code : "fetch_agents_failed"; - const message = - error instanceof Error ? error.message : "Failed to fetch agents"; - this.sessionLogger.error({ err: error }, "Failed to handle fetch_agents_request"); + const code = error instanceof SessionRequestError ? error.code : 'fetch_agents_failed' + const message = error instanceof Error ? error.message : 'Failed to fetch agents' + this.sessionLogger.error({ err: error }, 'Failed to handle fetch_agents_request') this.emit({ - type: "rpc_error", + type: 'rpc_error', payload: { requestId: request.requestId, requestType: request.type, error: message, code, }, - }); + }) } } private async handleFetchAgent(agentIdOrIdentifier: string, requestId: string): Promise { - const resolved = await this.resolveAgentIdentifier(agentIdOrIdentifier); + const resolved = await this.resolveAgentIdentifier(agentIdOrIdentifier) if (!resolved.ok) { this.emit({ - type: "fetch_agent_response", + type: 'fetch_agent_response', payload: { requestId, agent: null, error: resolved.error }, - }); - return; + }) + return } - const agent = await this.getAgentPayloadById(resolved.agentId); + const agent = await this.getAgentPayloadById(resolved.agentId) if (!agent) { this.emit({ - type: "fetch_agent_response", + type: 'fetch_agent_response', payload: { requestId, agent: null, error: `Agent not found: ${resolved.agentId}` }, - }); - return; + }) + return } this.emit({ - type: "fetch_agent_response", + type: 'fetch_agent_response', payload: { requestId, agent, error: null }, - }); + }) } private async handleFetchAgentTimelineRequest( - msg: Extract + msg: Extract ): Promise { - const direction: AgentTimelineFetchDirection = - msg.direction ?? (msg.cursor ? "after" : "tail"); - const projection: TimelineProjectionMode = msg.projection ?? "projected"; - const limit = msg.limit ?? (direction === "after" ? 0 : undefined); + const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? 'after' : 'tail') + const projection: TimelineProjectionMode = msg.projection ?? 'projected' + const limit = msg.limit ?? (direction === 'after' ? 0 : undefined) const cursor: AgentTimelineCursor | undefined = msg.cursor ? { epoch: msg.cursor.epoch, seq: msg.cursor.seq, } - : undefined; + : undefined try { - const snapshot = await this.ensureAgentLoaded(msg.agentId); + const snapshot = await this.ensureAgentLoaded(msg.agentId) const timeline = this.agentManager.fetchTimeline(msg.agentId, { direction, cursor, limit, - }); + }) - const projected = projectTimelineRows( - timeline.rows, - snapshot.provider, - projection - ); + const projected = projectTimelineRows(timeline.rows, snapshot.provider, projection) - const firstRow = timeline.rows[0]; - const lastRow = timeline.rows[timeline.rows.length - 1]; - const startCursor = firstRow - ? { epoch: timeline.epoch, seq: firstRow.seq } - : null; - const endCursor = lastRow - ? { epoch: timeline.epoch, seq: lastRow.seq } - : null; + const firstRow = timeline.rows[0] + const lastRow = timeline.rows[timeline.rows.length - 1] + const startCursor = firstRow ? { epoch: timeline.epoch, seq: firstRow.seq } : null + const endCursor = lastRow ? { epoch: timeline.epoch, seq: lastRow.seq } : null this.emit({ - type: "fetch_agent_timeline_response", + type: 'fetch_agent_timeline_response', payload: { requestId: msg.requestId, agentId: msg.agentId, @@ -5398,20 +5189,20 @@ export class Session { entries: projected, error: null, }, - }); + }) } catch (error) { this.sessionLogger.error( { err: error, agentId: msg.agentId }, - "Failed to handle fetch_agent_timeline_request" - ); + 'Failed to handle fetch_agent_timeline_request' + ) this.emit({ - type: "fetch_agent_timeline_response", + type: 'fetch_agent_timeline_response', payload: { requestId: msg.requestId, agentId: msg.agentId, direction, projection, - epoch: "", + epoch: '', reset: false, staleCursor: false, gap: false, @@ -5423,103 +5214,107 @@ export class Session { entries: [], error: error instanceof Error ? error.message : String(error), }, - }); + }) } } private async handleSendAgentMessageRequest( - msg: Extract + msg: Extract ): Promise { - const resolved = await this.resolveAgentIdentifier(msg.agentId); + const resolved = await this.resolveAgentIdentifier(msg.agentId) if (!resolved.ok) { this.emit({ - type: "send_agent_message_response", + type: 'send_agent_message_response', payload: { requestId: msg.requestId, agentId: msg.agentId, accepted: false, error: resolved.error, }, - }); - return; + }) + return } try { - const agentId = resolved.agentId; + const agentId = resolved.agentId - await this.ensureAgentLoaded(agentId); - await this.interruptAgentIfRunning(agentId); + await this.ensureAgentLoaded(agentId) + await this.interruptAgentIfRunning(agentId) try { this.agentManager.recordUserMessage(agentId, msg.text, { messageId: msg.messageId, emitState: false, - }); + }) } catch (error) { this.sessionLogger.error( { err: error, agentId }, - "Failed to record user message for send_agent_message_request" - ); + 'Failed to record user message for send_agent_message_request' + ) } - const prompt = this.buildAgentPrompt(msg.text, msg.images); - const started = this.startAgentStream(agentId, prompt); + const prompt = this.buildAgentPrompt(msg.text, msg.images) + const started = this.startAgentStream(agentId, prompt) if (!started.ok) { this.emit({ - type: "send_agent_message_response", + type: 'send_agent_message_response', payload: { requestId: msg.requestId, agentId, accepted: false, error: started.error, }, - }); - return; + }) + return } - const startAbort = new AbortController(); - const startTimeoutMs = 15_000; - const startTimeout = setTimeout(() => startAbort.abort("timeout"), startTimeoutMs); + const startAbort = new AbortController() + const startTimeoutMs = 15_000 + const startTimeout = setTimeout(() => startAbort.abort('timeout'), startTimeoutMs) try { - await this.agentManager.waitForAgentRunStart(agentId, { signal: startAbort.signal }); + await this.agentManager.waitForAgentRunStart(agentId, { signal: startAbort.signal }) } catch (error) { const message = - error instanceof Error ? error.message : typeof error === "string" ? error : "Unknown error"; + error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : 'Unknown error' this.emit({ - type: "send_agent_message_response", + type: 'send_agent_message_response', payload: { requestId: msg.requestId, agentId, accepted: false, error: message, }, - }); - return; + }) + return } finally { - clearTimeout(startTimeout); + clearTimeout(startTimeout) } this.emit({ - type: "send_agent_message_response", + type: 'send_agent_message_response', payload: { requestId: msg.requestId, agentId, accepted: true, error: null, }, - }); + }) } catch (error) { const message = - error instanceof Error ? error.message : typeof error === "string" ? error : "Unknown error"; + error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error' this.emit({ - type: "send_agent_message_response", + type: 'send_agent_message_response', payload: { requestId: msg.requestId, agentId: resolved.agentId, accepted: false, error: message, }, - }); + }) } } @@ -5528,105 +5323,110 @@ export class Session { requestId: string, timeoutMs?: number ): Promise { - const resolved = await this.resolveAgentIdentifier(agentIdOrIdentifier); + const resolved = await this.resolveAgentIdentifier(agentIdOrIdentifier) if (!resolved.ok) { this.emit({ - type: "wait_for_finish_response", - payload: { requestId, status: "error", final: null, error: resolved.error, lastMessage: null }, - }); - return; + type: 'wait_for_finish_response', + payload: { + requestId, + status: 'error', + final: null, + error: resolved.error, + lastMessage: null, + }, + }) + return } - const agentId = resolved.agentId; - const live = this.agentManager.getAgent(agentId); + const agentId = resolved.agentId + const live = this.agentManager.getAgent(agentId) if (!live) { - const record = await this.agentStorage.get(agentId); + const record = await this.agentStorage.get(agentId) if (!record || record.internal) { this.emit({ - type: "wait_for_finish_response", + type: 'wait_for_finish_response', payload: { requestId, - status: "error", + status: 'error', final: null, error: `Agent not found: ${agentId}`, lastMessage: null, }, - }); - return; + }) + return } - const final = this.buildStoredAgentPayload(record); + const final = this.buildStoredAgentPayload(record) const status = - record.attentionReason === "permission" - ? "permission" - : record.lastStatus === "error" - ? "error" - : "idle"; + record.attentionReason === 'permission' + ? 'permission' + : record.lastStatus === 'error' + ? 'error' + : 'idle' this.emit({ - type: "wait_for_finish_response", + type: 'wait_for_finish_response', payload: { requestId, status, final, error: null, lastMessage: null }, - }); - return; + }) + return } - const abortController = new AbortController(); - const effectiveTimeoutMs = timeoutMs ?? 600_000; // 10 minutes default + const abortController = new AbortController() + const effectiveTimeoutMs = timeoutMs ?? 600_000 // 10 minutes default const timeoutHandle = setTimeout(() => { - abortController.abort("timeout"); - }, effectiveTimeoutMs); + abortController.abort('timeout') + }, effectiveTimeoutMs) try { const result = await this.agentManager.waitForAgentEvent(agentId, { signal: abortController.signal, - }); + }) - const final = await this.getAgentPayloadById(agentId); + const final = await this.getAgentPayloadById(agentId) if (!final) { - throw new Error(`Agent ${agentId} disappeared while waiting`); + throw new Error(`Agent ${agentId} disappeared while waiting`) } - const status = - result.permission - ? "permission" - : result.status === "error" - ? "error" - : "idle"; + const status = result.permission ? 'permission' : result.status === 'error' ? 'error' : 'idle' this.emit({ - type: "wait_for_finish_response", + type: 'wait_for_finish_response', payload: { requestId, status, final, error: null, lastMessage: result.lastMessage }, - }); + }) } catch (error) { const isAbort = error instanceof Error && - (error.name === "AbortError" || error.message.toLowerCase().includes("aborted")); + (error.name === 'AbortError' || error.message.toLowerCase().includes('aborted')) if (!isAbort) { const message = - error instanceof Error ? error.message : typeof error === "string" ? error : "Unknown error"; - this.sessionLogger.error({ err: error, agentId }, "wait_for_finish_request failed"); - const final = await this.getAgentPayloadById(agentId); + error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : 'Unknown error' + this.sessionLogger.error({ err: error, agentId }, 'wait_for_finish_request failed') + const final = await this.getAgentPayloadById(agentId) this.emit({ - type: "wait_for_finish_response", + type: 'wait_for_finish_response', payload: { requestId, - status: "error", + status: 'error', final, error: message, lastMessage: null, }, - }); - return; + }) + return } - const final = await this.getAgentPayloadById(agentId); + const final = await this.getAgentPayloadById(agentId) if (!final) { - throw new Error(`Agent ${agentId} disappeared while waiting`); + throw new Error(`Agent ${agentId} disappeared while waiting`) } this.emit({ - type: "wait_for_finish_response", - payload: { requestId, status: "timeout", final, error: null, lastMessage: null }, - }); + type: 'wait_for_finish_response', + payload: { requestId, status: 'timeout', final, error: null, lastMessage: null }, + }) } finally { - clearTimeout(timeoutHandle); + clearTimeout(timeoutHandle) } } @@ -5634,34 +5434,34 @@ export class Session { * Handle audio chunk for buffering and transcription */ private async handleAudioChunk( - msg: Extract + msg: Extract ): Promise { if (!this.isVoiceMode) { this.sessionLogger.warn( - "Received voice_audio_chunk while voice mode is disabled; transcript will be emitted but voice assistant turn is skipped" - ); + 'Received voice_audio_chunk while voice mode is disabled; transcript will be emitted but voice assistant turn is skipped' + ) } - await this.handleVoiceSpeechStart(); + await this.handleVoiceSpeechStart() - const chunkFormat = msg.format || "audio/wav"; + const chunkFormat = msg.format || 'audio/wav' if (this.isVoiceMode) { - await this.appendToActiveVoiceDictationStream(msg.audio, chunkFormat); + await this.appendToActiveVoiceDictationStream(msg.audio, chunkFormat) if (!msg.isLast) { - this.setVoiceModeInactivityTimeout(); - this.sessionLogger.debug("Voice mode: streaming chunk, waiting for speech end"); - return; + this.setVoiceModeInactivityTimeout() + this.sessionLogger.debug('Voice mode: streaming chunk, waiting for speech end') + return } - this.clearVoiceModeInactivityTimeout(); - this.sessionLogger.debug("Voice mode: speech ended, finalizing streaming transcription"); - await this.finalizeActiveVoiceDictationStream("speech ended"); - return; + this.clearVoiceModeInactivityTimeout() + this.sessionLogger.debug('Voice mode: speech ended, finalizing streaming transcription') + await this.finalizeActiveVoiceDictationStream('speech ended') + return } - const chunkBuffer = Buffer.from(msg.audio, "base64"); - const isPCMChunk = chunkFormat.toLowerCase().includes("pcm"); + const chunkBuffer = Buffer.from(msg.audio, 'base64') + const isPCMChunk = chunkFormat.toLowerCase().includes('pcm') if (!this.audioBuffer) { this.audioBuffer = { @@ -5669,170 +5469,167 @@ export class Session { format: chunkFormat, isPCM: isPCMChunk, totalPCMBytes: 0, - }; + } } // If the format changes mid-stream, flush what we have first if (this.audioBuffer.isPCM !== isPCMChunk) { this.sessionLogger.debug( - { oldFormat: this.audioBuffer.isPCM ? "pcm" : this.audioBuffer.format, newFormat: chunkFormat }, + { + oldFormat: this.audioBuffer.isPCM ? 'pcm' : this.audioBuffer.format, + newFormat: chunkFormat, + }, `Audio format changed mid-stream, flushing current buffer` - ); - const finalized = this.finalizeBufferedAudio(); + ) + const finalized = this.finalizeBufferedAudio() if (finalized) { - await this.processCompletedAudio(finalized.audio, finalized.format); + await this.processCompletedAudio(finalized.audio, finalized.format) } this.audioBuffer = { chunks: [], format: chunkFormat, isPCM: isPCMChunk, totalPCMBytes: 0, - }; + } } else if (!this.audioBuffer.isPCM) { // Keep latest format info for non-PCM blobs - this.audioBuffer.format = chunkFormat; + this.audioBuffer.format = chunkFormat } - this.audioBuffer.chunks.push(chunkBuffer); + this.audioBuffer.chunks.push(chunkBuffer) if (this.audioBuffer.isPCM) { - this.audioBuffer.totalPCMBytes += chunkBuffer.length; + this.audioBuffer.totalPCMBytes += chunkBuffer.length } // In non-voice mode, use streaming threshold to process chunks const reachedStreamingThreshold = !this.isVoiceMode && this.audioBuffer.isPCM && - this.audioBuffer.totalPCMBytes >= MIN_STREAMING_SEGMENT_BYTES; + this.audioBuffer.totalPCMBytes >= MIN_STREAMING_SEGMENT_BYTES if (!msg.isLast && reachedStreamingThreshold) { - return; + return } - const bufferedState = this.audioBuffer; - const finalized = this.finalizeBufferedAudio(); + const bufferedState = this.audioBuffer + const finalized = this.finalizeBufferedAudio() if (!finalized) { - return; + return } if (!msg.isLast && reachedStreamingThreshold) { this.sessionLogger.debug( - { minDuration: MIN_STREAMING_SEGMENT_DURATION_MS, pcmBytes: bufferedState?.totalPCMBytes ?? 0 }, + { + minDuration: MIN_STREAMING_SEGMENT_DURATION_MS, + pcmBytes: bufferedState?.totalPCMBytes ?? 0, + }, `Minimum chunk duration reached (~${MIN_STREAMING_SEGMENT_DURATION_MS}ms, ${ bufferedState?.totalPCMBytes ?? 0 } PCM bytes) – triggering STT` - ); + ) } else { this.sessionLogger.debug( { audioBytes: finalized.audio.length, chunks: bufferedState?.chunks.length ?? 0 }, `Complete audio segment (${finalized.audio.length} bytes, ${bufferedState?.chunks.length ?? 0} chunk(s))` - ); + ) } - await this.processCompletedAudio(finalized.audio, finalized.format); + await this.processCompletedAudio(finalized.audio, finalized.format) } - private finalizeBufferedAudio(): - | { audio: Buffer; format: string } - | null { + private finalizeBufferedAudio(): { audio: Buffer; format: string } | null { if (!this.audioBuffer) { - return null; + return null } - const bufferState = this.audioBuffer; - this.audioBuffer = null; + const bufferState = this.audioBuffer + this.audioBuffer = null if (bufferState.isPCM) { - const pcmBuffer = Buffer.concat(bufferState.chunks); + const pcmBuffer = Buffer.concat(bufferState.chunks) const wavBuffer = convertPCMToWavBuffer( pcmBuffer, PCM_SAMPLE_RATE, PCM_CHANNELS, PCM_BITS_PER_SAMPLE - ); + ) return { audio: wavBuffer, - format: "audio/wav", - }; + format: 'audio/wav', + } } return { audio: Buffer.concat(bufferState.chunks), format: bufferState.format, - }; + } } - private async processCompletedAudio( - audio: Buffer, - format: string - ): Promise { + private async processCompletedAudio(audio: Buffer, format: string): Promise { const shouldBuffer = - this.processingPhase === "transcribing" && - this.pendingAudioSegments.length === 0; + this.processingPhase === 'transcribing' && this.pendingAudioSegments.length === 0 if (shouldBuffer) { this.sessionLogger.debug( { phase: this.processingPhase }, `Buffering audio segment (phase: ${this.processingPhase})` - ); + ) this.pendingAudioSegments.push({ audio, format, - }); - this.setBufferTimeout(); - return; + }) + this.setBufferTimeout() + return } if (this.pendingAudioSegments.length > 0) { this.pendingAudioSegments.push({ audio, format, - }); + }) this.sessionLogger.debug( { segmentCount: this.pendingAudioSegments.length }, `Processing ${this.pendingAudioSegments.length} buffered segments together` - ); + ) - const pendingSegments = [...this.pendingAudioSegments]; - this.pendingAudioSegments = []; - this.clearBufferTimeout(); + const pendingSegments = [...this.pendingAudioSegments] + this.pendingAudioSegments = [] + this.clearBufferTimeout() - const combinedAudio = Buffer.concat( - pendingSegments.map((segment) => segment.audio) - ); - const combinedFormat = - pendingSegments[pendingSegments.length - 1].format; + const combinedAudio = Buffer.concat(pendingSegments.map((segment) => segment.audio)) + const combinedFormat = pendingSegments[pendingSegments.length - 1].format - await this.processAudio(combinedAudio, combinedFormat); - return; + await this.processAudio(combinedAudio, combinedFormat) + return } - await this.processAudio(audio, format); + await this.processAudio(audio, format) } /** * Process audio through STT and then LLM */ private async processAudio(audio: Buffer, format: string): Promise { - this.setPhase("transcribing"); + this.setPhase('transcribing') this.emit({ - type: "activity_log", + type: 'activity_log', payload: { id: uuidv4(), timestamp: new Date(), - type: "system", - content: "Transcribing audio...", + type: 'system', + content: 'Transcribing audio...', }, - }); + }) try { - const requestId = uuidv4(); + const requestId = uuidv4() const result = await this.sttManager.transcribe(audio, format, { requestId, - label: this.isVoiceMode ? "voice" : "buffered", - }); + label: this.isVoiceMode ? 'voice' : 'buffered', + }) - const transcriptText = result.text.trim(); + const transcriptText = result.text.trim() this.sessionLogger.info( { requestId, @@ -5840,8 +5637,8 @@ export class Session { transcriptLength: transcriptText.length, transcript: transcriptText, }, - "Transcription result" - ); + 'Transcription result' + ) await this.handleTranscriptionResultPayload({ text: result.text, @@ -5853,60 +5650,62 @@ export class Session { byteLength: result.byteLength, format: result.format, debugRecordingPath: result.debugRecordingPath, - }); + }) } catch (error: any) { - this.setPhase("idle"); - this.clearSpeechInProgress("transcription error"); + this.setPhase('idle') + this.clearSpeechInProgress('transcription error') this.emit({ - type: "activity_log", + type: 'activity_log', payload: { id: uuidv4(), timestamp: new Date(), - type: "error", + type: 'error', content: `Transcription error: ${error.message}`, }, - }); - throw error; + }) + throw error } } private async handleTranscriptionResultPayload( result: VoiceTranscriptionResultPayload ): Promise { - const transcriptText = result.text.trim(); + const transcriptText = result.text.trim() this.emit({ - type: "transcription_result", + type: 'transcription_result', payload: { text: result.text, ...(result.language ? { language: result.language } : {}), ...(result.duration !== undefined ? { duration: result.duration } : {}), requestId: result.requestId, ...(result.avgLogprob !== undefined ? { avgLogprob: result.avgLogprob } : {}), - ...(result.isLowConfidence !== undefined ? { isLowConfidence: result.isLowConfidence } : {}), + ...(result.isLowConfidence !== undefined + ? { isLowConfidence: result.isLowConfidence } + : {}), ...(result.byteLength !== undefined ? { byteLength: result.byteLength } : {}), ...(result.format ? { format: result.format } : {}), ...(result.debugRecordingPath ? { debugRecordingPath: result.debugRecordingPath } : {}), }, - }); + }) if (!transcriptText) { - this.sessionLogger.debug("Empty transcription (false positive), not aborting"); - this.setPhase("idle"); - this.clearSpeechInProgress("empty transcription"); - return; + this.sessionLogger.debug('Empty transcription (false positive), not aborting') + this.setPhase('idle') + this.clearSpeechInProgress('empty transcription') + return } // Has content - abort any in-progress stream now - this.createAbortController(); + this.createAbortController() if (result.debugRecordingPath) { this.emit({ - type: "activity_log", + type: 'activity_log', payload: { id: uuidv4(), timestamp: new Date(), - type: "system", + type: 'system', content: `Saved input audio: ${result.debugRecordingPath}`, metadata: { recordingPath: result.debugRecordingPath, @@ -5914,45 +5713,45 @@ export class Session { requestId: result.requestId, }, }, - }); + }) } this.emit({ - type: "activity_log", + type: 'activity_log', payload: { id: uuidv4(), timestamp: new Date(), - type: "transcript", + type: 'transcript', content: result.text, metadata: { ...(result.language ? { language: result.language } : {}), ...(result.duration !== undefined ? { duration: result.duration } : {}), }, }, - }); + }) - this.clearSpeechInProgress("transcription complete"); - this.setPhase("idle"); + this.clearSpeechInProgress('transcription complete') + this.setPhase('idle') if (!this.isVoiceMode) { this.sessionLogger.debug( { requestId: result.requestId }, - "Skipping voice agent processing because voice mode is disabled" - ); - return; + 'Skipping voice agent processing because voice mode is disabled' + ) + return } - const agentId = this.voiceModeAgentId; + const agentId = this.voiceModeAgentId if (!agentId) { this.sessionLogger.warn( { requestId: result.requestId }, - "Skipping voice agent processing because no agent is currently voice-enabled" - ); - return; + 'Skipping voice agent processing because no agent is currently voice-enabled' + ) + return } // Route voice utterances through the same send path as regular text input: // interrupt-if-running, record message, then start a new stream. - await this.handleSendAgentMessage(agentId, result.text); + await this.handleSendAgentMessage(agentId, result.text) } private registerVoiceBridgeForAgent(agentId: string): void { @@ -5963,35 +5762,35 @@ export class Session { textLength: text.length, preview: text.slice(0, 160), }, - "Voice speak tool call received by session handler" - ); - const abortSignal = signal ?? this.abortController.signal; + 'Voice speak tool call received by session handler' + ) + const abortSignal = signal ?? this.abortController.signal await this.ttsManager.generateAndWaitForPlayback( text, (msg) => this.emit(msg), abortSignal, true - ); + ) this.sessionLogger.info( { agentId, textLength: text.length }, - "Voice speak tool call finished playback" - ); + 'Voice speak tool call finished playback' + ) this.emit({ - type: "activity_log", + type: 'activity_log', payload: { id: uuidv4(), timestamp: new Date(), - type: "assistant", + type: 'assistant', content: text, }, - }); - }); + }) + }) this.registerVoiceCallerContext?.(agentId, { - childAgentDefaultLabels: { ui: "true" }, + childAgentDefaultLabels: { ui: 'true' }, allowCustomCwd: false, enableVoiceTools: true, - }); + }) } /** @@ -6001,41 +5800,41 @@ export class Session { this.sessionLogger.info( { phase: this.processingPhase }, `Abort request, phase: ${this.processingPhase}` - ); + ) - this.abortController.abort(); - this.ttsManager.cancelPendingPlaybacks("abort request"); + this.abortController.abort() + this.ttsManager.cancelPendingPlaybacks('abort request') // Voice abort should always interrupt active agent output immediately. if (this.isVoiceMode && this.voiceModeAgentId) { try { - await this.interruptAgentIfRunning(this.voiceModeAgentId); + await this.interruptAgentIfRunning(this.voiceModeAgentId) } catch (error) { this.sessionLogger.warn( { err: error, agentId: this.voiceModeAgentId }, - "Failed to interrupt active voice-mode agent on abort" - ); + 'Failed to interrupt active voice-mode agent on abort' + ) } } - if (this.processingPhase === "transcribing") { + if (this.processingPhase === 'transcribing') { // Still in STT phase - we'll buffer the next audio - this.sessionLogger.debug("Will buffer next audio (currently transcribing)"); + this.sessionLogger.debug('Will buffer next audio (currently transcribing)') // Phase stays as 'transcribing', handleAudioChunk will handle buffering - return; + return } // Reset phase to idle and clear pending non-voice buffers. - this.setPhase("idle"); - this.pendingAudioSegments = []; - this.clearBufferTimeout(); + this.setPhase('idle') + this.pendingAudioSegments = [] + this.clearBufferTimeout() } /** * Handle audio playback confirmation from client */ private handleAudioPlayed(id: string): void { - this.ttsManager.confirmAudioPlayed(id); + this.ttsManager.confirmAudioPlayed(id) } /** @@ -6043,48 +5842,46 @@ export class Session { */ private async handleVoiceSpeechStart(): Promise { if (this.speechInProgress) { - return; + return } - const chunkReceivedAt = Date.now(); - const phaseBeforeAbort = this.processingPhase; - const hadActiveStream = this.hasActiveAgentRun(this.voiceModeAgentId); + const chunkReceivedAt = Date.now() + const phaseBeforeAbort = this.processingPhase + const hadActiveStream = this.hasActiveAgentRun(this.voiceModeAgentId) - this.speechInProgress = true; - this.sessionLogger.debug("Voice speech detected – aborting playback and active agent run"); + this.speechInProgress = true + this.sessionLogger.debug('Voice speech detected – aborting playback and active agent run') if (this.pendingAudioSegments.length > 0) { this.sessionLogger.debug( { segmentCount: this.pendingAudioSegments.length }, `Dropping ${this.pendingAudioSegments.length} buffered audio segment(s) due to voice speech` - ); - this.pendingAudioSegments = []; + ) + this.pendingAudioSegments = [] } if (this.audioBuffer) { this.sessionLogger.debug( { chunks: this.audioBuffer.chunks.length, pcmBytes: this.audioBuffer.totalPCMBytes }, `Clearing partial audio buffer (${this.audioBuffer.chunks.length} chunk(s)${ - this.audioBuffer.isPCM - ? `, ${this.audioBuffer.totalPCMBytes} PCM bytes` - : "" + this.audioBuffer.isPCM ? `, ${this.audioBuffer.totalPCMBytes} PCM bytes` : '' })` - ); - this.audioBuffer = null; + ) + this.audioBuffer = null } - this.cancelActiveVoiceDictationStream("new speech turn started"); - this.clearVoiceModeInactivityTimeout(); - this.clearBufferTimeout(); + this.cancelActiveVoiceDictationStream('new speech turn started') + this.clearVoiceModeInactivityTimeout() + this.clearBufferTimeout() - this.abortController.abort(); - await this.handleAbort(); + this.abortController.abort() + await this.handleAbort() - const latencyMs = Date.now() - chunkReceivedAt; + const latencyMs = Date.now() - chunkReceivedAt this.sessionLogger.debug( { latencyMs, phaseBeforeAbort, hadActiveStream }, - "[Telemetry] barge_in.llm_abort_latency" - ); + '[Telemetry] barge_in.llm_abort_latency' + ) } /** @@ -6092,64 +5889,61 @@ export class Session { */ private clearSpeechInProgress(reason: string): void { if (!this.speechInProgress) { - return; + return } - this.speechInProgress = false; - this.sessionLogger.debug( - { reason }, - `Speech turn complete (${reason}) – resuming TTS` - ); + this.speechInProgress = false + this.sessionLogger.debug({ reason }, `Speech turn complete (${reason}) – resuming TTS`) } /** * Create new AbortController, aborting the previous one */ private createAbortController(): AbortController { - this.abortController.abort(); - this.abortController = new AbortController(); - this.ttsDebugStreams.clear(); - return this.abortController; + this.abortController.abort() + this.abortController = new AbortController() + this.ttsDebugStreams.clear() + return this.abortController } /** * Set the processing phase */ private setPhase(phase: ProcessingPhase): void { - this.processingPhase = phase; - this.sessionLogger.debug({ phase }, `Phase: ${phase}`); + this.processingPhase = phase + this.sessionLogger.debug({ phase }, `Phase: ${phase}`) } /** * Set timeout to process buffered audio segments */ private setBufferTimeout(): void { - this.clearBufferTimeout(); + this.clearBufferTimeout() this.bufferTimeout = setTimeout(async () => { - this.sessionLogger.debug("Buffer timeout reached, processing pending segments"); + this.sessionLogger.debug('Buffer timeout reached, processing pending segments') if (this.pendingAudioSegments.length > 0) { - const segments = [...this.pendingAudioSegments]; - this.pendingAudioSegments = []; - this.bufferTimeout = null; + const segments = [...this.pendingAudioSegments] + this.pendingAudioSegments = [] + this.bufferTimeout = null - const combined = Buffer.concat(segments.map((s) => s.audio)); - await this.processAudio(combined, segments[0].format); + const combined = Buffer.concat(segments.map((s) => s.audio)) + await this.processAudio(combined, segments[0].format) } - }, 10000); // 10 second timeout + }, 10000) // 10 second timeout } private setVoiceModeInactivityTimeout(): void { if (!this.isVoiceMode) { - return; + return } - this.clearVoiceModeInactivityTimeout(); + this.clearVoiceModeInactivityTimeout() this.voiceModeInactivityTimeout = setTimeout(() => { - this.voiceModeInactivityTimeout = null; + this.voiceModeInactivityTimeout = null if (!this.isVoiceMode || !this.activeVoiceDictationId) { - return; + return } this.sessionLogger.warn( @@ -6158,22 +5952,22 @@ export class Session { dictationId: this.activeVoiceDictationId, nextSeq: this.activeVoiceDictationNextSeq, }, - "Voice mode inactivity timeout reached without isLast; finalizing active voice dictation stream" - ); + 'Voice mode inactivity timeout reached without isLast; finalizing active voice dictation stream' + ) - void this.finalizeActiveVoiceDictationStream("inactivity timeout").catch((error) => { + void this.finalizeActiveVoiceDictationStream('inactivity timeout').catch((error) => { this.sessionLogger.error( { err: error }, - "Failed to finalize voice dictation stream after inactivity timeout" - ); - }); - }, VOICE_MODE_INACTIVITY_FLUSH_MS); + 'Failed to finalize voice dictation stream after inactivity timeout' + ) + }) + }, VOICE_MODE_INACTIVITY_FLUSH_MS) } private clearVoiceModeInactivityTimeout(): void { if (this.voiceModeInactivityTimeout) { - clearTimeout(this.voiceModeInactivityTimeout); - this.voiceModeInactivityTimeout = null; + clearTimeout(this.voiceModeInactivityTimeout) + this.voiceModeInactivityTimeout = null } } @@ -6182,8 +5976,8 @@ export class Session { */ private clearBufferTimeout(): void { if (this.bufferTimeout) { - clearTimeout(this.bufferTimeout); - this.bufferTimeout = null; + clearTimeout(this.bufferTimeout) + this.bufferTimeout = null } } @@ -6192,64 +5986,64 @@ export class Session { */ private emit(msg: SessionOutboundMessage): void { if ( - msg.type === "audio_output" && + msg.type === 'audio_output' && (process.env.TTS_DEBUG_AUDIO_DIR || isPaseoDictationDebugEnabled()) && msg.payload.groupId && - typeof msg.payload.audio === "string" + typeof msg.payload.audio === 'string' ) { - const groupId = msg.payload.groupId; + const groupId = msg.payload.groupId const existing = this.ttsDebugStreams.get(groupId) ?? ({ format: msg.payload.format, chunks: [] } satisfies { - format: string; - chunks: Buffer[]; - }); + format: string + chunks: Buffer[] + }) try { - existing.chunks.push(Buffer.from(msg.payload.audio, "base64")); - existing.format = msg.payload.format; - this.ttsDebugStreams.set(groupId, existing); + existing.chunks.push(Buffer.from(msg.payload.audio, 'base64')) + existing.format = msg.payload.format + this.ttsDebugStreams.set(groupId, existing) } catch { // ignore malformed base64 } if (msg.payload.isLastChunk) { - const final = this.ttsDebugStreams.get(groupId); - this.ttsDebugStreams.delete(groupId); + const final = this.ttsDebugStreams.get(groupId) + this.ttsDebugStreams.delete(groupId) if (final && final.chunks.length > 0) { void (async () => { const recordingPath = await maybePersistTtsDebugAudio( Buffer.concat(final.chunks), { sessionId: this.sessionId, groupId, format: final.format }, this.sessionLogger - ); + ) if (recordingPath) { this.onMessage({ - type: "activity_log", + type: 'activity_log', payload: { id: uuidv4(), timestamp: new Date(), - type: "system", + type: 'system', content: `Saved TTS audio: ${recordingPath}`, metadata: { recordingPath, format: final.format, groupId }, }, - }); + }) } - })(); + })() } } } - this.onMessage(msg); + this.onMessage(msg) } private emitBinary(frame: BinaryMuxFrame): void { if (!this.onBinaryMessage) { - return; + return } try { - this.onBinaryMessage(frame); + this.onBinaryMessage(frame) } catch (error) { - this.sessionLogger.error({ err: error }, "Failed to emit binary frame"); + this.sessionLogger.error({ err: error }, 'Failed to emit binary frame') } } @@ -6257,71 +6051,68 @@ export class Session { * Clean up session resources */ public async cleanup(): Promise { - this.sessionLogger.trace("Cleaning up"); + this.sessionLogger.trace('Cleaning up') if (this.unsubscribeAgentEvents) { - this.unsubscribeAgentEvents(); - this.unsubscribeAgentEvents = null; + this.unsubscribeAgentEvents() + this.unsubscribeAgentEvents = null } // Abort any ongoing operations - this.abortController.abort(); + this.abortController.abort() // Clear timeouts - this.clearVoiceModeInactivityTimeout(); - this.clearBufferTimeout(); + this.clearVoiceModeInactivityTimeout() + this.clearBufferTimeout() // Clear buffers - this.cancelActiveVoiceDictationStream("session cleanup"); - this.pendingAudioSegments = []; - this.audioBuffer = null; + this.cancelActiveVoiceDictationStream('session cleanup') + this.pendingAudioSegments = [] + this.audioBuffer = null // Cleanup managers - this.ttsManager.cleanup(); - this.sttManager.cleanup(); - this.voiceStreamManager.cleanupAll(); - this.dictationStreamManager.cleanupAll(); + this.ttsManager.cleanup() + this.sttManager.cleanup() + this.voiceStreamManager.cleanupAll() + this.dictationStreamManager.cleanupAll() // Close MCP clients if (this.agentMcpClient) { try { - await this.agentMcpClient.close(); - this.sessionLogger.debug("Agent MCP client closed"); + await this.agentMcpClient.close() + this.sessionLogger.debug('Agent MCP client closed') } catch (error) { - this.sessionLogger.error( - { err: error }, - "Failed to close Agent MCP client" - ); + this.sessionLogger.error({ err: error }, 'Failed to close Agent MCP client') } - this.agentMcpClient = null; - this.agentTools = null; + this.agentMcpClient = null + this.agentTools = null } - await this.disableVoiceModeForActiveAgent(true); - this.isVoiceMode = false; + await this.disableVoiceModeForActiveAgent(true) + this.isVoiceMode = false // Unsubscribe from all terminals if (this.unsubscribeTerminalsChanged) { - this.unsubscribeTerminalsChanged(); - this.unsubscribeTerminalsChanged = null; + this.unsubscribeTerminalsChanged() + this.unsubscribeTerminalsChanged = null } - this.subscribedTerminalDirectories.clear(); + this.subscribedTerminalDirectories.clear() for (const unsubscribe of this.terminalSubscriptions.values()) { - unsubscribe(); + unsubscribe() } - this.terminalSubscriptions.clear(); + this.terminalSubscriptions.clear() for (const unsubscribeExit of this.terminalExitSubscriptions.values()) { - unsubscribeExit(); + unsubscribeExit() } - this.terminalExitSubscriptions.clear(); - this.detachAllTerminalStreams({ emitExit: false }); + this.terminalExitSubscriptions.clear() + this.detachAllTerminalStreams({ emitExit: false }) for (const target of this.checkoutDiffTargets.values()) { - this.closeCheckoutDiffWatchTarget(target); + this.closeCheckoutDiffWatchTarget(target) } - this.checkoutDiffTargets.clear(); - this.checkoutDiffSubscriptions.clear(); + this.checkoutDiffTargets.clear() + this.checkoutDiffSubscriptions.clear() } // ============================================================================ @@ -6330,57 +6121,57 @@ export class Session { private ensureTerminalExitSubscription(terminal: TerminalSession): void { if (this.terminalExitSubscriptions.has(terminal.id)) { - return; + return } const unsubscribeExit = terminal.onExit(() => { - this.handleTerminalExited(terminal.id); - }); - this.terminalExitSubscriptions.set(terminal.id, unsubscribeExit); + this.handleTerminalExited(terminal.id) + }) + this.terminalExitSubscriptions.set(terminal.id, unsubscribeExit) } private handleTerminalExited(terminalId: string): void { - const unsubscribeExit = this.terminalExitSubscriptions.get(terminalId); + const unsubscribeExit = this.terminalExitSubscriptions.get(terminalId) if (unsubscribeExit) { - unsubscribeExit(); - this.terminalExitSubscriptions.delete(terminalId); + unsubscribeExit() + this.terminalExitSubscriptions.delete(terminalId) } - const unsubscribe = this.terminalSubscriptions.get(terminalId); + const unsubscribe = this.terminalSubscriptions.get(terminalId) if (unsubscribe) { try { - unsubscribe(); + unsubscribe() } catch (error) { this.sessionLogger.warn( { err: error, terminalId }, - "Failed to unsubscribe terminal after process exit" - ); + 'Failed to unsubscribe terminal after process exit' + ) } - this.terminalSubscriptions.delete(terminalId); + this.terminalSubscriptions.delete(terminalId) } - const streamId = this.terminalStreamByTerminalId.get(terminalId); - if (typeof streamId === "number") { - this.detachTerminalStream(streamId, { emitExit: true }); + const streamId = this.terminalStreamByTerminalId.get(terminalId) + if (typeof streamId === 'number') { + this.detachTerminalStream(streamId, { emitExit: true }) } } private emitTerminalsChangedSnapshot(input: { - cwd: string; - terminals: Array<{ id: string; name: string }>; + cwd: string + terminals: Array<{ id: string; name: string }> }): void { this.emit({ - type: "terminals_changed", + type: 'terminals_changed', payload: { cwd: input.cwd, terminals: input.terminals, }, - }); + }) } private handleTerminalsChanged(event: TerminalsChangedEvent): void { if (!this.subscribedTerminalDirectories.has(event.cwd)) { - return; + return } this.emitTerminalsChangedSnapshot({ @@ -6389,40 +6180,38 @@ export class Session { id: terminal.id, name: terminal.name, })), - }); + }) } private handleSubscribeTerminalsRequest(msg: SubscribeTerminalsRequest): void { - this.subscribedTerminalDirectories.add(msg.cwd); - void this.emitInitialTerminalsChangedSnapshot(msg.cwd); + this.subscribedTerminalDirectories.add(msg.cwd) + void this.emitInitialTerminalsChangedSnapshot(msg.cwd) } private handleUnsubscribeTerminalsRequest(msg: UnsubscribeTerminalsRequest): void { - this.subscribedTerminalDirectories.delete(msg.cwd); + this.subscribedTerminalDirectories.delete(msg.cwd) } private async emitInitialTerminalsChangedSnapshot(cwd: string): Promise { if (!this.terminalManager || !this.subscribedTerminalDirectories.has(cwd)) { - return; + return } - const hadDirectoryBeforeSubscribe = this.terminalManager - .listDirectories() - .includes(cwd); + const hadDirectoryBeforeSubscribe = this.terminalManager.listDirectories().includes(cwd) try { - const terminals = await this.terminalManager.getTerminals(cwd); + const terminals = await this.terminalManager.getTerminals(cwd) for (const terminal of terminals) { - this.ensureTerminalExitSubscription(terminal); + this.ensureTerminalExitSubscription(terminal) } // New directories auto-create Terminal 1, which already emits through // terminal-manager change listeners. if (!hadDirectoryBeforeSubscribe) { - return; + return } if (!this.subscribedTerminalDirectories.has(cwd)) { - return; + return } this.emitTerminalsChangedSnapshot({ @@ -6431,258 +6220,251 @@ export class Session { id: terminal.id, name: terminal.name, })), - }); + }) } catch (error) { - this.sessionLogger.warn( - { err: error, cwd }, - "Failed to emit initial terminal snapshot" - ); + this.sessionLogger.warn({ err: error, cwd }, 'Failed to emit initial terminal snapshot') } } private async handleListTerminalsRequest(msg: ListTerminalsRequest): Promise { if (!this.terminalManager) { this.emit({ - type: "list_terminals_response", + type: 'list_terminals_response', payload: { cwd: msg.cwd, terminals: [], requestId: msg.requestId, }, - }); - return; + }) + return } try { - const terminals = await this.terminalManager.getTerminals(msg.cwd); + const terminals = await this.terminalManager.getTerminals(msg.cwd) for (const terminal of terminals) { - this.ensureTerminalExitSubscription(terminal); + this.ensureTerminalExitSubscription(terminal) } this.emit({ - type: "list_terminals_response", + type: 'list_terminals_response', payload: { cwd: msg.cwd, terminals: terminals.map((t) => ({ id: t.id, name: t.name })), requestId: msg.requestId, }, - }); + }) } catch (error: any) { - this.sessionLogger.error({ err: error, cwd: msg.cwd }, "Failed to list terminals"); + this.sessionLogger.error({ err: error, cwd: msg.cwd }, 'Failed to list terminals') this.emit({ - type: "list_terminals_response", + type: 'list_terminals_response', payload: { cwd: msg.cwd, terminals: [], requestId: msg.requestId, }, - }); + }) } } private async handleCreateTerminalRequest(msg: CreateTerminalRequest): Promise { if (!this.terminalManager) { this.emit({ - type: "create_terminal_response", + type: 'create_terminal_response', payload: { terminal: null, - error: "Terminal manager not available", + error: 'Terminal manager not available', requestId: msg.requestId, }, - }); - return; + }) + return } try { const session = await this.terminalManager.createTerminal({ cwd: msg.cwd, name: msg.name, - }); - this.ensureTerminalExitSubscription(session); + }) + this.ensureTerminalExitSubscription(session) this.emit({ - type: "create_terminal_response", + type: 'create_terminal_response', payload: { terminal: { id: session.id, name: session.name, cwd: session.cwd }, error: null, requestId: msg.requestId, }, - }); + }) } catch (error: any) { - this.sessionLogger.error({ err: error, cwd: msg.cwd }, "Failed to create terminal"); + this.sessionLogger.error({ err: error, cwd: msg.cwd }, 'Failed to create terminal') this.emit({ - type: "create_terminal_response", + type: 'create_terminal_response', payload: { terminal: null, error: error.message, requestId: msg.requestId, }, - }); + }) } } private async handleSubscribeTerminalRequest(msg: SubscribeTerminalRequest): Promise { if (!this.terminalManager) { this.emit({ - type: "subscribe_terminal_response", + type: 'subscribe_terminal_response', payload: { terminalId: msg.terminalId, state: null, - error: "Terminal manager not available", + error: 'Terminal manager not available', requestId: msg.requestId, }, - }); - return; + }) + return } - const session = this.terminalManager.getTerminal(msg.terminalId); + const session = this.terminalManager.getTerminal(msg.terminalId) if (!session) { this.emit({ - type: "subscribe_terminal_response", + type: 'subscribe_terminal_response', payload: { terminalId: msg.terminalId, state: null, - error: "Terminal not found", + error: 'Terminal not found', requestId: msg.requestId, }, - }); - return; + }) + return } - this.ensureTerminalExitSubscription(session); + this.ensureTerminalExitSubscription(session) // Unsubscribe from previous subscription if any - const existing = this.terminalSubscriptions.get(msg.terminalId); + const existing = this.terminalSubscriptions.get(msg.terminalId) if (existing) { - existing(); + existing() } // Subscribe to terminal updates const unsubscribe = session.subscribe((serverMsg) => { - if (serverMsg.type === "full") { + if (serverMsg.type === 'full') { this.emit({ - type: "terminal_output", + type: 'terminal_output', payload: { terminalId: msg.terminalId, state: serverMsg.state, }, - }); + }) } - }); - this.terminalSubscriptions.set(msg.terminalId, unsubscribe); + }) + this.terminalSubscriptions.set(msg.terminalId, unsubscribe) // Send initial state this.emit({ - type: "subscribe_terminal_response", + type: 'subscribe_terminal_response', payload: { terminalId: msg.terminalId, state: session.getState(), error: null, requestId: msg.requestId, }, - }); + }) } private handleUnsubscribeTerminalRequest(msg: UnsubscribeTerminalRequest): void { - const unsubscribe = this.terminalSubscriptions.get(msg.terminalId); + const unsubscribe = this.terminalSubscriptions.get(msg.terminalId) if (unsubscribe) { - unsubscribe(); - this.terminalSubscriptions.delete(msg.terminalId); + unsubscribe() + this.terminalSubscriptions.delete(msg.terminalId) } } private handleTerminalInput(msg: TerminalInput): void { if (!this.terminalManager) { - return; + return } - const session = this.terminalManager.getTerminal(msg.terminalId); + const session = this.terminalManager.getTerminal(msg.terminalId) if (!session) { - this.sessionLogger.warn({ terminalId: msg.terminalId }, "Terminal not found for input"); - return; + this.sessionLogger.warn({ terminalId: msg.terminalId }, 'Terminal not found for input') + return } - this.ensureTerminalExitSubscription(session); + this.ensureTerminalExitSubscription(session) - session.send(msg.message); + session.send(msg.message) } private killTrackedTerminal(terminalId: string, options?: { emitExit: boolean }): void { - const unsubscribe = this.terminalSubscriptions.get(terminalId); + const unsubscribe = this.terminalSubscriptions.get(terminalId) if (unsubscribe) { - unsubscribe(); - this.terminalSubscriptions.delete(terminalId); + unsubscribe() + this.terminalSubscriptions.delete(terminalId) } - const streamId = this.terminalStreamByTerminalId.get(terminalId); - if (typeof streamId === "number") { - this.detachTerminalStream(streamId, { emitExit: options?.emitExit ?? true }); + const streamId = this.terminalStreamByTerminalId.get(terminalId) + if (typeof streamId === 'number') { + this.detachTerminalStream(streamId, { emitExit: options?.emitExit ?? true }) } - this.terminalManager?.killTerminal(terminalId); + this.terminalManager?.killTerminal(terminalId) } private async killTerminalsUnderPath(rootPath: string): Promise { if (!this.terminalManager) { - return; + return } - const cleanupErrors: Array<{ cwd: string; message: string }> = []; - const terminalDirectories = [...this.terminalManager.listDirectories()]; + const cleanupErrors: Array<{ cwd: string; message: string }> = [] + const terminalDirectories = [...this.terminalManager.listDirectories()] for (const terminalCwd of terminalDirectories) { if (!this.isPathWithinRoot(rootPath, terminalCwd)) { - continue; + continue } try { - const terminals = await this.terminalManager.getTerminals(terminalCwd); + const terminals = await this.terminalManager.getTerminals(terminalCwd) for (const terminal of [...terminals]) { - this.killTrackedTerminal(terminal.id, { emitExit: true }); + this.killTrackedTerminal(terminal.id, { emitExit: true }) } } catch (error) { - const message = error instanceof Error ? error.message : String(error); - cleanupErrors.push({ cwd: terminalCwd, message }); + const message = error instanceof Error ? error.message : String(error) + cleanupErrors.push({ cwd: terminalCwd, message }) this.sessionLogger.warn( { err: error, cwd: terminalCwd }, - "Failed to clean up worktree terminals during archive" - ); + 'Failed to clean up worktree terminals during archive' + ) } } if (cleanupErrors.length > 0) { - const details = cleanupErrors - .map((entry) => `${entry.cwd}: ${entry.message}`) - .join("; "); - throw new Error(`Failed to clean up worktree terminals during archive (${details})`); + const details = cleanupErrors.map((entry) => `${entry.cwd}: ${entry.message}`).join('; ') + throw new Error(`Failed to clean up worktree terminals during archive (${details})`) } } private async handleKillTerminalRequest(msg: KillTerminalRequest): Promise { if (!this.terminalManager) { this.emit({ - type: "kill_terminal_response", + type: 'kill_terminal_response', payload: { terminalId: msg.terminalId, success: false, requestId: msg.requestId, }, - }); - return; + }) + return } - this.killTrackedTerminal(msg.terminalId, { emitExit: true }); + this.killTrackedTerminal(msg.terminalId, { emitExit: true }) this.emit({ - type: "kill_terminal_response", + type: 'kill_terminal_response', payload: { terminalId: msg.terminalId, success: true, requestId: msg.requestId, }, - }); + }) } - private async handleAttachTerminalStreamRequest( - msg: AttachTerminalStreamRequest - ): Promise { + private async handleAttachTerminalStreamRequest(msg: AttachTerminalStreamRequest): Promise { if (!this.terminalManager || !this.onBinaryMessage) { this.emit({ - type: "attach_terminal_stream_response", + type: 'attach_terminal_stream_response', payload: { terminalId: msg.terminalId, streamId: null, @@ -6690,17 +6472,17 @@ export class Session { currentOffset: 0, earliestAvailableOffset: 0, reset: true, - error: "Terminal streaming not available", + error: 'Terminal streaming not available', requestId: msg.requestId, }, - }); - return; + }) + return } - const session = this.terminalManager.getTerminal(msg.terminalId); + const session = this.terminalManager.getTerminal(msg.terminalId) if (!session) { this.emit({ - type: "attach_terminal_stream_response", + type: 'attach_terminal_stream_response', payload: { terminalId: msg.terminalId, streamId: null, @@ -6708,36 +6490,36 @@ export class Session { currentOffset: 0, earliestAvailableOffset: 0, reset: true, - error: "Terminal not found", + error: 'Terminal not found', requestId: msg.requestId, }, - }); - return; + }) + return } if (msg.rows || msg.cols) { - const state = session.getState(); + const state = session.getState() session.send({ - type: "resize", + type: 'resize', rows: msg.rows ?? state.rows, cols: msg.cols ?? state.cols, - }); + }) } - const existingStreamId = this.terminalStreamByTerminalId.get(msg.terminalId); - if (typeof existingStreamId === "number") { - this.detachTerminalStream(existingStreamId, { emitExit: false }); + const existingStreamId = this.terminalStreamByTerminalId.get(msg.terminalId) + if (typeof existingStreamId === 'number') { + this.detachTerminalStream(existingStreamId, { emitExit: false }) } - const streamId = this.allocateTerminalStreamId(); - const initialOffset = Math.max(0, Math.floor(msg.resumeOffset ?? 0)); + const streamId = this.allocateTerminalStreamId() + const initialOffset = Math.max(0, Math.floor(msg.resumeOffset ?? 0)) const binding: { - terminalId: string; - unsubscribe: () => void; - lastOutputOffset: number; - lastAckOffset: number; - pendingChunks: TerminalStreamPendingChunk[]; - pendingBytes: number; + terminalId: string + unsubscribe: () => void + lastOutputOffset: number + lastAckOffset: number + pendingChunks: TerminalStreamPendingChunk[] + pendingBytes: number } = { terminalId: msg.terminalId, unsubscribe: () => {}, @@ -6745,42 +6527,42 @@ export class Session { lastAckOffset: initialOffset, pendingChunks: [], pendingBytes: 0, - }; - this.terminalStreams.set(streamId, binding); - this.terminalStreamByTerminalId.set(msg.terminalId, streamId); + } + this.terminalStreams.set(streamId, binding) + this.terminalStreamByTerminalId.set(msg.terminalId, streamId) - let rawSub; + let rawSub try { rawSub = session.subscribeRaw( (chunk) => { - const currentBinding = this.terminalStreams.get(streamId); + const currentBinding = this.terminalStreams.get(streamId) if (!currentBinding) { - return; + return } this.enqueueOrEmitTerminalStreamChunk(streamId, currentBinding, { data: chunk.data, startOffset: chunk.startOffset, endOffset: chunk.endOffset, replay: chunk.replay, - }); + }) }, { fromOffset: msg.resumeOffset ?? 0 } - ); + ) } catch (error) { - this.terminalStreams.delete(streamId); - this.terminalStreamByTerminalId.delete(msg.terminalId); - throw error; + this.terminalStreams.delete(streamId) + this.terminalStreamByTerminalId.delete(msg.terminalId) + throw error } - binding.unsubscribe = rawSub.unsubscribe; - binding.lastAckOffset = rawSub.replayedFrom; + binding.unsubscribe = rawSub.unsubscribe + binding.lastAckOffset = rawSub.replayedFrom if (binding.lastOutputOffset < rawSub.replayedFrom) { - binding.lastOutputOffset = rawSub.replayedFrom; + binding.lastOutputOffset = rawSub.replayedFrom } - this.flushPendingTerminalStreamChunks(streamId, binding); + this.flushPendingTerminalStreamChunks(streamId, binding) this.emit({ - type: "attach_terminal_stream_response", + type: 'attach_terminal_stream_response', payload: { terminalId: msg.terminalId, streamId, @@ -6791,30 +6573,30 @@ export class Session { error: null, requestId: msg.requestId, }, - }); + }) } private getTerminalStreamChunkByteLength(chunk: TerminalStreamPendingChunk): number { - return Math.max(0, chunk.endOffset - chunk.startOffset); + return Math.max(0, chunk.endOffset - chunk.startOffset) } private canEmitTerminalStreamChunk( binding: { - lastAckOffset: number; + lastAckOffset: number }, chunk: TerminalStreamPendingChunk ): boolean { - return chunk.startOffset < binding.lastAckOffset + TERMINAL_STREAM_WINDOW_BYTES; + return chunk.startOffset < binding.lastAckOffset + TERMINAL_STREAM_WINDOW_BYTES } private emitTerminalStreamChunk( streamId: number, binding: { - lastOutputOffset: number; + lastOutputOffset: number }, chunk: TerminalStreamPendingChunk ): void { - const payload = new Uint8Array(Buffer.from(chunk.data, "utf8")); + const payload = new Uint8Array(Buffer.from(chunk.data, 'utf8')) this.emitBinary({ channel: BinaryMuxChannel.Terminal, messageType: TerminalBinaryMessageType.OutputUtf8, @@ -6822,21 +6604,21 @@ export class Session { offset: chunk.startOffset, flags: chunk.replay ? TerminalBinaryFlags.Replay : 0, payload, - }); - binding.lastOutputOffset = chunk.endOffset; + }) + binding.lastOutputOffset = chunk.endOffset } private enqueueOrEmitTerminalStreamChunk( streamId: number, binding: { - lastAckOffset: number; - lastOutputOffset: number; - pendingChunks: TerminalStreamPendingChunk[]; - pendingBytes: number; + lastAckOffset: number + lastOutputOffset: number + pendingChunks: TerminalStreamPendingChunk[] + pendingBytes: number }, chunk: TerminalStreamPendingChunk ): void { - const chunkBytes = this.getTerminalStreamChunkByteLength(chunk); + const chunkBytes = this.getTerminalStreamChunkByteLength(chunk) if (binding.pendingChunks.length > 0 || !this.canEmitTerminalStreamChunk(binding, chunk)) { if ( @@ -6850,105 +6632,102 @@ export class Session { pendingBytes: binding.pendingBytes, chunkBytes, }, - "Terminal stream pending buffer overflow; closing stream" - ); - this.detachTerminalStream(streamId, { emitExit: true }); - return; + 'Terminal stream pending buffer overflow; closing stream' + ) + this.detachTerminalStream(streamId, { emitExit: true }) + return } - binding.pendingChunks.push(chunk); - binding.pendingBytes += chunkBytes; - return; + binding.pendingChunks.push(chunk) + binding.pendingBytes += chunkBytes + return } - this.emitTerminalStreamChunk(streamId, binding, chunk); + this.emitTerminalStreamChunk(streamId, binding, chunk) } private flushPendingTerminalStreamChunks( streamId: number, binding: { - lastAckOffset: number; - lastOutputOffset: number; - pendingChunks: TerminalStreamPendingChunk[]; - pendingBytes: number; + lastAckOffset: number + lastOutputOffset: number + pendingChunks: TerminalStreamPendingChunk[] + pendingBytes: number } ): void { while (binding.pendingChunks.length > 0) { - const next = binding.pendingChunks[0]; + const next = binding.pendingChunks[0] if (!next || !this.canEmitTerminalStreamChunk(binding, next)) { - break; + break } - binding.pendingChunks.shift(); - binding.pendingBytes -= this.getTerminalStreamChunkByteLength(next); + binding.pendingChunks.shift() + binding.pendingBytes -= this.getTerminalStreamChunkByteLength(next) if (binding.pendingBytes < 0) { - binding.pendingBytes = 0; + binding.pendingBytes = 0 } - this.emitTerminalStreamChunk(streamId, binding, next); + this.emitTerminalStreamChunk(streamId, binding, next) } } private handleDetachTerminalStreamRequest(msg: DetachTerminalStreamRequest): void { - const success = this.detachTerminalStream(msg.streamId, { emitExit: false }); + const success = this.detachTerminalStream(msg.streamId, { emitExit: false }) this.emit({ - type: "detach_terminal_stream_response", + type: 'detach_terminal_stream_response', payload: { streamId: msg.streamId, success, requestId: msg.requestId, }, - }); + }) } private detachAllTerminalStreams(options?: { emitExit: boolean }): void { for (const streamId of Array.from(this.terminalStreams.keys())) { - this.detachTerminalStream(streamId, options); + this.detachTerminalStream(streamId, options) } } - private detachTerminalStream( - streamId: number, - options?: { emitExit: boolean } - ): boolean { - const binding = this.terminalStreams.get(streamId); + private detachTerminalStream(streamId: number, options?: { emitExit: boolean }): boolean { + const binding = this.terminalStreams.get(streamId) if (!binding) { - return false; + return false } try { - binding.unsubscribe(); + binding.unsubscribe() } catch (error) { - this.sessionLogger.warn({ err: error, streamId }, "Failed to unsubscribe terminal stream"); + this.sessionLogger.warn({ err: error, streamId }, 'Failed to unsubscribe terminal stream') } - this.terminalStreams.delete(streamId); + this.terminalStreams.delete(streamId) if (this.terminalStreamByTerminalId.get(binding.terminalId) === streamId) { - this.terminalStreamByTerminalId.delete(binding.terminalId); + this.terminalStreamByTerminalId.delete(binding.terminalId) } if (options?.emitExit) { this.emit({ - type: "terminal_stream_exit", + type: 'terminal_stream_exit', payload: { streamId, terminalId: binding.terminalId, }, - }); + }) } - return true; + return true } private allocateTerminalStreamId(): number { - let attempts = 0; + let attempts = 0 while (attempts < 0xffffffff) { - const candidate = this.nextTerminalStreamId >>> 0; - this.nextTerminalStreamId = ((this.nextTerminalStreamId + 1) & 0xffffffff) >>> 0; + const candidate = this.nextTerminalStreamId >>> 0 + this.nextTerminalStreamId = ((this.nextTerminalStreamId + 1) & 0xffffffff) >>> 0 if (candidate === 0) { - attempts += 1; - continue; + attempts += 1 + continue } if (!this.terminalStreams.has(candidate)) { - return candidate; + return candidate } - attempts += 1; + attempts += 1 } - throw new Error("Unable to allocate terminal stream id"); + throw new Error('Unable to allocate terminal stream id') } } diff --git a/packages/server/src/shared/messages.stream-parsing.test.ts b/packages/server/src/shared/messages.stream-parsing.test.ts index 018cc07a2..abcfa3c4c 100644 --- a/packages/server/src/shared/messages.stream-parsing.test.ts +++ b/packages/server/src/shared/messages.stream-parsing.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from 'vitest' import { AgentStreamMessageSchema, @@ -6,172 +6,176 @@ import { SessionInboundMessageSchema, SessionOutboundMessageSchema, WSOutboundMessageSchema, -} from "./messages.js"; +} from './messages.js' -describe("shared messages stream parsing", () => { - it("parses representative fetch_agent_timeline_response payload", () => { +describe('shared messages stream parsing', () => { + it('parses representative fetch_agent_timeline_response payload', () => { const parsed = FetchAgentTimelineResponseMessageSchema.parse({ - type: "fetch_agent_timeline_response", + type: 'fetch_agent_timeline_response', payload: { - requestId: "req-1", - agentId: "agent_live", - direction: "tail", - projection: "projected", - epoch: "epoch-1", + requestId: 'req-1', + agentId: 'agent_live', + direction: 'tail', + projection: 'projected', + epoch: 'epoch-1', reset: false, staleCursor: false, gap: false, window: { minSeq: 1, maxSeq: 2, nextSeq: 3 }, - startCursor: { epoch: "epoch-1", seq: 1 }, - endCursor: { epoch: "epoch-1", seq: 2 }, + startCursor: { epoch: 'epoch-1', seq: 1 }, + endCursor: { epoch: 'epoch-1', seq: 2 }, hasOlder: false, hasNewer: false, entries: [ { - provider: "codex", - item: { type: "assistant_message", text: "hello" }, - timestamp: "2026-02-08T20:10:00.000Z", + provider: 'codex', + item: { type: 'assistant_message', text: 'hello' }, + timestamp: '2026-02-08T20:10:00.000Z', seqStart: 1, seqEnd: 2, sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }], - collapsed: ["assistant_merge"], + collapsed: ['assistant_merge'], }, ], error: null, }, - }); + }) - expect(parsed.payload.entries).toHaveLength(1); - expect(parsed.payload.entries[0]?.item.type).toBe("assistant_message"); - }); + expect(parsed.payload.entries).toHaveLength(1) + expect(parsed.payload.entries[0]?.item.type).toBe('assistant_message') + }) - it("parses representative agent_stream tool_call event", () => { + it('parses representative agent_stream tool_call event', () => { const parsed = AgentStreamMessageSchema.parse({ - type: "agent_stream", + type: 'agent_stream', payload: { - agentId: "agent_live", - timestamp: "2026-02-08T20:10:00.000Z", + agentId: 'agent_live', + timestamp: '2026-02-08T20:10:00.000Z', event: { - type: "timeline", - provider: "claude", + type: 'timeline', + provider: 'claude', item: { - type: "tool_call", - callId: "call_live", - name: "shell", - status: "running", + type: 'tool_call', + callId: 'call_live', + name: 'shell', + status: 'running', detail: { - type: "shell", - command: "ls", + type: 'shell', + command: 'ls', }, error: null, }, }, }, - }); + }) - expect(parsed.payload.event.type).toBe("timeline"); - if (parsed.payload.event.type === "timeline") { - expect(parsed.payload.event.item.type).toBe("tool_call"); - if (parsed.payload.event.item.type === "tool_call") { - expect(parsed.payload.event.item.status).toBe("running"); + expect(parsed.payload.event.type).toBe('timeline') + if (parsed.payload.event.type === 'timeline') { + expect(parsed.payload.event.item.type).toBe('tool_call') + if (parsed.payload.event.item.type === 'tool_call') { + expect(parsed.payload.event.item.status).toBe('running') } } - }); + }) - it("rejects removed initialize_agent_request inbound payload", () => { + it('rejects removed initialize_agent_request inbound payload', () => { const parsed = SessionInboundMessageSchema.safeParse({ - type: "initialize_agent_request", - agentId: "agent-legacy", - requestId: "req-legacy-1", - }); - expect(parsed.success).toBe(false); - }); + type: 'initialize_agent_request', + agentId: 'agent-legacy', + requestId: 'req-legacy-1', + }) + expect(parsed.success).toBe(false) + }) - it("rejects removed initialize_agent_request outbound payload", () => { + it('rejects removed initialize_agent_request outbound payload', () => { const parsed = SessionOutboundMessageSchema.safeParse({ - type: "initialize_agent_request", + type: 'initialize_agent_request', payload: { - requestId: "req-legacy-1", - agentId: "agent-legacy", - agentStatus: "running", + requestId: 'req-legacy-1', + agentId: 'agent-legacy', + agentStatus: 'running', timelineSize: 12, }, - }); - expect(parsed.success).toBe(false); - }); + }) + expect(parsed.success).toBe(false) + }) - it("parses directory suggestions request and response payloads", () => { + it('parses directory suggestions request and response payloads', () => { const requestParsed = SessionInboundMessageSchema.safeParse({ - type: "directory_suggestions_request", - query: "proj", + type: 'directory_suggestions_request', + query: 'proj', + cwd: '/tmp/project', + includeFiles: true, + includeDirectories: true, limit: 20, - requestId: "req-dir-1", - }); - expect(requestParsed.success).toBe(true); + requestId: 'req-dir-1', + }) + expect(requestParsed.success).toBe(true) const responseParsed = SessionOutboundMessageSchema.safeParse({ - type: "directory_suggestions_response", + type: 'directory_suggestions_response', payload: { - directories: ["/Users/test/projects/paseo"], + directories: ['/Users/test/projects/paseo'], + entries: [{ path: '/Users/test/projects/paseo', kind: 'directory' }], error: null, - requestId: "req-dir-1", + requestId: 'req-dir-1', }, - }); - expect(responseParsed.success).toBe(true); - }); + }) + expect(responseParsed.success).toBe(true) + }) - it("rejects websocket envelope for removed agent_stream_snapshot message type", () => { + it('rejects websocket envelope for removed agent_stream_snapshot message type', () => { const fixture = { - type: "agent_stream_snapshot", + type: 'agent_stream_snapshot', payload: { - agentId: "agent-legacy", + agentId: 'agent-legacy', events: [], }, - }; + } const wrapped = WSOutboundMessageSchema.safeParse({ - type: "session", + type: 'session', message: fixture, - }); - expect(wrapped.success).toBe(false); - }); + }) + expect(wrapped.success).toBe(false) + }) - it("rejects removed legacy git diff request messages", () => { + it('rejects removed legacy git diff request messages', () => { const gitDiffParsed = SessionInboundMessageSchema.safeParse({ - type: "git_diff_request", - agentId: "agent-1", - requestId: "req-1", - }); - expect(gitDiffParsed.success).toBe(false); + type: 'git_diff_request', + agentId: 'agent-1', + requestId: 'req-1', + }) + expect(gitDiffParsed.success).toBe(false) const highlightedParsed = SessionInboundMessageSchema.safeParse({ - type: "highlighted_diff_request", - agentId: "agent-1", - requestId: "req-2", - }); - expect(highlightedParsed.success).toBe(false); - }); + type: 'highlighted_diff_request', + agentId: 'agent-1', + requestId: 'req-2', + }) + expect(highlightedParsed.success).toBe(false) + }) - it("rejects removed legacy git diff response messages", () => { + it('rejects removed legacy git diff response messages', () => { const gitDiffParsed = SessionOutboundMessageSchema.safeParse({ - type: "git_diff_response", + type: 'git_diff_response', payload: { - agentId: "agent-1", - diff: "", + agentId: 'agent-1', + diff: '', error: null, - requestId: "req-1", + requestId: 'req-1', }, - }); - expect(gitDiffParsed.success).toBe(false); + }) + expect(gitDiffParsed.success).toBe(false) const highlightedParsed = SessionOutboundMessageSchema.safeParse({ - type: "highlighted_diff_response", + type: 'highlighted_diff_response', payload: { - agentId: "agent-1", + agentId: 'agent-1', files: [], error: null, - requestId: "req-2", + requestId: 'req-2', }, - }); - expect(highlightedParsed.success).toBe(false); - }); -}); + }) + expect(highlightedParsed.success).toBe(false) + }) +}) diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 48395cc3e..601cd3684 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -1,6 +1,6 @@ -import { z } from "zod"; -import { AGENT_LIFECYCLE_STATUSES } from "./agent-lifecycle.js"; -import { AgentProviderSchema } from "../server/agent/provider-manifest.js"; +import { z } from 'zod' +import { AGENT_LIFECYCLE_STATUSES } from './agent-lifecycle.js' +import { AgentProviderSchema } from '../server/agent/provider-manifest.js' import type { AgentCapabilityFlags, AgentModelDefinition, @@ -13,15 +13,15 @@ import type { ToolCallDetail, ToolCallTimelineItem, AgentUsage, -} from "../server/agent/agent-sdk-types.js"; +} from '../server/agent/agent-sdk-types.js' -export const AgentStatusSchema = z.enum(AGENT_LIFECYCLE_STATUSES); +export const AgentStatusSchema = z.enum(AGENT_LIFECYCLE_STATUSES) const AgentModeSchema: z.ZodType = z.object({ id: z.string(), label: z.string(), description: z.string().optional(), -}); +}) const AgentSelectOptionSchema = z.object({ id: z.string(), @@ -29,7 +29,7 @@ const AgentSelectOptionSchema = z.object({ description: z.string().optional(), isDefault: z.boolean().optional(), metadata: z.record(z.unknown()).optional(), -}); +}) const AgentModelDefinitionSchema: z.ZodType = z.object({ provider: AgentProviderSchema, @@ -40,7 +40,7 @@ const AgentModelDefinitionSchema: z.ZodType = z.object({ metadata: z.record(z.unknown()).optional(), thinkingOptions: z.array(AgentSelectOptionSchema).optional(), defaultThinkingOptionId: z.string().optional(), -}); +}) const AgentCapabilityFlagsSchema: z.ZodType = z.object({ supportsStreaming: z.boolean(), @@ -49,39 +49,39 @@ const AgentCapabilityFlagsSchema: z.ZodType = z.object({ supportsMcpServers: z.boolean(), supportsReasoningStream: z.boolean(), supportsToolInvocations: z.boolean(), -}); +}) const AgentUsageSchema: z.ZodType = z.object({ inputTokens: z.number().optional(), cachedInputTokens: z.number().optional(), outputTokens: z.number().optional(), totalCostUsd: z.number().optional(), -}); +}) const McpStdioServerConfigSchema = z.object({ - type: z.literal("stdio"), + type: z.literal('stdio'), command: z.string(), args: z.array(z.string()).optional(), env: z.record(z.string()).optional(), -}); +}) const McpHttpServerConfigSchema = z.object({ - type: z.literal("http"), + type: z.literal('http'), url: z.string(), headers: z.record(z.string()).optional(), -}); +}) const McpSseServerConfigSchema = z.object({ - type: z.literal("sse"), + type: z.literal('sse'), url: z.string(), headers: z.record(z.string()).optional(), -}); +}) -const McpServerConfigSchema = z.discriminatedUnion("type", [ +const McpServerConfigSchema = z.discriminatedUnion('type', [ McpStdioServerConfigSchema, McpHttpServerConfigSchema, McpSseServerConfigSchema, -]); +]) const AgentSessionConfigSchema = z.object({ provider: AgentProviderSchema, @@ -89,13 +89,7 @@ const AgentSessionConfigSchema = z.object({ modeId: z.string().optional(), model: z.string().optional(), thinkingOptionId: z.string().optional(), - title: z - .string() - .trim() - .min(1) - .max(40) - .optional() - .nullable(), + title: z.string().trim().min(1).max(40).optional().nullable(), approvalPolicy: z.string().optional(), sandboxMode: z.string().optional(), networkAccess: z.boolean().optional(), @@ -109,36 +103,34 @@ const AgentSessionConfigSchema = z.object({ .optional(), systemPrompt: z.string().optional(), mcpServers: z.record(McpServerConfigSchema).optional(), -}); +}) -const AgentPermissionUpdateSchema = z.record(z.unknown()); +const AgentPermissionUpdateSchema = z.record(z.unknown()) -export const AgentPermissionResponseSchema: z.ZodType = - z.union([ - z.object({ - behavior: z.literal("allow"), - updatedInput: z.record(z.unknown()).optional(), - updatedPermissions: z.array(AgentPermissionUpdateSchema).optional(), - }), - z.object({ - behavior: z.literal("deny"), - message: z.string().optional(), - interrupt: z.boolean().optional(), - }), - ]); - -export const AgentPermissionRequestPayloadSchema: z.ZodType = +export const AgentPermissionResponseSchema: z.ZodType = z.union([ z.object({ - id: z.string(), - provider: AgentProviderSchema, - name: z.string(), - kind: z.enum(["tool", "plan", "question", "mode", "other"]), - title: z.string().optional(), - description: z.string().optional(), - input: z.record(z.unknown()).optional(), - suggestions: z.array(AgentPermissionUpdateSchema).optional(), - metadata: z.record(z.unknown()).optional(), - }); + behavior: z.literal('allow'), + updatedInput: z.record(z.unknown()).optional(), + updatedPermissions: z.array(AgentPermissionUpdateSchema).optional(), + }), + z.object({ + behavior: z.literal('deny'), + message: z.string().optional(), + interrupt: z.boolean().optional(), + }), +]) + +export const AgentPermissionRequestPayloadSchema: z.ZodType = z.object({ + id: z.string(), + provider: AgentProviderSchema, + name: z.string(), + kind: z.enum(['tool', 'plan', 'question', 'mode', 'other']), + title: z.string().optional(), + description: z.string().optional(), + input: z.record(z.unknown()).optional(), + suggestions: z.array(AgentPermissionUpdateSchema).optional(), + metadata: z.record(z.unknown()).optional(), +}) const UnknownValueSchema = z.union([ z.null(), @@ -147,7 +139,7 @@ const UnknownValueSchema = z.union([ z.string(), z.array(z.unknown()), z.object({}).passthrough(), -]); +]) const NonNullUnknownSchema = z.union([ z.boolean(), @@ -155,41 +147,41 @@ const NonNullUnknownSchema = z.union([ z.string(), z.array(z.unknown()), z.object({}).passthrough(), -]); +]) -const ToolCallDetailPayloadSchema: z.ZodType = z.discriminatedUnion("type", [ +const ToolCallDetailPayloadSchema: z.ZodType = z.discriminatedUnion('type', [ z.object({ - type: z.literal("shell"), + type: z.literal('shell'), command: z.string(), cwd: z.string().optional(), output: z.string().optional(), exitCode: z.number().nullable().optional(), }), z.object({ - type: z.literal("read"), + type: z.literal('read'), filePath: z.string(), content: z.string().optional(), offset: z.number().optional(), limit: z.number().optional(), }), z.object({ - type: z.literal("edit"), + type: z.literal('edit'), filePath: z.string(), oldString: z.string().optional(), newString: z.string().optional(), unifiedDiff: z.string().optional(), }), z.object({ - type: z.literal("write"), + type: z.literal('write'), filePath: z.string(), content: z.string().optional(), }), z.object({ - type: z.literal("search"), + type: z.literal('search'), query: z.string(), }), z.object({ - type: z.literal("worktree_setup"), + type: z.literal('worktree_setup'), worktreePath: z.string(), branchName: z.string(), log: z.string(), @@ -198,7 +190,7 @@ const ToolCallDetailPayloadSchema: z.ZodType = z.discriminatedUn index: z.number().int().positive(), command: z.string(), cwd: z.string(), - status: z.enum(["running", "completed", "failed"]), + status: z.enum(['running', 'completed', 'failed']), exitCode: z.number().nullable(), durationMs: z.number().nonnegative().optional(), }) @@ -206,39 +198,41 @@ const ToolCallDetailPayloadSchema: z.ZodType = z.discriminatedUn truncated: z.boolean().optional(), }), z.object({ - type: z.literal("unknown"), + type: z.literal('unknown'), input: UnknownValueSchema, output: UnknownValueSchema, }), -]); +]) -const ToolCallBasePayloadSchema = z.object({ - type: z.literal("tool_call"), - callId: z.string(), - name: z.string(), - detail: ToolCallDetailPayloadSchema, - metadata: z.record(z.unknown()).optional(), -}).strict(); +const ToolCallBasePayloadSchema = z + .object({ + type: z.literal('tool_call'), + callId: z.string(), + name: z.string(), + detail: ToolCallDetailPayloadSchema, + metadata: z.record(z.unknown()).optional(), + }) + .strict() const ToolCallRunningPayloadSchema = ToolCallBasePayloadSchema.extend({ - status: z.literal("running"), + status: z.literal('running'), error: z.null(), -}); +}) const ToolCallCompletedPayloadSchema = ToolCallBasePayloadSchema.extend({ - status: z.literal("completed"), + status: z.literal('completed'), error: z.null(), -}); +}) const ToolCallFailedPayloadSchema = ToolCallBasePayloadSchema.extend({ - status: z.literal("failed"), + status: z.literal('failed'), error: NonNullUnknownSchema, -}); +}) const ToolCallCanceledPayloadSchema = ToolCallBasePayloadSchema.extend({ - status: z.literal("canceled"), + status: z.literal('canceled'), error: z.null(), -}); +}) const ToolCallTimelineItemPayloadSchema: z.ZodType = z.union([ @@ -246,26 +240,26 @@ const ToolCallTimelineItemPayloadSchema: z.ZodType = z.union([ z.object({ - type: z.literal("user_message"), + type: z.literal('user_message'), text: z.string(), messageId: z.string().optional(), }), z.object({ - type: z.literal("assistant_message"), + type: z.literal('assistant_message'), text: z.string(), }), z.object({ - type: z.literal("reasoning"), + type: z.literal('reasoning'), text: z.string(), }), ToolCallTimelineItemPayloadSchema, z.object({ - type: z.literal("todo"), + type: z.literal('todo'), items: z.array( z.object({ text: z.string(), @@ -274,87 +268,86 @@ export const AgentTimelineItemPayloadSchema: z.ZodType = - z - .object({ - provider: AgentProviderSchema, - sessionId: z.string(), - nativeHandle: z.string().optional(), - metadata: z.record(z.unknown()).optional(), - }) - .nullable(); +const AgentPersistenceHandleSchema: z.ZodType = z + .object({ + provider: AgentProviderSchema, + sessionId: z.string(), + nativeHandle: z.string().optional(), + metadata: z.record(z.unknown()).optional(), + }) + .nullable() const AgentRuntimeInfoSchema: z.ZodType = z.object({ provider: AgentProviderSchema, @@ -362,7 +355,7 @@ const AgentRuntimeInfoSchema: z.ZodType = z.object({ model: z.string().nullable().optional(), modeId: z.string().nullable().optional(), extra: z.record(z.unknown()).optional(), -}); +}) export const AgentSnapshotPayloadSchema = z.object({ id: z.string(), @@ -385,36 +378,34 @@ export const AgentSnapshotPayloadSchema = z.object({ title: z.string().nullable(), labels: z.record(z.string()).default({}), requiresAttention: z.boolean().optional(), - attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(), + attentionReason: z.enum(['finished', 'error', 'permission']).nullable().optional(), attentionTimestamp: z.string().nullable().optional(), archivedAt: z.string().nullable().optional(), -}); +}) -export type AgentSnapshotPayload = z.infer; +export type AgentSnapshotPayload = z.infer -export type AgentStreamEventPayload = z.infer< - typeof AgentStreamEventPayloadSchema ->; +export type AgentStreamEventPayload = z.infer // ============================================================================ // Session Inbound Messages (Session receives these) // ============================================================================ export const VoiceAudioChunkMessageSchema = z.object({ - type: z.literal("voice_audio_chunk"), + type: z.literal('voice_audio_chunk'), audio: z.string(), // base64 encoded format: z.string(), isLast: z.boolean(), -}); +}) export const AbortRequestMessageSchema = z.object({ - type: z.literal("abort_request"), -}); + type: z.literal('abort_request'), +}) export const AudioPlayedMessageSchema = z.object({ - type: z.literal("audio_played"), + type: z.literal('audio_played'), id: z.string(), -}); +}) const AgentDirectoryFilterSchema = z.object({ labels: z.record(z.string()).optional(), @@ -422,59 +413,63 @@ const AgentDirectoryFilterSchema = z.object({ statuses: z.array(AgentStatusSchema).optional(), includeArchived: z.boolean().optional(), requiresAttention: z.boolean().optional(), -}); +}) export const DeleteAgentRequestMessageSchema = z.object({ - type: z.literal("delete_agent_request"), + type: z.literal('delete_agent_request'), agentId: z.string(), requestId: z.string(), -}); +}) export const ArchiveAgentRequestMessageSchema = z.object({ - type: z.literal("archive_agent_request"), + type: z.literal('archive_agent_request'), agentId: z.string(), requestId: z.string(), -}); +}) export const UpdateAgentRequestMessageSchema = z.object({ - type: z.literal("update_agent_request"), + type: z.literal('update_agent_request'), agentId: z.string(), name: z.string().optional(), labels: z.record(z.string()).optional(), requestId: z.string(), -}); +}) export const SetVoiceModeMessageSchema = z.object({ - type: z.literal("set_voice_mode"), + type: z.literal('set_voice_mode'), enabled: z.boolean(), agentId: z.string().optional(), requestId: z.string().optional(), -}); +}) export const SendAgentMessageSchema = z.object({ - type: z.literal("send_agent_message"), + type: z.literal('send_agent_message'), agentId: z.string(), text: z.string(), messageId: z.string().optional(), // Client-provided ID for deduplication - images: z.array(z.object({ - data: z.string(), // base64 encoded image - mimeType: z.string(), // e.g., "image/jpeg", "image/png" - })).optional(), -}); + images: z + .array( + z.object({ + data: z.string(), // base64 encoded image + mimeType: z.string(), // e.g., "image/jpeg", "image/png" + }) + ) + .optional(), +}) // ============================================================================ // Agent RPCs (requestId-correlated) // ============================================================================ export const FetchAgentsRequestMessageSchema = z.object({ - type: z.literal("fetch_agents_request"), + type: z.literal('fetch_agents_request'), requestId: z.string(), filter: AgentDirectoryFilterSchema.optional(), sort: z .array( z.object({ - key: z.enum(["status_priority", "created_at", "updated_at", "title"]), - direction: z.enum(["asc", "desc"]), + key: z.enum(['status_priority', 'created_at', 'updated_at', 'title']), + direction: z.enum(['asc', 'desc']), }) ) .optional(), @@ -489,17 +484,17 @@ export const FetchAgentsRequestMessageSchema = z.object({ subscriptionId: z.string().optional(), }) .optional(), -}); +}) export const FetchAgentRequestMessageSchema = z.object({ - type: z.literal("fetch_agent_request"), + type: z.literal('fetch_agent_request'), requestId: z.string(), /** Accepts full ID, unique prefix, or exact full title (server resolves). */ agentId: z.string(), -}); +}) export const SendAgentMessageRequestSchema = z.object({ - type: z.literal("send_agent_message_request"), + type: z.literal('send_agent_message_request'), requestId: z.string(), /** Accepts full ID, unique prefix, or exact full title (server resolves). */ agentId: z.string(), @@ -513,44 +508,44 @@ export const SendAgentMessageRequestSchema = z.object({ }) ) .optional(), -}); +}) export const WaitForFinishRequestSchema = z.object({ - type: z.literal("wait_for_finish_request"), + type: z.literal('wait_for_finish_request'), requestId: z.string(), /** Accepts full ID, unique prefix, or exact full title (server resolves). */ agentId: z.string(), timeoutMs: z.number().int().positive().optional(), -}); +}) // ============================================================================ // Dictation Streaming (lossless, resumable) // ============================================================================ export const DictationStreamStartMessageSchema = z.object({ - type: z.literal("dictation_stream_start"), + type: z.literal('dictation_stream_start'), dictationId: z.string(), format: z.string(), // e.g. "audio/pcm;rate=16000;bits=16" -}); +}) export const DictationStreamChunkMessageSchema = z.object({ - type: z.literal("dictation_stream_chunk"), + type: z.literal('dictation_stream_chunk'), dictationId: z.string(), seq: z.number().int().nonnegative(), audio: z.string(), // base64 encoded chunk format: z.string(), // e.g. "audio/pcm;rate=16000;bits=16" -}); +}) export const DictationStreamFinishMessageSchema = z.object({ - type: z.literal("dictation_stream_finish"), + type: z.literal('dictation_stream_finish'), dictationId: z.string(), finalSeq: z.number().int().nonnegative(), -}); +}) export const DictationStreamCancelMessageSchema = z.object({ - type: z.literal("dictation_stream_cancel"), + type: z.literal('dictation_stream_cancel'), dictationId: z.string(), -}); +}) const GitSetupOptionsSchema = z.object({ baseBranch: z.string().optional(), @@ -558,152 +553,156 @@ const GitSetupOptionsSchema = z.object({ newBranchName: z.string().optional(), createWorktree: z.boolean().optional(), worktreeSlug: z.string().optional(), -}); +}) -export type GitSetupOptions = z.infer; +export type GitSetupOptions = z.infer export const CreateAgentRequestMessageSchema = z.object({ - type: z.literal("create_agent_request"), + type: z.literal('create_agent_request'), config: AgentSessionConfigSchema, worktreeName: z.string().optional(), initialPrompt: z.string().optional(), outputSchema: z.record(z.unknown()).optional(), - images: z.array(z.object({ - data: z.string(), // base64 encoded image - mimeType: z.string(), // e.g., "image/jpeg", "image/png" - })).optional(), + images: z + .array( + z.object({ + data: z.string(), // base64 encoded image + mimeType: z.string(), // e.g., "image/jpeg", "image/png" + }) + ) + .optional(), git: GitSetupOptionsSchema.optional(), labels: z.record(z.string()).default({}), requestId: z.string(), -}); +}) export const ListProviderModelsRequestMessageSchema = z.object({ - type: z.literal("list_provider_models_request"), + type: z.literal('list_provider_models_request'), provider: AgentProviderSchema, cwd: z.string().optional(), requestId: z.string(), -}); +}) export const ListAvailableProvidersRequestMessageSchema = z.object({ - type: z.literal("list_available_providers_request"), + type: z.literal('list_available_providers_request'), requestId: z.string(), -}); +}) export const SpeechModelsListRequestSchema = z.object({ - type: z.literal("speech_models_list_request"), + type: z.literal('speech_models_list_request'), requestId: z.string(), -}); +}) export const SpeechModelsDownloadRequestSchema = z.object({ - type: z.literal("speech_models_download_request"), + type: z.literal('speech_models_download_request'), modelIds: z.array(z.string()).optional(), requestId: z.string(), -}); +}) export const ResumeAgentRequestMessageSchema = z.object({ - type: z.literal("resume_agent_request"), + type: z.literal('resume_agent_request'), handle: AgentPersistenceHandleSchema, overrides: AgentSessionConfigSchema.partial().optional(), requestId: z.string(), -}); +}) export const RefreshAgentRequestMessageSchema = z.object({ - type: z.literal("refresh_agent_request"), + type: z.literal('refresh_agent_request'), agentId: z.string(), requestId: z.string(), -}); +}) export const CancelAgentRequestMessageSchema = z.object({ - type: z.literal("cancel_agent_request"), + type: z.literal('cancel_agent_request'), agentId: z.string(), -}); +}) export const RestartServerRequestMessageSchema = z.object({ - type: z.literal("restart_server_request"), + type: z.literal('restart_server_request'), reason: z.string().optional(), requestId: z.string(), -}); +}) export const AgentTimelineCursorSchema = z.object({ epoch: z.string(), seq: z.number().int().nonnegative(), -}); +}) export const FetchAgentTimelineRequestMessageSchema = z.object({ - type: z.literal("fetch_agent_timeline_request"), + type: z.literal('fetch_agent_timeline_request'), agentId: z.string(), requestId: z.string(), - direction: z.enum(["tail", "before", "after"]).optional(), + direction: z.enum(['tail', 'before', 'after']).optional(), cursor: AgentTimelineCursorSchema.optional(), // 0 means "all matching rows for this query window". limit: z.number().int().nonnegative().optional(), // Default should be projected for app timeline loading. - projection: z.enum(["projected", "canonical"]).optional(), -}); + projection: z.enum(['projected', 'canonical']).optional(), +}) export const SetAgentModeRequestMessageSchema = z.object({ - type: z.literal("set_agent_mode_request"), + type: z.literal('set_agent_mode_request'), agentId: z.string(), modeId: z.string(), requestId: z.string(), -}); +}) export const SetAgentModeResponseMessageSchema = z.object({ - type: z.literal("set_agent_mode_response"), + type: z.literal('set_agent_mode_response'), payload: z.object({ requestId: z.string(), agentId: z.string(), accepted: z.boolean(), error: z.string().nullable(), }), -}); +}) export const SetAgentModelRequestMessageSchema = z.object({ - type: z.literal("set_agent_model_request"), + type: z.literal('set_agent_model_request'), agentId: z.string(), modelId: z.string().nullable(), requestId: z.string(), -}); +}) export const SetAgentModelResponseMessageSchema = z.object({ - type: z.literal("set_agent_model_response"), + type: z.literal('set_agent_model_response'), payload: z.object({ requestId: z.string(), agentId: z.string(), accepted: z.boolean(), error: z.string().nullable(), }), -}); +}) export const SetAgentThinkingRequestMessageSchema = z.object({ - type: z.literal("set_agent_thinking_request"), + type: z.literal('set_agent_thinking_request'), agentId: z.string(), thinkingOptionId: z.string().nullable(), requestId: z.string(), -}); +}) export const SetAgentThinkingResponseMessageSchema = z.object({ - type: z.literal("set_agent_thinking_response"), + type: z.literal('set_agent_thinking_response'), payload: z.object({ requestId: z.string(), agentId: z.string(), accepted: z.boolean(), error: z.string().nullable(), }), -}); +}) export const UpdateAgentResponseMessageSchema = z.object({ - type: z.literal("update_agent_response"), + type: z.literal('update_agent_response'), payload: z.object({ requestId: z.string(), agentId: z.string(), accepted: z.boolean(), error: z.string().nullable(), }), -}); +}) export const SetVoiceModeResponseMessageSchema = z.object({ - type: z.literal("set_voice_mode_response"), + type: z.literal('set_voice_mode_response'), payload: z.object({ requestId: z.string(), enabled: z.boolean(), @@ -714,146 +713,144 @@ export const SetVoiceModeResponseMessageSchema = z.object({ retryable: z.boolean().optional(), missingModelIds: z.array(z.string()).optional(), }), -}); +}) export const AgentPermissionResponseMessageSchema = z.object({ - type: z.literal("agent_permission_response"), + type: z.literal('agent_permission_response'), agentId: z.string(), requestId: z.string(), response: AgentPermissionResponseSchema, -}); +}) -const CheckoutErrorCodeSchema = z.enum([ - "NOT_GIT_REPO", - "NOT_ALLOWED", - "MERGE_CONFLICT", - "UNKNOWN", -]); +const CheckoutErrorCodeSchema = z.enum(['NOT_GIT_REPO', 'NOT_ALLOWED', 'MERGE_CONFLICT', 'UNKNOWN']) const CheckoutErrorSchema = z.object({ code: CheckoutErrorCodeSchema, message: z.string(), -}); +}) const CheckoutDiffCompareSchema = z.object({ - mode: z.enum(["uncommitted", "base"]), + mode: z.enum(['uncommitted', 'base']), baseRef: z.string().optional(), -}); +}) export const CheckoutStatusRequestSchema = z.object({ - type: z.literal("checkout_status_request"), + type: z.literal('checkout_status_request'), cwd: z.string(), requestId: z.string(), -}); +}) export const SubscribeCheckoutDiffRequestSchema = z.object({ - type: z.literal("subscribe_checkout_diff_request"), + type: z.literal('subscribe_checkout_diff_request'), subscriptionId: z.string(), cwd: z.string(), compare: CheckoutDiffCompareSchema, requestId: z.string(), -}); +}) export const UnsubscribeCheckoutDiffRequestSchema = z.object({ - type: z.literal("unsubscribe_checkout_diff_request"), + type: z.literal('unsubscribe_checkout_diff_request'), subscriptionId: z.string(), -}); +}) export const CheckoutCommitRequestSchema = z.object({ - type: z.literal("checkout_commit_request"), + type: z.literal('checkout_commit_request'), cwd: z.string(), message: z.string().optional(), addAll: z.boolean().optional(), requestId: z.string(), -}); +}) export const CheckoutMergeRequestSchema = z.object({ - type: z.literal("checkout_merge_request"), + type: z.literal('checkout_merge_request'), cwd: z.string(), baseRef: z.string().optional(), - strategy: z.enum(["merge", "squash"]).optional(), + strategy: z.enum(['merge', 'squash']).optional(), requireCleanTarget: z.boolean().optional(), requestId: z.string(), -}); +}) export const CheckoutMergeFromBaseRequestSchema = z.object({ - type: z.literal("checkout_merge_from_base_request"), + type: z.literal('checkout_merge_from_base_request'), cwd: z.string(), baseRef: z.string().optional(), requireCleanTarget: z.boolean().optional(), requestId: z.string(), -}); +}) export const CheckoutPushRequestSchema = z.object({ - type: z.literal("checkout_push_request"), + type: z.literal('checkout_push_request'), cwd: z.string(), requestId: z.string(), -}); +}) export const CheckoutPrCreateRequestSchema = z.object({ - type: z.literal("checkout_pr_create_request"), + type: z.literal('checkout_pr_create_request'), cwd: z.string(), title: z.string().optional(), body: z.string().optional(), baseRef: z.string().optional(), requestId: z.string(), -}); +}) export const CheckoutPrStatusRequestSchema = z.object({ - type: z.literal("checkout_pr_status_request"), + type: z.literal('checkout_pr_status_request'), cwd: z.string(), requestId: z.string(), -}); +}) export const ValidateBranchRequestSchema = z.object({ - type: z.literal("validate_branch_request"), + type: z.literal('validate_branch_request'), cwd: z.string(), branchName: z.string(), requestId: z.string(), -}); +}) export const BranchSuggestionsRequestSchema = z.object({ - type: z.literal("branch_suggestions_request"), + type: z.literal('branch_suggestions_request'), cwd: z.string(), query: z.string().optional(), limit: z.number().int().min(1).max(200).optional(), requestId: z.string(), -}); +}) export const DirectorySuggestionsRequestSchema = z.object({ - type: z.literal("directory_suggestions_request"), + type: z.literal('directory_suggestions_request'), query: z.string(), + cwd: z.string().optional(), + includeFiles: z.boolean().optional(), + includeDirectories: z.boolean().optional(), limit: z.number().int().min(1).max(100).optional(), requestId: z.string(), -}); +}) export const PaseoWorktreeListRequestSchema = z.object({ - type: z.literal("paseo_worktree_list_request"), + type: z.literal('paseo_worktree_list_request'), cwd: z.string().optional(), repoRoot: z.string().optional(), requestId: z.string(), -}); +}) export const PaseoWorktreeArchiveRequestSchema = z.object({ - type: z.literal("paseo_worktree_archive_request"), + type: z.literal('paseo_worktree_archive_request'), worktreePath: z.string().optional(), repoRoot: z.string().optional(), branchName: z.string().optional(), requestId: z.string(), -}); +}) // Highlighted diff token schema // Note: style can be a compound class name (e.g., "heading meta") from the syntax highlighter const HighlightTokenSchema = z.object({ text: z.string(), style: z.string().nullable(), -}); +}) const DiffLineSchema = z.object({ - type: z.enum(["add", "remove", "context", "header"]), + type: z.enum(['add', 'remove', 'context', 'header']), content: z.string(), tokens: z.array(HighlightTokenSchema).optional(), -}); +}) const DiffHunkSchema = z.object({ oldStart: z.number(), @@ -861,7 +858,7 @@ const DiffHunkSchema = z.object({ newStart: z.number(), newCount: z.number(), lines: z.array(DiffLineSchema), -}); +}) const ParsedDiffFileSchema = z.object({ path: z.string(), @@ -870,72 +867,72 @@ const ParsedDiffFileSchema = z.object({ additions: z.number(), deletions: z.number(), hunks: z.array(DiffHunkSchema), - status: z.enum(["ok", "too_large", "binary"]).optional(), -}); + status: z.enum(['ok', 'too_large', 'binary']).optional(), +}) const FileExplorerEntrySchema = z.object({ name: z.string(), path: z.string(), - kind: z.enum(["file", "directory"]), + kind: z.enum(['file', 'directory']), size: z.number(), modifiedAt: z.string(), -}); +}) const FileExplorerFileSchema = z.object({ path: z.string(), - kind: z.enum(["text", "image", "binary"]), - encoding: z.enum(["utf-8", "base64", "none"]), + kind: z.enum(['text', 'image', 'binary']), + encoding: z.enum(['utf-8', 'base64', 'none']), content: z.string().optional(), mimeType: z.string().optional(), size: z.number(), modifiedAt: z.string(), -}); +}) const FileExplorerDirectorySchema = z.object({ path: z.string(), entries: z.array(FileExplorerEntrySchema), -}); +}) export const FileExplorerRequestSchema = z.object({ - type: z.literal("file_explorer_request"), + type: z.literal('file_explorer_request'), agentId: z.string(), path: z.string().optional(), - mode: z.enum(["list", "file"]), + mode: z.enum(['list', 'file']), requestId: z.string(), -}); +}) export const ProjectIconRequestSchema = z.object({ - type: z.literal("project_icon_request"), + type: z.literal('project_icon_request'), cwd: z.string(), requestId: z.string(), -}); +}) export const FileDownloadTokenRequestSchema = z.object({ - type: z.literal("file_download_token_request"), + type: z.literal('file_download_token_request'), agentId: z.string(), path: z.string(), requestId: z.string(), -}); +}) export const ClearAgentAttentionMessageSchema = z.object({ - type: z.literal("clear_agent_attention"), + type: z.literal('clear_agent_attention'), agentId: z.union([z.string(), z.array(z.string())]), -}); +}) export const ClientHeartbeatMessageSchema = z.object({ - type: z.literal("client_heartbeat"), - deviceType: z.enum(["web", "mobile"]), + type: z.literal('client_heartbeat'), + deviceType: z.enum(['web', 'mobile']), focusedAgentId: z.string().nullable(), lastActivityAt: z.string(), appVisible: z.boolean(), appVisibilityChangedAt: z.string().optional(), -}); +}) export const PingMessageSchema = z.object({ - type: z.literal("ping"), + type: z.literal('ping'), requestId: z.string(), clientSentAt: z.number().int().optional(), -}); +}) const ListCommandsDraftConfigSchema = z.object({ provider: AgentProviderSchema, @@ -943,98 +940,98 @@ const ListCommandsDraftConfigSchema = z.object({ modeId: z.string().optional(), model: z.string().optional(), thinkingOptionId: z.string().optional(), -}); +}) export const ListCommandsRequestSchema = z.object({ - type: z.literal("list_commands_request"), + type: z.literal('list_commands_request'), agentId: z.string(), draftConfig: ListCommandsDraftConfigSchema.optional(), requestId: z.string(), -}); +}) export const RegisterPushTokenMessageSchema = z.object({ - type: z.literal("register_push_token"), + type: z.literal('register_push_token'), token: z.string(), -}); +}) // ============================================================================ // Terminal Messages // ============================================================================ export const ListTerminalsRequestSchema = z.object({ - type: z.literal("list_terminals_request"), + type: z.literal('list_terminals_request'), cwd: z.string(), requestId: z.string(), -}); +}) export const SubscribeTerminalsRequestSchema = z.object({ - type: z.literal("subscribe_terminals_request"), + type: z.literal('subscribe_terminals_request'), cwd: z.string(), -}); +}) export const UnsubscribeTerminalsRequestSchema = z.object({ - type: z.literal("unsubscribe_terminals_request"), + type: z.literal('unsubscribe_terminals_request'), cwd: z.string(), -}); +}) export const CreateTerminalRequestSchema = z.object({ - type: z.literal("create_terminal_request"), + type: z.literal('create_terminal_request'), cwd: z.string(), name: z.string().optional(), requestId: z.string(), -}); +}) export const SubscribeTerminalRequestSchema = z.object({ - type: z.literal("subscribe_terminal_request"), + type: z.literal('subscribe_terminal_request'), terminalId: z.string(), requestId: z.string(), -}); +}) export const UnsubscribeTerminalRequestSchema = z.object({ - type: z.literal("unsubscribe_terminal_request"), + type: z.literal('unsubscribe_terminal_request'), terminalId: z.string(), -}); +}) -const TerminalClientMessageSchema = z.discriminatedUnion("type", [ - z.object({ type: z.literal("input"), data: z.string() }), - z.object({ type: z.literal("resize"), rows: z.number(), cols: z.number() }), +const TerminalClientMessageSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('input'), data: z.string() }), + z.object({ type: z.literal('resize'), rows: z.number(), cols: z.number() }), z.object({ - type: z.literal("mouse"), + type: z.literal('mouse'), row: z.number(), col: z.number(), button: z.number(), - action: z.enum(["down", "up", "move"]), + action: z.enum(['down', 'up', 'move']), }), -]); +]) export const TerminalInputSchema = z.object({ - type: z.literal("terminal_input"), + type: z.literal('terminal_input'), terminalId: z.string(), message: TerminalClientMessageSchema, -}); +}) export const KillTerminalRequestSchema = z.object({ - type: z.literal("kill_terminal_request"), + type: z.literal('kill_terminal_request'), terminalId: z.string(), requestId: z.string(), -}); +}) export const AttachTerminalStreamRequestSchema = z.object({ - type: z.literal("attach_terminal_stream_request"), + type: z.literal('attach_terminal_stream_request'), terminalId: z.string(), resumeOffset: z.number().int().nonnegative().optional(), rows: z.number().int().positive().optional(), cols: z.number().int().positive().optional(), requestId: z.string(), -}); +}) export const DetachTerminalStreamRequestSchema = z.object({ - type: z.literal("detach_terminal_stream_request"), + type: z.literal('detach_terminal_stream_request'), streamId: z.number().int().nonnegative(), requestId: z.string(), -}); +}) -export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ +export const SessionInboundMessageSchema = z.discriminatedUnion('type', [ VoiceAudioChunkMessageSchema, AbortRequestMessageSchema, AudioPlayedMessageSchema, @@ -1096,9 +1093,9 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ KillTerminalRequestSchema, AttachTerminalStreamRequestSchema, DetachTerminalStreamRequestSchema, -]); +]) -export type SessionInboundMessage = z.infer; +export type SessionInboundMessage = z.infer // ============================================================================ // Session Outbound Messages (Session emits these) @@ -1107,32 +1104,25 @@ export type SessionInboundMessage = z.infer; export const ActivityLogPayloadSchema = z.object({ id: z.string(), timestamp: z.coerce.date(), - type: z.enum([ - "transcript", - "assistant", - "tool_call", - "tool_result", - "error", - "system", - ]), + type: z.enum(['transcript', 'assistant', 'tool_call', 'tool_result', 'error', 'system']), content: z.string(), metadata: z.record(z.unknown()).optional(), -}); +}) export const ActivityLogMessageSchema = z.object({ - type: z.literal("activity_log"), + type: z.literal('activity_log'), payload: ActivityLogPayloadSchema, -}); +}) export const AssistantChunkMessageSchema = z.object({ - type: z.literal("assistant_chunk"), + type: z.literal('assistant_chunk'), payload: z.object({ chunk: z.string(), }), -}); +}) export const AudioOutputMessageSchema = z.object({ - type: z.literal("audio_output"), + type: z.literal('audio_output'), payload: z.object({ audio: z.string(), // base64 encoded format: z.string(), @@ -1142,10 +1132,10 @@ export const AudioOutputMessageSchema = z.object({ chunkIndex: z.number().int().nonnegative().optional(), isLastChunk: z.boolean().optional(), }), -}); +}) export const TranscriptionResultMessageSchema = z.object({ - type: z.literal("transcription_result"), + type: z.literal('transcription_result'), payload: z.object({ text: z.string(), language: z.string().optional(), @@ -1157,43 +1147,43 @@ export const TranscriptionResultMessageSchema = z.object({ format: z.string().optional(), debugRecordingPath: z.string().optional(), }), -}); +}) export const DictationStreamAckMessageSchema = z.object({ - type: z.literal("dictation_stream_ack"), + type: z.literal('dictation_stream_ack'), payload: z.object({ dictationId: z.string(), ackSeq: z.number().int(), }), -}); +}) export const DictationStreamFinishAcceptedMessageSchema = z.object({ - type: z.literal("dictation_stream_finish_accepted"), + type: z.literal('dictation_stream_finish_accepted'), payload: z.object({ dictationId: z.string(), timeoutMs: z.number().int().positive(), }), -}); +}) export const DictationStreamPartialMessageSchema = z.object({ - type: z.literal("dictation_stream_partial"), + type: z.literal('dictation_stream_partial'), payload: z.object({ dictationId: z.string(), text: z.string(), }), -}); +}) export const DictationStreamFinalMessageSchema = z.object({ - type: z.literal("dictation_stream_final"), + type: z.literal('dictation_stream_final'), payload: z.object({ dictationId: z.string(), text: z.string(), debugRecordingPath: z.string().optional(), }), -}); +}) export const DictationStreamErrorMessageSchema = z.object({ - type: z.literal("dictation_stream_error"), + type: z.literal('dictation_stream_error'), payload: z.object({ dictationId: z.string(), error: z.string(), @@ -1202,51 +1192,49 @@ export const DictationStreamErrorMessageSchema = z.object({ missingModelIds: z.array(z.string()).optional(), debugRecordingPath: z.string().optional(), }), -}); +}) export const ServerCapabilityStateSchema = z.object({ enabled: z.boolean(), reason: z.string(), -}); +}) export const ServerVoiceCapabilitiesSchema = z.object({ dictation: ServerCapabilityStateSchema, voice: ServerCapabilityStateSchema, -}); +}) export const ServerCapabilitiesSchema = z .object({ voice: ServerVoiceCapabilitiesSchema.optional(), }) - .passthrough(); + .passthrough() -const ServerInfoHostnameSchema = z - .unknown() - .transform((value): string | null => { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; - }); +const ServerInfoHostnameSchema = z.unknown().transform((value): string | null => { + if (typeof value !== 'string') { + return null + } + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : null +}) const ServerCapabilitiesFromUnknownSchema = z .unknown() .optional() .transform((value): z.infer | undefined => { if (value === undefined) { - return undefined; + return undefined } - const parsed = ServerCapabilitiesSchema.safeParse(value); + const parsed = ServerCapabilitiesSchema.safeParse(value) if (!parsed.success) { - return undefined; + return undefined } - return parsed.data; - }); + return parsed.data + }) export const ServerInfoStatusPayloadSchema = z .object({ - status: z.literal("server_info"), + status: z.literal('server_info'), serverId: z.string().trim().min(1), hostname: ServerInfoHostnameSchema.optional(), capabilities: ServerCapabilitiesFromUnknownSchema, @@ -1255,99 +1243,99 @@ export const ServerInfoStatusPayloadSchema = z .transform((payload) => ({ ...payload, hostname: payload.hostname ?? null, - })); + })) export const StatusMessageSchema = z.object({ - type: z.literal("status"), + type: z.literal('status'), payload: z .object({ status: z.string(), }) .passthrough(), // Allow additional fields -}); +}) export const PongMessageSchema = z.object({ - type: z.literal("pong"), + type: z.literal('pong'), payload: z.object({ requestId: z.string(), clientSentAt: z.number().int().optional(), serverReceivedAt: z.number().int(), serverSentAt: z.number().int(), }), -}); +}) export const RpcErrorMessageSchema = z.object({ - type: z.literal("rpc_error"), + type: z.literal('rpc_error'), payload: z.object({ requestId: z.string(), requestType: z.string().optional(), error: z.string(), code: z.string().optional(), }), -}); +}) const AgentStatusWithRequestSchema = z.object({ agentId: z.string(), requestId: z.string(), -}); +}) const AgentStatusWithTimelineSchema = AgentStatusWithRequestSchema.extend({ timelineSize: z.number().optional(), -}); +}) export const AgentCreatedStatusPayloadSchema = z .object({ - status: z.literal("agent_created"), + status: z.literal('agent_created'), agent: AgentSnapshotPayloadSchema, }) - .extend(AgentStatusWithRequestSchema.shape); + .extend(AgentStatusWithRequestSchema.shape) export const AgentCreateFailedStatusPayloadSchema = z.object({ - status: z.literal("agent_create_failed"), + status: z.literal('agent_create_failed'), requestId: z.string(), error: z.string(), -}); +}) export const AgentResumedStatusPayloadSchema = z .object({ - status: z.literal("agent_resumed"), + status: z.literal('agent_resumed'), agent: AgentSnapshotPayloadSchema, }) - .extend(AgentStatusWithTimelineSchema.shape); + .extend(AgentStatusWithTimelineSchema.shape) export const AgentRefreshedStatusPayloadSchema = z .object({ - status: z.literal("agent_refreshed"), + status: z.literal('agent_refreshed'), }) - .extend(AgentStatusWithTimelineSchema.shape); + .extend(AgentStatusWithTimelineSchema.shape) export const RestartRequestedStatusPayloadSchema = z.object({ - status: z.literal("restart_requested"), + status: z.literal('restart_requested'), clientId: z.string(), reason: z.string().optional(), requestId: z.string(), -}); +}) -export const KnownStatusPayloadSchema = z.discriminatedUnion("status", [ +export const KnownStatusPayloadSchema = z.discriminatedUnion('status', [ AgentCreatedStatusPayloadSchema, AgentCreateFailedStatusPayloadSchema, AgentResumedStatusPayloadSchema, AgentRefreshedStatusPayloadSchema, RestartRequestedStatusPayloadSchema, -]); +]) -export type KnownStatusPayload = z.infer; +export type KnownStatusPayload = z.infer export const ArtifactMessageSchema = z.object({ - type: z.literal("artifact"), + type: z.literal('artifact'), payload: z.object({ - type: z.enum(["markdown", "diff", "image", "code"]), + type: z.enum(['markdown', 'diff', 'image', 'code']), id: z.string(), title: z.string(), content: z.string(), isBase64: z.boolean(), }), -}); +}) export const ProjectCheckoutLiteNotGitPayloadSchema = z.object({ cwd: z.string(), @@ -1356,7 +1344,7 @@ export const ProjectCheckoutLiteNotGitPayloadSchema = z.object({ remoteUrl: z.null(), isPaseoOwnedWorktree: z.literal(false), mainRepoRoot: z.null(), -}); +}) export const ProjectCheckoutLiteGitNonPaseoPayloadSchema = z.object({ cwd: z.string(), @@ -1365,7 +1353,7 @@ export const ProjectCheckoutLiteGitNonPaseoPayloadSchema = z.object({ remoteUrl: z.string().nullable(), isPaseoOwnedWorktree: z.literal(false), mainRepoRoot: z.null(), -}); +}) export const ProjectCheckoutLiteGitPaseoPayloadSchema = z.object({ cwd: z.string(), @@ -1374,37 +1362,37 @@ export const ProjectCheckoutLiteGitPaseoPayloadSchema = z.object({ remoteUrl: z.string().nullable(), isPaseoOwnedWorktree: z.literal(true), mainRepoRoot: z.string(), -}); +}) export const ProjectCheckoutLitePayloadSchema = z.union([ ProjectCheckoutLiteNotGitPayloadSchema, ProjectCheckoutLiteGitNonPaseoPayloadSchema, ProjectCheckoutLiteGitPaseoPayloadSchema, -]); +]) export const ProjectPlacementPayloadSchema = z.object({ projectKey: z.string(), projectName: z.string(), checkout: ProjectCheckoutLitePayloadSchema, -}); +}) export const AgentUpdateMessageSchema = z.object({ - type: z.literal("agent_update"), - payload: z.discriminatedUnion("kind", [ + type: z.literal('agent_update'), + payload: z.discriminatedUnion('kind', [ z.object({ - kind: z.literal("upsert"), + kind: z.literal('upsert'), agent: AgentSnapshotPayloadSchema, project: ProjectPlacementPayloadSchema, }), z.object({ - kind: z.literal("remove"), + kind: z.literal('remove'), agentId: z.string(), }), ]), -}); +}) export const AgentStreamMessageSchema = z.object({ - type: z.literal("agent_stream"), + type: z.literal('agent_stream'), payload: z.object({ agentId: z.string(), event: AgentStreamEventPayloadSchema, @@ -1413,26 +1401,26 @@ export const AgentStreamMessageSchema = z.object({ seq: z.number().int().nonnegative().optional(), epoch: z.string().optional(), }), -}); +}) export const AgentStatusMessageSchema = z.object({ - type: z.literal("agent_status"), + type: z.literal('agent_status'), payload: z.object({ agentId: z.string(), status: z.string(), info: AgentSnapshotPayloadSchema, }), -}); +}) export const AgentListMessageSchema = z.object({ - type: z.literal("agent_list"), + type: z.literal('agent_list'), payload: z.object({ agents: z.array(AgentSnapshotPayloadSchema), }), -}); +}) export const FetchAgentsResponseMessageSchema = z.object({ - type: z.literal("fetch_agents_response"), + type: z.literal('fetch_agents_response'), payload: z.object({ requestId: z.string(), subscriptionId: z.string().nullable().optional(), @@ -1448,21 +1436,21 @@ export const FetchAgentsResponseMessageSchema = z.object({ hasMore: z.boolean(), }), }), -}); +}) export const FetchAgentResponseMessageSchema = z.object({ - type: z.literal("fetch_agent_response"), + type: z.literal('fetch_agent_response'), payload: z.object({ requestId: z.string(), agent: AgentSnapshotPayloadSchema.nullable(), error: z.string().nullable(), }), -}); +}) const AgentTimelineSeqRangeSchema = z.object({ startSeq: z.number().int().nonnegative(), endSeq: z.number().int().nonnegative(), -}); +}) export const AgentTimelineEntryPayloadSchema = z.object({ provider: AgentProviderSchema, @@ -1471,16 +1459,16 @@ export const AgentTimelineEntryPayloadSchema = z.object({ seqStart: z.number().int().nonnegative(), seqEnd: z.number().int().nonnegative(), sourceSeqRanges: z.array(AgentTimelineSeqRangeSchema), - collapsed: z.array(z.enum(["assistant_merge", "tool_lifecycle"])), -}); + collapsed: z.array(z.enum(['assistant_merge', 'tool_lifecycle'])), +}) export const FetchAgentTimelineResponseMessageSchema = z.object({ - type: z.literal("fetch_agent_timeline_response"), + type: z.literal('fetch_agent_timeline_response'), payload: z.object({ requestId: z.string(), agentId: z.string(), - direction: z.enum(["tail", "before", "after"]), - projection: z.enum(["projected", "canonical"]), + direction: z.enum(['tail', 'before', 'after']), + projection: z.enum(['projected', 'canonical']), epoch: z.string(), reset: z.boolean(), staleCursor: z.boolean(), @@ -1497,73 +1485,73 @@ export const FetchAgentTimelineResponseMessageSchema = z.object({ entries: z.array(AgentTimelineEntryPayloadSchema), error: z.string().nullable(), }), -}); +}) export const SendAgentMessageResponseMessageSchema = z.object({ - type: z.literal("send_agent_message_response"), + type: z.literal('send_agent_message_response'), payload: z.object({ requestId: z.string(), agentId: z.string(), accepted: z.boolean(), error: z.string().nullable(), }), -}); +}) export const WaitForFinishResponseMessageSchema = z.object({ - type: z.literal("wait_for_finish_response"), + type: z.literal('wait_for_finish_response'), payload: z.object({ requestId: z.string(), - status: z.enum(["idle", "error", "permission", "timeout"]), + status: z.enum(['idle', 'error', 'permission', 'timeout']), final: AgentSnapshotPayloadSchema.nullable(), error: z.string().nullable(), lastMessage: z.string().nullable(), }), -}); +}) export const AgentPermissionRequestMessageSchema = z.object({ - type: z.literal("agent_permission_request"), + type: z.literal('agent_permission_request'), payload: z.object({ agentId: z.string(), request: AgentPermissionRequestPayloadSchema, }), -}); +}) export const AgentPermissionResolvedMessageSchema = z.object({ - type: z.literal("agent_permission_resolved"), + type: z.literal('agent_permission_resolved'), payload: z.object({ agentId: z.string(), requestId: z.string(), resolution: AgentPermissionResponseSchema, }), -}); +}) export const AgentDeletedMessageSchema = z.object({ - type: z.literal("agent_deleted"), + type: z.literal('agent_deleted'), payload: z.object({ agentId: z.string(), requestId: z.string(), }), -}); +}) export const AgentArchivedMessageSchema = z.object({ - type: z.literal("agent_archived"), + type: z.literal('agent_archived'), payload: z.object({ agentId: z.string(), archivedAt: z.string(), requestId: z.string(), }), -}); +}) const AheadBehindSchema = z.object({ ahead: z.number(), behind: z.number(), -}); +}) const CheckoutStatusCommonSchema = z.object({ cwd: z.string(), error: CheckoutErrorSchema.nullable(), requestId: z.string(), -}); +}) const CheckoutStatusNotGitSchema = CheckoutStatusCommonSchema.extend({ isGit: z.literal(false), @@ -1576,7 +1564,7 @@ const CheckoutStatusNotGitSchema = CheckoutStatusCommonSchema.extend({ aheadOfOrigin: z.null(), hasRemote: z.boolean(), remoteUrl: z.null(), -}); +}) const CheckoutStatusGitNonPaseoSchema = CheckoutStatusCommonSchema.extend({ isGit: z.literal(true), @@ -1589,7 +1577,7 @@ const CheckoutStatusGitNonPaseoSchema = CheckoutStatusCommonSchema.extend({ aheadOfOrigin: z.number().nullable(), hasRemote: z.boolean(), remoteUrl: z.string().nullable(), -}); +}) const CheckoutStatusGitPaseoSchema = CheckoutStatusCommonSchema.extend({ isGit: z.literal(true), @@ -1603,78 +1591,78 @@ const CheckoutStatusGitPaseoSchema = CheckoutStatusCommonSchema.extend({ aheadOfOrigin: z.number().nullable(), hasRemote: z.boolean(), remoteUrl: z.string().nullable(), -}); +}) export const CheckoutStatusResponseSchema = z.object({ - type: z.literal("checkout_status_response"), + type: z.literal('checkout_status_response'), payload: z.union([ CheckoutStatusNotGitSchema, CheckoutStatusGitNonPaseoSchema, CheckoutStatusGitPaseoSchema, ]), -}); +}) const CheckoutDiffSubscriptionPayloadSchema = z.object({ subscriptionId: z.string(), cwd: z.string(), files: z.array(ParsedDiffFileSchema), error: CheckoutErrorSchema.nullable(), -}); +}) export const SubscribeCheckoutDiffResponseSchema = z.object({ - type: z.literal("subscribe_checkout_diff_response"), + type: z.literal('subscribe_checkout_diff_response'), payload: CheckoutDiffSubscriptionPayloadSchema.extend({ requestId: z.string(), }), -}); +}) export const CheckoutDiffUpdateSchema = z.object({ - type: z.literal("checkout_diff_update"), + type: z.literal('checkout_diff_update'), payload: CheckoutDiffSubscriptionPayloadSchema, -}); +}) export const CheckoutCommitResponseSchema = z.object({ - type: z.literal("checkout_commit_response"), + type: z.literal('checkout_commit_response'), payload: z.object({ cwd: z.string(), success: z.boolean(), error: CheckoutErrorSchema.nullable(), requestId: z.string(), }), -}); +}) export const CheckoutMergeResponseSchema = z.object({ - type: z.literal("checkout_merge_response"), + type: z.literal('checkout_merge_response'), payload: z.object({ cwd: z.string(), success: z.boolean(), error: CheckoutErrorSchema.nullable(), requestId: z.string(), }), -}); +}) export const CheckoutMergeFromBaseResponseSchema = z.object({ - type: z.literal("checkout_merge_from_base_response"), + type: z.literal('checkout_merge_from_base_response'), payload: z.object({ cwd: z.string(), success: z.boolean(), error: CheckoutErrorSchema.nullable(), requestId: z.string(), }), -}); +}) export const CheckoutPushResponseSchema = z.object({ - type: z.literal("checkout_push_response"), + type: z.literal('checkout_push_response'), payload: z.object({ cwd: z.string(), success: z.boolean(), error: CheckoutErrorSchema.nullable(), requestId: z.string(), }), -}); +}) export const CheckoutPrCreateResponseSchema = z.object({ - type: z.literal("checkout_pr_create_response"), + type: z.literal('checkout_pr_create_response'), payload: z.object({ cwd: z.string(), url: z.string().nullable(), @@ -1682,7 +1670,7 @@ export const CheckoutPrCreateResponseSchema = z.object({ error: CheckoutErrorSchema.nullable(), requestId: z.string(), }), -}); +}) const CheckoutPrStatusSchema = z.object({ url: z.string(), @@ -1690,10 +1678,10 @@ const CheckoutPrStatusSchema = z.object({ state: z.string(), baseRefName: z.string(), headRefName: z.string(), -}); +}) export const CheckoutPrStatusResponseSchema = z.object({ - type: z.literal("checkout_pr_status_response"), + type: z.literal('checkout_pr_status_response'), payload: z.object({ cwd: z.string(), status: CheckoutPrStatusSchema.nullable(), @@ -1701,10 +1689,10 @@ export const CheckoutPrStatusResponseSchema = z.object({ error: CheckoutErrorSchema.nullable(), requestId: z.string(), }), -}); +}) export const ValidateBranchResponseSchema = z.object({ - type: z.literal("validate_branch_response"), + type: z.literal('validate_branch_response'), payload: z.object({ exists: z.boolean(), resolvedRef: z.string().nullable(), @@ -1712,81 +1700,90 @@ export const ValidateBranchResponseSchema = z.object({ error: z.string().nullable(), requestId: z.string(), }), -}); +}) export const BranchSuggestionsResponseSchema = z.object({ - type: z.literal("branch_suggestions_response"), + type: z.literal('branch_suggestions_response'), payload: z.object({ branches: z.array(z.string()), error: z.string().nullable(), requestId: z.string(), }), -}); +}) export const DirectorySuggestionsResponseSchema = z.object({ - type: z.literal("directory_suggestions_response"), + type: z.literal('directory_suggestions_response'), payload: z.object({ directories: z.array(z.string()), + entries: z + .array( + z.object({ + path: z.string(), + kind: z.enum(['file', 'directory']), + }) + ) + .optional() + .default([]), error: z.string().nullable(), requestId: z.string(), }), -}); +}) const PaseoWorktreeSchema = z.object({ worktreePath: z.string(), branchName: z.string().nullable().optional(), head: z.string().nullable().optional(), -}); +}) export const PaseoWorktreeListResponseSchema = z.object({ - type: z.literal("paseo_worktree_list_response"), + type: z.literal('paseo_worktree_list_response'), payload: z.object({ worktrees: z.array(PaseoWorktreeSchema), error: CheckoutErrorSchema.nullable(), requestId: z.string(), }), -}); +}) export const PaseoWorktreeArchiveResponseSchema = z.object({ - type: z.literal("paseo_worktree_archive_response"), + type: z.literal('paseo_worktree_archive_response'), payload: z.object({ success: z.boolean(), removedAgents: z.array(z.string()).optional(), error: CheckoutErrorSchema.nullable(), requestId: z.string(), }), -}); +}) export const FileExplorerResponseSchema = z.object({ - type: z.literal("file_explorer_response"), + type: z.literal('file_explorer_response'), payload: z.object({ agentId: z.string(), path: z.string(), - mode: z.enum(["list", "file"]), + mode: z.enum(['list', 'file']), directory: FileExplorerDirectorySchema.nullable(), file: FileExplorerFileSchema.nullable(), error: z.string().nullable(), requestId: z.string(), }), -}); +}) const ProjectIconSchema = z.object({ data: z.string(), mimeType: z.string(), -}); +}) export const ProjectIconResponseSchema = z.object({ - type: z.literal("project_icon_response"), + type: z.literal('project_icon_response'), payload: z.object({ cwd: z.string(), icon: ProjectIconSchema.nullable(), error: z.string().nullable(), requestId: z.string(), }), -}); +}) export const FileDownloadTokenResponseSchema = z.object({ - type: z.literal("file_download_token_response"), + type: z.literal('file_download_token_response'), payload: z.object({ agentId: z.string(), path: z.string(), @@ -1797,10 +1794,10 @@ export const FileDownloadTokenResponseSchema = z.object({ error: z.string().nullable(), requestId: z.string(), }), -}); +}) export const ListProviderModelsResponseMessageSchema = z.object({ - type: z.literal("list_provider_models_response"), + type: z.literal('list_provider_models_response'), payload: z.object({ provider: AgentProviderSchema, models: z.array(AgentModelDefinitionSchema).optional(), @@ -1808,26 +1805,26 @@ export const ListProviderModelsResponseMessageSchema = z.object({ fetchedAt: z.string(), requestId: z.string(), }), -}); +}) const ProviderAvailabilitySchema = z.object({ provider: AgentProviderSchema, available: z.boolean(), error: z.string().nullable().optional(), -}); +}) export const ListAvailableProvidersResponseSchema = z.object({ - type: z.literal("list_available_providers_response"), + type: z.literal('list_available_providers_response'), payload: z.object({ providers: z.array(ProviderAvailabilitySchema), error: z.string().nullable().optional(), fetchedAt: z.string(), requestId: z.string(), }), -}); +}) export const SpeechModelsListResponseSchema = z.object({ - type: z.literal("speech_models_list_response"), + type: z.literal('speech_models_list_response'), payload: z.object({ modelsDir: z.string(), models: z.array( @@ -1842,33 +1839,33 @@ export const SpeechModelsListResponseSchema = z.object({ ), requestId: z.string(), }), -}); +}) export const SpeechModelsDownloadResponseSchema = z.object({ - type: z.literal("speech_models_download_response"), + type: z.literal('speech_models_download_response'), payload: z.object({ modelsDir: z.string(), downloadedModelIds: z.array(z.string()), error: z.string().nullable(), requestId: z.string(), }), -}); +}) const AgentSlashCommandSchema = z.object({ name: z.string(), description: z.string(), argumentHint: z.string(), -}); +}) export const ListCommandsResponseSchema = z.object({ - type: z.literal("list_commands_response"), + type: z.literal('list_commands_response'), payload: z.object({ agentId: z.string(), commands: z.array(AgentSlashCommandSchema), error: z.string().nullable(), requestId: z.string(), }), -}); +}) // ============================================================================ // Terminal Outbound Messages @@ -1878,7 +1875,7 @@ const TerminalInfoSchema = z.object({ id: z.string(), name: z.string(), cwd: z.string(), -}); +}) const TerminalCellSchema = z.object({ char: z.string(), @@ -1889,7 +1886,7 @@ const TerminalCellSchema = z.object({ bold: z.boolean().optional(), italic: z.boolean().optional(), underline: z.boolean().optional(), -}); +}) const TerminalStateSchema = z.object({ rows: z.number(), @@ -1897,63 +1894,63 @@ const TerminalStateSchema = z.object({ grid: z.array(z.array(TerminalCellSchema)), scrollback: z.array(z.array(TerminalCellSchema)), cursor: z.object({ row: z.number(), col: z.number() }), -}); +}) export const ListTerminalsResponseSchema = z.object({ - type: z.literal("list_terminals_response"), + type: z.literal('list_terminals_response'), payload: z.object({ cwd: z.string(), terminals: z.array(TerminalInfoSchema.omit({ cwd: true })), requestId: z.string(), }), -}); +}) export const TerminalsChangedSchema = z.object({ - type: z.literal("terminals_changed"), + type: z.literal('terminals_changed'), payload: z.object({ cwd: z.string(), terminals: z.array(TerminalInfoSchema.omit({ cwd: true })), }), -}); +}) export const CreateTerminalResponseSchema = z.object({ - type: z.literal("create_terminal_response"), + type: z.literal('create_terminal_response'), payload: z.object({ terminal: TerminalInfoSchema.nullable(), error: z.string().nullable(), requestId: z.string(), }), -}); +}) export const SubscribeTerminalResponseSchema = z.object({ - type: z.literal("subscribe_terminal_response"), + type: z.literal('subscribe_terminal_response'), payload: z.object({ terminalId: z.string(), state: TerminalStateSchema.nullable(), error: z.string().nullable(), requestId: z.string(), }), -}); +}) export const TerminalOutputSchema = z.object({ - type: z.literal("terminal_output"), + type: z.literal('terminal_output'), payload: z.object({ terminalId: z.string(), state: TerminalStateSchema, }), -}); +}) export const KillTerminalResponseSchema = z.object({ - type: z.literal("kill_terminal_response"), + type: z.literal('kill_terminal_response'), payload: z.object({ terminalId: z.string(), success: z.boolean(), requestId: z.string(), }), -}); +}) export const AttachTerminalStreamResponseSchema = z.object({ - type: z.literal("attach_terminal_stream_response"), + type: z.literal('attach_terminal_stream_response'), payload: z.object({ terminalId: z.string(), streamId: z.number().int().nonnegative().nullable(), @@ -1964,26 +1961,26 @@ export const AttachTerminalStreamResponseSchema = z.object({ error: z.string().nullable(), requestId: z.string(), }), -}); +}) export const DetachTerminalStreamResponseSchema = z.object({ - type: z.literal("detach_terminal_stream_response"), + type: z.literal('detach_terminal_stream_response'), payload: z.object({ streamId: z.number().int().nonnegative(), success: z.boolean(), requestId: z.string(), }), -}); +}) export const TerminalStreamExitSchema = z.object({ - type: z.literal("terminal_stream_exit"), + type: z.literal('terminal_stream_exit'), payload: z.object({ streamId: z.number().int().nonnegative(), terminalId: z.string(), }), -}); +}) -export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ +export const SessionOutboundMessageSchema = z.discriminatedUnion('type', [ ActivityLogMessageSchema, AssistantChunkMessageSchema, AudioOutputMessageSchema, @@ -2045,159 +2042,137 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ AttachTerminalStreamResponseSchema, DetachTerminalStreamResponseSchema, TerminalStreamExitSchema, -]); +]) -export type SessionOutboundMessage = z.infer< - typeof SessionOutboundMessageSchema ->; +export type SessionOutboundMessage = z.infer // Type exports for individual message types -export type ActivityLogMessage = z.infer; -export type AssistantChunkMessage = z.infer; -export type AudioOutputMessage = z.infer; -export type TranscriptionResultMessage = z.infer; -export type StatusMessage = z.infer; -export type ServerCapabilityState = z.infer; -export type ServerVoiceCapabilities = z.infer; -export type ServerCapabilities = z.infer; -export type ServerInfoStatusPayload = z.infer; -export type RpcErrorMessage = z.infer; -export type ArtifactMessage = z.infer; -export type AgentUpdateMessage = z.infer; -export type AgentStreamMessage = z.infer; -export type AgentStatusMessage = z.infer; -export type ProjectCheckoutLitePayload = z.infer; -export type ProjectPlacementPayload = z.infer; -export type FetchAgentsResponseMessage = z.infer< - typeof FetchAgentsResponseMessageSchema ->; -export type FetchAgentResponseMessage = z.infer< - typeof FetchAgentResponseMessageSchema ->; +export type ActivityLogMessage = z.infer +export type AssistantChunkMessage = z.infer +export type AudioOutputMessage = z.infer +export type TranscriptionResultMessage = z.infer +export type StatusMessage = z.infer +export type ServerCapabilityState = z.infer +export type ServerVoiceCapabilities = z.infer +export type ServerCapabilities = z.infer +export type ServerInfoStatusPayload = z.infer +export type RpcErrorMessage = z.infer +export type ArtifactMessage = z.infer +export type AgentUpdateMessage = z.infer +export type AgentStreamMessage = z.infer +export type AgentStatusMessage = z.infer +export type ProjectCheckoutLitePayload = z.infer +export type ProjectPlacementPayload = z.infer +export type FetchAgentsResponseMessage = z.infer +export type FetchAgentResponseMessage = z.infer export type FetchAgentTimelineResponseMessage = z.infer< typeof FetchAgentTimelineResponseMessageSchema ->; -export type SendAgentMessageResponseMessage = z.infer< - typeof SendAgentMessageResponseMessageSchema ->; -export type SetVoiceModeResponseMessage = z.infer< - typeof SetVoiceModeResponseMessageSchema ->; -export type UpdateAgentResponseMessage = z.infer; -export type WaitForFinishResponseMessage = z.infer< - typeof WaitForFinishResponseMessageSchema ->; -export type AgentPermissionRequestMessage = z.infer; -export type AgentPermissionResolvedMessage = z.infer; -export type AgentDeletedMessage = z.infer; +> +export type SendAgentMessageResponseMessage = z.infer +export type SetVoiceModeResponseMessage = z.infer +export type UpdateAgentResponseMessage = z.infer +export type WaitForFinishResponseMessage = z.infer +export type AgentPermissionRequestMessage = z.infer +export type AgentPermissionResolvedMessage = z.infer +export type AgentDeletedMessage = z.infer export type ListProviderModelsResponseMessage = z.infer< typeof ListProviderModelsResponseMessageSchema ->; -export type ListAvailableProvidersResponse = z.infer< - typeof ListAvailableProvidersResponseSchema ->; -export type SpeechModelsListResponse = z.infer; -export type SpeechModelsDownloadResponse = z.infer; +> +export type ListAvailableProvidersResponse = z.infer +export type SpeechModelsListResponse = z.infer +export type SpeechModelsDownloadResponse = z.infer // Type exports for payload types -export type ActivityLogPayload = z.infer; +export type ActivityLogPayload = z.infer // Type exports for inbound message types -export type VoiceAudioChunkMessage = z.infer; -export type FetchAgentsRequestMessage = z.infer; -export type FetchAgentRequestMessage = z.infer; -export type SendAgentMessageRequest = z.infer; -export type WaitForFinishRequest = z.infer; -export type DictationStreamStartMessage = z.infer; -export type DictationStreamChunkMessage = z.infer; -export type DictationStreamFinishMessage = z.infer; -export type DictationStreamCancelMessage = z.infer; -export type CreateAgentRequestMessage = z.infer; +export type VoiceAudioChunkMessage = z.infer +export type FetchAgentsRequestMessage = z.infer +export type FetchAgentRequestMessage = z.infer +export type SendAgentMessageRequest = z.infer +export type WaitForFinishRequest = z.infer +export type DictationStreamStartMessage = z.infer +export type DictationStreamChunkMessage = z.infer +export type DictationStreamFinishMessage = z.infer +export type DictationStreamCancelMessage = z.infer +export type CreateAgentRequestMessage = z.infer export type ListProviderModelsRequestMessage = z.infer< typeof ListProviderModelsRequestMessageSchema ->; +> export type ListAvailableProvidersRequestMessage = z.infer< typeof ListAvailableProvidersRequestMessageSchema ->; -export type SpeechModelsListRequestMessage = z.infer; -export type SpeechModelsDownloadRequestMessage = z.infer< - typeof SpeechModelsDownloadRequestSchema ->; -export type ResumeAgentRequestMessage = z.infer; -export type DeleteAgentRequestMessage = z.infer; -export type UpdateAgentRequestMessage = z.infer; -export type SetAgentModeRequestMessage = z.infer; -export type SetAgentModelRequestMessage = z.infer; -export type SetAgentThinkingRequestMessage = z.infer; -export type AgentPermissionResponseMessage = z.infer; -export type CheckoutStatusRequest = z.infer; -export type CheckoutStatusResponse = z.infer; -export type SubscribeCheckoutDiffRequest = z.infer< - typeof SubscribeCheckoutDiffRequestSchema ->; -export type UnsubscribeCheckoutDiffRequest = z.infer< - typeof UnsubscribeCheckoutDiffRequestSchema ->; -export type SubscribeCheckoutDiffResponse = z.infer< - typeof SubscribeCheckoutDiffResponseSchema ->; -export type CheckoutDiffUpdate = z.infer; -export type CheckoutCommitRequest = z.infer; -export type CheckoutCommitResponse = z.infer; -export type CheckoutMergeRequest = z.infer; -export type CheckoutMergeResponse = z.infer; -export type CheckoutMergeFromBaseRequest = z.infer; -export type CheckoutMergeFromBaseResponse = z.infer; -export type CheckoutPushRequest = z.infer; -export type CheckoutPushResponse = z.infer; -export type CheckoutPrCreateRequest = z.infer; -export type CheckoutPrCreateResponse = z.infer; -export type CheckoutPrStatusRequest = z.infer; -export type CheckoutPrStatusResponse = z.infer; -export type ValidateBranchRequest = z.infer; -export type ValidateBranchResponse = z.infer; -export type BranchSuggestionsRequest = z.infer; -export type BranchSuggestionsResponse = z.infer; -export type DirectorySuggestionsRequest = z.infer; -export type DirectorySuggestionsResponse = z.infer; -export type PaseoWorktreeListRequest = z.infer; -export type PaseoWorktreeListResponse = z.infer; -export type PaseoWorktreeArchiveRequest = z.infer; -export type PaseoWorktreeArchiveResponse = z.infer; -export type FileExplorerRequest = z.infer; -export type FileExplorerResponse = z.infer; -export type ProjectIconRequest = z.infer; -export type ProjectIconResponse = z.infer; -export type ProjectIcon = z.infer; -export type FileDownloadTokenRequest = z.infer; -export type FileDownloadTokenResponse = z.infer; -export type RestartServerRequestMessage = z.infer; -export type ClearAgentAttentionMessage = z.infer; -export type ClientHeartbeatMessage = z.infer; -export type ListCommandsRequest = z.infer; -export type ListCommandsResponse = z.infer; -export type RegisterPushTokenMessage = z.infer; +> +export type SpeechModelsListRequestMessage = z.infer +export type SpeechModelsDownloadRequestMessage = z.infer +export type ResumeAgentRequestMessage = z.infer +export type DeleteAgentRequestMessage = z.infer +export type UpdateAgentRequestMessage = z.infer +export type SetAgentModeRequestMessage = z.infer +export type SetAgentModelRequestMessage = z.infer +export type SetAgentThinkingRequestMessage = z.infer +export type AgentPermissionResponseMessage = z.infer +export type CheckoutStatusRequest = z.infer +export type CheckoutStatusResponse = z.infer +export type SubscribeCheckoutDiffRequest = z.infer +export type UnsubscribeCheckoutDiffRequest = z.infer +export type SubscribeCheckoutDiffResponse = z.infer +export type CheckoutDiffUpdate = z.infer +export type CheckoutCommitRequest = z.infer +export type CheckoutCommitResponse = z.infer +export type CheckoutMergeRequest = z.infer +export type CheckoutMergeResponse = z.infer +export type CheckoutMergeFromBaseRequest = z.infer +export type CheckoutMergeFromBaseResponse = z.infer +export type CheckoutPushRequest = z.infer +export type CheckoutPushResponse = z.infer +export type CheckoutPrCreateRequest = z.infer +export type CheckoutPrCreateResponse = z.infer +export type CheckoutPrStatusRequest = z.infer +export type CheckoutPrStatusResponse = z.infer +export type ValidateBranchRequest = z.infer +export type ValidateBranchResponse = z.infer +export type BranchSuggestionsRequest = z.infer +export type BranchSuggestionsResponse = z.infer +export type DirectorySuggestionsRequest = z.infer +export type DirectorySuggestionsResponse = z.infer +export type PaseoWorktreeListRequest = z.infer +export type PaseoWorktreeListResponse = z.infer +export type PaseoWorktreeArchiveRequest = z.infer +export type PaseoWorktreeArchiveResponse = z.infer +export type FileExplorerRequest = z.infer +export type FileExplorerResponse = z.infer +export type ProjectIconRequest = z.infer +export type ProjectIconResponse = z.infer +export type ProjectIcon = z.infer +export type FileDownloadTokenRequest = z.infer +export type FileDownloadTokenResponse = z.infer +export type RestartServerRequestMessage = z.infer +export type ClearAgentAttentionMessage = z.infer +export type ClientHeartbeatMessage = z.infer +export type ListCommandsRequest = z.infer +export type ListCommandsResponse = z.infer +export type RegisterPushTokenMessage = z.infer // Terminal message types -export type ListTerminalsRequest = z.infer; -export type ListTerminalsResponse = z.infer; -export type SubscribeTerminalsRequest = z.infer; -export type UnsubscribeTerminalsRequest = z.infer; -export type TerminalsChanged = z.infer; -export type CreateTerminalRequest = z.infer; -export type CreateTerminalResponse = z.infer; -export type SubscribeTerminalRequest = z.infer; -export type SubscribeTerminalResponse = z.infer; -export type UnsubscribeTerminalRequest = z.infer; -export type TerminalInput = z.infer; -export type TerminalOutput = z.infer; -export type KillTerminalRequest = z.infer; -export type KillTerminalResponse = z.infer; -export type AttachTerminalStreamRequest = z.infer; -export type AttachTerminalStreamResponse = z.infer; -export type DetachTerminalStreamRequest = z.infer; -export type DetachTerminalStreamResponse = z.infer; -export type TerminalStreamExit = z.infer; +export type ListTerminalsRequest = z.infer +export type ListTerminalsResponse = z.infer +export type SubscribeTerminalsRequest = z.infer +export type UnsubscribeTerminalsRequest = z.infer +export type TerminalsChanged = z.infer +export type CreateTerminalRequest = z.infer +export type CreateTerminalResponse = z.infer +export type SubscribeTerminalRequest = z.infer +export type SubscribeTerminalResponse = z.infer +export type UnsubscribeTerminalRequest = z.infer +export type TerminalInput = z.infer +export type TerminalOutput = z.infer +export type KillTerminalRequest = z.infer +export type KillTerminalResponse = z.infer +export type AttachTerminalStreamRequest = z.infer +export type AttachTerminalStreamResponse = z.infer +export type DetachTerminalStreamRequest = z.infer +export type DetachTerminalStreamResponse = z.infer +export type TerminalStreamExit = z.infer // ============================================================================ // WebSocket Level Messages (wraps session messages) @@ -2205,43 +2180,43 @@ export type TerminalStreamExit = z.infer; // WebSocket-only messages (not session messages) export const WSPingMessageSchema = z.object({ - type: z.literal("ping"), -}); + type: z.literal('ping'), +}) export const WSPongMessageSchema = z.object({ - type: z.literal("pong"), -}); + type: z.literal('pong'), +}) export const WSRecordingStateMessageSchema = z.object({ - type: z.literal("recording_state"), + type: z.literal('recording_state'), isRecording: z.boolean(), -}); +}) // Wrapped session message export const WSSessionInboundSchema = z.object({ - type: z.literal("session"), + type: z.literal('session'), message: SessionInboundMessageSchema, -}); +}) export const WSSessionOutboundSchema = z.object({ - type: z.literal("session"), + type: z.literal('session'), message: SessionOutboundMessageSchema, -}); +}) // Complete WebSocket message schemas -export const WSInboundMessageSchema = z.discriminatedUnion("type", [ +export const WSInboundMessageSchema = z.discriminatedUnion('type', [ WSPingMessageSchema, WSRecordingStateMessageSchema, WSSessionInboundSchema, -]); +]) -export const WSOutboundMessageSchema = z.discriminatedUnion("type", [ +export const WSOutboundMessageSchema = z.discriminatedUnion('type', [ WSPongMessageSchema, WSSessionOutboundSchema, -]); +]) -export type WSInboundMessage = z.infer; -export type WSOutboundMessage = z.infer; +export type WSInboundMessage = z.infer +export type WSOutboundMessage = z.infer // ============================================================================ // Helper functions for message conversion @@ -2251,34 +2226,28 @@ export type WSOutboundMessage = z.infer; * Extract session message from WebSocket message * Returns null if message should be handled at WS level only */ -export function extractSessionMessage( - wsMsg: WSInboundMessage -): SessionInboundMessage | null { - if (wsMsg.type === "session") { - return wsMsg.message; +export function extractSessionMessage(wsMsg: WSInboundMessage): SessionInboundMessage | null { + if (wsMsg.type === 'session') { + return wsMsg.message } // Ping and recording_state are WS-level only - return null; + return null } /** * Wrap session message in WebSocket envelope */ -export function wrapSessionMessage( - sessionMsg: SessionOutboundMessage -): WSOutboundMessage { +export function wrapSessionMessage(sessionMsg: SessionOutboundMessage): WSOutboundMessage { return { - type: "session", + type: 'session', message: sessionMsg, - }; + } } -export function parseServerInfoStatusPayload( - payload: unknown -): ServerInfoStatusPayload | null { - const parsed = ServerInfoStatusPayloadSchema.safeParse(payload); +export function parseServerInfoStatusPayload(payload: unknown): ServerInfoStatusPayload | null { + const parsed = ServerInfoStatusPayloadSchema.safeParse(payload) if (!parsed.success) { - return null; + return null } - return parsed.data; + return parsed.data } diff --git a/packages/server/src/utils/directory-suggestions.test.ts b/packages/server/src/utils/directory-suggestions.test.ts index 329d69ed7..2177ee17b 100644 --- a/packages/server/src/utils/directory-suggestions.test.ts +++ b/packages/server/src/utils/directory-suggestions.test.ts @@ -1,165 +1,237 @@ -import { - mkdtempSync, - mkdirSync, - realpathSync, - rmSync, - symlinkSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { searchHomeDirectories } from "./directory-suggestions.js"; +import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { searchHomeDirectories, searchWorkspaceEntries } from './directory-suggestions.js' -describe("searchHomeDirectories", () => { - let tempRoot: string; - let homeDir: string; - let outsideDir: string; +describe('searchHomeDirectories', () => { + let tempRoot: string + let homeDir: string + let outsideDir: string beforeEach(() => { - tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "directory-suggestions-"))); - homeDir = path.join(tempRoot, "home"); - outsideDir = path.join(tempRoot, "outside"); + tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), 'directory-suggestions-'))) + homeDir = path.join(tempRoot, 'home') + outsideDir = path.join(tempRoot, 'outside') - mkdirSync(homeDir, { recursive: true }); - mkdirSync(outsideDir, { recursive: true }); + mkdirSync(homeDir, { recursive: true }) + mkdirSync(outsideDir, { recursive: true }) - mkdirSync(path.join(homeDir, "projects", "paseo"), { recursive: true }); - mkdirSync(path.join(homeDir, "projects", "playground"), { recursive: true }); - mkdirSync(path.join(homeDir, "documents", "plans"), { recursive: true }); - mkdirSync(path.join(homeDir, ".hidden", "cache"), { recursive: true }); - writeFileSync(path.join(homeDir, "projects", "README.md"), "not a directory\n"); + mkdirSync(path.join(homeDir, 'projects', 'paseo'), { recursive: true }) + mkdirSync(path.join(homeDir, 'projects', 'playground'), { recursive: true }) + mkdirSync(path.join(homeDir, 'documents', 'plans'), { recursive: true }) + mkdirSync(path.join(homeDir, '.hidden', 'cache'), { recursive: true }) + writeFileSync(path.join(homeDir, 'projects', 'README.md'), 'not a directory\n') - mkdirSync(path.join(outsideDir, "outside-match"), { recursive: true }); - symlinkSync(path.join(outsideDir, "outside-match"), path.join(homeDir, "outside-link")); - }); + mkdirSync(path.join(outsideDir, 'outside-match'), { recursive: true }) + symlinkSync(path.join(outsideDir, 'outside-match'), path.join(homeDir, 'outside-link')) + }) afterEach(() => { - rmSync(tempRoot, { recursive: true, force: true }); - }); + rmSync(tempRoot, { recursive: true, force: true }) + }) - it("returns an empty list for blank queries", async () => { + it('returns an empty list for blank queries', async () => { await expect( searchHomeDirectories({ homeDir, - query: " ", + query: ' ', limit: 10, }) - ).resolves.toEqual([]); - }); + ).resolves.toEqual([]) + }) - it("returns only existing directories", async () => { + it('returns only existing directories', async () => { const results = await searchHomeDirectories({ homeDir, - query: "proj", + query: 'proj', limit: 10, - }); + }) - expect(results).toContain(path.join(homeDir, "projects")); - expect(results).toContain(path.join(homeDir, "projects", "paseo")); - expect(results).not.toContain(path.join(homeDir, "projects", "README.md")); - }); + expect(results).toContain(path.join(homeDir, 'projects')) + expect(results).toContain(path.join(homeDir, 'projects', 'paseo')) + expect(results).not.toContain(path.join(homeDir, 'projects', 'README.md')) + }) - it("supports home-relative path query syntax", async () => { + it('supports home-relative path query syntax', async () => { const results = await searchHomeDirectories({ homeDir, - query: "~/projects/pa", + query: '~/projects/pa', limit: 10, - }); + }) - expect(results).toEqual([ - path.join(homeDir, "projects", "paseo"), - ]); - }); + expect(results).toEqual([path.join(homeDir, 'projects', 'paseo')]) + }) - it("prioritizes exact segment matches before segment-prefix matches", async () => { - const exactSegmentPath = path.join( - homeDir, - "something", - "faro", - "something-else" - ); - const prefixSegmentPath = path.join( - homeDir, - "something", - "somethingelse", - "faro-bla" - ); - mkdirSync(exactSegmentPath, { recursive: true }); - mkdirSync(prefixSegmentPath, { recursive: true }); + it('prioritizes exact segment matches before segment-prefix matches', async () => { + const exactSegmentPath = path.join(homeDir, 'something', 'faro', 'something-else') + const prefixSegmentPath = path.join(homeDir, 'something', 'somethingelse', 'faro-bla') + mkdirSync(exactSegmentPath, { recursive: true }) + mkdirSync(prefixSegmentPath, { recursive: true }) const results = await searchHomeDirectories({ homeDir, - query: "faro", + query: 'faro', limit: 30, - }); + }) - const exactIndex = results.indexOf(exactSegmentPath); - const prefixIndex = results.indexOf(prefixSegmentPath); - expect(exactIndex).toBeGreaterThanOrEqual(0); - expect(prefixIndex).toBeGreaterThanOrEqual(0); - expect(exactIndex).toBeLessThan(prefixIndex); - }); + const exactIndex = results.indexOf(exactSegmentPath) + const prefixIndex = results.indexOf(prefixSegmentPath) + expect(exactIndex).toBeGreaterThanOrEqual(0) + expect(prefixIndex).toBeGreaterThanOrEqual(0) + expect(exactIndex).toBeLessThan(prefixIndex) + }) - it("prioritizes partial matches that appear earlier in the path", async () => { - const earlierPath = path.join(homeDir, "farofoo"); - const laterPath = path.join(homeDir, "x", "y", "farofoo"); - mkdirSync(earlierPath, { recursive: true }); - mkdirSync(laterPath, { recursive: true }); + it('prioritizes partial matches that appear earlier in the path', async () => { + const earlierPath = path.join(homeDir, 'farofoo') + const laterPath = path.join(homeDir, 'x', 'y', 'farofoo') + mkdirSync(earlierPath, { recursive: true }) + mkdirSync(laterPath, { recursive: true }) const results = await searchHomeDirectories({ homeDir, - query: "arofo", + query: 'arofo', limit: 30, - }); + }) - const earlierIndex = results.indexOf(earlierPath); - const laterIndex = results.indexOf(laterPath); - expect(earlierIndex).toBeGreaterThanOrEqual(0); - expect(laterIndex).toBeGreaterThanOrEqual(0); - expect(earlierIndex).toBeLessThan(laterIndex); - }); + const earlierIndex = results.indexOf(earlierPath) + const laterIndex = results.indexOf(laterPath) + expect(earlierIndex).toBeGreaterThanOrEqual(0) + expect(laterIndex).toBeGreaterThanOrEqual(0) + expect(earlierIndex).toBeLessThan(laterIndex) + }) it("returns home-root suggestions when query is '~'", async () => { const results = await searchHomeDirectories({ homeDir, - query: "~", + query: '~', limit: 20, - }); + }) - expect(results).toContain(path.join(homeDir, "projects")); - expect(results).toContain(path.join(homeDir, "documents")); - expect(results).not.toContain(path.join(homeDir, ".hidden")); - }); + expect(results).toContain(path.join(homeDir, 'projects')) + expect(results).toContain(path.join(homeDir, 'documents')) + expect(results).not.toContain(path.join(homeDir, '.hidden')) + }) - it("does not return hidden directories during tree search", async () => { + it('does not return hidden directories during tree search', async () => { const results = await searchHomeDirectories({ homeDir, - query: "cache", + query: 'cache', limit: 20, - }); + }) - expect(results).not.toContain(path.join(homeDir, ".hidden", "cache")); - }); + expect(results).not.toContain(path.join(homeDir, '.hidden', 'cache')) + }) - it("does not return paths that escape home through symlinks", async () => { + it('does not return paths that escape home through symlinks', async () => { const results = await searchHomeDirectories({ homeDir, - query: "outside", + query: 'outside', limit: 20, - }); + }) - expect(results).not.toContain(path.join(homeDir, "outside-link")); - expect(results).not.toContain(path.join(outsideDir, "outside-match")); - }); + expect(results).not.toContain(path.join(homeDir, 'outside-link')) + expect(results).not.toContain(path.join(outsideDir, 'outside-match')) + }) - it("respects the result limit", async () => { + it('respects the result limit', async () => { const results = await searchHomeDirectories({ homeDir, - query: "p", + query: 'p', limit: 1, - }); + }) - expect(results).toHaveLength(1); - }); -}); + expect(results).toHaveLength(1) + }) +}) + +describe('searchWorkspaceEntries', () => { + let tempRoot: string + let workspaceDir: string + let outsideDir: string + + beforeEach(() => { + tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), 'workspace-suggestions-'))) + workspaceDir = path.join(tempRoot, 'workspace') + outsideDir = path.join(tempRoot, 'outside') + + mkdirSync(path.join(workspaceDir, 'src', 'components'), { + recursive: true, + }) + mkdirSync(path.join(workspaceDir, 'docs'), { recursive: true }) + mkdirSync(path.join(outsideDir, 'escaped'), { recursive: true }) + + writeFileSync(path.join(workspaceDir, 'README.md'), '# paseo\n') + writeFileSync( + path.join(workspaceDir, 'src', 'components', 'chat-input.tsx'), + 'export const ChatInput = null;\n' + ) + writeFileSync(path.join(workspaceDir, 'docs', 'notes.md'), 'notes\n') + + symlinkSync(path.join(outsideDir, 'escaped'), path.join(workspaceDir, 'escaped-link')) + }) + + afterEach(() => { + rmSync(tempRoot, { recursive: true, force: true }) + }) + + it('returns relative file and directory suggestions for workspace queries', async () => { + const results = await searchWorkspaceEntries({ + cwd: workspaceDir, + query: 'chat', + limit: 20, + includeFiles: true, + includeDirectories: true, + }) + + expect(results).toContainEqual({ + path: 'src/components/chat-input.tsx', + kind: 'file', + }) + expect(results.some((entry) => entry.path === path.join(workspaceDir, 'src'))).toBe(false) + }) + + it('filters entries by kind', async () => { + const dirsOnly = await searchWorkspaceEntries({ + cwd: workspaceDir, + query: 'src', + limit: 20, + includeFiles: false, + includeDirectories: true, + }) + expect(dirsOnly.some((entry) => entry.kind === 'file')).toBe(false) + expect(dirsOnly.some((entry) => entry.path === 'src')).toBe(true) + + const filesOnly = await searchWorkspaceEntries({ + cwd: workspaceDir, + query: 'readme', + limit: 20, + includeFiles: true, + includeDirectories: false, + }) + expect(filesOnly).toEqual([{ path: 'README.md', kind: 'file' }]) + }) + + it('supports path-style queries and does not escape cwd through symlinks', async () => { + const pathResults = await searchWorkspaceEntries({ + cwd: workspaceDir, + query: 'src/co', + limit: 20, + includeFiles: true, + includeDirectories: true, + }) + expect(pathResults).toContainEqual({ + path: 'src/components', + kind: 'directory', + }) + + const escapedResults = await searchWorkspaceEntries({ + cwd: workspaceDir, + query: 'escaped', + limit: 20, + includeFiles: true, + includeDirectories: true, + }) + expect(escapedResults.some((entry) => entry.path.includes('escaped-link'))).toBe(false) + }) +}) diff --git a/packages/server/src/utils/directory-suggestions.ts b/packages/server/src/utils/directory-suggestions.ts index c2630ebfe..2298e28b6 100644 --- a/packages/server/src/utils/directory-suggestions.ts +++ b/packages/server/src/utils/directory-suggestions.ts @@ -1,67 +1,96 @@ -import type { Dirent } from "node:fs"; -import { readdir, realpath, stat } from "node:fs/promises"; -import path from "node:path"; +import type { Dirent } from 'node:fs' +import { readdir, realpath, stat } from 'node:fs/promises' +import path from 'node:path' export interface SearchHomeDirectoriesOptions { - homeDir: string; - query: string; - limit?: number; - maxDepth?: number; - maxDirectoriesScanned?: number; + homeDir: string + query: string + limit?: number + maxDepth?: number + maxDirectoriesScanned?: number } -const DEFAULT_LIMIT = 30; -const MAX_LIMIT = 100; -const DEFAULT_MAX_DEPTH = 6; -const DEFAULT_MAX_DIRECTORIES_SCANNED = 5000; -const DIRECTORY_LIST_CACHE_TTL_MS = 8_000; -const DIRECTORY_LIST_CACHE_MAX_ENTRIES = 4_000; +export type WorkspaceSuggestionKind = 'file' | 'directory' + +export interface WorkspaceSuggestionEntry { + path: string + kind: WorkspaceSuggestionKind +} + +export interface SearchWorkspaceEntriesOptions { + cwd: string + query: string + limit?: number + includeFiles?: boolean + includeDirectories?: boolean + maxDepth?: number + maxEntriesScanned?: number +} + +const DEFAULT_LIMIT = 30 +const MAX_LIMIT = 100 +const DEFAULT_MAX_DEPTH = 6 +const DEFAULT_MAX_DIRECTORIES_SCANNED = 5000 +const DIRECTORY_LIST_CACHE_TTL_MS = 8_000 +const DIRECTORY_LIST_CACHE_MAX_ENTRIES = 4_000 type QueryParts = { - isPathQuery: boolean; - parentPart: string; - searchTerm: string; -}; + isPathQuery: boolean + parentPart: string + searchTerm: string +} type RankedDirectory = { - absolutePath: string; - matchTier: number; - segmentIndex: number; - matchOffset: number; - depth: number; -}; + absolutePath: string + matchTier: number + segmentIndex: number + matchOffset: number + depth: number +} type ChildDirectoryEntry = { - name: string; - absolutePath: string; -}; + name: string + absolutePath: string +} + +type ChildWorkspaceEntry = { + name: string + absolutePath: string + kind: WorkspaceSuggestionKind +} type DirectoryListCacheEntry = { - expiresAt: number; - entries: ChildDirectoryEntry[]; -}; + expiresAt: number + entries: ChildDirectoryEntry[] +} -const directoryListCache = new Map(); -const NO_SEGMENT_INDEX = Number.MAX_SAFE_INTEGER; -const NO_MATCH_OFFSET = Number.MAX_SAFE_INTEGER; +type WorkspaceEntryListCacheEntry = { + expiresAt: number + entries: ChildWorkspaceEntry[] +} + +const directoryListCache = new Map() +const workspaceEntryListCache = new Map() +const NO_SEGMENT_INDEX = Number.MAX_SAFE_INTEGER +const NO_MATCH_OFFSET = Number.MAX_SAFE_INTEGER export async function searchHomeDirectories( options: SearchHomeDirectoriesOptions ): Promise { - const query = options.query.trim(); + const query = options.query.trim() if (!query) { - return []; + return [] } - const limit = normalizeLimit(options.limit); - const homeRoot = await resolveDirectory(options.homeDir); + const limit = normalizeLimit(options.limit) + const homeRoot = await resolveDirectory(options.homeDir) if (!homeRoot) { - return []; + return [] } - const queryParts = normalizeQueryParts(query, homeRoot); + const queryParts = normalizeQueryParts(query, homeRoot) if (!queryParts) { - return []; + return [] } if (queryParts.isPathQuery) { @@ -70,7 +99,7 @@ export async function searchHomeDirectories( parentPart: queryParts.parentPart, searchTerm: queryParts.searchTerm, limit, - }); + }) } return searchAcrossHomeTree({ @@ -78,42 +107,92 @@ export async function searchHomeDirectories( searchTerm: queryParts.searchTerm, limit, maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH, - maxDirectoriesScanned: - options.maxDirectoriesScanned ?? DEFAULT_MAX_DIRECTORIES_SCANNED, - }); + maxDirectoriesScanned: options.maxDirectoriesScanned ?? DEFAULT_MAX_DIRECTORIES_SCANNED, + }) +} + +type RankedWorkspaceEntry = { + relativePath: string + kind: WorkspaceSuggestionKind + matchTier: number + segmentIndex: number + matchOffset: number + depth: number +} + +export async function searchWorkspaceEntries( + options: SearchWorkspaceEntriesOptions +): Promise { + const limit = normalizeLimit(options.limit) + const includeDirectories = options.includeDirectories ?? true + const includeFiles = options.includeFiles ?? false + if (!includeDirectories && !includeFiles) { + return [] + } + + const workspaceRoot = await resolveDirectory(options.cwd) + if (!workspaceRoot) { + return [] + } + + const queryParts = normalizeWorkspaceQueryParts(options.query, workspaceRoot) + if (!queryParts) { + return [] + } + + if (queryParts.isPathQuery) { + return searchWorkspaceWithinParentDirectory({ + workspaceRoot, + parentPart: queryParts.parentPart, + searchTerm: queryParts.searchTerm, + limit, + includeDirectories, + includeFiles, + }) + } + + return searchWorkspaceAcrossTree({ + workspaceRoot, + searchTerm: queryParts.searchTerm, + limit, + includeDirectories, + includeFiles, + maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH, + maxEntriesScanned: options.maxEntriesScanned ?? DEFAULT_MAX_DIRECTORIES_SCANNED, + }) } function normalizeLimit(limit: number | undefined): number { - const candidate = limit ?? DEFAULT_LIMIT; + const candidate = limit ?? DEFAULT_LIMIT if (!Number.isFinite(candidate)) { - return DEFAULT_LIMIT; + return DEFAULT_LIMIT } - const bounded = Math.trunc(candidate); - return Math.max(1, Math.min(MAX_LIMIT, bounded)); + const bounded = Math.trunc(candidate) + return Math.max(1, Math.min(MAX_LIMIT, bounded)) } async function searchWithinParentDirectory(input: { - homeRoot: string; - parentPart: string; - searchTerm: string; - limit: number; + homeRoot: string + parentPart: string + searchTerm: string + limit: number }): Promise { - const parentPath = path.resolve(input.homeRoot, input.parentPart || "."); - const parentRoot = await resolveDirectory(parentPath); + const parentPath = path.resolve(input.homeRoot, input.parentPart || '.') + const parentRoot = await resolveDirectory(parentPath) if (!parentRoot || !isPathInsideRoot(input.homeRoot, parentRoot)) { - return []; + return [] } - const searchLower = input.searchTerm.toLowerCase(); - const ranked: RankedDirectory[] = []; + const searchLower = input.searchTerm.toLowerCase() + const ranked: RankedDirectory[] = [] const entries = await listChildDirectories({ directory: parentRoot, homeRoot: input.homeRoot, - }); + }) for (const entry of entries) { if (searchLower && !entry.name.toLowerCase().includes(searchLower)) { - continue; + continue } ranked.push( @@ -122,48 +201,48 @@ async function searchWithinParentDirectory(input: { homeRoot: input.homeRoot, searchLower, }) - ); + ) } - return dedupeAndSort(ranked).slice(0, input.limit); + return dedupeAndSort(ranked).slice(0, input.limit) } async function searchAcrossHomeTree(input: { - homeRoot: string; - searchTerm: string; - limit: number; - maxDepth: number; - maxDirectoriesScanned: number; + homeRoot: string + searchTerm: string + limit: number + maxDepth: number + maxDirectoriesScanned: number }): Promise { const queue: Array<{ directory: string; depth: number }> = [ { directory: input.homeRoot, depth: 0 }, - ]; - const visited = new Set([input.homeRoot]); - const ranked: RankedDirectory[] = []; - let scanned = 0; - const searchLower = input.searchTerm.toLowerCase(); + ] + const visited = new Set([input.homeRoot]) + const ranked: RankedDirectory[] = [] + let scanned = 0 + const searchLower = input.searchTerm.toLowerCase() for ( let queueIndex = 0; queueIndex < queue.length && scanned < input.maxDirectoriesScanned; queueIndex += 1 ) { - const current = queue[queueIndex]; - if (!current) continue; + const current = queue[queueIndex] + if (!current) continue const entries = await listChildDirectories({ directory: current.directory, homeRoot: input.homeRoot, - }); + }) for (const entry of entries) { - const resolvedCandidate = entry.absolutePath; + const resolvedCandidate = entry.absolutePath if (visited.has(resolvedCandidate)) { - continue; + continue } - visited.add(resolvedCandidate); - scanned += 1; + visited.add(resolvedCandidate) + scanned += 1 - const relativePath = normalizeRelativePath(input.homeRoot, resolvedCandidate); + const relativePath = normalizeRelativePath(input.homeRoot, resolvedCandidate) if ( relativePath.toLowerCase().includes(searchLower) || entry.name.toLowerCase().includes(searchLower) @@ -174,63 +253,221 @@ async function searchAcrossHomeTree(input: { homeRoot: input.homeRoot, searchLower, }) - ); + ) } - if ( - current.depth < input.maxDepth && - scanned < input.maxDirectoriesScanned - ) { - queue.push({ directory: resolvedCandidate, depth: current.depth + 1 }); + if (current.depth < input.maxDepth && scanned < input.maxDirectoriesScanned) { + queue.push({ directory: resolvedCandidate, depth: current.depth + 1 }) } } } - return dedupeAndSort(ranked).slice(0, input.limit); + return dedupeAndSort(ranked).slice(0, input.limit) +} + +async function searchWorkspaceWithinParentDirectory(input: { + workspaceRoot: string + parentPart: string + searchTerm: string + limit: number + includeDirectories: boolean + includeFiles: boolean +}): Promise { + const parentPath = path.resolve(input.workspaceRoot, input.parentPart || '.') + const parentRoot = await resolveDirectory(parentPath) + if (!parentRoot || !isPathInsideRoot(input.workspaceRoot, parentRoot)) { + return [] + } + + const searchLower = input.searchTerm.toLowerCase() + const ranked: RankedWorkspaceEntry[] = [] + const entries = await listWorkspaceChildEntries({ + directory: parentRoot, + workspaceRoot: input.workspaceRoot, + }) + + for (const entry of entries) { + if (entry.kind === 'directory' && !input.includeDirectories) { + continue + } + if (entry.kind === 'file' && !input.includeFiles) { + continue + } + if (searchLower && !entry.name.toLowerCase().includes(searchLower)) { + continue + } + + ranked.push( + rankWorkspaceEntry({ + absolutePath: entry.absolutePath, + kind: entry.kind, + workspaceRoot: input.workspaceRoot, + searchLower, + }) + ) + } + + return dedupeAndSortWorkspaceEntries(ranked).slice(0, input.limit) +} + +async function searchWorkspaceAcrossTree(input: { + workspaceRoot: string + searchTerm: string + limit: number + includeDirectories: boolean + includeFiles: boolean + maxDepth: number + maxEntriesScanned: number +}): Promise { + const queue: Array<{ directory: string; depth: number }> = [ + { directory: input.workspaceRoot, depth: 0 }, + ] + const visited = new Set([input.workspaceRoot]) + const ranked: RankedWorkspaceEntry[] = [] + let scanned = 0 + const searchLower = input.searchTerm.toLowerCase() + + for ( + let queueIndex = 0; + queueIndex < queue.length && scanned < input.maxEntriesScanned; + queueIndex += 1 + ) { + const current = queue[queueIndex] + if (!current) continue + + const entries = await listWorkspaceChildEntries({ + directory: current.directory, + workspaceRoot: input.workspaceRoot, + }) + + for (const entry of entries) { + scanned += 1 + + if (entry.kind === 'directory') { + if ( + !visited.has(entry.absolutePath) && + current.depth < input.maxDepth && + scanned < input.maxEntriesScanned + ) { + visited.add(entry.absolutePath) + queue.push({ + directory: entry.absolutePath, + depth: current.depth + 1, + }) + } + } + + if (entry.kind === 'directory' && !input.includeDirectories) { + continue + } + if (entry.kind === 'file' && !input.includeFiles) { + continue + } + + const relativePath = normalizeRelativePath(input.workspaceRoot, entry.absolutePath) + if ( + searchLower && + !relativePath.toLowerCase().includes(searchLower) && + !entry.name.toLowerCase().includes(searchLower) + ) { + continue + } + + ranked.push( + rankWorkspaceEntry({ + absolutePath: entry.absolutePath, + kind: entry.kind, + workspaceRoot: input.workspaceRoot, + searchLower, + }) + ) + } + } + + return dedupeAndSortWorkspaceEntries(ranked).slice(0, input.limit) +} + +function dedupeAndSortWorkspaceEntries( + rankedEntries: RankedWorkspaceEntry[] +): WorkspaceSuggestionEntry[] { + const byPath = new Map() + for (const entry of rankedEntries) { + const key = `${entry.kind}:${entry.relativePath}` + const existing = byPath.get(key) + if (!existing || compareRankedWorkspaceEntries(entry, existing) < 0) { + byPath.set(key, entry) + } + } + + return Array.from(byPath.values()) + .sort(compareRankedWorkspaceEntries) + .map((entry) => ({ + path: entry.relativePath, + kind: entry.kind, + })) +} + +function compareRankedWorkspaceEntries( + left: RankedWorkspaceEntry, + right: RankedWorkspaceEntry +): number { + if (left.matchTier !== right.matchTier) { + return left.matchTier - right.matchTier + } + if (left.segmentIndex !== right.segmentIndex) { + return left.segmentIndex - right.segmentIndex + } + if (left.matchOffset !== right.matchOffset) { + return left.matchOffset - right.matchOffset + } + if (left.depth !== right.depth) { + return left.depth - right.depth + } + if (left.kind !== right.kind) { + return left.kind === 'directory' ? -1 : 1 + } + return left.relativePath.localeCompare(right.relativePath) } function dedupeAndSort(ranked: RankedDirectory[]): string[] { - const byPath = new Map(); + const byPath = new Map() for (const entry of ranked) { - const existing = byPath.get(entry.absolutePath); + const existing = byPath.get(entry.absolutePath) if (!existing || compareRankedDirectories(entry, existing) < 0) { - byPath.set(entry.absolutePath, entry); + byPath.set(entry.absolutePath, entry) } } return Array.from(byPath.values()) .sort(compareRankedDirectories) - .map((entry) => entry.absolutePath); + .map((entry) => entry.absolutePath) } -function compareRankedDirectories( - left: RankedDirectory, - right: RankedDirectory -): number { +function compareRankedDirectories(left: RankedDirectory, right: RankedDirectory): number { if (left.matchTier !== right.matchTier) { - return left.matchTier - right.matchTier; + return left.matchTier - right.matchTier } if (left.segmentIndex !== right.segmentIndex) { - return left.segmentIndex - right.segmentIndex; + return left.segmentIndex - right.segmentIndex } if (left.matchOffset !== right.matchOffset) { - return left.matchOffset - right.matchOffset; + return left.matchOffset - right.matchOffset } if (left.depth !== right.depth) { - return left.depth - right.depth; + return left.depth - right.depth } - return left.absolutePath.localeCompare(right.absolutePath); + return left.absolutePath.localeCompare(right.absolutePath) } function rankDirectory(input: { - absolutePath: string; - homeRoot: string; - searchLower: string; + absolutePath: string + homeRoot: string + searchLower: string }): RankedDirectory { - const relative = normalizeRelativePath(input.homeRoot, input.absolutePath); - const relativeLower = relative.toLowerCase(); - const depth = relative === "." ? 0 : relative.split("/").length; - const searchLower = input.searchLower; + const relative = normalizeRelativePath(input.homeRoot, input.absolutePath) + const relativeLower = relative.toLowerCase() + const depth = relative === '.' ? 0 : relative.split('/').length + const searchLower = input.searchLower if (!searchLower) { return { absolutePath: input.absolutePath, @@ -238,36 +475,31 @@ function rankDirectory(input: { segmentIndex: NO_SEGMENT_INDEX, matchOffset: 0, depth, - }; + } } - const segments = relativeLower === "." ? [] : relativeLower.split("/"); - const exactSegmentIndex = findSegmentMatchIndex( - segments, - (segment) => segment === searchLower - ); - const prefixSegmentIndex = findSegmentMatchIndex( - segments, - (segment) => segment.startsWith(searchLower) - ); - const partialSegmentIndex = findSegmentMatchIndex( - segments, - (segment) => segment.includes(searchLower) - ); - const matchOffset = relativeLower.indexOf(searchLower); - let matchTier = 4; - let segmentIndex = NO_SEGMENT_INDEX; + const segments = relativeLower === '.' ? [] : relativeLower.split('/') + const exactSegmentIndex = findSegmentMatchIndex(segments, (segment) => segment === searchLower) + const prefixSegmentIndex = findSegmentMatchIndex(segments, (segment) => + segment.startsWith(searchLower) + ) + const partialSegmentIndex = findSegmentMatchIndex(segments, (segment) => + segment.includes(searchLower) + ) + const matchOffset = relativeLower.indexOf(searchLower) + let matchTier = 4 + let segmentIndex = NO_SEGMENT_INDEX if (exactSegmentIndex >= 0) { - matchTier = 0; - segmentIndex = exactSegmentIndex; + matchTier = 0 + segmentIndex = exactSegmentIndex } else if (prefixSegmentIndex >= 0) { - matchTier = 1; - segmentIndex = prefixSegmentIndex; + matchTier = 1 + segmentIndex = prefixSegmentIndex } else if (partialSegmentIndex >= 0) { - matchTier = 2; - segmentIndex = partialSegmentIndex; + matchTier = 2 + segmentIndex = partialSegmentIndex } else if (relativeLower.startsWith(searchLower)) { - matchTier = 3; + matchTier = 3 } return { @@ -276,7 +508,63 @@ function rankDirectory(input: { segmentIndex, matchOffset: matchOffset >= 0 ? matchOffset : NO_MATCH_OFFSET, depth, - }; + } +} + +function rankWorkspaceEntry(input: { + absolutePath: string + kind: WorkspaceSuggestionKind + workspaceRoot: string + searchLower: string +}): RankedWorkspaceEntry { + const relativePath = normalizeRelativePath(input.workspaceRoot, input.absolutePath) + const relativeLower = relativePath.toLowerCase() + const depth = relativePath === '.' ? 0 : relativePath.split('/').length + const searchLower = input.searchLower + if (!searchLower) { + return { + relativePath, + kind: input.kind, + matchTier: 3, + segmentIndex: NO_SEGMENT_INDEX, + matchOffset: 0, + depth, + } + } + + const segments = relativeLower === '.' ? [] : relativeLower.split('/') + const exactSegmentIndex = findSegmentMatchIndex(segments, (segment) => segment === searchLower) + const prefixSegmentIndex = findSegmentMatchIndex(segments, (segment) => + segment.startsWith(searchLower) + ) + const partialSegmentIndex = findSegmentMatchIndex(segments, (segment) => + segment.includes(searchLower) + ) + const matchOffset = relativeLower.indexOf(searchLower) + let matchTier = 4 + let segmentIndex = NO_SEGMENT_INDEX + + if (exactSegmentIndex >= 0) { + matchTier = 0 + segmentIndex = exactSegmentIndex + } else if (prefixSegmentIndex >= 0) { + matchTier = 1 + segmentIndex = prefixSegmentIndex + } else if (partialSegmentIndex >= 0) { + matchTier = 2 + segmentIndex = partialSegmentIndex + } else if (relativeLower.startsWith(searchLower)) { + matchTier = 3 + } + + return { + relativePath, + kind: input.kind, + matchTier, + segmentIndex, + matchOffset: matchOffset >= 0 ? matchOffset : NO_MATCH_OFFSET, + depth, + } } function findSegmentMatchIndex( @@ -284,180 +572,321 @@ function findSegmentMatchIndex( predicate: (segment: string) => boolean ): number { for (let index = 0; index < segments.length; index += 1) { - const segment = segments[index]; + const segment = segments[index] if (!segment) { - continue; + continue } if (predicate(segment)) { - return index; + return index } } - return -1; + return -1 } function normalizeRelativePath(homeRoot: string, absolutePath: string): string { - const relative = path.relative(homeRoot, absolutePath); + const relative = path.relative(homeRoot, absolutePath) if (!relative) { - return "."; + return '.' } - return relative.split(path.sep).join("/"); + return relative.split(path.sep).join('/') } function isPathInsideRoot(root: string, target: string): boolean { - const relative = path.relative(root, target); - return ( - relative === "" || - (!relative.startsWith("..") && !path.isAbsolute(relative)) - ); + const relative = path.relative(root, target) + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)) } function normalizeQueryParts(query: string, homeRoot: string): QueryParts | null { - const typedQuery = query.trim().replace(/\\/g, "/"); - let normalized = typedQuery; + const typedQuery = query.trim().replace(/\\/g, '/') + let normalized = typedQuery if (!normalized) { - return null; + return null } - if (normalized.startsWith("~")) { - normalized = normalized.slice(1); - if (normalized.startsWith("/")) { - normalized = normalized.slice(1); + if (normalized.startsWith('~')) { + normalized = normalized.slice(1) + if (normalized.startsWith('/')) { + normalized = normalized.slice(1) } } if (path.isAbsolute(normalized)) { - const absolute = path.resolve(normalized); + const absolute = path.resolve(normalized) if (!isPathInsideRoot(homeRoot, absolute)) { - return null; + return null } - normalized = normalizeRelativePath(homeRoot, absolute); + normalized = normalizeRelativePath(homeRoot, absolute) } - normalized = normalized.replace(/^\.\/+/, "").replace(/\/{2,}/g, "/"); + normalized = normalized.replace(/^\.\/+/, '').replace(/\/{2,}/g, '/') if (!normalized) { // Treat "~" and "~/" as a request to browse the home root. - if (typedQuery === "~" || typedQuery === "~/") { + if (typedQuery === '~' || typedQuery === '~/') { return { isPathQuery: true, - parentPart: "", - searchTerm: "", - }; + parentPart: '', + searchTerm: '', + } } - return null; + return null } - const isPathQuery = normalized.includes("/"); - const slashIndex = normalized.lastIndexOf("/"); - const parentPart = slashIndex >= 0 ? normalized.slice(0, slashIndex) : ""; - const searchTerm = slashIndex >= 0 ? normalized.slice(slashIndex + 1) : normalized; + const isPathQuery = normalized.includes('/') + const slashIndex = normalized.lastIndexOf('/') + const parentPart = slashIndex >= 0 ? normalized.slice(0, slashIndex) : '' + const searchTerm = slashIndex >= 0 ? normalized.slice(slashIndex + 1) : normalized return { isPathQuery, parentPart, searchTerm, - }; + } +} + +function normalizeWorkspaceQueryParts(query: string, workspaceRoot: string): QueryParts | null { + let normalized = query.trim().replace(/\\/g, '/') + + if (path.isAbsolute(normalized)) { + const absolute = path.resolve(normalized) + if (!isPathInsideRoot(workspaceRoot, absolute)) { + return null + } + normalized = normalizeRelativePath(workspaceRoot, absolute) + } + + normalized = normalized.replace(/^\.\/+/, '').replace(/\/{2,}/g, '/') + if (!normalized) { + return { + isPathQuery: true, + parentPart: '', + searchTerm: '', + } + } + + const isPathQuery = normalized.includes('/') + const slashIndex = normalized.lastIndexOf('/') + const parentPart = slashIndex >= 0 ? normalized.slice(0, slashIndex) : '' + const searchTerm = slashIndex >= 0 ? normalized.slice(slashIndex + 1) : normalized + + return { + isPathQuery, + parentPart, + searchTerm, + } } async function resolveDirectory(inputPath: string): Promise { try { - const resolved = await realpath(path.resolve(inputPath)); - const stats = await stat(resolved); + const resolved = await realpath(path.resolve(inputPath)) + const stats = await stat(resolved) if (!stats.isDirectory()) { - return null; + return null } - return resolved; + return resolved } catch { - return null; + return null } } async function listChildDirectories(input: { - directory: string; - homeRoot: string; + directory: string + homeRoot: string }): Promise { - const now = Date.now(); - const cached = directoryListCache.get(input.directory); + const now = Date.now() + const cached = directoryListCache.get(input.directory) if (cached && cached.expiresAt > now) { - return cached.entries; + return cached.entries } const dirents = await readdir(input.directory, { withFileTypes: true }).catch( () => [] as Dirent[] - ); - const entries: ChildDirectoryEntry[] = []; + ) + const entries: ChildDirectoryEntry[] = [] for (const dirent of dirents) { if (isHiddenDirectoryName(dirent.name)) { - continue; + continue } if (!dirent.isDirectory() && !dirent.isSymbolicLink()) { - continue; + continue } - const candidatePath = path.join(input.directory, dirent.name); + const candidatePath = path.join(input.directory, dirent.name) const absolutePath = await resolveDirectoryCandidate({ candidatePath, dirent, homeRoot: input.homeRoot, - }); + }) if (!absolutePath) { - continue; + continue } entries.push({ name: dirent.name, absolutePath, - }); + }) } setDirectoryListCache(input.directory, { expiresAt: now + DIRECTORY_LIST_CACHE_TTL_MS, entries, - }); + }) - return entries; + return entries +} + +async function listWorkspaceChildEntries(input: { + directory: string + workspaceRoot: string +}): Promise { + const now = Date.now() + const cached = workspaceEntryListCache.get(input.directory) + if (cached && cached.expiresAt > now) { + return cached.entries + } + + const dirents = await readdir(input.directory, { withFileTypes: true }).catch( + () => [] as Dirent[] + ) + const entries: ChildWorkspaceEntry[] = [] + for (const dirent of dirents) { + if (isHiddenDirectoryName(dirent.name)) { + continue + } + + const candidatePath = path.join(input.directory, dirent.name) + const entry = await resolveWorkspaceCandidate({ + candidatePath, + dirent, + workspaceRoot: input.workspaceRoot, + }) + if (!entry) { + continue + } + entries.push({ + name: dirent.name, + absolutePath: entry.absolutePath, + kind: entry.kind, + }) + } + + setWorkspaceEntryListCache(input.directory, { + expiresAt: now + DIRECTORY_LIST_CACHE_TTL_MS, + entries, + }) + + return entries } async function resolveDirectoryCandidate(input: { - candidatePath: string; - dirent: Dirent; - homeRoot: string; + candidatePath: string + dirent: Dirent + homeRoot: string }): Promise { if (input.dirent.isDirectory()) { - const resolved = path.resolve(input.candidatePath); - return isPathInsideRoot(input.homeRoot, resolved) ? resolved : null; + const resolved = path.resolve(input.candidatePath) + return isPathInsideRoot(input.homeRoot, resolved) ? resolved : null } - const resolved = await resolveDirectory(input.candidatePath); + const resolved = await resolveDirectory(input.candidatePath) if (!resolved || !isPathInsideRoot(input.homeRoot, resolved)) { - return null; + return null + } + return resolved +} + +async function resolveWorkspaceCandidate(input: { + candidatePath: string + dirent: Dirent + workspaceRoot: string +}): Promise<{ absolutePath: string; kind: WorkspaceSuggestionKind } | null> { + if (input.dirent.isDirectory()) { + const resolved = path.resolve(input.candidatePath) + if (!isPathInsideRoot(input.workspaceRoot, resolved)) { + return null + } + return { absolutePath: resolved, kind: 'directory' } + } + + if (input.dirent.isFile()) { + const resolved = path.resolve(input.candidatePath) + if (!isPathInsideRoot(input.workspaceRoot, resolved)) { + return null + } + return { absolutePath: resolved, kind: 'file' } + } + + if (!input.dirent.isSymbolicLink()) { + return null + } + + try { + const resolved = await realpath(input.candidatePath) + if (!isPathInsideRoot(input.workspaceRoot, resolved)) { + return null + } + const stats = await stat(resolved) + if (stats.isDirectory()) { + return { absolutePath: resolved, kind: 'directory' } + } + if (stats.isFile()) { + return { absolutePath: resolved, kind: 'file' } + } + return null + } catch { + return null } - return resolved; } function isHiddenDirectoryName(name: string): boolean { - return name.startsWith("."); + return name.startsWith('.') } function setDirectoryListCache(cacheKey: string, entry: DirectoryListCacheEntry): void { - directoryListCache.set(cacheKey, entry); - pruneDirectoryListCache(); + directoryListCache.set(cacheKey, entry) + pruneDirectoryListCache() +} + +function setWorkspaceEntryListCache(cacheKey: string, entry: WorkspaceEntryListCacheEntry): void { + workspaceEntryListCache.set(cacheKey, entry) + pruneWorkspaceEntryListCache() } function pruneDirectoryListCache(): void { if (directoryListCache.size <= DIRECTORY_LIST_CACHE_MAX_ENTRIES) { - return; + return } - const now = Date.now(); + const now = Date.now() for (const [cacheKey, entry] of directoryListCache) { if (entry.expiresAt <= now) { - directoryListCache.delete(cacheKey); + directoryListCache.delete(cacheKey) } } while (directoryListCache.size > DIRECTORY_LIST_CACHE_MAX_ENTRIES) { - const oldestKey = directoryListCache.keys().next().value as string | undefined; + const oldestKey = directoryListCache.keys().next().value as string | undefined if (!oldestKey) { - return; + return } - directoryListCache.delete(oldestKey); + directoryListCache.delete(oldestKey) + } +} + +function pruneWorkspaceEntryListCache(): void { + if (workspaceEntryListCache.size <= DIRECTORY_LIST_CACHE_MAX_ENTRIES) { + return + } + + const now = Date.now() + for (const [cacheKey, entry] of workspaceEntryListCache) { + if (entry.expiresAt <= now) { + workspaceEntryListCache.delete(cacheKey) + } + } + + while (workspaceEntryListCache.size > DIRECTORY_LIST_CACHE_MAX_ENTRIES) { + const oldestKey = workspaceEntryListCache.keys().next().value as string | undefined + if (!oldestKey) { + return + } + workspaceEntryListCache.delete(oldestKey) } }