diff --git a/ccw/frontend/src/App.tsx b/ccw/frontend/src/App.tsx index e20283ab..e928b5dc 100644 --- a/ccw/frontend/src/App.tsx +++ b/ccw/frontend/src/App.tsx @@ -12,7 +12,10 @@ import { router } from './router'; import queryClient from './lib/query-client'; import type { Locale } from './lib/i18n'; import { useWorkflowStore } from '@/stores/workflowStore'; -import { useActiveCliExecutions } from '@/hooks/useActiveCliExecutions'; +import { useCliStreamStore } from '@/stores/cliStreamStore'; +import { useExecutionMonitorStore } from '@/stores/executionMonitorStore'; +import { useTerminalPanelStore } from '@/stores/terminalPanelStore'; +import { useActiveCliExecutions, ACTIVE_CLI_EXECUTIONS_QUERY_KEY } from '@/hooks/useActiveCliExecutions'; import { DialogStyleProvider } from '@/contexts/DialogStyleContext'; import { initializeCsrfToken } from './lib/api'; @@ -55,6 +58,10 @@ function QueryInvalidator() { useEffect(() => { // Register callback to invalidate all workspace-related queries on workspace switch const callback = () => { + useCliStreamStore.getState().resetState(); + useExecutionMonitorStore.getState().resetState(); + useTerminalPanelStore.getState().resetState(); + queryClient.invalidateQueries({ queryKey: ACTIVE_CLI_EXECUTIONS_QUERY_KEY }); queryClient.invalidateQueries({ predicate: (query) => { const queryKey = query.queryKey; diff --git a/ccw/frontend/src/components/shared/CliStreamMonitor/CliStreamMonitorNew.tsx b/ccw/frontend/src/components/shared/CliStreamMonitor/CliStreamMonitorNew.tsx index e0bbdd4e..cdc77403 100644 --- a/ccw/frontend/src/components/shared/CliStreamMonitor/CliStreamMonitorNew.tsx +++ b/ccw/frontend/src/components/shared/CliStreamMonitor/CliStreamMonitorNew.tsx @@ -3,7 +3,7 @@ // ======================================== // Redesigned CLI streaming monitor with smart parsing and message-based layout -import { useState, useCallback, useMemo } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { useIntl } from 'react-intl'; import { Terminal, @@ -11,6 +11,7 @@ import { import { cn } from '@/lib/utils'; import { useCliStreamStore, type CliOutputLine } from '@/stores/cliStreamStore'; import { useActiveCliExecutions } from '@/hooks/useActiveCliExecutions'; +import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore'; import { useCliStreamWebSocket } from '@/hooks/useCliStreamWebSocket'; // New layout components @@ -169,6 +170,7 @@ export function CliStreamMonitorNew({ isOpen, onClose }: CliStreamMonitorNewProp const executions = useCliStreamStore((state) => state.executions); const currentExecutionId = useCliStreamStore((state) => state.currentExecutionId); const removeExecution = useCliStreamStore((state) => state.removeExecution); + const projectPath = useWorkflowStore(selectProjectPath); // Active execution sync const { isLoading: isSyncing, refetch } = useActiveCliExecutions(isOpen); @@ -221,6 +223,12 @@ export function CliStreamMonitorNew({ isOpen, onClose }: CliStreamMonitorNewProp return filtered; }, [messages, filter, searchQuery]); + useEffect(() => { + setSearchQuery(''); + setFilter('all'); + setViewMode('preview'); + }, [projectPath]); + // Copy message content const handleCopy = useCallback(async (content: string) => { try { diff --git a/ccw/frontend/src/components/shared/CliStreamMonitorLegacy.tsx b/ccw/frontend/src/components/shared/CliStreamMonitorLegacy.tsx index 848f603a..8f7482c2 100644 --- a/ccw/frontend/src/components/shared/CliStreamMonitorLegacy.tsx +++ b/ccw/frontend/src/components/shared/CliStreamMonitorLegacy.tsx @@ -25,6 +25,7 @@ import { Badge } from '@/components/ui/Badge'; import { LogBlockList } from '@/components/shared/LogBlock'; import { useCliStreamStore, type CliOutputLine } from '@/stores/cliStreamStore'; import { useActiveCliExecutions } from '@/hooks/useActiveCliExecutions'; +import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore'; import { useCliStreamWebSocket } from '@/hooks/useCliStreamWebSocket'; // New components for Tab + JSON Cards @@ -186,6 +187,7 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) { const setCurrentExecution = useCliStreamStore((state) => state.setCurrentExecution); const removeExecution = useCliStreamStore((state) => state.removeExecution); const markExecutionClosedByUser = useCliStreamStore((state) => state.markExecutionClosedByUser); + const projectPath = useWorkflowStore(selectProjectPath); // Active execution sync const { isLoading: isSyncing, refetch } = useActiveCliExecutions(isOpen); @@ -214,6 +216,13 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) { } }, [executions, currentExecutionId, autoScroll, isUserScrolling]); + useEffect(() => { + setSearchQuery(''); + setAutoScroll(true); + setIsUserScrolling(false); + setViewMode('list'); + }, [projectPath]); + // Handle scroll to detect user scrolling (with debounce for performance) const handleScrollRef = useRef(null); const handleScroll = useCallback(() => { diff --git a/ccw/frontend/src/hooks/useActiveCliExecutions.test.tsx b/ccw/frontend/src/hooks/useActiveCliExecutions.test.tsx new file mode 100644 index 00000000..2a977037 --- /dev/null +++ b/ccw/frontend/src/hooks/useActiveCliExecutions.test.tsx @@ -0,0 +1,288 @@ +// ======================================== +// useActiveCliExecutions Hook Tests +// ======================================== + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import * as React from 'react'; +import * as api from '@/lib/api'; +import { useActiveCliExecutions } from './useActiveCliExecutions'; + +const mockProjectState = vi.hoisted(() => ({ + projectPath: '/test/project', +})); + +const mockStoreState = vi.hoisted(() => ({ + executions: {} as Record, + cleanupUserClosedExecutions: vi.fn(), + isExecutionClosedByUser: vi.fn(() => false), + removeExecution: vi.fn(), + upsertExecution: vi.fn(), + setCurrentExecution: vi.fn(), +})); + +const mockUseCliStreamStore = vi.hoisted(() => { + const store = vi.fn(); + Object.assign(store, { + getState: vi.fn(() => mockStoreState), + }); + return store; +}); + +vi.mock('@/stores/cliStreamStore', () => ({ + useCliStreamStore: mockUseCliStreamStore, +})); + +vi.mock('@/stores/workflowStore', () => ({ + useWorkflowStore: vi.fn((selector?: (state: { projectPath: string }) => unknown) => ( + selector + ? selector({ projectPath: mockProjectState.projectPath }) + : { projectPath: mockProjectState.projectPath } + )), + selectProjectPath: (state: { projectPath: string }) => state.projectPath, +})); + +vi.mock('@/lib/api', async () => { + const actual = await vi.importActual('@/lib/api'); + return { + ...actual, + fetchExecutionDetail: vi.fn(), + }; +}); + +const fetchMock = vi.fn(); + +function createTestQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); +} + +function createWrapper() { + const queryClient = createTestQueryClient(); + + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +} + +function createActiveResponse(executions: Array>) { + return { + ok: true, + statusText: 'OK', + json: vi.fn().mockResolvedValue({ executions }), + }; +} + +describe('useActiveCliExecutions', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('fetch', fetchMock); + + mockProjectState.projectPath = '/test/project'; + mockStoreState.executions = {}; + mockStoreState.cleanupUserClosedExecutions.mockReset(); + mockStoreState.isExecutionClosedByUser.mockReset(); + mockStoreState.isExecutionClosedByUser.mockReturnValue(false); + mockStoreState.removeExecution.mockReset(); + mockStoreState.upsertExecution.mockReset(); + mockStoreState.setCurrentExecution.mockReset(); + (mockUseCliStreamStore as any).getState.mockReset(); + (mockUseCliStreamStore as any).getState.mockImplementation(() => mockStoreState); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('requests active executions with scoped project path', async () => { + fetchMock.mockResolvedValue(createActiveResponse([])); + + const { result } = renderHook(() => useActiveCliExecutions(true, 60_000), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.data).toEqual([]); + }); + + expect(fetchMock).toHaveBeenCalledWith('/api/cli/active?path=%2Ftest%2Fproject'); + }); + + it('filters stale recovered running executions when saved detail is newer', async () => { + const startTime = 1_741_392_000_000; + mockStoreState.executions = { + 'exec-stale': { + tool: 'codex', + mode: 'analysis', + status: 'running', + output: [], + startTime, + recovered: true, + }, + }; + + fetchMock.mockResolvedValue(createActiveResponse([ + { + id: 'exec-stale', + tool: 'codex', + mode: 'analysis', + status: 'running', + output: '[响应] stale output', + startTime, + }, + ])); + + vi.mocked(api.fetchExecutionDetail).mockResolvedValue({ + id: 'exec-stale', + tool: 'codex', + mode: 'analysis', + turns: [], + turn_count: 1, + created_at: new Date(startTime - 2_000).toISOString(), + updated_at: new Date(startTime + 2_000).toISOString(), + } as any); + + const { result } = renderHook(() => useActiveCliExecutions(true, 60_000), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.data).toEqual([]); + }); + + expect(api.fetchExecutionDetail).toHaveBeenCalledWith('exec-stale', '/test/project'); + expect(mockStoreState.removeExecution).toHaveBeenCalledWith('exec-stale'); + expect(mockStoreState.upsertExecution).not.toHaveBeenCalled(); + }); + + it('removes recovered running executions that are absent from the current workspace active list', async () => { + mockStoreState.executions = { + 'exec-old-workspace': { + tool: 'codex', + mode: 'analysis', + status: 'running', + output: [], + startTime: 1_741_394_000_000, + recovered: true, + }, + }; + + fetchMock.mockResolvedValue(createActiveResponse([])); + + const { result } = renderHook(() => useActiveCliExecutions(true, 60_000), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.data).toEqual([]); + }); + + expect(mockStoreState.removeExecution).toHaveBeenCalledWith('exec-old-workspace'); + expect(api.fetchExecutionDetail).not.toHaveBeenCalled(); + }); + + it('reselects the best remaining execution when current selection becomes invalid', async () => { + mockStoreState.executions = { + 'exec-running': { + tool: 'codex', + mode: 'analysis', + status: 'running', + output: [], + startTime: 1_741_395_000_000, + recovered: false, + }, + 'exec-completed': { + tool: 'codex', + mode: 'analysis', + status: 'completed', + output: [], + startTime: 1_741_394_000_000, + recovered: false, + }, + }; + + (mockUseCliStreamStore as any).getState.mockImplementation(() => ({ + ...mockStoreState, + currentExecutionId: 'exec-missing', + })); + fetchMock.mockResolvedValue(createActiveResponse([])); + + const { result } = renderHook(() => useActiveCliExecutions(true, 60_000), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.data).toEqual([]); + }); + + expect(mockStoreState.setCurrentExecution).toHaveBeenCalledWith('exec-running'); + }); + + it('clears current selection when no executions remain after sync', async () => { + mockStoreState.executions = {}; + (mockUseCliStreamStore as any).getState.mockImplementation(() => ({ + ...mockStoreState, + currentExecutionId: 'exec-missing', + })); + fetchMock.mockResolvedValue(createActiveResponse([])); + + const { result } = renderHook(() => useActiveCliExecutions(true, 60_000), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.data).toEqual([]); + }); + + expect(mockStoreState.setCurrentExecution).toHaveBeenCalledWith(null); + }); + + it('keeps running executions when saved detail is older than active start time', async () => { + const startTime = 1_741_393_000_000; + + fetchMock.mockResolvedValue(createActiveResponse([ + { + id: 'exec-live', + tool: 'codex', + mode: 'analysis', + status: 'running', + output: '[响应] live output', + startTime, + }, + ])); + + vi.mocked(api.fetchExecutionDetail).mockResolvedValue({ + id: 'exec-live', + tool: 'codex', + mode: 'analysis', + turns: [], + turn_count: 1, + created_at: new Date(startTime - 20_000).toISOString(), + updated_at: new Date(startTime - 10_000).toISOString(), + } as any); + + const { result } = renderHook(() => useActiveCliExecutions(true, 60_000), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.data?.map((execution) => execution.id)).toEqual(['exec-live']); + }); + + expect(mockStoreState.removeExecution).not.toHaveBeenCalled(); + expect(mockStoreState.upsertExecution).toHaveBeenCalledWith( + 'exec-live', + expect.objectContaining({ + status: 'running', + recovered: true, + }) + ); + expect(mockStoreState.setCurrentExecution).toHaveBeenCalledWith('exec-live'); + }); +}); diff --git a/ccw/frontend/src/hooks/useActiveCliExecutions.ts b/ccw/frontend/src/hooks/useActiveCliExecutions.ts index bcdffe94..15c64533 100644 --- a/ccw/frontend/src/hooks/useActiveCliExecutions.ts +++ b/ccw/frontend/src/hooks/useActiveCliExecutions.ts @@ -4,7 +4,9 @@ // Hook for syncing active CLI executions from server import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { useCliStreamStore } from '@/stores/cliStreamStore'; +import { fetchExecutionDetail, type ConversationRecord } from '@/lib/api'; +import { useCliStreamStore, type CliExecutionState } from '@/stores/cliStreamStore'; +import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore'; /** * Response type from /api/cli/active endpoint @@ -84,6 +86,104 @@ function parseHistoricalOutput(rawOutput: string, startTime: number) { return historicalLines; } +function normalizeTimestampMs(value: unknown): number | undefined { + if (value instanceof Date) { + const time = value.getTime(); + return Number.isFinite(time) ? time : undefined; + } + + if (typeof value === 'number' && Number.isFinite(value)) { + return value > 0 && value < 1_000_000_000_000 ? value * 1000 : value; + } + + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return undefined; + + const numericValue = Number(trimmed); + if (Number.isFinite(numericValue)) { + return numericValue > 0 && numericValue < 1_000_000_000_000 ? numericValue * 1000 : numericValue; + } + + const parsed = Date.parse(trimmed); + return Number.isNaN(parsed) ? undefined : parsed; + } + + return undefined; +} + +function isSavedExecutionNewerThanActive(activeStartTime: unknown, savedTimestamp: unknown): boolean { + const activeStartTimeMs = normalizeTimestampMs(activeStartTime); + if (activeStartTimeMs === undefined) { + return false; + } + + const savedTimestampMs = normalizeTimestampMs(savedTimestamp); + if (savedTimestampMs === undefined) { + return false; + } + + return savedTimestampMs >= activeStartTimeMs; +} + +async function filterSupersededRunningExecutions( + executions: ActiveCliExecution[], + currentExecutions: Record, + projectPath?: string +): Promise<{ filteredExecutions: ActiveCliExecution[]; removedIds: string[] }> { + const candidates = executions.filter((execution) => { + if (execution.status !== 'running') { + return false; + } + + const existing = currentExecutions[execution.id]; + return !existing || existing.recovered; + }); + + if (candidates.length === 0) { + return { filteredExecutions: executions, removedIds: [] }; + } + + const removedIds = new Set(); + + await Promise.all(candidates.map(async (execution) => { + try { + const detail = await fetchExecutionDetail(execution.id, projectPath) as ConversationRecord & { _active?: boolean }; + if (detail._active) { + return; + } + + if (isSavedExecutionNewerThanActive( + execution.startTime, + detail.updated_at || detail.created_at + )) { + removedIds.add(execution.id); + } + } catch { + // Ignore detail lookup failures and keep server active state. + } + })); + + if (removedIds.size === 0) { + return { filteredExecutions: executions, removedIds: [] }; + } + + return { + filteredExecutions: executions.filter((execution) => !removedIds.has(execution.id)), + removedIds: Array.from(removedIds), + }; +} + +function pickPreferredExecutionId(executions: Record): string | null { + const sortedEntries = Object.entries(executions).sort(([, executionA], [, executionB]) => { + if (executionA.status === 'running' && executionB.status !== 'running') return -1; + if (executionA.status !== 'running' && executionB.status === 'running') return 1; + return executionB.startTime - executionA.startTime; + }); + + return sortedEntries[0]?.[0] ?? null; +} + /** * Query key for active CLI executions */ @@ -104,42 +204,52 @@ export function useActiveCliExecutions( enabled: boolean, refetchInterval: number = 5000 ) { + const projectPath = useWorkflowStore(selectProjectPath); + return useQuery({ - queryKey: ACTIVE_CLI_EXECUTIONS_QUERY_KEY, + queryKey: [...ACTIVE_CLI_EXECUTIONS_QUERY_KEY, projectPath || 'default'], queryFn: async () => { - // Access store state at execution time to avoid stale closures const store = useCliStreamStore.getState(); const currentExecutions = store.executions; + const params = new URLSearchParams(); + if (projectPath) { + params.set('path', projectPath); + } - const response = await fetch('/api/cli/active'); + const activeUrl = params.size > 0 + ? `/api/cli/active?${params.toString()}` + : '/api/cli/active'; + const response = await fetch(activeUrl); if (!response.ok) { throw new Error(`Failed to fetch active executions: ${response.statusText}`); } const data: ActiveCliExecutionsResponse = await response.json(); + const { filteredExecutions, removedIds } = await filterSupersededRunningExecutions( + data.executions, + currentExecutions, + projectPath || undefined + ); - // Get server execution IDs - const serverIds = new Set(data.executions.map(e => e.id)); + removedIds.forEach((executionId) => { + store.removeExecution(executionId); + }); + + const serverIds = new Set(filteredExecutions.map(e => e.id)); - // Clean up userClosedExecutions - remove those no longer on server store.cleanupUserClosedExecutions(serverIds); - // Remove executions that are no longer on server and were closed by user for (const [id, exec] of Object.entries(currentExecutions)) { if (store.isExecutionClosedByUser(id)) { - // User closed this execution, remove from local state store.removeExecution(id); - } else if (exec.status !== 'running' && !serverIds.has(id) && exec.recovered) { - // Not running, not on server, and was recovered (not user-created) + } else if (exec.recovered && !serverIds.has(id)) { store.removeExecution(id); } } - // Process executions and sync to store let hasNewExecution = false; const now = Date.now(); - for (const exec of data.executions) { - // Skip if user closed this execution + for (const exec of filteredExecutions) { if (store.isExecutionClosedByUser(exec.id)) { continue; } @@ -151,13 +261,10 @@ export function useActiveCliExecutions( hasNewExecution = true; } - // Merge existing output with historical output const existingOutput = existing?.output || []; const existingContentSet = new Set(existingOutput.map(o => o.content)); const missingLines = historicalOutput.filter(h => !existingContentSet.has(h.content)); - // Prepend missing historical lines before existing output - // Skip system start message when prepending const systemMsgIndex = existingOutput.findIndex(o => o.type === 'system'); const insertIndex = systemMsgIndex >= 0 ? systemMsgIndex + 1 : 0; @@ -166,12 +273,10 @@ export function useActiveCliExecutions( mergedOutput.splice(insertIndex, 0, ...missingLines); } - // Trim if too long if (mergedOutput.length > MAX_OUTPUT_LINES) { mergedOutput.splice(0, mergedOutput.length - MAX_OUTPUT_LINES); } - // Add system message for new executions let finalOutput = mergedOutput; if (!existing) { finalOutput = [ @@ -195,19 +300,27 @@ export function useActiveCliExecutions( }); } - // Set current execution to first running execution if none selected if (hasNewExecution) { - const runningExec = data.executions.find(e => e.status === 'running' && !store.isExecutionClosedByUser(e.id)); + const runningExec = filteredExecutions.find(e => e.status === 'running' && !store.isExecutionClosedByUser(e.id)); if (runningExec && !currentExecutions[runningExec.id]) { store.setCurrentExecution(runningExec.id); } } - return data.executions; + const nextState = useCliStreamStore.getState(); + const currentExecutionId = nextState.currentExecutionId; + if (!currentExecutionId || !nextState.executions[currentExecutionId]) { + const preferredExecutionId = pickPreferredExecutionId(nextState.executions); + if (preferredExecutionId !== currentExecutionId) { + store.setCurrentExecution(preferredExecutionId); + } + } + + return filteredExecutions; }, enabled, refetchInterval, - staleTime: 2000, // Consider data fresh for 2 seconds + staleTime: 2000, }); } diff --git a/ccw/frontend/src/pages/CliViewerPage.test.ts b/ccw/frontend/src/pages/CliViewerPage.test.ts new file mode 100644 index 00000000..35f8861a --- /dev/null +++ b/ccw/frontend/src/pages/CliViewerPage.test.ts @@ -0,0 +1,55 @@ +// ======================================== +// CliViewerPage Helper Tests +// ======================================== + +import { describe, it, expect } from 'vitest'; +import { getStaleViewerTabs } from './cliViewerPage.utils'; + +describe('getStaleViewerTabs', () => { + it('returns tabs whose execution ids are missing from the current execution map', () => { + const panes = { + 'pane-1': { + id: 'pane-1', + activeTabId: 'tab-1', + tabs: [ + { id: 'tab-1', executionId: 'exec-stale', title: 'stale', isPinned: false, order: 1 }, + { id: 'tab-2', executionId: 'exec-live', title: 'live', isPinned: false, order: 2 }, + ], + }, + 'pane-2': { + id: 'pane-2', + activeTabId: 'tab-3', + tabs: [ + { id: 'tab-3', executionId: 'exec-missing', title: 'missing', isPinned: true, order: 1 }, + ], + }, + }; + + const executions = { + 'exec-live': { tool: 'codex', mode: 'analysis' }, + }; + + expect(getStaleViewerTabs(panes as any, executions)).toEqual([ + { paneId: 'pane-1', tabId: 'tab-1', executionId: 'exec-stale' }, + { paneId: 'pane-2', tabId: 'tab-3', executionId: 'exec-missing' }, + ]); + }); + + it('returns an empty list when all tabs map to current executions', () => { + const panes = { + 'pane-1': { + id: 'pane-1', + activeTabId: 'tab-1', + tabs: [ + { id: 'tab-1', executionId: 'exec-live', title: 'live', isPinned: false, order: 1 }, + ], + }, + }; + + const executions = { + 'exec-live': { tool: 'codex', mode: 'analysis' }, + }; + + expect(getStaleViewerTabs(panes as any, executions)).toEqual([]); + }); +}); diff --git a/ccw/frontend/src/pages/CliViewerPage.tsx b/ccw/frontend/src/pages/CliViewerPage.tsx index aa64cc1f..20c5d29f 100644 --- a/ccw/frontend/src/pages/CliViewerPage.tsx +++ b/ccw/frontend/src/pages/CliViewerPage.tsx @@ -18,6 +18,7 @@ import { import { useCliStreamStore } from '@/stores/cliStreamStore'; import { useActiveCliExecutions } from '@/hooks/useActiveCliExecutions'; import { useCliStreamWebSocket } from '@/hooks/useCliStreamWebSocket'; +import { getStaleViewerTabs } from './cliViewerPage.utils'; // ======================================== // Constants @@ -61,13 +62,13 @@ export function CliViewerPage() { const layout = useViewerLayout(); const panes = useViewerPanes(); const focusedPaneId = useFocusedPaneId(); - const { initializeDefaultLayout, addTab } = useViewerStore(); + const { initializeDefaultLayout, addTab, removeTab } = useViewerStore(); // CLI Stream Store hooks const executions = useCliStreamStore((state) => state.executions); // Active execution sync from server - useActiveCliExecutions(true); + const { isLoading: isSyncing, isFetching: isRefreshing } = useActiveCliExecutions(true); // CENTRALIZED WebSocket handler - processes each message only ONCE globally useCliStreamWebSocket(); @@ -106,6 +107,18 @@ export function CliViewerPage() { }); }, [executions, panes]); + useEffect(() => { + if (isSyncing || isRefreshing) return; + + const staleTabs = getStaleViewerTabs(panes, executions); + if (staleTabs.length === 0) return; + + staleTabs.forEach(({ paneId, tabId, executionId }) => { + addedExecutionsRef.current.delete(executionId); + removeTab(paneId, tabId); + }); + }, [executions, isRefreshing, isSyncing, panes, removeTab]); + // Initialize layout if empty useEffect(() => { const paneCount = countPanes(layout); diff --git a/ccw/frontend/src/pages/cliViewerPage.utils.ts b/ccw/frontend/src/pages/cliViewerPage.utils.ts new file mode 100644 index 00000000..e4e81d75 --- /dev/null +++ b/ccw/frontend/src/pages/cliViewerPage.utils.ts @@ -0,0 +1,22 @@ +// ======================================== +// CliViewerPage Utilities +// ======================================== + +import type { PaneId, PaneState, TabId } from '@/stores/viewerStore'; + +export function getStaleViewerTabs( + panes: Record, + executions: Record +): Array<{ paneId: PaneId; tabId: TabId; executionId: string }> { + const executionIds = new Set(Object.keys(executions)); + + return Object.entries(panes).flatMap(([paneId, pane]) => ( + pane.tabs + .filter((tab) => !executionIds.has(tab.executionId)) + .map((tab) => ({ + paneId, + tabId: tab.id, + executionId: tab.executionId, + })) + )); +} diff --git a/ccw/frontend/src/stores/cliStreamStore.test.ts b/ccw/frontend/src/stores/cliStreamStore.test.ts new file mode 100644 index 00000000..61a2ab55 --- /dev/null +++ b/ccw/frontend/src/stores/cliStreamStore.test.ts @@ -0,0 +1,63 @@ +// ======================================== +// CLI Stream Store Tests +// ======================================== + +import { describe, it, expect, beforeEach } from 'vitest'; +import { useCliStreamStore, selectActiveExecutionCount } from './cliStreamStore'; + +describe('cliStreamStore', () => { + beforeEach(() => { + useCliStreamStore.getState().resetState(); + }); + + it('removeExecution clears outputs and execution state together', () => { + const store = useCliStreamStore.getState(); + + store.upsertExecution('exec-1', { + tool: 'codex', + mode: 'analysis', + status: 'running', + output: [], + startTime: 1_741_400_000_000, + }); + store.addOutput('exec-1', { + type: 'stdout', + content: 'hello', + timestamp: 1_741_400_000_100, + }); + + expect(useCliStreamStore.getState().outputs['exec-1']).toHaveLength(1); + expect(useCliStreamStore.getState().executions['exec-1']).toBeDefined(); + + store.removeExecution('exec-1'); + + expect(useCliStreamStore.getState().outputs['exec-1']).toBeUndefined(); + expect(useCliStreamStore.getState().executions['exec-1']).toBeUndefined(); + }); + + it('resetState clears execution badge state for workspace switches', () => { + const store = useCliStreamStore.getState(); + + store.upsertExecution('exec-running', { + tool: 'codex', + mode: 'analysis', + status: 'running', + output: [], + startTime: 1_741_401_000_000, + }); + store.setCurrentExecution('exec-running'); + store.markExecutionClosedByUser('exec-running'); + + expect(selectActiveExecutionCount(useCliStreamStore.getState() as any)).toBe(1); + expect(useCliStreamStore.getState().currentExecutionId).toBe('exec-running'); + + store.resetState(); + + const nextState = useCliStreamStore.getState(); + expect(selectActiveExecutionCount(nextState as any)).toBe(0); + expect(nextState.currentExecutionId).toBeNull(); + expect(Object.keys(nextState.executions)).toEqual([]); + expect(Object.keys(nextState.outputs)).toEqual([]); + expect(nextState.userClosedExecutions.size).toBe(0); + }); +}); diff --git a/ccw/frontend/src/stores/cliStreamStore.ts b/ccw/frontend/src/stores/cliStreamStore.ts index a885caa8..e9c8cf54 100644 --- a/ccw/frontend/src/stores/cliStreamStore.ts +++ b/ccw/frontend/src/stores/cliStreamStore.ts @@ -93,6 +93,7 @@ interface CliStreamState extends BlockCacheState { isExecutionClosedByUser: (executionId: string) => boolean; cleanupUserClosedExecutions: (serverIds: Set) => void; setCurrentExecution: (executionId: string | null) => void; + resetState: () => void; // Block cache methods getBlocks: (executionId: string) => LogBlockData[]; @@ -462,15 +463,18 @@ export const useCliStreamStore = create()( removeExecution: (executionId: string) => { set((state) => { + const newOutputs = { ...state.outputs }; const newExecutions = { ...state.executions }; const newBlocks = { ...state.blocks }; const newLastUpdate = { ...state.lastUpdate }; const newDeduplicationWindows = { ...state.deduplicationWindows }; + delete newOutputs[executionId]; delete newExecutions[executionId]; delete newBlocks[executionId]; delete newLastUpdate[executionId]; delete newDeduplicationWindows[executionId]; return { + outputs: newOutputs, executions: newExecutions, blocks: newBlocks, lastUpdate: newLastUpdate, @@ -513,6 +517,18 @@ export const useCliStreamStore = create()( set({ currentExecutionId: executionId }, false, 'cliStream/setCurrentExecution'); }, + resetState: () => { + set({ + outputs: {}, + executions: {}, + currentExecutionId: null, + userClosedExecutions: new Set(), + deduplicationWindows: {}, + blocks: {}, + lastUpdate: {}, + }, false, 'cliStream/resetState'); + }, + // Block cache methods getBlocks: (executionId: string) => { const state = get(); diff --git a/ccw/frontend/src/stores/executionMonitorStore.test.ts b/ccw/frontend/src/stores/executionMonitorStore.test.ts new file mode 100644 index 00000000..73a8a4bf --- /dev/null +++ b/ccw/frontend/src/stores/executionMonitorStore.test.ts @@ -0,0 +1,46 @@ +// ======================================== +// Execution Monitor Store Tests +// ======================================== + +import { beforeEach, describe, expect, it } from 'vitest'; +import { + useExecutionMonitorStore, + selectActiveExecutionCount, + type ExecutionWSMessage, +} from './executionMonitorStore'; + +describe('executionMonitorStore', () => { + beforeEach(() => { + useExecutionMonitorStore.getState().resetState(); + }); + + it('resetState clears workspace-scoped execution monitor state', () => { + const store = useExecutionMonitorStore.getState(); + const startMessage: ExecutionWSMessage = { + type: 'EXECUTION_STARTED', + payload: { + executionId: 'exec-running', + flowId: 'flow-1', + sessionKey: 'session-1', + stepName: 'Workspace Flow', + totalSteps: 3, + timestamp: '2026-03-08T12:00:00.000Z', + }, + }; + + store.handleExecutionMessage(startMessage); + + const activeState = useExecutionMonitorStore.getState(); + expect(selectActiveExecutionCount(activeState as any)).toBe(1); + expect(activeState.currentExecutionId).toBe('exec-running'); + expect(activeState.isPanelOpen).toBe(true); + + store.resetState(); + + const nextState = useExecutionMonitorStore.getState(); + expect(selectActiveExecutionCount(nextState as any)).toBe(0); + expect(nextState.activeExecutions).toEqual({}); + expect(nextState.currentExecutionId).toBeNull(); + expect(nextState.isPanelOpen).toBe(false); + }); +}); diff --git a/ccw/frontend/src/stores/executionMonitorStore.ts b/ccw/frontend/src/stores/executionMonitorStore.ts index 04cae3b8..b7c72cfa 100644 --- a/ccw/frontend/src/stores/executionMonitorStore.ts +++ b/ccw/frontend/src/stores/executionMonitorStore.ts @@ -81,6 +81,7 @@ interface ExecutionMonitorActions { setPanelOpen: (open: boolean) => void; clearExecution: (executionId: string) => void; clearAllExecutions: () => void; + resetState: () => void; } type ExecutionMonitorStore = ExecutionMonitorState & ExecutionMonitorActions; @@ -318,6 +319,10 @@ export const useExecutionMonitorStore = create()( clearAllExecutions: () => { set({ activeExecutions: {}, currentExecutionId: null }, false, 'clearAllExecutions'); }, + + resetState: () => { + set({ ...initialState }, false, 'resetState'); + }, }), { name: 'ExecutionMonitorStore' } ) diff --git a/ccw/frontend/src/stores/terminalPanelStore.test.ts b/ccw/frontend/src/stores/terminalPanelStore.test.ts new file mode 100644 index 00000000..0dd62a52 --- /dev/null +++ b/ccw/frontend/src/stores/terminalPanelStore.test.ts @@ -0,0 +1,35 @@ +// ======================================== +// Terminal Panel Store Tests +// ======================================== + +import { beforeEach, describe, expect, it } from 'vitest'; +import { useTerminalPanelStore, selectTerminalCount } from './terminalPanelStore'; + +describe('terminalPanelStore', () => { + beforeEach(() => { + useTerminalPanelStore.getState().resetState(); + }); + + it('resetState clears workspace-scoped terminal tabs and selection', () => { + const store = useTerminalPanelStore.getState(); + + store.openTerminal('session-a'); + store.addTerminal('session-b'); + store.setPanelView('queue'); + + const activeState = useTerminalPanelStore.getState(); + expect(selectTerminalCount(activeState as any)).toBe(2); + expect(activeState.activeTerminalId).toBe('session-a'); + expect(activeState.panelView).toBe('queue'); + expect(activeState.isPanelOpen).toBe(true); + + store.resetState(); + + const nextState = useTerminalPanelStore.getState(); + expect(selectTerminalCount(nextState as any)).toBe(0); + expect(nextState.terminalOrder).toEqual([]); + expect(nextState.activeTerminalId).toBeNull(); + expect(nextState.panelView).toBe('terminal'); + expect(nextState.isPanelOpen).toBe(false); + }); +}); diff --git a/ccw/frontend/src/stores/terminalPanelStore.ts b/ccw/frontend/src/stores/terminalPanelStore.ts index c128096b..b4b2fa0f 100644 --- a/ccw/frontend/src/stores/terminalPanelStore.ts +++ b/ccw/frontend/src/stores/terminalPanelStore.ts @@ -38,6 +38,8 @@ export interface TerminalPanelActions { addTerminal: (sessionKey: string) => void; /** Remove a terminal from the order list and adjust active if needed */ removeTerminal: (sessionKey: string) => void; + /** Reset workspace-scoped terminal panel UI state */ + resetState: () => void; } export type TerminalPanelStore = TerminalPanelState & TerminalPanelActions; @@ -153,6 +155,10 @@ export const useTerminalPanelStore = create()( 'removeTerminal' ); }, + + resetState: () => { + set({ ...initialState }, false, 'resetState'); + }, }), { name: 'TerminalPanelStore' } ) diff --git a/ccw/frontend/src/stores/workflowStore.ts b/ccw/frontend/src/stores/workflowStore.ts index c55a8a16..e025e28a 100644 --- a/ccw/frontend/src/stores/workflowStore.ts +++ b/ccw/frontend/src/stores/workflowStore.ts @@ -112,8 +112,7 @@ export const useWorkflowStore = create()( }, sessionDataStore, }, - false, - 'setSessions' + false ); }, @@ -131,8 +130,7 @@ export const useWorkflowStore = create()( [key]: session, }, }), - false, - 'addSession' + false ); }, @@ -140,7 +138,7 @@ export const useWorkflowStore = create()( const key = sessionKey(sessionId); set( - (state) => { + (state: WorkflowState) => { const session = state.sessionDataStore[key]; if (!session) return state; @@ -163,8 +161,7 @@ export const useWorkflowStore = create()( }, }; }, - false, - 'updateSession' + false ); }, @@ -172,7 +169,7 @@ export const useWorkflowStore = create()( const key = sessionKey(sessionId); set( - (state) => { + (state: WorkflowState) => { const { [key]: removed, ...remainingStore } = state.sessionDataStore; return { @@ -187,8 +184,7 @@ export const useWorkflowStore = create()( }, }; }, - false, - 'removeSession' + false ); }, @@ -196,7 +192,7 @@ export const useWorkflowStore = create()( const key = sessionKey(sessionId); set( - (state) => { + (state: WorkflowState) => { const session = state.sessionDataStore[key]; if (!session || session.location === 'archived') return state; @@ -220,8 +216,7 @@ export const useWorkflowStore = create()( }, }; }, - false, - 'archiveSession' + false ); }, @@ -231,7 +226,7 @@ export const useWorkflowStore = create()( const key = sessionKey(sessionId); set( - (state) => { + (state: WorkflowState) => { const session = state.sessionDataStore[key]; if (!session) return state; @@ -252,8 +247,7 @@ export const useWorkflowStore = create()( }, }; }, - false, - 'addTask' + false ); }, @@ -261,7 +255,7 @@ export const useWorkflowStore = create()( const key = sessionKey(sessionId); set( - (state) => { + (state: WorkflowState) => { const session = state.sessionDataStore[key]; if (!session?.tasks) return state; @@ -284,8 +278,7 @@ export const useWorkflowStore = create()( }, }; }, - false, - 'updateTask' + false ); }, @@ -293,7 +286,7 @@ export const useWorkflowStore = create()( const key = sessionKey(sessionId); set( - (state) => { + (state: WorkflowState) => { const session = state.sessionDataStore[key]; if (!session?.tasks) return state; @@ -310,8 +303,7 @@ export const useWorkflowStore = create()( }, }; }, - false, - 'removeTask' + false ); }, @@ -325,8 +317,7 @@ export const useWorkflowStore = create()( [key]: session, }, }), - false, - 'setLiteTaskSession' + false ); }, @@ -336,8 +327,7 @@ export const useWorkflowStore = create()( const { [key]: removed, ...remaining } = state.liteTaskDataStore; return { liteTaskDataStore: remaining }; }, - false, - 'removeLiteTaskSession' + false ); }, @@ -351,8 +341,7 @@ export const useWorkflowStore = create()( [key]: data, }, }), - false, - 'setTaskJson' + false ); }, @@ -362,38 +351,36 @@ export const useWorkflowStore = create()( const { [key]: removed, ...remaining } = state.taskJsonStore; return { taskJsonStore: remaining }; }, - false, - 'removeTaskJson' + false ); }, // ========== Active Session ========== setActiveSessionId: (sessionId: string | null) => { - set({ activeSessionId: sessionId }, false, 'setActiveSessionId'); + set({ activeSessionId: sessionId }, false); }, // ========== Project Path ========== setProjectPath: (path: string) => { - set({ projectPath: path }, false, 'setProjectPath'); + set({ projectPath: path }, false); }, addRecentPath: (path: string) => { set( - (state) => { + (state: WorkflowState) => { // Remove if exists, add to front const filtered = state.recentPaths.filter((p) => p !== path); const updated = [path, ...filtered].slice(0, 10); // Keep max 10 return { recentPaths: updated }; }, - false, - 'addRecentPath' + false ); }, setServerPlatform: (platform: 'win32' | 'darwin' | 'linux') => { - set({ serverPlatform: platform }, false, 'setServerPlatform'); + set({ serverPlatform: platform }, false); }, // ========== Workspace Actions ========== @@ -418,8 +405,7 @@ export const useWorkflowStore = create()( }, sessionDataStore, }, - false, - 'switchWorkspace' + false ); // Persist projectPath to localStorage manually @@ -434,16 +420,16 @@ export const useWorkflowStore = create()( removeRecentPath: async (path: string) => { const updatedPaths = await apiRemoveRecentPath(path); - set({ recentPaths: updatedPaths }, false, 'removeRecentPath'); + set({ recentPaths: updatedPaths }, false); }, refreshRecentPaths: async () => { const paths = await fetchRecentPaths(); - set({ recentPaths: paths }, false, 'refreshRecentPaths'); + set({ recentPaths: paths }, false); }, registerQueryInvalidator: (callback: () => void) => { - set({ _invalidateQueriesCallback: callback }, false, 'registerQueryInvalidator'); + set({ _invalidateQueriesCallback: callback }, false); }, // ========== Filters and Sorting ========== @@ -453,8 +439,7 @@ export const useWorkflowStore = create()( (state) => ({ filters: { ...state.filters, ...filters }, }), - false, - 'setFilters' + false ); }, @@ -463,13 +448,12 @@ export const useWorkflowStore = create()( (state) => ({ sorting: { ...state.sorting, ...sorting }, }), - false, - 'setSorting' + false ); }, resetFilters: () => { - set({ filters: defaultFilters, sorting: defaultSorting }, false, 'resetFilters'); + set({ filters: defaultFilters, sorting: defaultSorting }, false); }, // ========== Computed Selectors ========== diff --git a/ccw/src/cli.ts b/ccw/src/cli.ts index 90f21d12..62dfe221 100644 --- a/ccw/src/cli.ts +++ b/ccw/src/cli.ts @@ -226,7 +226,7 @@ export function run(argv: string[]): void { .option('--output-type ', 'Output type: stdout, stderr, both', 'both') .option('--turn ', 'Turn number for cache (default: latest)') .option('--raw', 'Raw output only (no formatting)') - .option('--final', 'Output final result only (agent_message content, now default)') + .option('--final', 'Output strict final result only (no parsed/stdout fallback)') .option('--verbose', 'Show full metadata + raw output') .option('--timeout ', 'Timeout for watch command') .option('--all', 'Show all executions in show command') diff --git a/ccw/src/commands/cli.ts b/ccw/src/commands/cli.ts index 449683fc..8a257c6a 100644 --- a/ccw/src/commands/cli.ts +++ b/ccw/src/commands/cli.ts @@ -181,7 +181,7 @@ interface OutputViewOptions { outputType?: 'stdout' | 'stderr' | 'both'; turn?: string; raw?: boolean; - final?: boolean; // Explicit --final (same as default, kept for compatibility) + final?: boolean; // Explicit --final (strict final result, no parsed/stdout fallback) verbose?: boolean; // Show full metadata + raw stdout/stderr project?: string; // Optional project path for lookup } @@ -470,10 +470,23 @@ async function outputAction(conversationId: string | undefined, options: OutputV return; } - // Default (and --final): output final result only - // Prefer finalOutput (agent_message only) > parsedOutput (filtered) > raw stdout - const outputContent = result.finalOutput?.content || result.parsedOutput?.content || result.stdout?.content; - if (outputContent) { + const finalOutputContent = result.finalOutput?.content; + + if (options.final) { + if (finalOutputContent !== undefined) { + console.log(finalOutputContent); + return; + } + + console.error(chalk.yellow('No final agent result found in cached output.')); + console.error(chalk.gray(' Try without --final for best-effort output, or use --verbose to inspect raw stdout/stderr.')); + process.exit(1); + return; + } + + // Default output: prefer strict final result, then fall back to best-effort parsed/plain output. + const outputContent = finalOutputContent ?? result.parsedOutput?.content ?? result.stdout?.content; + if (outputContent !== undefined) { console.log(outputContent); } } @@ -1351,7 +1364,7 @@ async function showAction(options: { all?: boolean }): Promise { // 1. Try to fetch active executions from dashboard let activeExecs: Array<{ id: string; tool: string; mode: string; status: string; - prompt: string; startTime: number; isComplete?: boolean; + prompt: string; startTime: number | string | Date; isComplete?: boolean; }> = []; try { @@ -1382,6 +1395,7 @@ async function showAction(options: { all?: boolean }): Promise { // 2. Get recent history from SQLite const historyLimit = options.all ? 100 : 20; const history = await getExecutionHistoryAsync(process.cwd(), { limit: historyLimit, recursive: true }); + const historyById = new Map(history.executions.map(exec => [exec.id, exec])); // 3. Build unified list: active first, then history (de-duped) const seenIds = new Set(); @@ -1393,16 +1407,26 @@ async function showAction(options: { all?: boolean }): Promise { // Active executions (running) for (const exec of activeExecs) { if (exec.status === 'running') { + const normalizedStartTime = normalizeTimestampMs(exec.startTime); + const matchingHistory = historyById.get(exec.id); + const shouldSuppressActiveRow = matchingHistory !== undefined && isSavedExecutionNewerThanActive( + normalizedStartTime, + matchingHistory.updated_at || matchingHistory.timestamp + ); + + if (shouldSuppressActiveRow) { + continue; + } + seenIds.add(exec.id); - const elapsed = Math.floor((Date.now() - exec.startTime) / 1000); rows.push({ id: exec.id, tool: exec.tool, mode: exec.mode, status: 'running', prompt: (exec.prompt || '').replace(/\n/g, ' ').substring(0, 50), - time: `${elapsed}s ago`, - duration: `${elapsed}s...`, + time: normalizedStartTime !== undefined ? getTimeAgo(new Date(normalizedStartTime)) : 'unknown', + duration: normalizedStartTime !== undefined ? formatRunningDuration(Date.now() - normalizedStartTime) : 'running', }); } } @@ -1513,6 +1537,18 @@ async function watchAction(watchId: string | undefined, options: { timeout?: str } if (exec.status === 'running') { + const savedConversation = getHistoryStore(process.cwd()).getConversation(watchId); + const shouldPreferSavedConversation = !!savedConversation && isSavedExecutionNewerThanActive( + normalizeTimestampMs((exec as { startTime?: unknown }).startTime), + savedConversation.updated_at || savedConversation.created_at + ); + + if (shouldPreferSavedConversation) { + process.stderr.write(chalk.gray(`\nExecution already completed (status: ${savedConversation.latest_status}).\n`)); + process.stderr.write(chalk.dim(`Use: ccw cli output ${watchId}\n`)); + return savedConversation.latest_status === 'success' ? 0 : 1; + } + // Still running — wait and poll again await new Promise(r => setTimeout(r, 1000)); return poll(); @@ -1667,7 +1703,7 @@ async function detailAction(conversationId: string | undefined): Promise { * @returns {string} */ function getTimeAgo(date: Date): string { - const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000); + const seconds = Math.floor((Date.now() - date.getTime()) / 1000); if (seconds < 60) return 'just now'; if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; @@ -1676,6 +1712,71 @@ function getTimeAgo(date: Date): string { return date.toLocaleDateString(); } +function normalizeTimestampMs(value: unknown): number | undefined { + if (value instanceof Date) { + const time = value.getTime(); + return Number.isFinite(time) ? time : undefined; + } + + if (typeof value === 'number' && Number.isFinite(value)) { + return value > 0 && value < 1_000_000_000_000 ? value * 1000 : value; + } + + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return undefined; + + const numericValue = Number(trimmed); + if (Number.isFinite(numericValue)) { + return numericValue > 0 && numericValue < 1_000_000_000_000 ? numericValue * 1000 : numericValue; + } + + const parsed = Date.parse(trimmed); + return Number.isNaN(parsed) ? undefined : parsed; + } + + return undefined; +} + +function formatRunningDuration(elapsedMs: number): string { + const safeElapsedMs = Math.max(0, elapsedMs); + const totalSeconds = Math.floor(safeElapsedMs / 1000); + + if (totalSeconds < 60) return `${totalSeconds}s...`; + + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (totalSeconds < 3600) { + return seconds === 0 ? `${minutes}m...` : `${minutes}m ${seconds}s...`; + } + + const hours = Math.floor(totalSeconds / 3600); + const remainingMinutes = Math.floor((totalSeconds % 3600) / 60); + if (totalSeconds < 86400) { + return remainingMinutes === 0 ? `${hours}h...` : `${hours}h ${remainingMinutes}m...`; + } + + const days = Math.floor(totalSeconds / 86400); + const remainingHours = Math.floor((totalSeconds % 86400) / 3600); + return remainingHours === 0 ? `${days}d...` : `${days}d ${remainingHours}h...`; +} + +function isSavedExecutionNewerThanActive( + activeStartTimeMs: number | undefined, + savedTimestamp: unknown +): boolean { + if (activeStartTimeMs === undefined) { + return false; + } + + const savedTimestampMs = normalizeTimestampMs(savedTimestamp); + if (savedTimestampMs === undefined) { + return false; + } + + return savedTimestampMs >= activeStartTimeMs; +} + /**ccw cli -p * CLI command entry point * @param {string} subcommand - Subcommand (status, exec, history, detail) diff --git a/ccw/src/core/routes/cli-routes.ts b/ccw/src/core/routes/cli-routes.ts index ed115381..84271a48 100644 --- a/ccw/src/core/routes/cli-routes.ts +++ b/ccw/src/core/routes/cli-routes.ts @@ -24,6 +24,8 @@ import { getEnrichedConversation, getHistoryWithNativeInfo } from '../../tools/cli-executor.js'; +import { getHistoryStore } from '../../tools/cli-history-store.js'; +import { StoragePaths } from '../../config/storage-paths.js'; import { listAllNativeSessions } from '../../tools/native-session-discovery.js'; import { SmartContentFormatter } from '../../tools/cli-output-converter.js'; import { generateSmartContext, formatSmartContext } from '../../tools/smart-context.js'; @@ -51,6 +53,7 @@ import { getCodeIndexMcp } from '../../tools/claude-cli-tools.js'; import type { RouteContext } from './types.js'; +import { existsSync } from 'fs'; import { resolve, normalize } from 'path'; import { homedir } from 'os'; @@ -171,6 +174,84 @@ export function getActiveExecutions(): ActiveExecutionDto[] { })); } +function normalizeTimestampMs(value: unknown): number | undefined { + if (value instanceof Date) { + const time = value.getTime(); + return Number.isFinite(time) ? time : undefined; + } + + if (typeof value === 'number' && Number.isFinite(value)) { + return value > 0 && value < 1_000_000_000_000 ? value * 1000 : value; + } + + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return undefined; + + const numericValue = Number(trimmed); + if (Number.isFinite(numericValue)) { + return numericValue > 0 && numericValue < 1_000_000_000_000 ? numericValue * 1000 : numericValue; + } + + const parsed = Date.parse(trimmed); + return Number.isNaN(parsed) ? undefined : parsed; + } + + return undefined; +} + +function isSavedExecutionNewerThanActive(activeStartTimeMs: number | undefined, savedTimestamp: unknown): boolean { + if (activeStartTimeMs === undefined) { + return false; + } + + const savedTimestampMs = normalizeTimestampMs(savedTimestamp); + if (savedTimestampMs === undefined) { + return false; + } + + return savedTimestampMs >= activeStartTimeMs; +} + +function getSavedConversationWithNativeInfo(projectPath: string, executionId: string) { + const historyDbPath = StoragePaths.project(projectPath).historyDb; + if (!existsSync(historyDbPath)) { + return null; + } + + try { + return getHistoryStore(projectPath).getConversationWithNativeInfo(executionId); + } catch { + return null; + } +} + +function cleanupSupersededActiveExecutions(projectPath: string): void { + const supersededIds: string[] = []; + + for (const [executionId, activeExec] of activeExecutions.entries()) { + const savedConversation = getSavedConversationWithNativeInfo(projectPath, executionId); + if (!savedConversation) { + continue; + } + + if (isSavedExecutionNewerThanActive( + normalizeTimestampMs(activeExec.startTime), + savedConversation.updated_at || savedConversation.created_at + )) { + supersededIds.push(executionId); + } + } + + supersededIds.forEach(executionId => { + activeExecutions.delete(executionId); + }); + + if (supersededIds.length > 0) { + console.log(`[ActiveExec] Removed ${supersededIds.length} superseded execution(s): ${supersededIds.join(', ')}`); + } +} + /** * Update active execution state from hook events * Called by hooks-routes when CLI events are received from terminal execution @@ -240,6 +321,10 @@ export async function handleCliRoutes(ctx: RouteContext): Promise { // API: Get Active CLI Executions (for state recovery) if (pathname === '/api/cli/active' && req.method === 'GET') { + const projectPath = url.searchParams.get('path') || initialPath; + cleanupStaleExecutions(); + cleanupSupersededActiveExecutions(projectPath); + const executions = getActiveExecutions().map(exec => ({ ...exec, isComplete: exec.status !== 'running' @@ -537,6 +622,8 @@ export async function handleCliRoutes(ctx: RouteContext): Promise { // API: CLI Execution Detail (GET) or Delete (DELETE) if (pathname === '/api/cli/execution') { const projectPath = url.searchParams.get('path') || initialPath; + cleanupStaleExecutions(); + cleanupSupersededActiveExecutions(projectPath); const executionId = url.searchParams.get('id'); if (!executionId) { @@ -564,10 +651,17 @@ export async function handleCliRoutes(ctx: RouteContext): Promise { return true; } + const conversation = getSavedConversationWithNativeInfo(projectPath, executionId) || getConversationDetailWithNativeInfo(projectPath, executionId); + // Handle GET request - return conversation with native session info // First check in-memory active executions (for running/recently completed) const activeExec = activeExecutions.get(executionId); - if (activeExec) { + const shouldPreferSavedConversation = !!activeExec && !!conversation && isSavedExecutionNewerThanActive( + normalizeTimestampMs(activeExec.startTime), + conversation.updated_at || conversation.created_at + ); + + if (activeExec && !shouldPreferSavedConversation) { // Return active execution data as conversation record format // Note: Convert output array buffer back to string for API compatibility const activeConversation = { @@ -594,8 +688,6 @@ export async function handleCliRoutes(ctx: RouteContext): Promise { return true; } - // Fall back to database query for saved conversations - const conversation = getConversationDetailWithNativeInfo(projectPath, executionId); if (!conversation) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Conversation not found' })); diff --git a/ccw/src/tools/cli-history-store.ts b/ccw/src/tools/cli-history-store.ts index 8215c0bf..4cb37a02 100644 --- a/ccw/src/tools/cli-history-store.ts +++ b/ccw/src/tools/cli-history-store.ts @@ -9,7 +9,7 @@ import { join, dirname, resolve } from 'path'; import { parseSessionFile, formatConversation, extractConversationPairs, type ParsedSession, type ParsedTurn } from './session-content-parser.js'; import { getDiscoverer, getNativeSessions } from './native-session-discovery.js'; import { StoragePaths, ensureStorageDir, getProjectId, getCCWHome } from '../config/storage-paths.js'; -import type { CliOutputUnit } from './cli-output-converter.js'; +import { createOutputParser, flattenOutputUnits, type CliOutputUnit } from './cli-output-converter.js'; // Debug logging for history save investigation (Iteration 4) const DEBUG_SESSION_ID = 'DBG-parallel-ccw-cli-test-2026-03-07'; @@ -34,6 +34,27 @@ function writeDebugLog(event: string, data: Record): void { } } +function reconstructFinalOutputFromStdout(rawStdout: string, canTrustStdout: boolean): string | undefined { + if (!canTrustStdout || !rawStdout.trim()) { + return undefined; + } + + try { + const parser = createOutputParser('json-lines'); + const units = parser.parse(Buffer.from(rawStdout, 'utf8'), 'stdout'); + units.push(...parser.flush()); + + const reconstructed = flattenOutputUnits(units, { + includeTypes: ['agent_message'], + stripCommandJsonBlocks: true + }); + + return reconstructed || undefined; + } catch { + return undefined; + } +} + // Types export interface ConversationTurn { turn: number; @@ -764,8 +785,14 @@ export class CliHistoryStore { } // Add final output if available (agent_message only for --final flag) - if (turn.final_output) { - const finalContent = turn.final_output; + // For older records that lack final_output, attempt reconstruction from raw JSONL stdout. + const canTrustStdoutForFinal = !!(turn.cached || !turn.truncated); + const reconstructedFinalOutput = turn.final_output + ? undefined + : reconstructFinalOutputFromStdout(turn.cached ? (turn.stdout_full || '') : (turn.stdout || ''), canTrustStdoutForFinal); + const finalContent = turn.final_output ?? reconstructedFinalOutput; + + if (finalContent !== undefined) { const totalBytes = finalContent.length; const content = finalContent.substring(offset, offset + limit); result.finalOutput = { diff --git a/ccw/src/tools/codex-lens.ts b/ccw/src/tools/codex-lens.ts index 81842e7a..643dc498 100644 --- a/ccw/src/tools/codex-lens.ts +++ b/ccw/src/tools/codex-lens.ts @@ -185,6 +185,7 @@ interface ExecuteResult { output?: string; error?: string; message?: string; + warning?: string; results?: unknown; files?: unknown; symbols?: unknown; @@ -1228,6 +1229,143 @@ function parseProgressLine(line: string): ProgressInfo | null { return null; } +function shouldRetryWithoutEnrich(args: string[], error?: string): boolean { + return args.includes('--enrich') && Boolean(error && /No such option:\s+--enrich/i.test(error)); +} + +function shouldRetryWithoutLanguageFilters(args: string[], error?: string): boolean { + return args.includes('--language') && Boolean(error && /Got unexpected extra arguments?\b/i.test(error)); +} + +function stripFlag(args: string[], flag: string): string[] { + return args.filter((arg) => arg !== flag); +} + +function stripOptionWithValues(args: string[], option: string): string[] { + const nextArgs: string[] = []; + for (let index = 0; index < args.length; index += 1) { + if (args[index] === option) { + index += 1; + continue; + } + nextArgs.push(args[index]); + } + return nextArgs; +} + +function shouldRetryWithAstGrepPreference(args: string[], error?: string): boolean { + return !args.includes('--use-astgrep') + && !args.includes('--no-use-astgrep') + && Boolean(error && /Options --use-astgrep and --no-use-astgrep are mutually exclusive/i.test(error)); +} + +function shouldRetryWithStaticGraphPreference(args: string[], error?: string): boolean { + return !args.includes('--static-graph') + && !args.includes('--no-static-graph') + && Boolean(error && /Options --static-graph and --no-static-graph are mutually exclusive/i.test(error)); +} + +function stripAnsiCodes(value: string): string { + return value + .replace(/\x1b\[[0-9;]*m/g, '') + .replace(/\x1b\][0-9;]*\x07/g, '') + .replace(/\x1b\][^\x07]*\x07/g, ''); +} + +function tryExtractJsonPayload(raw: string): unknown | null { + const cleanOutput = stripAnsiCodes(raw).trim(); + const jsonStart = cleanOutput.search(/[\[{]/); + if (jsonStart === -1) { + return null; + } + + const startChar = cleanOutput[jsonStart]; + const endChar = startChar === '{' ? '}' : ']'; + let depth = 0; + let inString = false; + let escapeNext = false; + let jsonEnd = -1; + + for (let index = jsonStart; index < cleanOutput.length; index += 1) { + const char = cleanOutput[index]; + + if (escapeNext) { + escapeNext = false; + continue; + } + + if (char === '\\' && inString) { + escapeNext = true; + continue; + } + + if (char === '"') { + inString = !inString; + continue; + } + + if (!inString) { + if (char === startChar) { + depth += 1; + } else if (char === endChar) { + depth -= 1; + if (depth === 0) { + jsonEnd = index + 1; + break; + } + } + } + } + + if (jsonEnd === -1) { + return null; + } + + try { + return JSON.parse(cleanOutput.slice(jsonStart, jsonEnd)); + } catch { + return null; + } +} + +function extractStructuredError(payload: unknown): string | null { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return null; + } + + const record = payload as Record; + if (typeof record.error === 'string' && record.error.trim()) { + return record.error.trim(); + } + if (typeof record.message === 'string' && record.message.trim()) { + return record.message.trim(); + } + + return null; +} + +function extractCodexLensFailure(stdout: string, stderr: string, code: number | null): string { + const structuredStdout = extractStructuredError(tryExtractJsonPayload(stdout)); + if (structuredStdout) { + return structuredStdout; + } + + const structuredStderr = extractStructuredError(tryExtractJsonPayload(stderr)); + if (structuredStderr) { + return structuredStderr; + } + + const cleanStdout = stripAnsiCodes(stdout).trim(); + const cleanStderr = stripAnsiCodes(stderr) + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line && !/^DEBUG\b/i.test(line)) + .join('\n') + .trim(); + + return cleanStderr || cleanStdout || stripAnsiCodes(stderr).trim() || `Process exited with code ${code ?? 'unknown'}`; +} + /** * Execute CodexLens CLI command with real-time progress updates * @param args - CLI arguments @@ -1235,6 +1373,64 @@ function parseProgressLine(line: string): ProgressInfo | null { * @returns Execution result */ async function executeCodexLens(args: string[], options: ExecuteOptions = {}): Promise { + let attemptArgs = [...args]; + let result = await executeCodexLensOnce(attemptArgs, options); + const compatibilityWarnings: string[] = []; + + const compatibilityRetries = [ + { + shouldRetry: shouldRetryWithoutEnrich, + transform: (currentArgs: string[]) => stripFlag(currentArgs, '--enrich'), + warning: 'CodexLens CLI does not support --enrich; retried without it.', + }, + { + shouldRetry: shouldRetryWithoutLanguageFilters, + transform: (currentArgs: string[]) => stripOptionWithValues(currentArgs, '--language'), + warning: 'CodexLens CLI rejected --language filters; retried without language scoping.', + }, + { + shouldRetry: shouldRetryWithAstGrepPreference, + transform: (currentArgs: string[]) => [...currentArgs, '--use-astgrep'], + warning: 'CodexLens CLI hit a Typer ast-grep option conflict; retried with explicit --use-astgrep.', + }, + { + shouldRetry: shouldRetryWithStaticGraphPreference, + transform: (currentArgs: string[]) => [...currentArgs, '--static-graph'], + warning: 'CodexLens CLI hit a Typer static-graph option conflict; retried with explicit --static-graph.', + }, + ]; + + for (const retry of compatibilityRetries) { + if (result.success || !retry.shouldRetry(attemptArgs, result.error)) { + continue; + } + + compatibilityWarnings.push(retry.warning); + attemptArgs = retry.transform(attemptArgs); + const retryResult = await executeCodexLensOnce(attemptArgs, options); + result = retryResult.success + ? retryResult + : { + ...retryResult, + error: retryResult.error + ? `${retryResult.error} (after compatibility retry; initial error: ${result.error})` + : result.error, + }; + } + + if (compatibilityWarnings.length === 0) { + return result; + } + + const warning = compatibilityWarnings.join(' '); + return { + ...result, + warning, + message: result.message ? `${result.message} ${warning}` : warning, + }; +} + +async function executeCodexLensOnce(args: string[], options: ExecuteOptions = {}): Promise { const { timeout = 300000, cwd = process.cwd(), onProgress } = options; // Default 5 min // Ensure ready @@ -1362,7 +1558,11 @@ async function executeCodexLens(args: string[], options: ExecuteOptions = {}): P if (code === 0) { safeResolve({ success: true, output: stdout.trim() }); } else { - safeResolve({ success: false, error: stderr.trim() || `Process exited with code ${code}` }); + safeResolve({ + success: false, + error: extractCodexLensFailure(stdout, stderr, code), + output: stdout.trim() || undefined, + }); } }); }); @@ -1379,7 +1579,7 @@ async function initIndex(params: Params): Promise { // Use 'index init' subcommand (new CLI structure) const args = ['index', 'init', path]; if (languages && languages.length > 0) { - args.push('--language', languages.join(',')); + args.push(...languages.flatMap((language) => ['--language', language])); } return executeCodexLens(args, { cwd: path }); diff --git a/ccw/src/tools/smart-search.ts b/ccw/src/tools/smart-search.ts index f2f24178..d9d299ec 100644 --- a/ccw/src/tools/smart-search.ts +++ b/ccw/src/tools/smart-search.ts @@ -20,6 +20,8 @@ import { z } from 'zod'; import type { ToolSchema, ToolResult } from '../types/tool.js'; import { spawn, execSync } from 'child_process'; +import { statSync } from 'fs'; +import { dirname, resolve } from 'path'; import { ensureReady as ensureCodexLensReady, executeCodexLens, @@ -398,17 +400,106 @@ function splitResultsWithExtraFiles( return { results, extra_files }; } +interface SearchScope { + workingDirectory: string; + searchPaths: string[]; + targetFile?: string; +} + +function sanitizeSearchQuery(query: string | undefined): string | undefined { + if (!query) { + return query; + } + + return query.replace(/\r?\n\s*/g, ' ').trim(); +} + +function sanitizeSearchPath(pathValue: string | undefined): string | undefined { + if (!pathValue) { + return pathValue; + } + + return pathValue.replace(/\r?\n\s*/g, '').trim(); +} + +function resolveSearchScope(pathValue: string = '.', paths: string[] = []): SearchScope { + const normalizedPath = sanitizeSearchPath(pathValue) || '.'; + const normalizedPaths = paths.map((item) => sanitizeSearchPath(item) || item); + const fallbackPath = normalizedPath || getProjectRoot(); + + try { + const resolvedPath = resolve(fallbackPath); + const stats = statSync(resolvedPath); + + if (stats.isFile()) { + return { + workingDirectory: dirname(resolvedPath), + searchPaths: normalizedPaths.length > 0 ? normalizedPaths : [resolvedPath], + targetFile: resolvedPath, + }; + } + + return { + workingDirectory: resolvedPath, + searchPaths: normalizedPaths.length > 0 ? normalizedPaths : ['.'], + }; + } catch { + return { + workingDirectory: fallbackPath, + searchPaths: normalizedPaths.length > 0 ? normalizedPaths : [normalizedPath || '.'], + }; + } +} + +function normalizeResultFilePath(filePath: string, workingDirectory: string): string { + return resolve(workingDirectory, filePath).replace(/\\/g, '/'); +} + +function filterResultsToTargetFile(results: T[], scope: SearchScope): T[] { + if (!scope.targetFile) { + return results; + } + + const normalizedTarget = scope.targetFile.replace(/\\/g, '/'); + return results.filter((result) => normalizeResultFilePath(result.file, scope.workingDirectory) === normalizedTarget); +} + +function collectBackendError( + errors: string[], + backendName: string, + backendResult: PromiseSettledResult, +): void { + if (backendResult.status === 'rejected') { + errors.push(`${backendName}: ${String(backendResult.reason)}`); + return; + } + + if (!backendResult.value.success) { + errors.push(`${backendName}: ${backendResult.value.error || 'unknown error'}`); + } +} + +function mergeWarnings(...warnings: Array): string | undefined { + const merged = [...new Set( + warnings + .filter((warning): warning is string => typeof warning === 'string' && warning.trim().length > 0) + .map((warning) => warning.trim()) + )]; + return merged.length > 0 ? merged.join(' | ') : undefined; +} + /** * Check if CodexLens index exists for current directory * @param path - Directory path to check * @returns Index status */ async function checkIndexStatus(path: string = '.'): Promise { + const scope = resolveSearchScope(path); try { // Fetch both status and config in parallel const [statusResult, configResult] = await Promise.all([ - executeCodexLens(['status', '--json'], { cwd: path }), - executeCodexLens(['config', 'show', '--json'], { cwd: path }), + executeCodexLens(['status', '--json'], { cwd: scope.workingDirectory }), + executeCodexLens(['config', 'show', '--json'], { cwd: scope.workingDirectory }), ]); // Parse config @@ -694,6 +785,7 @@ function buildRipgrepCommand(params: { */ async function executeInitAction(params: Params, force: boolean = false): Promise { const { path = '.', languages } = params; + const scope = resolveSearchScope(path); // Check CodexLens availability const readyStatus = await ensureCodexLensReady(); @@ -706,12 +798,12 @@ async function executeInitAction(params: Params, force: boolean = false): Promis // Build args with --no-embeddings for FTS-only index (faster) // Use 'index init' subcommand (new CLI structure) - const args = ['index', 'init', path, '--no-embeddings']; + const args = ['index', 'init', scope.workingDirectory, '--no-embeddings']; if (force) { args.push('--force'); // Force full rebuild } if (languages && languages.length > 0) { - args.push('--language', languages.join(',')); + args.push(...languages.flatMap((language) => ['--language', language])); } // Track progress updates @@ -719,7 +811,7 @@ async function executeInitAction(params: Params, force: boolean = false): Promis let lastProgress: ProgressInfo | null = null; const result = await executeCodexLens(args, { - cwd: path, + cwd: scope.workingDirectory, timeout: 1800000, // 30 minutes for large codebases onProgress: (progress: ProgressInfo) => { progressUpdates.push(progress); @@ -730,7 +822,7 @@ async function executeInitAction(params: Params, force: boolean = false): Promis // Build metadata with progress info const metadata: SearchMetadata = { action: force ? 'init_force' : 'init', - path, + path: scope.workingDirectory, }; if (lastProgress !== null) { @@ -766,8 +858,9 @@ async function executeInitAction(params: Params, force: boolean = false): Promis */ async function executeStatusAction(params: Params): Promise { const { path = '.' } = params; + const scope = resolveSearchScope(path); - const indexStatus = await checkIndexStatus(path); + const indexStatus = await checkIndexStatus(scope.workingDirectory); // Build detailed status message const statusParts: string[] = []; @@ -815,6 +908,7 @@ async function executeStatusAction(params: Params): Promise { */ async function executeUpdateAction(params: Params): Promise { const { path = '.', languages } = params; + const scope = resolveSearchScope(path); // Check CodexLens availability const readyStatus = await ensureCodexLensReady(); @@ -826,7 +920,7 @@ async function executeUpdateAction(params: Params): Promise { } // Check if index exists first - const indexStatus = await checkIndexStatus(path); + const indexStatus = await checkIndexStatus(scope.workingDirectory); if (!indexStatus.indexed) { return { success: false, @@ -836,9 +930,9 @@ async function executeUpdateAction(params: Params): Promise { // Build args for incremental init (without --force) // Use 'index init' subcommand (new CLI structure) - const args = ['index', 'init', path]; + const args = ['index', 'init', scope.workingDirectory]; if (languages && languages.length > 0) { - args.push('--language', languages.join(',')); + args.push(...languages.flatMap((language) => ['--language', language])); } // Track progress updates @@ -846,7 +940,7 @@ async function executeUpdateAction(params: Params): Promise { let lastProgress: ProgressInfo | null = null; const result = await executeCodexLens(args, { - cwd: path, + cwd: scope.workingDirectory, timeout: 600000, // 10 minutes for incremental updates onProgress: (progress: ProgressInfo) => { progressUpdates.push(progress); @@ -891,6 +985,7 @@ async function executeUpdateAction(params: Params): Promise { */ async function executeWatchAction(params: Params): Promise { const { path = '.', languages, debounce = 1000 } = params; + const scope = resolveSearchScope(path); // Check CodexLens availability const readyStatus = await ensureCodexLensReady(); @@ -902,7 +997,7 @@ async function executeWatchAction(params: Params): Promise { } // Check if index exists first - const indexStatus = await checkIndexStatus(path); + const indexStatus = await checkIndexStatus(scope.workingDirectory); if (!indexStatus.indexed) { return { success: false, @@ -911,15 +1006,15 @@ async function executeWatchAction(params: Params): Promise { } // Build args for watch command - const args = ['watch', path, '--debounce', debounce.toString()]; + const args = ['watch', scope.workingDirectory, '--debounce', debounce.toString()]; if (languages && languages.length > 0) { - args.push('--language', languages.join(',')); + args.push(...languages.flatMap((language) => ['--language', language])); } // Start watcher in background (non-blocking) // Note: The watcher runs until manually stopped const result = await executeCodexLens(args, { - cwd: path, + cwd: scope.workingDirectory, timeout: 5000, // Short timeout for initial startup check }); @@ -975,11 +1070,11 @@ async function executeFuzzyMode(params: Params): Promise { // If both failed, return error if (resultsMap.size === 0) { const errors: string[] = []; - if (ftsResult.status === 'rejected') errors.push(`FTS: ${ftsResult.reason}`); - if (ripgrepResult.status === 'rejected') errors.push(`Ripgrep: ${ripgrepResult.reason}`); + collectBackendError(errors, 'FTS', ftsResult); + collectBackendError(errors, 'Ripgrep', ripgrepResult); return { success: false, - error: `Both search backends failed: ${errors.join('; ')}`, + error: `Both search backends failed: ${errors.join('; ') || 'unknown error'}`, }; } @@ -1032,6 +1127,7 @@ async function executeFuzzyMode(params: Params): Promise { */ async function executeAutoMode(params: Params): Promise { const { query, path = '.' } = params; + const scope = resolveSearchScope(path); if (!query) { return { @@ -1041,7 +1137,7 @@ async function executeAutoMode(params: Params): Promise { } // Check index status - const indexStatus = await checkIndexStatus(path); + const indexStatus = await checkIndexStatus(scope.workingDirectory); // Classify intent with index and embeddings awareness const classification = classifyIntent( @@ -1098,6 +1194,7 @@ async function executeAutoMode(params: Params): Promise { */ async function executeRipgrepMode(params: Params): Promise { const { query, paths = [], contextLines = 0, maxResults = 5, extraFilesCount = 10, maxContentLength = 200, includeHidden = false, path = '.', regex = true, caseSensitive = true, tokenize = true, codeOnly = true, withDoc = false, excludeExtensions } = params; + const scope = resolveSearchScope(path, paths); // withDoc overrides codeOnly const effectiveCodeOnly = withDoc ? false : codeOnly; @@ -1126,7 +1223,7 @@ async function executeRipgrepMode(params: Params): Promise { // Use CodexLens fts mode as fallback const args = ['search', query, '--limit', totalToFetch.toString(), '--method', 'fts', '--json']; - const result = await executeCodexLens(args, { cwd: path }); + const result = await executeCodexLens(args, { cwd: scope.workingDirectory }); if (!result.success) { return { @@ -1156,8 +1253,10 @@ async function executeRipgrepMode(params: Params): Promise { // Keep empty results } + const scopedResults = filterResultsToTargetFile(allResults, scope); + // Split results: first N with full content, rest as file paths only - const { results, extra_files } = splitResultsWithExtraFiles(allResults, maxResults, extraFilesCount); + const { results, extra_files } = splitResultsWithExtraFiles(scopedResults, maxResults, extraFilesCount); return { success: true, @@ -1176,7 +1275,7 @@ async function executeRipgrepMode(params: Params): Promise { // Use ripgrep - request more results to support split const { command, args, tokens } = buildRipgrepCommand({ query, - paths: paths.length > 0 ? paths : [path], + paths: scope.searchPaths, contextLines, maxResults: totalToFetch, // Fetch more to support split includeHidden, @@ -1187,7 +1286,7 @@ async function executeRipgrepMode(params: Params): Promise { return new Promise((resolve) => { const child = spawn(command, args, { - cwd: path || getProjectRoot(), + cwd: scope.workingDirectory || getProjectRoot(), stdio: ['ignore', 'pipe', 'pipe'], }); @@ -1312,6 +1411,7 @@ async function executeRipgrepMode(params: Params): Promise { */ async function executeCodexLensExactMode(params: Params): Promise { const { query, path = '.', maxResults = 5, extraFilesCount = 10, maxContentLength = 200, enrich = false, excludeExtensions, codeOnly = true, withDoc = false, offset = 0 } = params; + const scope = resolveSearchScope(path); // withDoc overrides codeOnly const effectiveCodeOnly = withDoc ? false : codeOnly; @@ -1332,7 +1432,7 @@ async function executeCodexLensExactMode(params: Params): Promise } // Check index status - const indexStatus = await checkIndexStatus(path); + const indexStatus = await checkIndexStatus(scope.workingDirectory); // Request more results to support split (full content + extra files) const totalToFetch = maxResults + extraFilesCount; @@ -1348,7 +1448,7 @@ async function executeCodexLensExactMode(params: Params): Promise if (excludeExtensions && excludeExtensions.length > 0) { args.push('--exclude-extensions', excludeExtensions.join(',')); } - const result = await executeCodexLens(args, { cwd: path }); + const result = await executeCodexLens(args, { cwd: scope.workingDirectory }); if (!result.success) { return { @@ -1359,7 +1459,7 @@ async function executeCodexLensExactMode(params: Params): Promise backend: 'codexlens', count: 0, query, - warning: indexStatus.warning, + warning: mergeWarnings(indexStatus.warning, result.warning), }, }; } @@ -1379,6 +1479,8 @@ async function executeCodexLensExactMode(params: Params): Promise // Keep empty results } + allResults = filterResultsToTargetFile(allResults, scope); + // Fallback to fuzzy mode if exact returns no results if (allResults.length === 0) { const fuzzyArgs = ['search', query, '--limit', totalToFetch.toString(), '--offset', offset.toString(), '--method', 'fts', '--use-fuzzy', '--json']; @@ -1393,18 +1495,18 @@ async function executeCodexLensExactMode(params: Params): Promise if (excludeExtensions && excludeExtensions.length > 0) { fuzzyArgs.push('--exclude-extensions', excludeExtensions.join(',')); } - const fuzzyResult = await executeCodexLens(fuzzyArgs, { cwd: path }); + const fuzzyResult = await executeCodexLens(fuzzyArgs, { cwd: scope.workingDirectory }); if (fuzzyResult.success) { try { const parsed = JSON.parse(stripAnsi(fuzzyResult.output || '{}')); const data = parsed.result?.results || parsed.results || parsed; - allResults = (Array.isArray(data) ? data : []).map((item: any) => ({ + allResults = filterResultsToTargetFile((Array.isArray(data) ? data : []).map((item: any) => ({ file: item.path || item.file, score: item.score || 0, content: truncateContent(item.content || item.excerpt, maxContentLength), symbol: item.symbol || null, - })); + })), scope); } catch { // Keep empty results } @@ -1421,7 +1523,7 @@ async function executeCodexLensExactMode(params: Params): Promise backend: 'codexlens', count: results.length, query, - warning: indexStatus.warning, + warning: mergeWarnings(indexStatus.warning, fuzzyResult.warning), note: 'No exact matches found, showing fuzzy results', fallback: 'fuzzy', }, @@ -1442,7 +1544,7 @@ async function executeCodexLensExactMode(params: Params): Promise backend: 'codexlens', count: results.length, query, - warning: indexStatus.warning, + warning: mergeWarnings(indexStatus.warning, result.warning), }, }; } @@ -1455,6 +1557,7 @@ async function executeCodexLensExactMode(params: Params): Promise async function executeHybridMode(params: Params): Promise { const timer = createTimer(); const { query, path = '.', maxResults = 5, extraFilesCount = 10, maxContentLength = 200, enrich = false, excludeExtensions, codeOnly = true, withDoc = false, offset = 0 } = params; + const scope = resolveSearchScope(path); // withDoc overrides codeOnly const effectiveCodeOnly = withDoc ? false : codeOnly; @@ -1476,7 +1579,7 @@ async function executeHybridMode(params: Params): Promise { } // Check index status - const indexStatus = await checkIndexStatus(path); + const indexStatus = await checkIndexStatus(scope.workingDirectory); timer.mark('index_status_check'); // Request more results to support split (full content + extra files) @@ -1493,7 +1596,7 @@ async function executeHybridMode(params: Params): Promise { if (excludeExtensions && excludeExtensions.length > 0) { args.push('--exclude-extensions', excludeExtensions.join(',')); } - const result = await executeCodexLens(args, { cwd: path }); + const result = await executeCodexLens(args, { cwd: scope.workingDirectory }); timer.mark('codexlens_search'); if (!result.success) { @@ -1506,7 +1609,7 @@ async function executeHybridMode(params: Params): Promise { backend: 'codexlens', count: 0, query, - warning: indexStatus.warning, + warning: mergeWarnings(indexStatus.warning, result.warning), }, }; } @@ -1519,7 +1622,7 @@ async function executeHybridMode(params: Params): Promise { try { const parsed = JSON.parse(stripAnsi(result.output || '{}')); const data = parsed.result?.results || parsed.results || parsed; - allResults = (Array.isArray(data) ? data : []).map((item: any) => { + allResults = filterResultsToTargetFile((Array.isArray(data) ? data : []).map((item: any) => { const rawScore = item.score || 0; // Hybrid mode returns distance scores (lower is better). // Convert to similarity scores (higher is better) for consistency. @@ -1531,7 +1634,7 @@ async function executeHybridMode(params: Params): Promise { content: truncateContent(item.content || item.excerpt, maxContentLength), symbol: item.symbol || null, }; - }); + }), scope); timer.mark('parse_results'); initialCount = allResults.length; @@ -1562,7 +1665,7 @@ async function executeHybridMode(params: Params): Promise { backend: 'codexlens', count: 0, query, - warning: indexStatus.warning || 'Failed to parse JSON output', + warning: mergeWarnings(indexStatus.warning, result.warning, 'Failed to parse JSON output'), }, }; } @@ -1591,7 +1694,7 @@ async function executeHybridMode(params: Params): Promise { count: results.length, query, note, - warning: indexStatus.warning, + warning: mergeWarnings(indexStatus.warning, result.warning), suggested_weights: getRRFWeights(query), timing: TIMING_ENABLED ? timings : undefined, }, @@ -1943,6 +2046,7 @@ function withTimeout(promise: Promise, ms: number, modeName: string): Prom */ async function executePriorityFallbackMode(params: Params): Promise { const { query, path = '.' } = params; + const scope = resolveSearchScope(path); const fallbackHistory: string[] = []; if (!query) { @@ -1950,7 +2054,7 @@ async function executePriorityFallbackMode(params: Params): Promise