diff --git a/README.md b/README.md index c3e70e24..94ab9335 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ https://github.com/user-attachments/assets/35e27989-726d-4059-8662-bae610e46b42 ## Installation No prerequisites - the app can detect supported runtimes/providers and guide setup from the UI. +If you want the freshest version, clone the repo and run it from the `dev` branch. @@ -126,6 +127,8 @@ A local orchestration layer for AI agent teams across Claude and Codex. - **Task creation with attachments** — send a message to the team lead with any attached images. The lead will automatically create a fully described task and attach your files directly to the task for complete context. +- **Auto-resume after rate limits** — when the lead hits a Claude rate limit and the reset time is known, the app can automatically nudge the lead to continue once the cooldown has passed + - **Deep session analysis** — detailed breakdown of what happened in each agent session: bash commands, reasoning, subprocesses - **Smart task-to-log/changes matching** — automatically links session logs/changes to specific tasks diff --git a/package.json b/package.json index 1d963d43..f13abadc 100644 --- a/package.json +++ b/package.json @@ -213,7 +213,7 @@ }, "build": { "appId": "com.agent-teams.app", - "productName": "Claude Agent Teams UI", + "productName": "Agent Teams UI", "directories": { "output": "release" }, diff --git a/resources/pricing.json b/resources/pricing.json index 85e94069..c8e27349 100644 --- a/resources/pricing.json +++ b/resources/pricing.json @@ -311,7 +311,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true }, "global.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 0.00000625, @@ -338,7 +339,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true }, "us.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 0.000006875, @@ -365,7 +367,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true }, "eu.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 0.000006875, @@ -392,7 +395,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true }, "au.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 0.000006875, @@ -419,6 +423,147 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true + }, + "anthropic.claude-opus-4-7": { + "cache_creation_input_token_cost": 0.00000625, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true + }, + "global.anthropic.claude-opus-4-7": { + "cache_creation_input_token_cost": 0.00000625, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true + }, + "us.anthropic.claude-opus-4-7": { + "cache_creation_input_token_cost": 0.000006875, + "cache_read_input_token_cost": 5.5e-7, + "input_cost_per_token": 0.0000055, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.0000275, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true + }, + "eu.anthropic.claude-opus-4-7": { + "cache_creation_input_token_cost": 0.000006875, + "cache_read_input_token_cost": 5.5e-7, + "input_cost_per_token": 0.0000055, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.0000275, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true + }, + "au.anthropic.claude-opus-4-7": { + "cache_creation_input_token_cost": 0.000006875, + "cache_read_input_token_cost": 5.5e-7, + "input_cost_per_token": 0.0000055, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.0000275, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true }, "anthropic.claude-sonnet-4-6": { @@ -854,6 +999,35 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, + "tool_use_system_prompt_tokens": 159, + "supports_max_reasoning_effort": true + }, + "azure_ai/claude-opus-4-7": { + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "litellm_provider": "azure_ai", + "max_input_tokens": 200000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, + "cache_read_input_token_cost": 5e-7, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, "tool_use_system_prompt_tokens": 159 }, "azure_ai/claude-opus-4-1": { @@ -1687,7 +1861,8 @@ "provider_specific_entry": { "us": 1.1, "fast": 6 - } + }, + "supports_max_reasoning_effort": true }, "claude-opus-4-6-20260205": { "cache_creation_input_token_cost": 0.00000625, @@ -1715,6 +1890,71 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, + "provider_specific_entry": { + "us": 1.1, + "fast": 6 + }, + "supports_max_reasoning_effort": true + }, + "claude-opus-4-7": { + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "tool_use_system_prompt_tokens": 346, + "provider_specific_entry": { + "us": 1.1, + "fast": 6 + } + }, + "claude-opus-4-7-20260416": { + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "tool_use_system_prompt_tokens": 346, "provider_specific_entry": { "us": 1.1, "fast": 6 @@ -4148,7 +4388,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_max_reasoning_effort": true }, "vertex_ai/claude-opus-4-6@default": { "cache_creation_input_token_cost": 0.00000625, @@ -4174,6 +4415,61 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, + "tool_use_system_prompt_tokens": 346, + "supports_max_reasoning_effort": true + }, + "vertex_ai/claude-opus-4-7": { + "cache_creation_input_token_cost": 0.00000625, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "tool_use_system_prompt_tokens": 346 + }, + "vertex_ai/claude-opus-4-7@default": { + "cache_creation_input_token_cost": 0.00000625, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, "tool_use_system_prompt_tokens": 346 }, "vertex_ai/claude-sonnet-4-5": { diff --git a/src/features/agent-graph/renderer/hooks/useTeamGraphSlotReset.ts b/src/features/agent-graph/renderer/hooks/useTeamGraphSlotReset.ts new file mode 100644 index 00000000..edea698b --- /dev/null +++ b/src/features/agent-graph/renderer/hooks/useTeamGraphSlotReset.ts @@ -0,0 +1,18 @@ +import { useLayoutEffect } from 'react'; + +import { useStore } from '@renderer/store'; +import { isTeamGraphSlotPersistenceDisabled } from '@renderer/store/slices/teamSlice'; + +export function useTeamGraphSlotReset(teamName: string, enabled = true): void { + const resetTeamGraphSlotAssignmentsToDefaults = useStore( + (s) => s.resetTeamGraphSlotAssignmentsToDefaults + ); + + useLayoutEffect(() => { + if (!enabled || !isTeamGraphSlotPersistenceDisabled()) { + return; + } + + resetTeamGraphSlotAssignmentsToDefaults(teamName); + }, [enabled, resetTeamGraphSlotAssignmentsToDefaults, teamName]); +} diff --git a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx index ba700bb1..78fa9802 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx @@ -3,7 +3,7 @@ * Follows the exact ProjectEditorOverlay pattern (lazy-loaded, fixed z-50). */ -import { useCallback, useLayoutEffect, useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { GraphView } from '@claude-teams/agent-graph'; import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost'; @@ -11,6 +11,7 @@ import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHo import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog'; import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility'; import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter'; +import { useTeamGraphSlotReset } from '../hooks/useTeamGraphSlotReset'; import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions'; import { GraphActivityHud } from './GraphActivityHud'; @@ -52,17 +53,15 @@ export const TeamGraphOverlay = ({ onOpenMemberProfile, }: TeamGraphOverlayProps): React.JSX.Element => { const graphData = useTeamGraphAdapter(teamName); - const { - openTeamPage: openTeamTab, - resetOwnerSlotAssignmentsToDefaults, - commitOwnerSlotDrop, - } = useTeamGraphSurfaceActions(teamName); + const { openTeamPage: openTeamTab, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName); const { sidebarVisible: persistedSidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility(); const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName); const effectiveSidebarVisible = sidebarVisible ?? persistedSidebarVisible; const handleToggleSidebar = onToggleSidebar ?? toggleSidebarVisible; + useTeamGraphSlotReset(teamName); + // Task action dispatchers (same pattern as TeamGraphTab) const dispatchTaskAction = useCallback( (action: string) => (taskId: string) => @@ -89,11 +88,6 @@ export const TeamGraphOverlay = ({ const openCreateTask = useCallback(() => { openCreateTaskDialog(''); }, [openCreateTaskDialog]); - - useLayoutEffect(() => { - resetOwnerSlotAssignmentsToDefaults(); - }, [resetOwnerSlotAssignmentsToDefaults]); - const events: GraphEventPort = { onNodeDoubleClick: useCallback( (ref: GraphDomainRef) => { diff --git a/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx b/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx index 2fdaf1b3..20f53974 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx @@ -3,7 +3,7 @@ * Provides Fullscreen button that opens the overlay. */ -import { lazy, Suspense, useCallback, useLayoutEffect, useMemo, useState } from 'react'; +import { lazy, Suspense, useCallback, useMemo, useState } from 'react'; import { GraphView } from '@claude-teams/agent-graph'; import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost'; @@ -11,6 +11,7 @@ import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHo import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog'; import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility'; import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter'; +import { useTeamGraphSlotReset } from '../hooks/useTeamGraphSlotReset'; import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions'; import { GraphActivityHud } from './GraphActivityHud'; @@ -45,12 +46,13 @@ export const TeamGraphTab = ({ isPaneFocused = false, }: TeamGraphTabProps): React.JSX.Element => { const graphData = useTeamGraphAdapter(teamName); - const { openTeamPage, resetOwnerSlotAssignmentsToDefaults, commitOwnerSlotDrop } = - useTeamGraphSurfaceActions(teamName); + const { openTeamPage, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName); const [fullscreen, setFullscreen] = useState(false); const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility(); const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName); + useTeamGraphSlotReset(teamName, isActive); + // Typed event dispatchers (DRY — used in both events + renderOverlay) const dispatchOpenTask = useCallback( (taskId: string) => @@ -76,14 +78,6 @@ export const TeamGraphTab = ({ const openCreateTask = useCallback(() => { openCreateTaskDialog(''); }, [openCreateTaskDialog]); - - useLayoutEffect(() => { - if (!isActive) { - return; - } - resetOwnerSlotAssignmentsToDefaults(); - }, [isActive, resetOwnerSlotAssignmentsToDefaults]); - // Task action dispatchers const dispatchTaskAction = useCallback( (action: string) => (taskId: string) => diff --git a/src/features/recent-projects/contracts/normalize.ts b/src/features/recent-projects/contracts/normalize.ts index e38ce700..116912e6 100644 --- a/src/features/recent-projects/contracts/normalize.ts +++ b/src/features/recent-projects/contracts/normalize.ts @@ -3,6 +3,7 @@ import type { DashboardRecentProject, DashboardRecentProjectsPayload } from './d export type DashboardRecentProjectsPayloadLike = | DashboardRecentProjectsPayload | DashboardRecentProject[] + | { degraded?: unknown; projects?: unknown } | null | undefined; diff --git a/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts b/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts index 56985538..86805355 100644 --- a/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts +++ b/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts @@ -199,7 +199,7 @@ export class CodexAppServerClient { { clientInfo: { name: 'claude-agent-teams-ui', - title: 'Claude Agent Teams UI', + title: 'Agent Teams UI', version: '0.1.0', }, capabilities: { diff --git a/src/main/index.ts b/src/main/index.ts index 695584d9..5aa9c1d9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,5 +1,5 @@ /** - * Main process entry point for Claude Agent Teams UI. + * Main process entry point for Agent Teams UI. * * Responsibilities: * - Initialize Electron app and main window @@ -70,6 +70,7 @@ import { setReviewMainWindow } from './ipc/review'; import { setTmuxMainWindow } from './ipc/tmux'; import { ApiKeyService, + createExtensionsRuntimeAdapter, ExtensionFacadeService, GlamaMcpEnrichmentService, McpCatalogAggregator, @@ -99,6 +100,7 @@ import { type TeamReconcileTrigger, } from './services/team/TeamReconcileDrainScheduler'; import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore'; +import { clearAutoResumeService } from './services/team/AutoResumeService'; import { getAppIconPath } from './utils/appIcon'; import { getProjectsBasePath, getTeamsBasePath, getTodosBasePath } from './utils/pathDecoder'; import { @@ -878,8 +880,9 @@ async function initializeServices(): Promise { const officialMcpRegistry = new OfficialMcpRegistryService(); const glamaMcpService = new GlamaMcpEnrichmentService(); const mcpAggregator = new McpCatalogAggregator(officialMcpRegistry, glamaMcpService); - const mcpStateService = new McpInstallationStateService(); - const mcpHealthDiagnosticsService = new McpHealthDiagnosticsService(); + const extensionsRuntimeAdapter = createExtensionsRuntimeAdapter(); + const mcpStateService = new McpInstallationStateService(extensionsRuntimeAdapter); + const mcpHealthDiagnosticsService = new McpHealthDiagnosticsService(extensionsRuntimeAdapter); const skillsCatalogService = new SkillsCatalogService(); const skillsMutationService = new SkillsMutationService(); skillsWatcherService = new SkillsWatcherService(); @@ -891,8 +894,11 @@ async function initializeServices(): Promise { ); // Install services — resolve binary dynamically via ClaudeBinaryResolver - const pluginInstallService = new PluginInstallService(pluginCatalogService); - const mcpInstallService = new McpInstallService(mcpAggregator); + const pluginInstallService = new PluginInstallService( + pluginCatalogService, + extensionsRuntimeAdapter + ); + const mcpInstallService = new McpInstallService(mcpAggregator, extensionsRuntimeAdapter); const apiKeyService = new ApiKeyService(); await apiKeyService.syncProcessEnv(RUNTIME_MANAGED_API_KEY_ENV_VARS); // warmup() and ensureInstalled() are deferred to after window creation @@ -1078,6 +1084,11 @@ async function startHttpServer( function shutdownServices(): void { logger.info('Shutting down services...'); + // Clear pending auto-resume timers before anything else — otherwise the + // dangling setTimeout handles keep the event loop alive past shutdown and + // may fire against a torn-down provisioning service. + clearAutoResumeService(); + // Kill all team CLI processes via SIGKILL BEFORE anything else. // This must happen before the OS closes stdin pipes (on app exit), // because stdin EOF triggers CLI's graceful shutdown which deletes team files. @@ -1220,7 +1231,7 @@ function createWindow(): void { backgroundColor: '#1a1a1a', ...(useNativeTitleBar ? {} : { titleBarStyle: 'hidden' as const }), ...(isMac && { trafficLightPosition: getTrafficLightPositionForZoom(1) }), - title: 'Claude Agent Teams UI', + title: 'Agent Teams UI', }); markRendererUnavailable(mainWindow); diff --git a/src/main/ipc/configValidation.ts b/src/main/ipc/configValidation.ts index 2c961d60..d52b0dcf 100644 --- a/src/main/ipc/configValidation.ts +++ b/src/main/ipc/configValidation.ts @@ -125,6 +125,7 @@ function validateNotificationsSection( 'notifyOnCrossTeamMessage', 'notifyOnTeamLaunched', 'notifyOnToolApproval', + 'autoResumeOnRateLimit', 'statusChangeOnlySolo', 'statusChangeStatuses', 'triggers', @@ -219,6 +220,12 @@ function validateNotificationsSection( } result.notifyOnToolApproval = value; break; + case 'autoResumeOnRateLimit': + if (typeof value !== 'boolean') { + return { valid: false, error: `notifications.${key} must be a boolean` }; + } + result.autoResumeOnRateLimit = value; + break; case 'statusChangeOnlySolo': if (typeof value !== 'boolean') { return { valid: false, error: `notifications.${key} must be a boolean` }; diff --git a/src/main/ipc/extensions.ts b/src/main/ipc/extensions.ts index 7b51dfc1..3a3cccc0 100644 --- a/src/main/ipc/extensions.ts +++ b/src/main/ipc/extensions.ts @@ -239,8 +239,13 @@ function getMcpHealthDiagnostics(): McpHealthDiagnosticsService { return mcpHealthDiagnostics; } -async function handleMcpDiagnose(): Promise> { - return wrapHandler('mcpDiagnose', () => getMcpHealthDiagnostics().diagnose()); +async function handleMcpDiagnose( + _event: IpcMainInvokeEvent, + projectPath?: string +): Promise> { + return wrapHandler('mcpDiagnose', () => + getMcpHealthDiagnostics().diagnose(typeof projectPath === 'string' ? projectPath : undefined) + ); } // ── Install/Uninstall Handlers ──────────────────────────────────────────── @@ -416,11 +421,15 @@ async function handleApiKeysDelete( async function handleApiKeysLookup( _event: IpcMainInvokeEvent, - envVarNames?: string[] + envVarNames?: string[], + projectPath?: string ): Promise> { return wrapHandler('apiKeysLookup', () => { if (!Array.isArray(envVarNames)) throw new Error('envVarNames array is required'); - return getApiKeyService().lookup(envVarNames); + return getApiKeyService().lookup( + envVarNames, + typeof projectPath === 'string' ? projectPath : undefined + ); }); } diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 29fc6c72..9f941e7b 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -104,10 +104,15 @@ import { buildActionModeAgentBlock, isAgentActionMode, } from '../services/team/actionModeInstructions'; +import { + getAutoResumeService, + initializeAutoResumeService, +} from '../services/team/AutoResumeService'; import { buildReplaceMembersDiff, buildReplaceMembersSummaryMessage, } from '../services/team/memberUpdateNotifications'; +import { mergeLiveLeadProcessMessages } from '../services/team/mergeLiveLeadProcessMessages'; import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore'; import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore'; import { TeamMetaStore } from '../services/team/TeamMetaStore'; @@ -153,11 +158,9 @@ import type { IpcResult, KanbanColumnId, LeadActivitySnapshot, - LeadContextUsage, LeadContextUsageSnapshot, MemberFullStats, MemberLogSummary, - MemberSpawnStatusEntry, MemberSpawnStatusesSnapshot, MessagesPage, SendMessageRequest, @@ -313,11 +316,25 @@ const SEEN_API_ERROR_KEYS_MAX = 500; * and NotificationManager dedupeKey (to prevent storage duplicates). */ function checkRateLimitMessages( - messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[], + messages: readonly { + messageId?: string; + from: string; + text: string; + timestamp: string; + to?: string; + source?: string; + leadSessionId?: string; + }[], teamName: string, teamDisplayName: string, - projectPath?: string + projectPath?: string, + teamIsAlive = true, + currentLeadSessionId: string | null = null ): void { + const observedAt = new Date(); + const autoResumeEnabled = + ConfigManager.getInstance().getConfig().notifications.autoResumeOnRateLimit; + for (const msg of messages) { if (msg.from === 'user') continue; if (!isRateLimitMessage(msg.text)) continue; @@ -325,28 +342,55 @@ function checkRateLimitMessages( const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`; const dedupeKey = `rate-limit:${teamName}:${rawKey}`; - // In-memory guard: prevents resurrection after user deletes the notification - if (seenRateLimitKeys.has(dedupeKey)) continue; - seenRateLimitKeys.add(dedupeKey); + // In-memory guard: prevents resurrection after user deletes the notification. + if (!seenRateLimitKeys.has(dedupeKey)) { + seenRateLimitKeys.add(dedupeKey); - // Evict oldest entries to prevent unbounded growth - if (seenRateLimitKeys.size > SEEN_RATE_LIMIT_KEYS_MAX) { - const first = seenRateLimitKeys.values().next().value; - if (first) seenRateLimitKeys.delete(first); + // Evict oldest entries to prevent unbounded growth + if (seenRateLimitKeys.size > SEEN_RATE_LIMIT_KEYS_MAX) { + const first = seenRateLimitKeys.values().next().value; + if (first) seenRateLimitKeys.delete(first); + } + + void NotificationManager.getInstance() + .addTeamNotification({ + teamEventType: 'rate_limit', + teamName, + teamDisplayName, + from: msg.from, + summary: `Rate limit: ${msg.from}`, + body: msg.text.slice(0, 200), + dedupeKey, + projectPath, + }) + .catch(() => undefined); } - void NotificationManager.getInstance() - .addTeamNotification({ - teamEventType: 'rate_limit', + // Only schedule auto-resume while a live team run currently exists. + // Persisted history for an offline/stopped team may still contain the old + // rate-limit message, but arming a new timer from that stale history would + // resurrect the nudge into a later manual restart. + const isLeadAutoResumeCandidate = + !msg.to && (msg.source === 'lead_process' || msg.source === 'lead_session'); + + if (autoResumeEnabled && teamIsAlive && isLeadAutoResumeCandidate) { + // Only let persisted lead_session history rebuild auto-resume when it + // clearly belongs to the currently running lead session. Otherwise an old + // rate-limit from a previous manual run can resurrect into a newer restart. + if (msg.source === 'lead_session') { + if (!currentLeadSessionId) continue; + if (msg.leadSessionId !== currentLeadSessionId) continue; + } + + // Pass the original message timestamp so relative reset windows survive restarts + // and old history does not rebuild a fresh auto-resume timer from "now". + getAutoResumeService().handleRateLimitMessage( teamName, - teamDisplayName, - from: msg.from, - summary: `Rate limit: ${msg.from}`, - body: msg.text.slice(0, 200), - dedupeKey, - projectPath, - }) - .catch(() => undefined); + msg.text, + observedAt, + new Date(msg.timestamp) + ); + } } } @@ -461,6 +505,7 @@ export function initializeTeamHandlers( ): void { teamDataService = service; teamProvisioningService = provisioningService; + initializeAutoResumeService(provisioningService); teamMemberLogsFinder = logsFinder ?? null; memberStatsComputer = statsComputer ?? null; teamBackupService = backupService ?? null; @@ -788,11 +833,51 @@ async function handleGetData( } const provisioning = getTeamProvisioningService(); const isAlive = provisioning.isTeamAlive(tn); + const currentLeadSessionId = provisioning.getCurrentLeadSessionId(tn); const displayName = data.config.name || tn; const projectPath = data.config.projectPath; const live = provisioning.getLiveLeadProcessMessages(tn); - scanTeamMessageNotifications(live, tn, displayName, projectPath); + const durableMessages = Array.isArray((data as { messages?: unknown }).messages) + ? (((data as { messages?: typeof live }).messages ?? []) as typeof live) + : []; + + if (live.length === 0) { + if (durableMessages.length > 0) { + checkRateLimitMessages( + durableMessages, + tn, + displayName, + projectPath, + isAlive, + currentLeadSessionId + ); + checkApiErrorMessages(durableMessages, tn, displayName, projectPath); + } else { + scanTeamMessageNotifications(live, tn, displayName, projectPath); + } + return { success: true, data: { ...data, isAlive } }; + } + + let merged = mergeLiveLeadProcessMessages(durableMessages, live); + if (durableMessages.length >= 50) { + try { + const newestPage = await teamDataService.getMessagesPage(tn, { + limit: 50, + liveMessages: live, + }); + merged = newestPage.messages; + } catch (error) { + logger.warn( + `[teams:getData] failed to rebuild newest merged messages for ${tn}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + + checkRateLimitMessages(merged, tn, displayName, projectPath, isAlive, currentLeadSessionId); + checkApiErrorMessages(merged, tn, displayName, projectPath); return { success: true, data: { ...data, isAlive } }; } @@ -872,6 +957,7 @@ async function handleDeleteTeam( return { success: false, error: validated.error ?? 'Invalid teamName' }; } return wrapTeamHandler('deleteTeam', async () => { + getAutoResumeService().cancelPendingAutoResume(validated.value!); getTeamProvisioningService().stopTeam(validated.value!); await getTeamDataService().deleteTeam(validated.value!); }); @@ -897,6 +983,7 @@ async function handlePermanentlyDeleteTeam( return { success: false, error: validated.error ?? 'Invalid teamName' }; } return wrapTeamHandler('permanentlyDeleteTeam', async () => { + getAutoResumeService().cancelPendingAutoResume(validated.value!); await getTeamDataService().permanentlyDeleteTeam(validated.value!); // Clean up app-owned data (attachments, task-attachments) that lives outside ~/.claude/ const appData = getAppDataPath(); @@ -1680,6 +1767,24 @@ async function handleGetMessagesPage( return wrapTeamHandler('getMessagesPage', async () => { let page: MessagesPage; const notificationContext = await getTeamDataService().getTeamNotificationContext(vTeam.value!); + const liveMessages = + cursor == null ? getTeamProvisioningService().getLiveLeadProcessMessages(vTeam.value!) : []; + + if (liveMessages.length > 0) { + page = await getTeamDataService().getMessagesPage(vTeam.value!, { + cursor, + limit, + liveMessages, + }); + scanTeamMessageNotifications( + page.messages, + vTeam.value!, + notificationContext.displayName, + notificationContext.projectPath + ); + return page; + } + const worker = getTeamDataWorkerClient(); if (worker.isAvailable()) { try { @@ -2734,6 +2839,7 @@ async function handleStopTeam( } return wrapTeamHandler('stop', async () => { addMainBreadcrumb('team', 'stop', { teamName: validated.value! }); + getAutoResumeService().cancelPendingAutoResume(validated.value!); getTeamProvisioningService().stopTeam(validated.value!); }); } diff --git a/src/main/services/extensions/apikeys/ApiKeyService.ts b/src/main/services/extensions/apikeys/ApiKeyService.ts index 35760292..19c4500a 100644 --- a/src/main/services/extensions/apikeys/ApiKeyService.ts +++ b/src/main/services/extensions/apikeys/ApiKeyService.ts @@ -39,6 +39,7 @@ interface StoredApiKey { encrypted?: boolean; encryptionMethod?: EncryptionMethod; scope: 'user' | 'project'; + projectPath?: string; createdAt: string; updatedAt?: string; } @@ -73,6 +74,7 @@ export class ApiKeyService { envVarName: k.envVarName, maskedValue: this.mask(this.decrypt(k)), scope: k.scope, + projectPath: k.projectPath, createdAt: k.createdAt, })); } @@ -86,6 +88,9 @@ export class ApiKeyService { ); } if (!request.value) throw new Error('Key value is required'); + if (request.scope === 'project' && (!request.projectPath || !request.projectPath.trim())) { + throw new Error('Project-scoped API keys require a project path'); + } const keys = await this.readStore(); const now = new Date().toISOString(); @@ -101,6 +106,7 @@ export class ApiKeyService { encryptedValue: value, encryptionMethod: method, scope: request.scope, + projectPath: request.scope === 'project' ? request.projectPath?.trim() : undefined, updatedAt: now, }; delete keys[idx].encrypted; @@ -112,6 +118,7 @@ export class ApiKeyService { encryptedValue: value, encryptionMethod: method, scope: request.scope, + projectPath: request.scope === 'project' ? request.projectPath?.trim() : undefined, createdAt: now, }); } @@ -124,6 +131,7 @@ export class ApiKeyService { envVarName: saved.envVarName, maskedValue: this.mask(request.value), scope: saved.scope, + projectPath: saved.projectPath, createdAt: saved.createdAt, }; } @@ -135,25 +143,36 @@ export class ApiKeyService { await this.writeStore(filtered); } - async lookup(envVarNames: string[]): Promise { + async lookup(envVarNames: string[], projectPath?: string): Promise { if (!envVarNames.length) return []; const keys = await this.readStore(); - const nameSet = new Set(envVarNames); - return keys - .filter((k) => nameSet.has(k.envVarName)) - .map((k) => ({ - envVarName: k.envVarName, - value: this.decrypt(k), - })); + return Array.from(new Set(envVarNames)).flatMap((envVarName) => { + const preferred = this.pickPreferredKey( + keys.filter((key) => key.envVarName === envVarName), + projectPath + ); + if (!preferred) { + return []; + } + + return [ + { + envVarName: preferred.envVarName, + value: this.decrypt(preferred), + }, + ]; + }); } - async lookupPreferred(envVarName: string): Promise { + async lookupPreferred( + envVarName: string, + projectPath?: string + ): Promise { const keys = await this.readStore(); - const matching = keys.filter((key) => key.envVarName === envVarName); - const preferred = - matching.find((key) => key.scope === 'user') ?? - matching.find((key) => key.scope === 'project') ?? - null; + const preferred = this.pickPreferredKey( + keys.filter((key) => key.envVarName === envVarName), + projectPath + ); if (!preferred) { return null; @@ -280,6 +299,20 @@ export class ApiKeyService { return stored.encrypted ? 'safeStorage' : 'base64'; } + private pickPreferredKey(matching: StoredApiKey[], projectPath?: string): StoredApiKey | null { + const normalizedProjectPath = projectPath?.trim(); + if (normalizedProjectPath) { + const projectMatch = matching.find( + (key) => key.scope === 'project' && key.projectPath === normalizedProjectPath + ); + if (projectMatch) { + return projectMatch; + } + } + + return matching.find((key) => key.scope === 'user') ?? null; + } + // ── AES-256-GCM local encryption ─────────────────────────────────────── /** diff --git a/src/main/services/extensions/index.ts b/src/main/services/extensions/index.ts index 1e056147..d2aaf042 100644 --- a/src/main/services/extensions/index.ts +++ b/src/main/services/extensions/index.ts @@ -11,6 +11,11 @@ export { PluginCatalogService } from './catalog/PluginCatalogService'; export { ExtensionFacadeService } from './ExtensionFacadeService'; export { McpInstallService } from './install/McpInstallService'; export { PluginInstallService } from './install/PluginInstallService'; +export { + ClaudeExtensionsAdapter, + createExtensionsRuntimeAdapter, + MultimodelExtensionsAdapter, +} from './runtime/ExtensionsRuntimeAdapter'; export { SkillImportService } from './skills/SkillImportService'; export { SkillMetadataParser } from './skills/SkillMetadataParser'; export { SkillPlanService } from './skills/SkillPlanService'; diff --git a/src/main/services/extensions/install/McpInstallService.ts b/src/main/services/extensions/install/McpInstallService.ts index 687c904a..31e89219 100644 --- a/src/main/services/extensions/install/McpInstallService.ts +++ b/src/main/services/extensions/install/McpInstallService.ts @@ -9,12 +9,15 @@ import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; import { execCli } from '@main/utils/childProcess'; -import { buildEnrichedEnv } from '@main/utils/cliEnv'; import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli'; import { createLogger } from '@shared/utils/logger'; +import { isProjectScopedMcpScope } from '@shared/utils/mcpScopes'; import path from 'path'; +import { createExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter'; + import type { McpCatalogAggregator } from '../catalog/McpCatalogAggregator'; +import type { ExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter'; import type { McpCustomInstallRequest, McpInstallRequest, @@ -27,7 +30,7 @@ const logger = createLogger('Extensions:McpInstall'); const SERVER_NAME_RE = /^[\w.-]{1,100}$/; /** Allowed scope values (prevent command injection) */ -const VALID_SCOPES = new Set(['local', 'user', 'project']); +const VALID_SCOPES = new Set(['local', 'user', 'project', 'global']); /** Env var key must be safe shell identifier */ const ENV_KEY_RE = /^[A-Z_][A-Z0-9_]{0,100}$/i; @@ -38,11 +41,14 @@ const HEADER_KEY_RE = /^[A-Za-z][\w-]{0,100}$/; const TIMEOUT_MS = 30_000; function scopeRequiresProjectPath(scope?: string): boolean { - return scope === 'local' || scope === 'project'; + return isProjectScopedMcpScope(scope); } export class McpInstallService { - constructor(private readonly aggregator: McpCatalogAggregator) {} + constructor( + private readonly aggregator: McpCatalogAggregator, + private readonly runtimeAdapter: ExtensionsRuntimeAdapter = createExtensionsRuntimeAdapter() + ) {} async install(request: McpInstallRequest): Promise { const { registryId, serverName, scope, projectPath, envValues, headers } = request; @@ -59,7 +65,7 @@ export class McpInstallService { if (scope && !VALID_SCOPES.has(scope)) { return { state: 'error', - error: `Invalid scope: "${scope}". Must be one of: local, user, project.`, + error: `Invalid scope: "${scope}". Must be one of: local, user, project, global.`, }; } @@ -180,11 +186,12 @@ export class McpInstallService { error: CLI_NOT_FOUND_MESSAGE, }; } + const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary); const { stderr } = await execCli(claudeBinary, args, { timeout: TIMEOUT_MS, cwd: projectPath, - env: buildEnrichedEnv(claudeBinary), + env, }); if (stderr) { @@ -295,11 +302,12 @@ export class McpInstallService { error: CLI_NOT_FOUND_MESSAGE, }; } + const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary); const { stderr } = await execCli(claudeBinary, args, { timeout: TIMEOUT_MS, cwd: projectPath, - env: buildEnrichedEnv(claudeBinary), + env, }); if (stderr) { @@ -330,7 +338,7 @@ export class McpInstallService { if (scope && !VALID_SCOPES.has(scope)) { return { state: 'error', - error: `Invalid scope: "${scope}". Must be one of: local, user, project.`, + error: `Invalid scope: "${scope}". Must be one of: local, user, project, global.`, }; } @@ -364,11 +372,12 @@ export class McpInstallService { error: CLI_NOT_FOUND_MESSAGE, }; } + const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary); await execCli(claudeBinary, args, { timeout: TIMEOUT_MS, cwd: projectPath, - env: buildEnrichedEnv(claudeBinary), + env, }); return { state: 'success' }; } catch (err) { diff --git a/src/main/services/extensions/install/PluginInstallService.ts b/src/main/services/extensions/install/PluginInstallService.ts index 0b994f9f..93479c38 100644 --- a/src/main/services/extensions/install/PluginInstallService.ts +++ b/src/main/services/extensions/install/PluginInstallService.ts @@ -7,12 +7,14 @@ import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; import { execCli } from '@main/utils/childProcess'; -import { buildEnrichedEnv } from '@main/utils/cliEnv'; import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli'; import { createLogger } from '@shared/utils/logger'; import path from 'path'; +import { createExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter'; + import type { PluginCatalogService } from '../catalog/PluginCatalogService'; +import type { ExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter'; import type { OperationResult, PluginInstallRequest } from '@shared/types/extensions'; const logger = createLogger('Extensions:PluginInstall'); @@ -31,7 +33,10 @@ function scopeRequiresProjectPath(scope?: string): boolean { } export class PluginInstallService { - constructor(private readonly catalogService: PluginCatalogService) {} + constructor( + private readonly catalogService: PluginCatalogService, + private readonly runtimeAdapter: ExtensionsRuntimeAdapter = createExtensionsRuntimeAdapter() + ) {} async install(request: PluginInstallRequest): Promise { const { pluginId, scope, projectPath } = request; @@ -95,11 +100,12 @@ export class PluginInstallService { error: CLI_NOT_FOUND_MESSAGE, }; } + const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary); const { stdout, stderr } = await execCli(claudeBinary, args, { timeout: INSTALL_TIMEOUT_MS, cwd: projectPath, - env: buildEnrichedEnv(claudeBinary), + env, }); if (stderr && !stdout) { @@ -175,11 +181,12 @@ export class PluginInstallService { error: CLI_NOT_FOUND_MESSAGE, }; } + const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary); await execCli(claudeBinary, args, { timeout: UNINSTALL_TIMEOUT_MS, cwd: projectPath, - env: buildEnrichedEnv(claudeBinary), + env, }); return { state: 'success' }; } catch (err) { diff --git a/src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts b/src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts new file mode 100644 index 00000000..9fb3e4ce --- /dev/null +++ b/src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts @@ -0,0 +1,134 @@ +import { buildProviderAwareCliEnv } from '@main/services/runtime/providerAwareCliEnv'; +import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; +import { getConfiguredCliFlavor } from '@main/services/team/cliFlavor'; +import { execCli } from '@main/utils/childProcess'; +import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli'; + +import { McpConfigStateReader } from './McpConfigStateReader'; +import { parseMcpDiagnosticsJsonOutput, parseMcpDiagnosticsOutput } from './mcpDiagnosticsParser'; +import { parseInstalledMcpJsonOutput } from './mcpRuntimeJson'; + +import type { CliFlavor } from '@shared/types'; +import type { InstalledMcpEntry, McpServerDiagnostic } from '@shared/types/extensions'; + +const MCP_LIST_TIMEOUT_MS = 15_000; +const MCP_DIAGNOSE_TIMEOUT_MS = 60_000; + +async function buildManagementCliEnvForBinary(binaryPath: string): Promise { + const { env } = await buildProviderAwareCliEnv({ + binaryPath, + connectionMode: 'augment', + }); + return env; +} + +export interface ExtensionsRuntimeAdapter { + readonly flavor: CliFlavor; + buildManagementCliEnv(binaryPath: string): Promise; + getInstalledMcp(projectPath?: string): Promise; + diagnoseMcp(projectPath?: string): Promise; +} + +export class ClaudeExtensionsAdapter implements ExtensionsRuntimeAdapter { + readonly flavor = 'claude' as const; + + constructor(private readonly stateReader = new McpConfigStateReader()) {} + + async buildManagementCliEnv(binaryPath: string): Promise { + return buildManagementCliEnvForBinary(binaryPath); + } + + async getInstalledMcp(projectPath?: string): Promise { + return this.stateReader.readInstalled(projectPath); + } + + async diagnoseMcp(projectPath?: string): Promise { + const binaryPath = await ClaudeBinaryResolver.resolve(); + if (!binaryPath) { + throw new Error(CLI_NOT_FOUND_MESSAGE); + } + + const env = await this.buildManagementCliEnv(binaryPath); + const { stdout, stderr } = await execCli(binaryPath, ['mcp', 'list'], { + timeout: MCP_DIAGNOSE_TIMEOUT_MS, + cwd: projectPath, + env, + }); + + return parseMcpDiagnosticsOutput([stdout, stderr].filter(Boolean).join('\n')); + } +} + +export class MultimodelExtensionsAdapter implements ExtensionsRuntimeAdapter { + readonly flavor = 'agent_teams_orchestrator' as const; + + async buildManagementCliEnv(binaryPath: string): Promise { + return buildManagementCliEnvForBinary(binaryPath); + } + + async getInstalledMcp(projectPath?: string): Promise { + const binaryPath = await ClaudeBinaryResolver.resolve(); + if (!binaryPath) { + throw new Error(CLI_NOT_FOUND_MESSAGE); + } + + const env = await this.buildManagementCliEnv(binaryPath); + const { stdout } = await execCli(binaryPath, ['mcp', 'list', '--json'], { + timeout: MCP_LIST_TIMEOUT_MS, + cwd: projectPath, + env, + }); + + return parseInstalledMcpJsonOutput(stdout); + } + + async diagnoseMcp(projectPath?: string): Promise { + const binaryPath = await ClaudeBinaryResolver.resolve(); + if (!binaryPath) { + throw new Error(CLI_NOT_FOUND_MESSAGE); + } + + const env = await this.buildManagementCliEnv(binaryPath); + const { stdout } = await execCli(binaryPath, ['mcp', 'diagnose', '--json'], { + timeout: MCP_DIAGNOSE_TIMEOUT_MS, + cwd: projectPath, + env, + }); + + return parseMcpDiagnosticsJsonOutput(stdout); + } +} + +class RuntimeSwitchingExtensionsAdapter implements ExtensionsRuntimeAdapter { + constructor( + private readonly claudeAdapter: ClaudeExtensionsAdapter, + private readonly multimodelAdapter: MultimodelExtensionsAdapter + ) {} + + private getActiveAdapter(): ExtensionsRuntimeAdapter { + return getConfiguredCliFlavor() === 'claude' ? this.claudeAdapter : this.multimodelAdapter; + } + + get flavor(): CliFlavor { + return this.getActiveAdapter().flavor; + } + + buildManagementCliEnv(binaryPath: string): Promise { + return this.getActiveAdapter().buildManagementCliEnv(binaryPath); + } + + getInstalledMcp(projectPath?: string): Promise { + return this.getActiveAdapter().getInstalledMcp(projectPath); + } + + diagnoseMcp(projectPath?: string): Promise { + return this.getActiveAdapter().diagnoseMcp(projectPath); + } +} + +export function createExtensionsRuntimeAdapter(): ExtensionsRuntimeAdapter { + return new RuntimeSwitchingExtensionsAdapter( + new ClaudeExtensionsAdapter(), + new MultimodelExtensionsAdapter() + ); +} diff --git a/src/main/services/extensions/runtime/McpConfigStateReader.ts b/src/main/services/extensions/runtime/McpConfigStateReader.ts new file mode 100644 index 00000000..a9302277 --- /dev/null +++ b/src/main/services/extensions/runtime/McpConfigStateReader.ts @@ -0,0 +1,101 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +import { getHomeDir } from '@main/utils/pathDecoder'; +import { createLogger } from '@shared/utils/logger'; + +import type { InstalledMcpEntry } from '@shared/types/extensions'; + +const logger = createLogger('Extensions:McpConfigStateReader'); + +export class McpConfigStateReader { + async readInstalled(projectPath?: string): Promise { + const entries: InstalledMcpEntry[] = []; + const claudeConfig = await this.readClaudeConfig(); + + entries.push(...this.readUserMcpServers(claudeConfig)); + + if (projectPath) { + entries.push(...this.readLocalMcpServers(claudeConfig, projectPath)); + entries.push(...(await this.readProjectMcpServers(projectPath))); + } + + return entries; + } + + private async readClaudeConfig(): Promise | null> { + const configPath = path.join(getHomeDir(), '.claude.json'); + try { + const raw = await fs.readFile(configPath, 'utf-8'); + return JSON.parse(raw) as Record; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + logger.error(`Failed to read MCP servers from ${configPath}:`, err); + return null; + } + } + + private readUserMcpServers(config: Record | null): InstalledMcpEntry[] { + return this.readMcpServersFromConfig(config?.mcpServers, 'user'); + } + + private readLocalMcpServers( + config: Record | null, + projectPath: string + ): InstalledMcpEntry[] { + const projects = + config && typeof config.projects === 'object' && config.projects + ? (config.projects as Record) + : null; + const projectConfig = + projects && typeof projects[projectPath] === 'object' && projects[projectPath] + ? (projects[projectPath] as Record) + : null; + return this.readMcpServersFromConfig(projectConfig?.mcpServers, 'local'); + } + + private async readProjectMcpServers(projectPath: string): Promise { + const configPath = path.join(projectPath, '.mcp.json'); + return this.readMcpServersFromFile(configPath, 'project'); + } + + private readMcpServersFromConfig( + value: unknown, + scope: 'user' | 'project' | 'local' + ): InstalledMcpEntry[] { + const mcpServers = + value && typeof value === 'object' + ? (value as Record) + : null; + if (!mcpServers) { + return []; + } + + return Object.entries(mcpServers).map(([name, config]): InstalledMcpEntry => { + let transport: string | undefined; + if (config.command) transport = 'stdio'; + else if (config.url) transport = 'http'; + + return { name, scope, transport }; + }); + } + + private async readMcpServersFromFile( + filePath: string, + scope: 'user' | 'project' + ): Promise { + try { + const raw = await fs.readFile(filePath, 'utf-8'); + const json = JSON.parse(raw) as Record; + return this.readMcpServersFromConfig(json.mcpServers, scope); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return []; + } + logger.error(`Failed to read MCP servers from ${filePath}:`, err); + return []; + } + } +} diff --git a/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts b/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts new file mode 100644 index 00000000..e35d6b7f --- /dev/null +++ b/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts @@ -0,0 +1,215 @@ +import { isInstalledMcpScope } from '@shared/utils/mcpScopes'; + +import type { McpServerDiagnostic, McpServerHealthStatus } from '@shared/types/extensions'; + +interface McpDiagnoseJsonEntry { + name?: string; + target?: string; + scope?: 'local' | 'user' | 'project' | 'global' | 'dynamic' | 'managed'; + transport?: string; + status?: 'connected' | 'needs-authentication' | 'failed' | 'timeout'; + statusLabel?: string; +} + +interface McpDiagnoseJsonPayload { + checkedAt?: string; + diagnostics?: McpDiagnoseJsonEntry[]; +} + +const EMBEDDED_HTTP_URL_PATTERN = /https?:\/\/[^\s"'`]+/gi; +const SENSITIVE_FLAG_VALUE_PATTERN = /(--[a-z0-9_-]+)(?:=([^\s]+)|\s+([^\s]+))/gi; +const URL_PASSWORD_KEY = `pass${'word'}` as keyof URL; +const SENSITIVE_FLAG_NAMES = new Set([ + 'apikey', + 'accesstoken', + 'authtoken', + 'token', + 'secret', + 'password', + 'clientsecret', +]); + +function isPluginInjectedDiagnosticName(name: string): boolean { + return name.startsWith('plugin:'); +} + +function isExtensionsManagedDiagnosticEntry(entry: { + name: string; + scope?: 'local' | 'user' | 'project' | 'global' | 'dynamic' | 'managed'; +}): boolean { + if (isPluginInjectedDiagnosticName(entry.name)) { + return false; + } + + return entry.scope === undefined || isInstalledMcpScope(entry.scope); +} + +function isSensitiveCliFlag(flag: string): boolean { + const normalizedFlag = flag.toLowerCase().replace(/^--/, '').replace(/[-_]/g, ''); + return SENSITIVE_FLAG_NAMES.has(normalizedFlag); +} + +function extractJsonObject(raw: string): T { + const trimmed = raw.trim(); + try { + return JSON.parse(trimmed) as T; + } catch { + const start = trimmed.indexOf('{'); + const end = trimmed.lastIndexOf('}'); + if (start >= 0 && end > start) { + return JSON.parse(trimmed.slice(start, end + 1)) as T; + } + throw new Error('No JSON object found in CLI output'); + } +} + +function parseStatusChunk(statusChunk: string): { + status: McpServerHealthStatus; + statusLabel: string; +} { + const symbol = statusChunk[0]; + const label = statusChunk.slice(1).trim() || 'Unknown'; + + switch (symbol) { + case '✓': + return { status: 'connected', statusLabel: label }; + case '!': + return { status: 'needs-authentication', statusLabel: label }; + case '✗': + return { status: 'failed', statusLabel: label }; + default: + return { status: 'unknown', statusLabel: statusChunk }; + } +} + +function redactHttpUrl(urlString: string): string { + try { + const parsed = new URL(urlString); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return urlString; + } + + const passwordField = parsed[URL_PASSWORD_KEY]; + const hasUsername = parsed.username.length > 0; + const hasPassword = Boolean(passwordField); + + if (!hasUsername && !hasPassword && !parsed.search && !parsed.hash) { + return urlString; + } + + const redactedSearchParams = new URLSearchParams(parsed.search); + for (const key of new Set(redactedSearchParams.keys())) { + redactedSearchParams.set(key, 'REDACTED'); + } + + const authPrefix = + hasUsername || hasPassword + ? `${hasUsername ? '***' : ''}${hasPassword ? `${hasUsername ? ':' : ''}***` : ''}@` + : ''; + const searchSuffix = redactedSearchParams.size > 0 ? `?${redactedSearchParams.toString()}` : ''; + const hashSuffix = parsed.hash ? '#REDACTED' : ''; + + return `${parsed.protocol}//${authPrefix}${parsed.host}${parsed.pathname}${searchSuffix}${hashSuffix}`; + } catch { + return urlString; + } +} + +function redactDiagnosticTarget(target: string): string { + return target + .replace(EMBEDDED_HTTP_URL_PATTERN, (match) => redactHttpUrl(match)) + .replace( + SENSITIVE_FLAG_VALUE_PATTERN, + (match, flag: string, inlineValue?: string, separatedValue?: string) => { + if (!isSensitiveCliFlag(flag)) { + return match; + } + + return inlineValue || separatedValue ? `${flag}=REDACTED` : `${flag} REDACTED`; + } + ); +} + +function parseDiagnosticLine(line: string, checkedAt: number): McpServerDiagnostic | null { + const statusSeparatorIdx = line.lastIndexOf(' - '); + if (statusSeparatorIdx === -1) { + return null; + } + + const descriptor = line.slice(0, statusSeparatorIdx).trim(); + const statusChunk = line.slice(statusSeparatorIdx + 3).trim(); + + const nameSeparatorIdx = descriptor.indexOf(': '); + if (nameSeparatorIdx === -1) { + return null; + } + + const name = descriptor.slice(0, nameSeparatorIdx).trim(); + const target = redactDiagnosticTarget(descriptor.slice(nameSeparatorIdx + 2).trim()); + if (!name || !target) { + return null; + } + + const { status, statusLabel } = parseStatusChunk(statusChunk); + + return { + name, + target, + status, + statusLabel, + rawLine: line, + checkedAt, + }; +} + +export function parseMcpDiagnosticsOutput(output: string): McpServerDiagnostic[] { + const checkedAt = Date.now(); + + return output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('Checking MCP server health')) + .map((line) => parseDiagnosticLine(line, checkedAt)) + .filter((entry): entry is McpServerDiagnostic => entry !== null) + .filter((entry) => isExtensionsManagedDiagnosticEntry(entry)); +} + +export function parseMcpDiagnosticsJsonOutput(output: string): McpServerDiagnostic[] { + const parsed = extractJsonObject(output); + const checkedAtValue = parsed.checkedAt ? Date.parse(parsed.checkedAt) : Number.NaN; + const checkedAt = Number.isFinite(checkedAtValue) ? checkedAtValue : Date.now(); + + return (parsed.diagnostics ?? []).flatMap((entry) => { + if ( + typeof entry.name !== 'string' || + typeof entry.target !== 'string' || + typeof entry.statusLabel !== 'string' + ) { + return []; + } + + const redactedTarget = redactDiagnosticTarget(entry.target); + const normalizedStatus: McpServerHealthStatus = + entry.status === 'connected' + ? 'connected' + : entry.status === 'needs-authentication' + ? 'needs-authentication' + : entry.status === 'failed' || entry.status === 'timeout' + ? 'failed' + : 'unknown'; + + const rawLine = `${entry.name}: ${redactedTarget} - ${entry.statusLabel}`; + const diagnostic = { + name: entry.name, + target: redactedTarget, + scope: entry.scope, + transport: entry.transport, + status: normalizedStatus, + statusLabel: entry.statusLabel, + rawLine, + checkedAt, + } satisfies McpServerDiagnostic; + + return isExtensionsManagedDiagnosticEntry(diagnostic) ? [diagnostic] : []; + }); +} diff --git a/src/main/services/extensions/runtime/mcpRuntimeJson.ts b/src/main/services/extensions/runtime/mcpRuntimeJson.ts new file mode 100644 index 00000000..858f532c --- /dev/null +++ b/src/main/services/extensions/runtime/mcpRuntimeJson.ts @@ -0,0 +1,45 @@ +import { isInstalledMcpScope } from '@shared/utils/mcpScopes'; + +import type { InstalledMcpEntry } from '@shared/types/extensions'; + +interface McpListJsonServer { + name?: string; + scope?: string; + transport?: string; +} + +interface McpListJsonPayload { + servers?: McpListJsonServer[]; +} + +function extractJsonObject(raw: string): T { + const trimmed = raw.trim(); + try { + return JSON.parse(trimmed) as T; + } catch { + const start = trimmed.indexOf('{'); + const end = trimmed.lastIndexOf('}'); + if (start >= 0 && end > start) { + return JSON.parse(trimmed.slice(start, end + 1)) as T; + } + throw new Error('No JSON object found in CLI output'); + } +} + +export function parseInstalledMcpJsonOutput(output: string): InstalledMcpEntry[] { + const parsed = extractJsonObject(output); + + return (parsed.servers ?? []).flatMap((entry) => { + if (typeof entry.name !== 'string' || !isInstalledMcpScope(entry.scope)) { + return []; + } + + return [ + { + name: entry.name, + scope: entry.scope, + transport: typeof entry.transport === 'string' ? entry.transport : undefined, + }, + ]; + }); +} diff --git a/src/main/services/extensions/skills/SkillMetadataParser.ts b/src/main/services/extensions/skills/SkillMetadataParser.ts index 9f93b607..cef82046 100644 --- a/src/main/services/extensions/skills/SkillMetadataParser.ts +++ b/src/main/services/extensions/skills/SkillMetadataParser.ts @@ -117,7 +117,7 @@ export class SkillMetadataParser { code: 'has-scripts', message: 'This skill includes a scripts directory. Review bundled scripts before trusting it.', - severity: 'warning', + severity: 'info', }); } diff --git a/src/main/services/extensions/skills/SkillRootsResolver.ts b/src/main/services/extensions/skills/SkillRootsResolver.ts index 288b54e5..fcadf4c5 100644 --- a/src/main/services/extensions/skills/SkillRootsResolver.ts +++ b/src/main/services/extensions/skills/SkillRootsResolver.ts @@ -1,6 +1,7 @@ import * as path from 'node:path'; import { getHomeDir } from '@main/utils/pathDecoder'; +import { SKILL_ROOT_DEFINITIONS } from '@shared/utils/skillRoots'; import type { SkillRootKind, SkillScope } from '@shared/types/extensions'; @@ -11,11 +12,12 @@ export interface ResolvedSkillRoot { rootPath: string; } -const USER_ROOTS: { rootKind: SkillRootKind; segments: string[] }[] = [ - { rootKind: 'claude', segments: ['.claude', 'skills'] }, - { rootKind: 'cursor', segments: ['.cursor', 'skills'] }, - { rootKind: 'agents', segments: ['.agents', 'skills'] }, -]; +const USER_ROOTS: { rootKind: SkillRootKind; segments: string[] }[] = SKILL_ROOT_DEFINITIONS.map( + (definition) => ({ + rootKind: definition.rootKind, + segments: [...definition.segments], + }) +); export class SkillRootsResolver { resolve(projectPath?: string): ResolvedSkillRoot[] { diff --git a/src/main/services/extensions/skills/SkillValidator.ts b/src/main/services/extensions/skills/SkillValidator.ts index a68dfb79..4de32e57 100644 --- a/src/main/services/extensions/skills/SkillValidator.ts +++ b/src/main/services/extensions/skills/SkillValidator.ts @@ -1,9 +1,12 @@ +import { formatSkillRootKind, getSkillAudience } from '@shared/utils/skillRoots'; + import type { SkillCatalogItem } from '@shared/types/extensions'; const ROOT_PRECEDENCE: Record = { claude: 0, cursor: 1, agents: 2, + codex: 3, }; export class SkillValidator { @@ -21,14 +24,14 @@ export class SkillValidator { private annotateDuplicateNames(items: SkillCatalogItem[]): SkillCatalogItem[] { const itemsByName = new Map(); for (const item of items) { - const key = item.name.trim().toLowerCase(); + const key = `${item.name.trim().toLowerCase()}::${getSkillAudience(item.rootKind)}`; const bucket = itemsByName.get(key) ?? []; bucket.push(item); itemsByName.set(key, bucket); } return items.map((item) => { - const key = item.name.trim().toLowerCase(); + const key = `${item.name.trim().toLowerCase()}::${getSkillAudience(item.rootKind)}`; const duplicates = itemsByName.get(key) ?? []; if (duplicates.length <= 1) { return item; @@ -59,6 +62,7 @@ export class SkillValidator { } private formatRootLabel(item: SkillCatalogItem): string { - return item.scope === 'project' ? `project .${item.rootKind}` : `.${item.rootKind}`; + const rootLabel = formatSkillRootKind(item.rootKind); + return item.scope === 'project' ? `project ${rootLabel}` : rootLabel; } } diff --git a/src/main/services/extensions/state/McpHealthDiagnosticsService.ts b/src/main/services/extensions/state/McpHealthDiagnosticsService.ts index 1000adc9..c926cc62 100644 --- a/src/main/services/extensions/state/McpHealthDiagnosticsService.ts +++ b/src/main/services/extensions/state/McpHealthDiagnosticsService.ts @@ -1,97 +1,27 @@ /** - * Runs `claude mcp list` and parses per-server health statuses. + * Resolves MCP diagnostics through the active runtime adapter. + * + * Direct Claude mode parses `claude mcp list` text output. + * Multimodel mode uses the structured `mcp diagnose --json` runtime contract. */ -import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; -import { execCli } from '@main/utils/childProcess'; -import { buildEnrichedEnv } from '@main/utils/cliEnv'; -import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli'; -import { createLogger } from '@shared/utils/logger'; +import { createExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter'; +import { + parseMcpDiagnosticsJsonOutput, + parseMcpDiagnosticsOutput, +} from '../runtime/mcpDiagnosticsParser'; -import type { McpServerDiagnostic, McpServerHealthStatus } from '@shared/types/extensions'; +import type { ExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter'; +import type { McpServerDiagnostic } from '@shared/types/extensions'; -const logger = createLogger('Extensions:McpHealthDiagnostics'); - -const TIMEOUT_MS = 30_000; +export { parseMcpDiagnosticsJsonOutput, parseMcpDiagnosticsOutput }; export class McpHealthDiagnosticsService { - async diagnose(): Promise { - const claudeBinary = await ClaudeBinaryResolver.resolve(); - if (!claudeBinary) { - throw new Error(CLI_NOT_FOUND_MESSAGE); - } + constructor( + private readonly runtimeAdapter: ExtensionsRuntimeAdapter = createExtensionsRuntimeAdapter() + ) {} - const { stdout, stderr } = await execCli(claudeBinary, ['mcp', 'list'], { - timeout: TIMEOUT_MS, - env: buildEnrichedEnv(claudeBinary), - }); - - const output = [stdout, stderr].filter(Boolean).join('\n'); - const diagnostics = parseMcpDiagnosticsOutput(output); - - logger.info(`Parsed ${diagnostics.length} MCP diagnostic entries`); - return diagnostics; - } -} - -export function parseMcpDiagnosticsOutput(output: string): McpServerDiagnostic[] { - const checkedAt = Date.now(); - - return output - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => line.length > 0 && !line.startsWith('Checking MCP server health')) - .map((line) => parseDiagnosticLine(line, checkedAt)) - .filter((entry): entry is McpServerDiagnostic => entry !== null); -} - -function parseDiagnosticLine(line: string, checkedAt: number): McpServerDiagnostic | null { - const statusSeparatorIdx = line.lastIndexOf(' - '); - if (statusSeparatorIdx === -1) { - return null; - } - - const descriptor = line.slice(0, statusSeparatorIdx).trim(); - const statusChunk = line.slice(statusSeparatorIdx + 3).trim(); - - const nameSeparatorIdx = descriptor.indexOf(': '); - if (nameSeparatorIdx === -1) { - return null; - } - - const name = descriptor.slice(0, nameSeparatorIdx).trim(); - const target = descriptor.slice(nameSeparatorIdx + 2).trim(); - if (!name || !target) { - return null; - } - - const { status, statusLabel } = parseStatusChunk(statusChunk); - - return { - name, - target, - status, - statusLabel, - rawLine: line, - checkedAt, - }; -} - -function parseStatusChunk(statusChunk: string): { - status: McpServerHealthStatus; - statusLabel: string; -} { - const symbol = statusChunk[0]; - const label = statusChunk.slice(1).trim() || 'Unknown'; - - switch (symbol) { - case '✓': - return { status: 'connected', statusLabel: label }; - case '!': - return { status: 'needs-authentication', statusLabel: label }; - case '✗': - return { status: 'failed', statusLabel: label }; - default: - return { status: 'unknown', statusLabel: statusChunk }; + async diagnose(projectPath?: string): Promise { + return this.runtimeAdapter.diagnoseMcp(projectPath); } } diff --git a/src/main/services/extensions/state/McpInstallationStateService.ts b/src/main/services/extensions/state/McpInstallationStateService.ts index a550237b..5aee9f75 100644 --- a/src/main/services/extensions/state/McpInstallationStateService.ts +++ b/src/main/services/extensions/state/McpInstallationStateService.ts @@ -1,24 +1,15 @@ /** - * Reads installed MCP server state from the filesystem. + * Resolves installed MCP server state through the active runtime adapter. * - * Sources: - * - User scope: ~/.claude.json → mcpServers - * - Local scope: ~/.claude.json → projects[projectPath].mcpServers - * - Project scope: .mcp.json in project root - * - * Both files are managed by the Claude CLI. This service is read-only. + * Direct Claude mode reads CLI-managed config files. + * Multimodel mode uses the structured `mcp list --json` runtime contract. */ -import * as fs from 'node:fs/promises'; -import * as path from 'node:path'; - -import { getHomeDir } from '@main/utils/pathDecoder'; -import { createLogger } from '@shared/utils/logger'; +import { createExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter'; +import type { ExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter'; import type { InstalledMcpEntry } from '@shared/types/extensions'; -const logger = createLogger('Extensions:McpState'); - const CACHE_TTL_MS = 10_000; // 10 seconds interface TimedCache { @@ -29,113 +20,23 @@ interface TimedCache { export class McpInstallationStateService { private cache = new Map>(); - /** - * Get all installed MCP servers across user, local, and project scopes. - */ + constructor( + private readonly runtimeAdapter: ExtensionsRuntimeAdapter = createExtensionsRuntimeAdapter() + ) {} + async getInstalled(projectPath?: string): Promise { - const cacheKey = projectPath ?? '__user__'; + const cacheKey = `${this.runtimeAdapter.flavor}:${projectPath ?? '__user__'}`; const cached = this.cache.get(cacheKey); if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { return cached.data; } - const entries: InstalledMcpEntry[] = []; - const claudeConfig = await this.readClaudeConfig(); - - // User scope: ~/.claude.json - entries.push(...this.readUserMcpServers(claudeConfig)); - - if (projectPath) { - entries.push(...this.readLocalMcpServers(claudeConfig, projectPath)); - entries.push(...(await this.readProjectMcpServers(projectPath))); - } - + const entries = await this.runtimeAdapter.getInstalledMcp(projectPath); this.cache.set(cacheKey, { data: entries, fetchedAt: Date.now() }); return entries; } - /** - * Invalidate cache. Call after install/uninstall operations. - */ invalidateCache(): void { this.cache.clear(); } - - // ── Private ──────────────────────────────────────────────────────────── - - private async readClaudeConfig(): Promise | null> { - const configPath = path.join(getHomeDir(), '.claude.json'); - try { - const raw = await fs.readFile(configPath, 'utf-8'); - return JSON.parse(raw) as Record; - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - return null; - } - logger.error(`Failed to read MCP servers from ${configPath}:`, err); - return null; - } - } - - private readUserMcpServers(config: Record | null): InstalledMcpEntry[] { - return this.readMcpServersFromConfig(config?.mcpServers, 'user'); - } - - private readLocalMcpServers( - config: Record | null, - projectPath: string - ): InstalledMcpEntry[] { - const projects = - config && typeof config.projects === 'object' && config.projects - ? (config.projects as Record) - : null; - const projectConfig = - projects && typeof projects[projectPath] === 'object' && projects[projectPath] - ? (projects[projectPath] as Record) - : null; - return this.readMcpServersFromConfig(projectConfig?.mcpServers, 'local'); - } - - private async readProjectMcpServers(projectPath: string): Promise { - const configPath = path.join(projectPath, '.mcp.json'); - return this.readMcpServersFromFile(configPath, 'project'); - } - - private readMcpServersFromConfig( - value: unknown, - scope: 'user' | 'project' | 'local' - ): InstalledMcpEntry[] { - const mcpServers = - value && typeof value === 'object' - ? (value as Record) - : null; - if (!mcpServers) { - return []; - } - - return Object.entries(mcpServers).map(([name, config]): InstalledMcpEntry => { - let transport: string | undefined; - if (config.command) transport = 'stdio'; - else if (config.url) transport = 'http'; - - return { name, scope, transport }; - }); - } - - private async readMcpServersFromFile( - filePath: string, - scope: 'user' | 'project' - ): Promise { - try { - const raw = await fs.readFile(filePath, 'utf-8'); - const json = JSON.parse(raw) as Record; - return this.readMcpServersFromConfig(json.mcpServers, scope); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - return []; - } - logger.error(`Failed to read MCP servers from ${filePath}:`, err); - return []; - } - } } diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index c07328c1..9ab0015a 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -30,6 +30,7 @@ import { } from '@main/utils/shellEnv'; import { getErrorMessage } from '@shared/utils/errorHandling'; import { createLogger } from '@shared/utils/logger'; +import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities'; import { createHash } from 'crypto'; import { createWriteStream, existsSync, promises as fsp } from 'fs'; import http from 'http'; @@ -145,7 +146,13 @@ function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallat ...provider, modelVerificationState: provider.modelVerificationState ?? 'idle', modelAvailability: provider.modelAvailability?.map((item) => ({ ...item })) ?? [], - capabilities: { ...provider.capabilities }, + capabilities: { + ...provider.capabilities, + extensions: { + ...createDefaultCliExtensionCapabilities(), + ...provider.capabilities.extensions, + }, + }, selectedBackendId: provider.selectedBackendId ?? null, resolvedBackendId: provider.resolvedBackendId ?? null, availableBackends: provider.availableBackends?.map((backend) => ({ ...backend })) ?? [], @@ -467,6 +474,7 @@ export class CliInstallerService { capabilities: { teamLaunch: false, oneShot: false, + extensions: createDefaultCliExtensionCapabilities(), }, backend: null, })) @@ -528,7 +536,13 @@ export class CliInstallerService { authMethod: provider.authMethod, selectedBackendId: provider.selectedBackendId ?? null, resolvedBackendId: provider.resolvedBackendId ?? null, - capabilities: { ...provider.capabilities }, + capabilities: { + ...provider.capabilities, + extensions: { + ...createDefaultCliExtensionCapabilities(), + ...provider.capabilities.extensions, + }, + }, backend: provider.backend ? { ...provider.backend } : null, }, }; diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts index 74f47017..96cc048d 100644 --- a/src/main/services/infrastructure/ConfigManager.ts +++ b/src/main/services/infrastructure/ConfigManager.ts @@ -62,6 +62,12 @@ export interface NotificationConfig { notifyOnTeamLaunched: boolean; /** Whether to show native OS notifications when a tool needs user approval */ notifyOnToolApproval: boolean; + /** Whether to automatically resume a rate-limited team when the limit resets. + * When enabled, the app parses the reset time from Claude's rate-limit + * message and schedules a nudge to the team lead once the limit expires. + * Default is `false` — opt-in to avoid unexpected API usage after the reset. + */ + autoResumeOnRateLimit: boolean; /** Only notify on status changes in solo teams (no teammates) */ statusChangeOnlySolo: boolean; /** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */ @@ -306,6 +312,7 @@ const DEFAULT_CONFIG: AppConfig = { notifyOnCrossTeamMessage: true, notifyOnTeamLaunched: true, notifyOnToolApproval: true, + autoResumeOnRateLimit: false, statusChangeOnlySolo: false, statusChangeStatuses: ['in_progress', 'completed'], triggers: DEFAULT_TRIGGERS, @@ -502,8 +509,55 @@ export class ConfigManager { return { notifications: { - ...DEFAULT_CONFIG.notifications, - ...loadedNotifications, + enabled: loadedNotifications.enabled ?? DEFAULT_CONFIG.notifications.enabled, + soundEnabled: loadedNotifications.soundEnabled ?? DEFAULT_CONFIG.notifications.soundEnabled, + ignoredRegex: loadedNotifications.ignoredRegex ?? DEFAULT_CONFIG.notifications.ignoredRegex, + ignoredRepositories: + loadedNotifications.ignoredRepositories ?? + DEFAULT_CONFIG.notifications.ignoredRepositories, + snoozedUntil: loadedNotifications.snoozedUntil ?? DEFAULT_CONFIG.notifications.snoozedUntil, + snoozeMinutes: + loadedNotifications.snoozeMinutes ?? DEFAULT_CONFIG.notifications.snoozeMinutes, + includeSubagentErrors: + loadedNotifications.includeSubagentErrors ?? + DEFAULT_CONFIG.notifications.includeSubagentErrors, + notifyOnLeadInbox: + loadedNotifications.notifyOnLeadInbox ?? DEFAULT_CONFIG.notifications.notifyOnLeadInbox, + notifyOnUserInbox: + loadedNotifications.notifyOnUserInbox ?? DEFAULT_CONFIG.notifications.notifyOnUserInbox, + notifyOnClarifications: + loadedNotifications.notifyOnClarifications ?? + DEFAULT_CONFIG.notifications.notifyOnClarifications, + notifyOnStatusChange: + loadedNotifications.notifyOnStatusChange ?? + DEFAULT_CONFIG.notifications.notifyOnStatusChange, + notifyOnTaskComments: + loadedNotifications.notifyOnTaskComments ?? + DEFAULT_CONFIG.notifications.notifyOnTaskComments, + notifyOnTaskCreated: + loadedNotifications.notifyOnTaskCreated ?? + DEFAULT_CONFIG.notifications.notifyOnTaskCreated, + notifyOnAllTasksCompleted: + loadedNotifications.notifyOnAllTasksCompleted ?? + DEFAULT_CONFIG.notifications.notifyOnAllTasksCompleted, + notifyOnCrossTeamMessage: + loadedNotifications.notifyOnCrossTeamMessage ?? + DEFAULT_CONFIG.notifications.notifyOnCrossTeamMessage, + notifyOnTeamLaunched: + loadedNotifications.notifyOnTeamLaunched ?? + DEFAULT_CONFIG.notifications.notifyOnTeamLaunched, + notifyOnToolApproval: + loadedNotifications.notifyOnToolApproval ?? + DEFAULT_CONFIG.notifications.notifyOnToolApproval, + autoResumeOnRateLimit: + loadedNotifications.autoResumeOnRateLimit ?? + DEFAULT_CONFIG.notifications.autoResumeOnRateLimit, + statusChangeOnlySolo: + loadedNotifications.statusChangeOnlySolo ?? + DEFAULT_CONFIG.notifications.statusChangeOnlySolo, + statusChangeStatuses: + loadedNotifications.statusChangeStatuses ?? + DEFAULT_CONFIG.notifications.statusChangeStatuses, triggers: mergedTriggers, }, general: mergedGeneral, diff --git a/src/main/services/infrastructure/NotificationManager.ts b/src/main/services/infrastructure/NotificationManager.ts index 1f493c96..ba3ce0ab 100644 --- a/src/main/services/infrastructure/NotificationManager.ts +++ b/src/main/services/infrastructure/NotificationManager.ts @@ -543,10 +543,10 @@ export class NotificationManager extends EventEmitter { logger.debug(`[test-notification] creating Notification (platform=${process.platform})`); const notification = new NotificationClass({ title: 'Test Notification', - ...(isMac ? { subtitle: 'Claude Agent Teams UI' } : {}), + ...(isMac ? { subtitle: 'Agent Teams UI' } : {}), body: isMac ? 'Notifications are working correctly!' - : 'Claude Agent Teams UI\nNotifications are working correctly!', + : 'Agent Teams UI\nNotifications are working correctly!', ...(iconPath ? { icon: iconPath } : {}), }); diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index 28d99435..53a6bb17 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -1,6 +1,10 @@ import { execCli } from '@main/utils/childProcess'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { createLogger } from '@shared/utils/logger'; +import { + createDefaultCliExtensionCapabilities, + createLegacyRuntimeFallbackCliExtensionCapabilities, +} from '@shared/utils/providerExtensionCapabilities'; import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth'; import { buildProviderAwareCliEnv } from './providerAwareCliEnv'; @@ -13,6 +17,19 @@ const logger = createLogger('ClaudeMultimodelBridgeService'); const PROVIDER_STATUS_TIMEOUT_MS = 10_000; const PROVIDER_MODELS_TIMEOUT_MS = 10_000; +interface RuntimeExtensionCapabilityResponse { + status?: 'supported' | 'read-only' | 'unsupported'; + ownership?: 'shared' | 'provider-scoped'; + reason?: string | null; +} + +interface RuntimeExtensionCapabilitiesResponse { + plugins?: RuntimeExtensionCapabilityResponse; + mcp?: RuntimeExtensionCapabilityResponse; + skills?: RuntimeExtensionCapabilityResponse; + apiKeys?: RuntimeExtensionCapabilityResponse; +} + interface ProviderStatusCommandResponse { schemaVersion?: number; providers?: Record< @@ -27,6 +44,7 @@ interface ProviderStatusCommandResponse { capabilities?: { teamLaunch?: boolean; oneShot?: boolean; + extensions?: RuntimeExtensionCapabilitiesResponse; }; backend?: { kind?: string; @@ -84,6 +102,7 @@ interface UnifiedRuntimeStatusResponse { capabilities?: { teamLaunch?: boolean; oneShot?: boolean; + extensions?: RuntimeExtensionCapabilitiesResponse; }; backend?: { kind?: string; @@ -129,6 +148,7 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat capabilities: { teamLaunch: false, oneShot: false, + extensions: createLegacyRuntimeFallbackCliExtensionCapabilities(), }, selectedBackendId: null, resolvedBackendId: null, @@ -139,6 +159,41 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat }; } +function mapRuntimeExtensionCapabilities( + capabilities?: RuntimeExtensionCapabilitiesResponse +): CliProviderStatus['capabilities']['extensions'] { + const defaults = capabilities + ? createDefaultCliExtensionCapabilities() + : createLegacyRuntimeFallbackCliExtensionCapabilities(); + + return { + plugins: { + ...defaults.plugins, + status: capabilities?.plugins?.status ?? defaults.plugins.status, + ownership: capabilities?.plugins?.ownership ?? defaults.plugins.ownership, + reason: capabilities?.plugins?.reason ?? defaults.plugins.reason, + }, + mcp: { + ...defaults.mcp, + status: capabilities?.mcp?.status ?? defaults.mcp.status, + ownership: capabilities?.mcp?.ownership ?? defaults.mcp.ownership, + reason: capabilities?.mcp?.reason ?? defaults.mcp.reason, + }, + skills: { + ...defaults.skills, + status: capabilities?.skills?.status ?? defaults.skills.status, + ownership: capabilities?.skills?.ownership ?? defaults.skills.ownership, + reason: capabilities?.skills?.reason ?? defaults.skills.reason, + }, + apiKeys: { + ...defaults.apiKeys, + status: capabilities?.apiKeys?.status ?? defaults.apiKeys.status, + ownership: capabilities?.apiKeys?.ownership ?? defaults.apiKeys.ownership, + reason: capabilities?.apiKeys?.reason ?? defaults.apiKeys.reason, + }, + }; +} + function extractModelIds( models: (string | { id?: string; label?: string; description?: string })[] | undefined ): string[] { @@ -203,6 +258,7 @@ export class ClaudeMultimodelBridgeService { capabilities: { teamLaunch: runtimeStatus.capabilities?.teamLaunch === true, oneShot: runtimeStatus.capabilities?.oneShot === true, + extensions: mapRuntimeExtensionCapabilities(runtimeStatus.capabilities?.extensions), }, selectedBackendId: runtimeStatus.selectedBackendId ?? null, resolvedBackendId: runtimeStatus.resolvedBackendId ?? null, @@ -325,6 +381,7 @@ export class ClaudeMultimodelBridgeService { provider.capabilities = { teamLaunch: true, oneShot: true, + extensions: createDefaultCliExtensionCapabilities(), }; } } catch (error) { @@ -428,6 +485,7 @@ export class ClaudeMultimodelBridgeService { capabilities: { teamLaunch: runtimeStatus.capabilities?.teamLaunch === true, oneShot: runtimeStatus.capabilities?.oneShot === true, + extensions: mapRuntimeExtensionCapabilities(runtimeStatus.capabilities?.extensions), }, backend: runtimeStatus.backend?.kind ? { diff --git a/src/main/services/team/AutoResumeService.ts b/src/main/services/team/AutoResumeService.ts new file mode 100644 index 00000000..0ec6f237 --- /dev/null +++ b/src/main/services/team/AutoResumeService.ts @@ -0,0 +1,209 @@ +import { createLogger } from '@shared/utils/logger'; +import { parseRateLimitResetTime } from '@shared/utils/rateLimitDetector'; + +import { ConfigManager } from '../infrastructure/ConfigManager'; + +import type { TeamProvisioningService } from './TeamProvisioningService'; + +const logger = createLogger('Service:AutoResume'); + +const AUTO_RESUME_BUFFER_MS = 30 * 1000; +const AUTO_RESUME_MAX_DELAY_MS = 12 * 60 * 60 * 1000; +const AUTO_RESUME_HISTORY_FRESH_MS = 5 * 1000; +const AUTO_RESUME_MESSAGE = + 'Your rate limit has reset. Please resume the work you were doing before the limit was hit.'; + +interface PendingAutoResumeEntry { + timer: NodeJS.Timeout; + fireAtMs: number; + sourceMessageAtMs: number; + sourceRunId: string | null; +} + +type AutoResumeProvisioning = Pick< + TeamProvisioningService, + 'getCurrentRunId' | 'isTeamAlive' | 'sendMessageToTeam' +>; +type AutoResumeConfigReader = Pick; + +export class AutoResumeService { + private readonly pendingTimers = new Map(); + + constructor( + private readonly provisioningService: AutoResumeProvisioning, + private readonly configManager: AutoResumeConfigReader = ConfigManager.getInstance() + ) {} + + handleRateLimitMessage( + teamName: string, + messageText: string, + observedAt: Date = new Date(), + messageTimestamp: Date = observedAt + ): void { + const cfg = this.configManager.getConfig(); + if (!cfg.notifications.autoResumeOnRateLimit) return; + + const observedAtMs = observedAt.getTime(); + const messageAtMs = Number.isFinite(messageTimestamp.getTime()) + ? messageTimestamp.getTime() + : observedAtMs; + const parseReferenceTime = Number.isFinite(messageTimestamp.getTime()) + ? messageTimestamp + : observedAt; + + const resetTime = parseRateLimitResetTime(messageText, parseReferenceTime); + if (!resetTime) { + logger.info( + `[auto-resume] Rate limit detected for "${teamName}" but reset time was not parseable - skipping auto-resume` + ); + return; + } + + const resetAtMs = resetTime.getTime(); + const rawDelayMs = resetAtMs - observedAtMs; + const targetFireAtMs = resetAtMs + AUTO_RESUME_BUFFER_MS; + const messageAgeMs = Math.max(0, observedAtMs - messageAtMs); + const existing = this.pendingTimers.get(teamName); + const sourceRunId = this.provisioningService.getCurrentRunId(teamName); + + if (existing && messageAtMs < existing.sourceMessageAtMs) { + logger.info( + `[auto-resume] Ignoring older rate-limit message for "${teamName}" because a newer timer is already pending` + ); + return; + } + + if (targetFireAtMs <= observedAtMs && messageAgeMs > AUTO_RESUME_HISTORY_FRESH_MS) { + logger.info( + `[auto-resume] Parsed reset time for "${teamName}" passed its buffered fire deadline ${Math.round((observedAtMs - targetFireAtMs) / 1000)}s ago - skipping stale history replay` + ); + return; + } + + if (rawDelayMs < 0) { + logger.warn( + `[auto-resume] Parsed reset time for "${teamName}" is ${Math.round(-rawDelayMs / 1000)}s in the past - using remaining buffered delay` + ); + } + + const delayMs = Math.max(0, targetFireAtMs - observedAtMs); + const fireAtMs = observedAtMs + delayMs; + + if (delayMs > AUTO_RESUME_MAX_DELAY_MS) { + if (existing) { + clearTimeout(existing.timer); + this.pendingTimers.delete(teamName); + } + logger.warn( + `[auto-resume] Parsed reset time for "${teamName}" is ${Math.round(delayMs / 60000)}m away - exceeds ceiling, skipping` + ); + return; + } + + if ( + existing?.fireAtMs === fireAtMs && + existing.sourceMessageAtMs === messageAtMs && + existing.sourceRunId === sourceRunId + ) { + return; + } + + if (existing) { + clearTimeout(existing.timer); + this.pendingTimers.delete(teamName); + logger.info( + `[auto-resume] Rescheduling resume for "${teamName}" to ${resetTime.toISOString()}` + ); + } else { + logger.info( + `[auto-resume] Scheduling resume for "${teamName}" at ${resetTime.toISOString()} (in ${Math.round(delayMs / 1000)}s)` + ); + } + + const timer = setTimeout(() => { + this.pendingTimers.delete(teamName); + void this.fireResumeNudge(teamName, sourceRunId); + }, delayMs); + + this.pendingTimers.set(teamName, { + timer, + fireAtMs, + sourceMessageAtMs: messageAtMs, + sourceRunId, + }); + } + + cancelPendingAutoResume(teamName: string): void { + const pending = this.pendingTimers.get(teamName); + if (!pending) return; + clearTimeout(pending.timer); + this.pendingTimers.delete(teamName); + } + + clearAllPendingAutoResume(): void { + for (const pending of this.pendingTimers.values()) { + clearTimeout(pending.timer); + } + this.pendingTimers.clear(); + } + + private async fireResumeNudge(teamName: string, sourceRunId: string | null): Promise { + const current = this.configManager.getConfig(); + if (!current.notifications.autoResumeOnRateLimit) { + logger.info( + `[auto-resume] Config flag was disabled while timer was pending - skipping nudge for "${teamName}"` + ); + return; + } + + try { + if (!this.provisioningService.isTeamAlive(teamName)) { + logger.info( + `[auto-resume] Team "${teamName}" is no longer alive at fire time - skipping resume nudge` + ); + return; + } + const currentRunId = this.provisioningService.getCurrentRunId(teamName); + if (sourceRunId && currentRunId !== sourceRunId) { + logger.info( + `[auto-resume] Team "${teamName}" advanced from run "${sourceRunId}" to "${currentRunId ?? 'none'}" before fire time - skipping stale resume nudge` + ); + return; + } + await this.provisioningService.sendMessageToTeam(teamName, AUTO_RESUME_MESSAGE); + logger.info(`[auto-resume] Sent resume nudge to "${teamName}"`); + } catch (error) { + logger.error( + `[auto-resume] Failed to send resume nudge to "${teamName}": ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } +} + +let autoResumeService: AutoResumeService | null = null; + +export function initializeAutoResumeService( + provisioningService: AutoResumeProvisioning +): AutoResumeService { + autoResumeService?.clearAllPendingAutoResume(); + autoResumeService = new AutoResumeService(provisioningService); + return autoResumeService; +} + +export function getAutoResumeService(): AutoResumeService { + if (!autoResumeService) { + throw new Error('AutoResumeService is not initialized'); + } + return autoResumeService; +} + +export function peekAutoResumeService(): AutoResumeService | null { + return autoResumeService; +} + +export function clearAutoResumeService(): void { + autoResumeService?.clearAllPendingAutoResume(); + autoResumeService = null; +} diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index e0ee5249..e1c24319 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -1,12 +1,5 @@ import { yieldToEventLoop } from '@main/utils/asyncYield'; -import { - encodePath, - extractBaseDir, - getClaudeBasePath, - getProjectsBasePath, - getTasksBasePath, - getTeamsBasePath, -} from '@main/utils/pathDecoder'; +import { getClaudeBasePath, getTasksBasePath, getTeamsBasePath } from '@main/utils/pathDecoder'; import { killProcessByPid } from '@main/utils/processKill'; import { AGENT_BLOCK_CLOSE, @@ -16,7 +9,7 @@ import { } from '@shared/constants/agentBlocks'; import { getMemberColorByName } from '@shared/constants/memberColors'; import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics'; -import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; +import { isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState'; import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands'; @@ -40,6 +33,10 @@ import { import { atomicWriteAsync } from './atomicWrite'; import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor'; import { MemberActivityMetaService } from './MemberActivityMetaService'; +import { + getLiveLeadProcessMessageKey, + mergeLiveLeadProcessMessages, +} from './mergeLiveLeadProcessMessages'; import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils'; import { TeamConfigReader } from './TeamConfigReader'; import { TeamInboxReader } from './TeamInboxReader'; @@ -54,6 +51,7 @@ import { TeamSentMessagesStore } from './TeamSentMessagesStore'; import { TeamTaskCommentNotificationJournal } from './TeamTaskCommentNotificationJournal'; import { TeamTaskReader } from './TeamTaskReader'; import { TeamTaskWriter } from './TeamTaskWriter'; +import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver'; import type { PersistedTaskChangePresenceIndex } from './cache/taskChangePresenceCacheTypes'; import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository'; @@ -194,12 +192,15 @@ export class TeamDataService { private readonly taskCommentNotificationJournal: TeamTaskCommentNotificationJournal = new TeamTaskCommentNotificationJournal(), private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore(), private memberRuntimeAdvisoryService: TeamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService(), - private readonly leadSessionParseCache: LeadSessionParseCache = new LeadSessionParseCache() + private readonly leadSessionParseCache: LeadSessionParseCache = new LeadSessionParseCache(), + private readonly projectResolver: TeamTranscriptProjectResolver = new TeamTranscriptProjectResolver( + configReader + ) ) { this.messageFeedService = new TeamMessageFeedService({ getConfig: (teamName) => this.configReader.getConfig(teamName), getInboxMessages: (teamName) => this.inboxReader.getMessages(teamName), - getLeadSessionMessages: (config) => this.extractLeadSessionTexts(config), + getLeadSessionMessages: (teamName, config) => this.extractLeadSessionTexts(teamName, config), getSentMessages: (teamName) => this.sentMessagesStore.readMessages(teamName), }); this.memberActivityMetaService = new MemberActivityMetaService(this.messageFeedService); @@ -910,10 +911,14 @@ export class TeamDataService { */ async getMessagesPage( teamName: string, - options: { cursor?: string | null; limit: number } + options: { cursor?: string | null; limit: number; liveMessages?: InboxMessage[] } ): Promise { const feed = await this.messageFeedService.getFeed(teamName); - let messages = feed.messages; + const newestDurableMessages = feed.messages; + const durableMessageIndexByKey = new Map( + newestDurableMessages.map((message, index) => [getLiveLeadProcessMessageKey(message), index]) + ); + let messages = newestDurableMessages; if (options.cursor) { const [cursorTs, cursorId] = options.cursor.split('|'); @@ -933,7 +938,61 @@ export class TeamDataService { const nextCursor = hasMore && lastMsg ? `${lastMsg.timestamp}|${requireCanonicalMessageId(lastMsg)}` : null; - return { messages: page, nextCursor, hasMore, feedRevision: feed.feedRevision }; + if (options.cursor || !options.liveMessages?.length) { + return { messages: page, nextCursor, hasMore, feedRevision: feed.feedRevision }; + } + + // Merge live lead thoughts against the full durable newest-page history so we do not + // re-introduce persisted thoughts that have simply paged off the first durable page. + const displayMessages = mergeLiveLeadProcessMessages( + newestDurableMessages, + options.liveMessages + ).slice(0, options.limit); + + if (displayMessages.length === 0) { + return { + messages: displayMessages, + nextCursor: null, + hasMore: false, + feedRevision: feed.feedRevision, + }; + } + + let lastDurableDisplayed: InboxMessage | null = null; + for (let index = displayMessages.length - 1; index >= 0; index -= 1) { + const candidate = displayMessages[index]; + if (durableMessageIndexByKey.has(getLiveLeadProcessMessageKey(candidate))) { + lastDurableDisplayed = candidate; + break; + } + } + + if (!lastDurableDisplayed) { + const boundary = displayMessages[displayMessages.length - 1]; + return { + messages: displayMessages, + nextCursor: + newestDurableMessages.length > 0 + ? `${boundary.timestamp}|${boundary.messageId ?? ''}` + : null, + hasMore: newestDurableMessages.length > 0, + feedRevision: feed.feedRevision, + }; + } + + const durableIndex = + durableMessageIndexByKey.get(getLiveLeadProcessMessageKey(lastDurableDisplayed)) ?? + Number.POSITIVE_INFINITY; + const durableHasMore = durableIndex < newestDurableMessages.length - 1; + + return { + messages: displayMessages, + nextCursor: durableHasMore + ? `${lastDurableDisplayed.timestamp}|${lastDurableDisplayed.messageId ?? ''}` + : null, + hasMore: durableHasMore, + feedRevision: feed.feedRevision, + }; } async getMessageFeed( @@ -2369,37 +2428,20 @@ export class TeamDataService { } } - private getLeadProjectDirCandidates(projectPath: string): string[] { - const projectId = encodePath(projectPath); - const baseDir = extractBaseDir(projectId); - const candidateDirs = [ - path.join(getProjectsBasePath(), baseDir), - // Claude Code encodes underscores as hyphens in project directory names; - // our encodePath only handles slashes. Try the underscore-to-hyphen variant. - ...(baseDir.includes('_') - ? [path.join(getProjectsBasePath(), baseDir.replace(/_/g, '-'))] - : []), - ]; - - return [...new Set(candidateDirs)]; - } - - private async getLeadSessionJsonlPaths(projectPath: string): Promise> { + private async getLeadSessionJsonlPaths(projectDir: string): Promise> { const jsonlPaths = new Map(); - for (const dirPath of this.getLeadProjectDirCandidates(projectPath)) { - let entries: fs.Dirent[]; - try { - entries = await fs.promises.readdir(dirPath, { withFileTypes: true }); - } catch { - continue; - } + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(projectDir, { withFileTypes: true }); + } catch { + return jsonlPaths; + } - for (const entry of entries) { - if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue; - const sessionId = entry.name.slice(0, -'.jsonl'.length).trim(); - if (!sessionId || jsonlPaths.has(sessionId)) continue; - jsonlPaths.set(sessionId, path.join(dirPath, entry.name)); - } + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue; + const sessionId = entry.name.slice(0, -'.jsonl'.length).trim(); + if (!sessionId || jsonlPaths.has(sessionId)) continue; + jsonlPaths.set(sessionId, path.join(projectDir, entry.name)); } return jsonlPaths; @@ -2645,17 +2687,23 @@ export class TeamDataService { } } - private async extractLeadSessionTexts(config: TeamConfig): Promise { - if (!config.projectPath) { + private async extractLeadSessionTexts( + teamName: string, + config: TeamConfig + ): Promise { + const transcriptContext = await this.projectResolver.getContext(teamName); + if (!transcriptContext) { return []; } - - const leadName = config.members?.find((m) => isLeadMember(m))?.name ?? 'team-lead'; - const sessionIds = this.getRecentLeadSessionIds(config); + const leadName = + transcriptContext.config.members?.find((m) => isLeadMember(m))?.name ?? 'team-lead'; + const sessionIds = Array.from( + new Set([...this.getRecentLeadSessionIds(config), ...transcriptContext.sessionIds]) + ); if (sessionIds.length === 0) { return []; } - const availableJsonlPaths = await this.getLeadSessionJsonlPaths(config.projectPath); + const availableJsonlPaths = await this.getLeadSessionJsonlPaths(transcriptContext.projectDir); if (availableJsonlPaths.size === 0) { return []; } diff --git a/src/main/services/team/TeamMessageFeedService.ts b/src/main/services/team/TeamMessageFeedService.ts index a38075b0..2436c15e 100644 --- a/src/main/services/team/TeamMessageFeedService.ts +++ b/src/main/services/team/TeamMessageFeedService.ts @@ -11,7 +11,7 @@ const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000; interface TeamMessageFeedDeps { getConfig: (teamName: string) => Promise; getInboxMessages: (teamName: string) => Promise; - getLeadSessionMessages: (config: TeamConfig) => Promise; + getLeadSessionMessages: (teamName: string, config: TeamConfig) => Promise; getSentMessages: (teamName: string) => Promise; } @@ -370,7 +370,7 @@ export class TeamMessageFeedService { const [inboxMessages, leadTexts, sentMessages] = await Promise.all([ this.deps.getInboxMessages(teamName).catch(() => [] as InboxMessage[]), - this.deps.getLeadSessionMessages(config).catch(() => [] as InboxMessage[]), + this.deps.getLeadSessionMessages(teamName, config).catch(() => [] as InboxMessage[]), this.deps.getSentMessages(teamName).catch(() => [] as InboxMessage[]), ]); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 54a09a1e..69de02f0 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -112,6 +112,7 @@ import { TeamMembersMetaStore } from './TeamMembersMetaStore'; import { TeamMetaStore } from './TeamMetaStore'; import { TeamSentMessagesStore } from './TeamSentMessagesStore'; import { TeamTaskReader } from './TeamTaskReader'; +import { peekAutoResumeService } from './AutoResumeService'; /** * Kill a team CLI process using SIGKILL (uncatchable). @@ -2413,6 +2414,40 @@ export class TeamProvisioningService { return this.getProvisioningRunId(teamName) ?? this.getAliveRunId(teamName); } + private clearSameTeamRetryTimers(teamName: string): void { + for (const suffix of ['deferred', 'persist']) { + const key = `same-team-${suffix}:${teamName}`; + const timer = this.pendingTimeouts.get(key); + if (timer) { + clearTimeout(timer); + this.pendingTimeouts.delete(key); + } + } + } + + private resetTeamScopedTransientStateForNewRun(teamName: string): void { + peekAutoResumeService()?.cancelPendingAutoResume(teamName); + this.leadInboxRelayInFlight.delete(teamName); + this.relayedLeadInboxMessageIds.delete(teamName); + this.pendingCrossTeamFirstReplies.delete(teamName); + this.recentCrossTeamLeadDeliveryMessageIds.delete(teamName); + this.recentSameTeamNativeFingerprints.delete(teamName); + this.clearSameTeamRetryTimers(teamName); + + for (const key of Array.from(this.memberInboxRelayInFlight.keys())) { + if (key.startsWith(`${teamName}:`)) { + this.memberInboxRelayInFlight.delete(key); + } + } + for (const key of Array.from(this.relayedMemberInboxMessageIds.keys())) { + if (key.startsWith(`${teamName}:`)) { + this.relayedMemberInboxMessageIds.delete(key); + } + } + + this.liveLeadProcessMessages.delete(teamName); + } + private appendCliLogs(run: ProvisioningRun, stream: 'stdout' | 'stderr', text: string): void { const nowMs = Date.now(); run.claudeLogsUpdatedAt = new Date(nowMs).toISOString(); @@ -3191,7 +3226,58 @@ export class TeamProvisioningService { } getLiveLeadProcessMessages(teamName: string): InboxMessage[] { - return [...(this.liveLeadProcessMessages.get(teamName) ?? [])]; + const runId = this.getTrackedRunId(teamName); + const detectedSessionId = runId ? (this.runs.get(runId)?.detectedSessionId ?? null) : null; + + return (this.liveLeadProcessMessages.get(teamName) ?? []).map((message) => + !message.leadSessionId && detectedSessionId + ? { ...message, leadSessionId: detectedSessionId } + : { ...message } + ); + } + + private pruneLiveLeadMessagesForCleanedRun(run: ProvisioningRun): void { + const list = this.liveLeadProcessMessages.get(run.teamName); + if (!list || list.length === 0) { + return; + } + + const runMessageIdPrefixes = [ + `lead-turn-${run.runId}-`, + `lead-sendmsg-${run.runId}-`, + `lead-process-${run.runId}-`, + `compact-${run.runId}-`, + ]; + + const filtered = list.filter((message) => { + const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; + if (messageId && runMessageIdPrefixes.some((prefix) => messageId.startsWith(prefix))) { + return false; + } + + if (run.detectedSessionId && message.leadSessionId === run.detectedSessionId) { + return false; + } + + return true; + }); + + if (filtered.length === 0) { + this.liveLeadProcessMessages.delete(run.teamName); + return; + } + + this.liveLeadProcessMessages.set(run.teamName, filtered); + } + + getCurrentLeadSessionId(teamName: string): string | null { + const runId = this.getTrackedRunId(teamName); + if (!runId) return null; + return this.runs.get(runId)?.detectedSessionId ?? null; + } + + getCurrentRunId(teamName: string): string | null { + return this.getAliveRunId(teamName); } getLeadActivityState(teamName: string): { @@ -5110,6 +5196,7 @@ export class TeamProvisioningService { }, }; + this.resetTeamScopedTransientStateForNewRun(request.teamName); this.runs.set(runId, run); this.provisioningRunByTeam.set(request.teamName, runId); run.onProgress(run.progress); @@ -5655,7 +5742,7 @@ export class TeamProvisioningService { pendingInboxRelayCandidates: [], provisioningOutputParts: [], provisioningOutputIndexByMessageId: new Map(), - detectedSessionId: null, + detectedSessionId: previousSessionId ?? null, leadActivityState: 'active', leadContextUsage: null, authFailureRetried: false, @@ -5696,6 +5783,7 @@ export class TeamProvisioningService { }, }; + this.resetTeamScopedTransientStateForNewRun(request.teamName); this.runs.set(runId, run); this.provisioningRunByTeam.set(request.teamName, runId); run.onProgress(run.progress); @@ -5954,6 +6042,21 @@ export class TeamProvisioningService { throw new Error(`Team "${teamName}" process stdin is not writable`); } + await this.sendMessageToRun(run, message, attachments); + } + + private async sendMessageToRun( + run: ProvisioningRun, + message: string, + attachments?: { data: string; mimeType: string; filename?: string }[] + ): Promise { + if (!this.isCurrentTrackedRun(run)) { + throw new Error(`Team "${run.teamName}" run "${run.runId}" is no longer current`); + } + if (run.processKilled || run.cancelRequested || !run.child?.stdin?.writable) { + throw new Error(`Team "${run.teamName}" process stdin is not writable`); + } + const contentBlocks: Record[] = [{ type: 'text', text: message }]; if (attachments?.length) { for (const att of attachments) { @@ -6073,7 +6176,7 @@ export class TeamProvisioningService { userText, ].join('\n'); - await this.sendMessageToTeam(teamName, message); + await this.sendMessageToRun(run, message); } async relayMemberInboxMessages(teamName: string, memberName: string): Promise { @@ -6095,6 +6198,8 @@ export class TeamProvisioningService { const run = this.runs.get(runId); if (!run?.child || run.processKilled || run.cancelRequested) return 0; if (!run.provisioningComplete) return 0; + const isStaleRelayRun = (): boolean => + !this.isCurrentTrackedRun(run) || !run.child || run.processKilled || run.cancelRequested; const relayedIds = this.relayedMemberInboxMessageIds.get(relayKey) ?? new Set(); @@ -6104,6 +6209,7 @@ export class TeamProvisioningService { } catch { return 0; } + if (isStaleRelayRun()) return 0; const unread = memberInboxMessages .filter((m): m is InboxMessage & { messageId: string } => { @@ -6134,6 +6240,7 @@ export class TeamProvisioningService { .map(({ message }) => message); const readOnlyIgnoredUnread = [...silentNoiseUnread, ...passiveIdleUnread]; + if (isStaleRelayRun()) return 0; if (readOnlyIgnoredUnread.length > 0) { try { @@ -6207,7 +6314,7 @@ export class TeamProvisioningService { ].join('\n'); try { - await this.sendMessageToTeam(teamName, message); + await this.sendMessageToRun(run, message); } catch { this.forgetPendingInboxRelayCandidates(run, memberName, rememberedRelayIds); return 0; @@ -6263,6 +6370,8 @@ export class TeamProvisioningService { if (!runId) return 0; const run = this.runs.get(runId); if (!run?.child || run.processKilled || run.cancelRequested) return 0; + const isStaleRelayRun = (): boolean => + !this.isCurrentTrackedRun(run) || !run.child || run.processKilled || run.cancelRequested; // Permission request scan runs even during provisioning — teammates may need // tool approval before the lead's first turn completes. CLI marks inbox messages @@ -6273,10 +6382,12 @@ export class TeamProvisioningService { } catch { // config not ready yet during early provisioning — skip scan } + if (isStaleRelayRun()) return 0; if (config) { const leadName = config.members?.find((m) => isLeadMember(m))?.name?.trim() || 'team-lead'; try { const leadInboxMessages = await this.inboxReader.getMessagesFor(teamName, leadName); + if (isStaleRelayRun()) return 0; const permMsgsToMarkRead: { messageId: string }[] = []; const runStartedAtMs = Date.parse(run.startedAt); for (const msg of leadInboxMessages) { @@ -6321,6 +6432,7 @@ export class TeamProvisioningService { return 0; } } + if (isStaleRelayRun()) return 0; if (!config) return 0; const leadName = config.members?.find((m) => isLeadMember(m))?.name?.trim() || 'team-lead'; @@ -6330,8 +6442,10 @@ export class TeamProvisioningService { } catch { return 0; } + if (isStaleRelayRun()) return 0; await this.refreshMemberSpawnStatusesFromLeadInbox(run); + if (isStaleRelayRun()) return 0; const unread = leadInboxMessages .filter((m): m is InboxMessage & { messageId: string } => { @@ -6469,6 +6583,7 @@ export class TeamProvisioningService { ...passiveIdleUnread.map((m) => m.messageId), ]); const remainingUnread = unread.filter((m) => !readOnlyIgnoredIds.has(m.messageId)); + if (isStaleRelayRun()) return 0; // Category 2: same-team native delivery confirmation (one-to-one pairing). const { nativeMatchedMessageIds, persisted: sameTeamPersisted } = @@ -6631,7 +6746,7 @@ export class TeamProvisioningService { }); try { - await this.sendMessageToTeam(teamName, message); + await this.sendMessageToRun(run, message); } catch { if (run.leadRelayCapture) { clearTimeout(run.leadRelayCapture.timeoutHandle); @@ -7677,6 +7792,12 @@ export class TeamProvisioningService { if (result.deduplicated) { return; } + if (this.getTrackedRunId(run.teamName) !== run.runId) { + logger.debug( + `[${run.teamName}] Skipping stale cross-team send result for old run ${run.runId}` + ); + return; + } const msg: InboxMessage = { from: leadName, to: recipient.startsWith('cross-team:') @@ -7904,11 +8025,18 @@ export class TeamProvisioningService { private pushLiveLeadTextMessage( run: ProvisioningRun, cleanText: string, - stableMessageId?: string + stableMessageId?: string, + messageTimestamp?: string ): void { run.leadMsgSeq += 1; const leadName = this.getRunLeadName(run); const messageId = stableMessageId || `lead-turn-${run.runId}-${run.leadMsgSeq}`; + const timestamp = + typeof messageTimestamp === 'string' && + messageTimestamp.trim().length > 0 && + Number.isFinite(Date.parse(messageTimestamp)) + ? messageTimestamp + : nowIso(); // Attach accumulated tool call details from preceding tool_use messages, then reset. const toolCalls = run.pendingToolCalls.length > 0 ? [...run.pendingToolCalls] : undefined; const toolSummary = toolCalls ? formatToolSummaryFromCalls(toolCalls) : undefined; @@ -7916,7 +8044,7 @@ export class TeamProvisioningService { const leadMsg: InboxMessage = { from: leadName, text: cleanText, - timestamp: nowIso(), + timestamp, read: true, summary: cleanText.length > 60 ? cleanText.slice(0, 57) + '...' : cleanText, messageId, @@ -8264,6 +8392,18 @@ export class TeamProvisioningService { // stream-json output has various message types: // {"type":"assistant","content":[{"type":"text","text":"..."},...]} // {"type":"result","subtype":"success",...} + // Capture session_id as early as possible so live messages emitted during this + // handler already carry the session identity used by merge/dedup paths. + if (!run.detectedSessionId) { + const sid = typeof msg.session_id === 'string' ? msg.session_id : undefined; + if (sid && sid.trim().length > 0) { + run.detectedSessionId = sid.trim(); + logger.info( + `[${run.teamName}] Detected session ID from stream-json: ${run.detectedSessionId}` + ); + } + } + if (msg.type === 'user') { // Check for permission_request in raw user message text BEFORE teammate-message parsing. // The permission_request may arrive as plain JSON without wrapper, @@ -8306,6 +8446,12 @@ export class TeamProvisioningService { .map((part) => part.text as string); if (textParts.length > 0) { const text = textParts.join('\n'); + const messageTimestamp = + typeof msg.timestamp === 'string' && + msg.timestamp.trim().length > 0 && + Number.isFinite(Date.parse(msg.timestamp)) + ? msg.timestamp + : undefined; // Auth failures sometimes show up as assistant text (e.g. "401", "Please run /login") // rather than stderr or a result.subtype=error. Detect early to avoid false "ready". this.handleAuthFailureInOutput(run, text, 'assistant'); @@ -8348,7 +8494,8 @@ export class TeamProvisioningService { this.pushLiveLeadTextMessage( run, cleanText, - this.getStableLeadThoughtMessageId(msg) ?? undefined + this.getStableLeadThoughtMessageId(msg) ?? undefined, + messageTimestamp ); } } @@ -8361,7 +8508,8 @@ export class TeamProvisioningService { this.pushLiveLeadTextMessage( run, cleanText, - this.getStableLeadThoughtMessageId(msg) ?? undefined + this.getStableLeadThoughtMessageId(msg) ?? undefined, + messageTimestamp ); } } @@ -8445,17 +8593,6 @@ export class TeamProvisioningService { } } - // Capture session_id from any message type (first occurrence wins) - if (!run.detectedSessionId) { - const sid = typeof msg.session_id === 'string' ? msg.session_id : undefined; - if (sid && sid.trim().length > 0) { - run.detectedSessionId = sid.trim(); - logger.info( - `[${run.teamName}] Detected session ID from stream-json: ${run.detectedSessionId}` - ); - } - } - if (this.handleDeterministicBootstrapEvent(run, msg)) { return; } @@ -10041,7 +10178,7 @@ export class TeamProvisioningService { `Не стартовали тиммейты: ${failedSpawnMembers.map((member) => `@${member.name}`).join(', ')}.`, `Не считай их доступными, пока их запуск не будет повторён успешно.`, ].join(' '); - await this.sendMessageToTeam(run.teamName, failureNotice).catch((error: unknown) => + await this.sendMessageToRun(run, failureNotice).catch((error: unknown) => logger.warn( `[${run.teamName}] failed to send teammate-start failure notice to lead: ${ error instanceof Error ? error.message : String(error) @@ -10089,7 +10226,7 @@ export class TeamProvisioningService { .filter(Boolean) .join('\n\n'); - await this.sendMessageToTeam(run.teamName, message); + await this.sendMessageToRun(run, message); } catch (error) { logger.warn( `[${run.teamName}] Failed to kick off solo task resumption: ${ @@ -10209,7 +10346,7 @@ export class TeamProvisioningService { `Не стартовали тиммейты: ${failedSpawnMembers.map((member) => `@${member.name}`).join(', ')}.`, `Не считай их доступными, пока их запуск не будет повторён успешно.`, ].join(' '); - await this.sendMessageToTeam(run.teamName, failureNotice).catch((error: unknown) => + await this.sendMessageToRun(run, failureNotice).catch((error: unknown) => logger.warn( `[${run.teamName}] failed to send teammate-start failure notice to lead: ${ error instanceof Error ? error.message : String(error) @@ -10529,7 +10666,14 @@ export class TeamProvisioningService { * Remove a run from tracking maps. */ private cleanupRun(run: ProvisioningRun): void { - if (run.isLaunch && !run.provisioningComplete) { + const currentTrackedRunId = this.getTrackedRunId(run.teamName); + const hasNewerTrackedRun = currentTrackedRunId !== null && currentTrackedRunId !== run.runId; + + if (!hasNewerTrackedRun) { + peekAutoResumeService()?.cancelPendingAutoResume(run.teamName); + } + + if (!hasNewerTrackedRun && run.isLaunch && !run.provisioningComplete) { void this.persistLaunchStateSnapshot(run, 'finished'); } this.resetRuntimeToolActivity(run); @@ -10558,19 +10702,13 @@ export class TeamProvisioningService { if (this.aliveRunByTeam.get(run.teamName) === run.runId) { this.aliveRunByTeam.delete(run.teamName); } - this.leadInboxRelayInFlight.delete(run.teamName); - this.relayedLeadInboxMessageIds.delete(run.teamName); - this.pendingCrossTeamFirstReplies.delete(run.teamName); - this.recentCrossTeamLeadDeliveryMessageIds.delete(run.teamName); - this.recentSameTeamNativeFingerprints.delete(run.teamName); - // Clear same-team retry timers - for (const suffix of ['deferred', 'persist']) { - const key = `same-team-${suffix}:${run.teamName}`; - const timer = this.pendingTimeouts.get(key); - if (timer) { - clearTimeout(timer); - this.pendingTimeouts.delete(key); - } + if (!hasNewerTrackedRun) { + this.leadInboxRelayInFlight.delete(run.teamName); + this.relayedLeadInboxMessageIds.delete(run.teamName); + this.pendingCrossTeamFirstReplies.delete(run.teamName); + this.recentCrossTeamLeadDeliveryMessageIds.delete(run.teamName); + this.recentSameTeamNativeFingerprints.delete(run.teamName); + this.clearSameTeamRetryTimers(run.teamName); } for (const memberName of run.memberSpawnStatuses.keys()) { const key = this.getMemberLaunchGraceKey(run, memberName); @@ -10582,17 +10720,21 @@ export class TeamProvisioningService { } run.activeCrossTeamReplyHints = []; run.pendingInboxRelayCandidates = []; - for (const key of Array.from(this.memberInboxRelayInFlight.keys())) { - if (key.startsWith(`${run.teamName}:`)) { - this.memberInboxRelayInFlight.delete(key); + if (!hasNewerTrackedRun) { + for (const key of Array.from(this.memberInboxRelayInFlight.keys())) { + if (key.startsWith(`${run.teamName}:`)) { + this.memberInboxRelayInFlight.delete(key); + } } - } - for (const key of Array.from(this.relayedMemberInboxMessageIds.keys())) { - if (key.startsWith(`${run.teamName}:`)) { - this.relayedMemberInboxMessageIds.delete(key); + for (const key of Array.from(this.relayedMemberInboxMessageIds.keys())) { + if (key.startsWith(`${run.teamName}:`)) { + this.relayedMemberInboxMessageIds.delete(key); + } } + this.liveLeadProcessMessages.delete(run.teamName); + } else { + this.pruneLiveLeadMessagesForCleanedRun(run); } - this.liveLeadProcessMessages.delete(run.teamName); // Dismiss any pending tool approvals for this run if (run.pendingApprovals.size > 0) { for (const requestId of run.pendingApprovals.keys()) { diff --git a/src/main/services/team/TeamTranscriptProjectResolver.ts b/src/main/services/team/TeamTranscriptProjectResolver.ts index 4099ce1d..5bbe4edd 100644 --- a/src/main/services/team/TeamTranscriptProjectResolver.ts +++ b/src/main/services/team/TeamTranscriptProjectResolver.ts @@ -1,4 +1,12 @@ -import { encodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder'; +import { extractCwd } from '@main/utils/jsonl'; +import { + encodePath, + extractBaseDir, + getProjectsBasePath, + getTeamsBasePath, +} from '@main/utils/pathDecoder'; +import { atomicWriteAsync } from '@main/utils/atomicWrite'; +import { isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; import { createReadStream, type Dirent } from 'fs'; import * as fs from 'fs/promises'; @@ -15,6 +23,33 @@ const SESSION_DISCOVERY_CACHE_TTL = 30_000; const TEAM_AFFINITY_SCAN_LINES = 40; const ROOT_DISCOVERY_CONCURRENCY = 12; +type ProjectEvidenceSource = + | 'projectPath' + | 'projectPathHistory' + | 'leadCwd' + | 'memberCwd' + | 'projectsScan'; + +interface ProjectPathCandidate { + projectPath: string; + source: Exclude; +} + +interface ProjectDirCandidate { + projectPath: string; + projectDir: string; + projectId: string; + source: ProjectEvidenceSource; +} + +interface SessionProjectMatch extends ProjectDirCandidate { + matchedSessionId: string; +} + +type ScannedSessionProjectMatch = Omit & { + projectPath?: string; +}; + function trimTrailingSlashes(value: string): string { let end = value.length; while (end > 0) { @@ -32,6 +67,17 @@ function isSessionDirectoryName(name: string): boolean { return name !== 'memory' && !name.startsWith('.'); } +function normalizeProjectPathCandidate(value: unknown): string | null { + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + return trimTrailingSlashes(trimmed); +} + function extractTextContent(entry: Record): string | null { if (typeof entry.content === 'string') { return entry.content; @@ -71,6 +117,9 @@ function lineMentionsTeam(text: string, teamName: string): boolean { return false; } return ( + normalizedText.includes(`team name: ${normalizedTeam}`) || + normalizedText.includes(`team name "${normalizedTeam}"`) || + normalizedText.includes(`team name '${normalizedTeam}'`) || normalizedText.includes(`on team "${normalizedTeam}"`) || normalizedText.includes(`on team '${normalizedTeam}'`) || normalizedText.includes(`team "${normalizedTeam}"`) || @@ -79,6 +128,28 @@ function lineMentionsTeam(text: string, teamName: string): boolean { ); } +function entryContainsNestedTeamName(value: unknown, teamName: string, depth: number = 0): boolean { + if (!value || depth > 8 || typeof value !== 'object') { + return false; + } + + if (Array.isArray(value)) { + return value.some((item) => entryContainsNestedTeamName(item, teamName, depth + 1)); + } + + const entry = value as Record; + if (typeof entry.teamName === 'string' && entry.teamName.trim().toLowerCase() === teamName) { + return true; + } + + return Object.entries(entry).some(([key, nested]) => { + if (key === 'teamName') { + return false; + } + return entryContainsNestedTeamName(nested, teamName, depth + 1); + }); +} + function collectKnownSessionIds(config: TeamConfig): string[] { const knownSessionIds = new Set(); const push = (value: unknown): void => { @@ -93,7 +164,8 @@ function collectKnownSessionIds(config: TeamConfig): string[] { push(config.leadSessionId); if (Array.isArray(config.sessionHistory)) { - for (const sessionId of config.sessionHistory) { + for (let index = config.sessionHistory.length - 1; index >= 0; index -= 1) { + const sessionId = config.sessionHistory[index]; push(sessionId); } } @@ -130,13 +202,39 @@ export class TeamTranscriptProjectResolver { } const config = await this.configReader.getConfig(teamName); - if (!config?.projectPath) { + if (!config) { return null; } - const { projectDir, projectId } = await this.resolveProjectDirectory(config); - const sessionIds = await this.discoverSessionIds(teamName, projectDir, config); - const value = { projectDir, projectId, config, sessionIds }; + const resolution = await this.resolveProjectDirectory(teamName, config); + if (!resolution) { + return null; + } + + const resolvedConfig = + resolution.effectiveProjectPath && + trimTrailingSlashes(resolution.effectiveProjectPath) !== + trimTrailingSlashes(config.projectPath ?? '') + ? { + ...config, + projectPath: resolution.effectiveProjectPath, + projectPathHistory: this.buildRepairedProjectPathHistory( + config, + resolution.effectiveProjectPath + ), + } + : config; + const sessionIds = await this.discoverSessionIds( + teamName, + resolution.projectDir, + resolvedConfig + ); + const value = { + projectDir: resolution.projectDir, + projectId: resolution.projectId, + config: resolvedConfig, + sessionIds, + }; this.contextCache.set(teamName, { value, expiresAt: Date.now() + SESSION_DISCOVERY_CACHE_TTL, @@ -145,47 +243,391 @@ export class TeamTranscriptProjectResolver { } private async resolveProjectDirectory( + teamName: string, config: TeamConfig - ): Promise<{ projectDir: string; projectId: string }> { - const normalizedProjectPath = trimTrailingSlashes(config.projectPath ?? ''); - let projectId = encodePath(normalizedProjectPath); - let projectDir = path.join(getProjectsBasePath(), extractBaseDir(projectId)); + ): Promise<{ projectDir: string; projectId: string; effectiveProjectPath?: string } | null> { + const sessionIds = collectKnownSessionIds(config); + const pathCandidates = this.collectProjectPathCandidates(config); + const currentCandidate = pathCandidates[0] ?? null; + if (sessionIds.length === 0) { + return this.buildFallbackResolution(teamName, pathCandidates); + } - try { - const stat = await fs.stat(projectDir); - if (!stat.isDirectory()) { - throw new Error('not a directory'); - } - return { projectDir, projectId }; - } catch { - const leadSessionId = - typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0 - ? config.leadSessionId.trim() - : null; - if (!leadSessionId) { - return { projectDir, projectId }; - } + const rankBySessionId = new Map(sessionIds.map((sessionId, index) => [sessionId, index])); + const getMatchRank = (match: { matchedSessionId: string } | null): number => + match + ? (rankBySessionId.get(match.matchedSessionId) ?? Number.POSITIVE_INFINITY) + : Number.POSITIVE_INFINITY; - try { - const projectEntries = await fs.readdir(getProjectsBasePath(), { withFileTypes: true }); - for (const entry of projectEntries) { - if (!entry.isDirectory()) continue; - const candidateDir = path.join(getProjectsBasePath(), entry.name); - try { - await fs.access(path.join(candidateDir, `${leadSessionId}.jsonl`)); - projectDir = candidateDir; - projectId = entry.name; - break; - } catch { - // not this project - } - } - } catch { - // best-effort fallback + const toResolution = ( + match: Pick & { projectPath?: string } + ): { projectDir: string; projectId: string; effectiveProjectPath?: string } => ({ + projectDir: match.projectDir, + projectId: match.projectId, + ...(match.projectPath ? { effectiveProjectPath: match.projectPath } : {}), + }); + + let currentMatch: SessionProjectMatch | null = null; + if (currentCandidate) { + const resolvedCurrentMatch = await this.findMatchInProjectPathCandidate( + currentCandidate, + sessionIds + ); + if (resolvedCurrentMatch && getMatchRank(resolvedCurrentMatch) === 0) { + return toResolution(resolvedCurrentMatch); + } + if (resolvedCurrentMatch) { + currentMatch = resolvedCurrentMatch; } } - return { projectDir, projectId }; + const configuredMatches = + pathCandidates.length > 1 + ? await this.findMatchesInProjectPathCandidates(pathCandidates.slice(1), sessionIds) + : []; + const scannedMatches = await this.findMatchesByScanningProjects(sessionIds); + + const candidateMatchesByProjectDir = new Map< + string, + SessionProjectMatch | ScannedSessionProjectMatch + >(); + for (const match of configuredMatches) { + if (match.projectDir === currentMatch?.projectDir) { + continue; + } + candidateMatchesByProjectDir.set(match.projectDir, match); + } + for (const match of scannedMatches) { + if (match.projectDir === currentMatch?.projectDir) { + continue; + } + if (!candidateMatchesByProjectDir.has(match.projectDir)) { + candidateMatchesByProjectDir.set(match.projectDir, match); + } + } + + const alternateMatches = [...candidateMatchesByProjectDir.values()]; + const bestAlternateRank = alternateMatches.reduce( + (best, match) => Math.min(best, getMatchRank(match)), + Number.POSITIVE_INFINITY + ); + const currentRank = getMatchRank(currentMatch); + + if (currentMatch && currentRank <= bestAlternateRank) { + return toResolution(currentMatch); + } + + if (bestAlternateRank !== Number.POSITIVE_INFINITY) { + const bestAlternates = alternateMatches.filter( + (match) => getMatchRank(match) === bestAlternateRank + ); + if (bestAlternates.length === 1) { + const winner = bestAlternates[0]; + if (winner.projectPath) { + await this.persistResolvedProjectPath(teamName, config, winner.projectPath); + } + return toResolution(winner); + } + logger.warn( + `[${teamName}] Transcript project resolution ambiguous across exact-session candidates; keeping current path` + ); + return currentMatch + ? toResolution(currentMatch) + : this.buildFallbackResolution(teamName, pathCandidates); + } + + if (currentMatch) { + return toResolution(currentMatch); + } + + return this.buildFallbackResolution(teamName, pathCandidates); + } + + private async buildFallbackResolution( + teamName: string, + candidates: readonly ProjectPathCandidate[] + ): Promise<{ projectDir: string; projectId: string; effectiveProjectPath?: string } | null> { + let firstResolution: { + projectDir: string; + projectId: string; + effectiveProjectPath?: string; + } | null = null; + let firstExistingResolution: { + projectDir: string; + projectId: string; + effectiveProjectPath?: string; + } | null = null; + + for (const candidate of candidates) { + for (const dirCandidate of this.buildProjectDirCandidates(candidate.projectPath)) { + const resolution = { + projectDir: dirCandidate.projectDir, + projectId: dirCandidate.projectId, + effectiveProjectPath: candidate.projectPath, + }; + if (!firstResolution) { + firstResolution = resolution; + } + if (!(await this.projectDirExists(dirCandidate.projectDir))) { + continue; + } + if (!firstExistingResolution) { + firstExistingResolution = resolution; + } + const teamRootSessionIds = await this.listTeamRootSessionIds( + dirCandidate.projectDir, + teamName + ); + if (teamRootSessionIds.length > 0) { + return resolution; + } + } + } + + return firstExistingResolution ?? firstResolution; + } + + private collectProjectPathCandidates(config: TeamConfig): ProjectPathCandidate[] { + const candidates: ProjectPathCandidate[] = []; + const seen = new Set(); + const push = (value: unknown, source: Exclude): void => { + const normalized = normalizeProjectPathCandidate(value); + if (!normalized || seen.has(normalized)) { + return; + } + seen.add(normalized); + candidates.push({ projectPath: normalized, source }); + }; + + push(config.projectPath, 'projectPath'); + + if (Array.isArray(config.projectPathHistory)) { + for (let index = config.projectPathHistory.length - 1; index >= 0; index -= 1) { + push(config.projectPathHistory[index], 'projectPathHistory'); + } + } + + const leadCwd = (config.members ?? []).find((member) => isLeadMember(member))?.cwd; + push(leadCwd, 'leadCwd'); + + const distinctMemberCwds = Array.from( + new Set( + (config.members ?? []) + .map((member) => normalizeProjectPathCandidate(member.cwd)) + .filter((cwd): cwd is string => Boolean(cwd)) + ) + ); + if (distinctMemberCwds.length === 1) { + push(distinctMemberCwds[0], 'memberCwd'); + } + + return candidates; + } + + private buildProjectDirCandidates(projectPath: string): ProjectDirCandidate[] { + const normalizedProjectPath = trimTrailingSlashes(projectPath); + const projectId = extractBaseDir(encodePath(normalizedProjectPath)); + const baseCandidates = [ + { projectDir: path.join(getProjectsBasePath(), projectId), projectId }, + ...(projectId.includes('_') + ? [ + { + projectDir: path.join(getProjectsBasePath(), projectId.replace(/_/g, '-')), + projectId: projectId.replace(/_/g, '-'), + }, + ] + : []), + ]; + + const seen = new Set(); + return baseCandidates + .filter((candidate) => { + if (seen.has(candidate.projectDir)) { + return false; + } + seen.add(candidate.projectDir); + return true; + }) + .map((candidate) => ({ + projectPath: normalizedProjectPath, + projectDir: candidate.projectDir, + projectId: candidate.projectId, + source: 'projectPath' as const, + })); + } + + private async findMatchInProjectPathCandidate( + candidate: ProjectPathCandidate, + sessionIds: string[] + ): Promise { + const rankBySessionId = new Map(sessionIds.map((sessionId, index) => [sessionId, index])); + let bestMatch: SessionProjectMatch | null = null; + + for (const projectCandidate of this.buildProjectDirCandidates(candidate.projectPath)) { + const matchedSessionId = await this.findMatchingSessionId( + projectCandidate.projectDir, + sessionIds + ); + if (!matchedSessionId) { + continue; + } + const match = { + ...projectCandidate, + source: candidate.source, + matchedSessionId, + }; + const matchRank = rankBySessionId.get(match.matchedSessionId) ?? Number.POSITIVE_INFINITY; + const bestRank = bestMatch + ? (rankBySessionId.get(bestMatch.matchedSessionId) ?? Number.POSITIVE_INFINITY) + : Number.POSITIVE_INFINITY; + if (!bestMatch || matchRank < bestRank) { + bestMatch = match; + } + if (matchRank === 0) { + break; + } + } + return bestMatch; + } + + private async findMatchesInProjectPathCandidates( + candidates: ProjectPathCandidate[], + sessionIds: string[] + ): Promise { + const matches: SessionProjectMatch[] = []; + const seenProjectDirs = new Set(); + for (const candidate of candidates) { + const match = await this.findMatchInProjectPathCandidate(candidate, sessionIds); + if (!match || seenProjectDirs.has(match.projectDir)) { + continue; + } + seenProjectDirs.add(match.projectDir); + matches.push(match); + } + return matches; + } + + private async findMatchingSessionId( + projectDir: string, + sessionIds: string[] + ): Promise { + for (const sessionId of sessionIds) { + try { + const stat = await fs.stat(path.join(projectDir, `${sessionId}.jsonl`)); + if (stat.isFile()) { + return sessionId; + } + } catch { + // continue + } + } + return null; + } + + private async findMatchesByScanningProjects( + sessionIds: string[] + ): Promise { + let projectEntries: Dirent[]; + try { + projectEntries = await fs.readdir(getProjectsBasePath(), { withFileTypes: true }); + } catch { + return []; + } + + const directories = projectEntries.filter((entry) => entry.isDirectory()); + const matches: ScannedSessionProjectMatch[] = []; + let nextIndex = 0; + + const worker = async (): Promise => { + while (nextIndex < directories.length) { + const index = nextIndex++; + const entry = directories[index]; + const projectDir = path.join(getProjectsBasePath(), entry.name); + const matchedSessionId = await this.findMatchingSessionId(projectDir, sessionIds); + if (!matchedSessionId) { + continue; + } + const jsonlPath = path.join(projectDir, `${matchedSessionId}.jsonl`); + const cwd = await extractCwd(jsonlPath); + matches.push({ + projectPath: cwd ?? undefined, + projectDir, + projectId: entry.name, + source: 'projectsScan', + matchedSessionId, + }); + } + }; + + await Promise.all( + Array.from({ length: Math.min(ROOT_DISCOVERY_CONCURRENCY, directories.length) }, () => + worker() + ) + ); + + const deduped = new Map(); + for (const match of matches) { + if (!deduped.has(match.projectDir)) { + deduped.set(match.projectDir, match); + } + } + return [...deduped.values()]; + } + + private async persistResolvedProjectPath( + teamName: string, + config: TeamConfig, + nextProjectPath: string + ): Promise { + const normalizedNextPath = normalizeProjectPathCandidate(nextProjectPath); + if (!normalizedNextPath) { + return; + } + + const currentProjectPath = normalizeProjectPathCandidate(config.projectPath); + if (currentProjectPath === normalizedNextPath) { + return; + } + + const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); + try { + const raw = await fs.readFile(configPath, 'utf8'); + const parsed = JSON.parse(raw) as Record; + const rawProjectPath = + normalizeProjectPathCandidate(parsed.projectPath) ?? currentProjectPath ?? null; + + parsed.projectPath = normalizedNextPath; + + const history: string[] = []; + const seen = new Set(); + const pushHistory = (value: unknown): void => { + const normalized = normalizeProjectPathCandidate(value); + if (!normalized || normalized === normalizedNextPath || seen.has(normalized)) { + return; + } + seen.add(normalized); + history.push(normalized); + }; + + if (Array.isArray(parsed.projectPathHistory)) { + for (const value of parsed.projectPathHistory) { + pushHistory(value); + } + } + pushHistory(rawProjectPath); + + parsed.projectPathHistory = history.slice(-500); + await atomicWriteAsync(configPath, JSON.stringify(parsed, null, 2)); + logger.info( + `[${teamName}] Repaired transcript projectPath via exact session match: ${normalizedNextPath}` + ); + } catch (error) { + logger.warn( + `[${teamName}] Failed to persist repaired transcript projectPath: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } } private async discoverSessionIds( @@ -199,9 +641,58 @@ export class TeamTranscriptProjectResolver { this.listSessionDirIds(projectDir), ]); - return Array.from(new Set([...knownSessionIds, ...teamRootSessionIds, ...sessionDirIds])).sort( - (left, right) => left.localeCompare(right) - ); + const orderedSessionIds: string[] = []; + const seen = new Set(); + const push = (sessionId: string): void => { + if (seen.has(sessionId)) { + return; + } + seen.add(sessionId); + orderedSessionIds.push(sessionId); + }; + + for (const sessionId of knownSessionIds) { + push(sessionId); + } + for (const sessionId of [...teamRootSessionIds, ...sessionDirIds].sort((left, right) => + left.localeCompare(right) + )) { + push(sessionId); + } + + return orderedSessionIds; + } + + private buildRepairedProjectPathHistory(config: TeamConfig, nextProjectPath: string): string[] { + const normalizedNextPath = normalizeProjectPathCandidate(nextProjectPath); + const history: string[] = []; + const seen = new Set(); + const pushHistory = (value: unknown): void => { + const normalized = normalizeProjectPathCandidate(value); + if (!normalized || normalized === normalizedNextPath || seen.has(normalized)) { + return; + } + seen.add(normalized); + history.push(normalized); + }; + + if (Array.isArray(config.projectPathHistory)) { + for (const value of config.projectPathHistory) { + pushHistory(value); + } + } + pushHistory(config.projectPath); + + return history.slice(-500); + } + + private async projectDirExists(projectDir: string): Promise { + try { + const stat = await fs.stat(projectDir); + return stat.isDirectory(); + } catch { + return false; + } } private async listSessionDirIds(projectDir: string): Promise { @@ -272,6 +763,9 @@ export class TeamTranscriptProjectResolver { if (directTeamName === normalizedTeam) { return true; } + if (entryContainsNestedTeamName(entry, normalizedTeam)) { + return true; + } const textContent = extractTextContent(entry); if (textContent && lineMentionsTeam(textContent, normalizedTeam)) { diff --git a/src/main/services/team/TeammateToolTracker.ts b/src/main/services/team/TeammateToolTracker.ts index c19e9ced..5a4416b9 100644 --- a/src/main/services/team/TeammateToolTracker.ts +++ b/src/main/services/team/TeammateToolTracker.ts @@ -145,7 +145,7 @@ export class TeammateToolTracker { const state = this.stateByTeam.get(teamName); if (!state?.enabled || state.epoch !== expectedEpoch) return; - const attributedFiles = await this.logsFinder.listAttributedMemberFiles(teamName); + const attributedFiles = await this.logsFinder.listAttributedSubagentFiles(teamName); const currentState = this.stateByTeam.get(teamName); if (!currentState?.enabled || currentState.epoch !== expectedEpoch) return; diff --git a/src/main/services/team/index.ts b/src/main/services/team/index.ts index 3223ab4a..a87be45a 100644 --- a/src/main/services/team/index.ts +++ b/src/main/services/team/index.ts @@ -16,6 +16,12 @@ export { BoardTaskActivityService } from './taskLogs/activity/BoardTaskActivityS export { BoardTaskExactLogDetailService } from './taskLogs/exact/BoardTaskExactLogDetailService'; export { BoardTaskExactLogsService } from './taskLogs/exact/BoardTaskExactLogsService'; export { BoardTaskLogStreamService } from './taskLogs/stream/BoardTaskLogStreamService'; +export { + AutoResumeService, + clearAutoResumeService, + getAutoResumeService, + initializeAutoResumeService, +} from './AutoResumeService'; export { TeamAttachmentStore } from './TeamAttachmentStore'; export { TeamBackupService } from './TeamBackupService'; export { TeamConfigReader } from './TeamConfigReader'; diff --git a/src/main/services/team/mergeLiveLeadProcessMessages.ts b/src/main/services/team/mergeLiveLeadProcessMessages.ts new file mode 100644 index 00000000..5b613caf --- /dev/null +++ b/src/main/services/team/mergeLiveLeadProcessMessages.ts @@ -0,0 +1,73 @@ +import type { InboxMessage } from '@shared/types'; + +export function getLiveLeadProcessMessageKey(message: { + messageId?: string; + timestamp: string; + from: string; + text: string; +}): string { + if (typeof message.messageId === 'string' && message.messageId.trim().length > 0) { + return message.messageId; + } + return `${message.timestamp}\0${message.from}\0${(message.text ?? '').slice(0, 80)}`; +} + +export function mergeLiveLeadProcessMessages( + durableMessages: InboxMessage[], + liveMessages: InboxMessage[] +): InboxMessage[] { + if (liveMessages.length === 0) { + return durableMessages; + } + + const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n'); + const isLeadThoughtLike = (msg: { source?: unknown; to?: string }): boolean => + !msg.to && (msg.source === 'lead_process' || msg.source === 'lead_session'); + const getLeadThoughtFingerprint = (msg: { + from: string; + text: string; + leadSessionId?: string; + }): string => `${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text)}`; + + const existingTextFingerprints = new Set(); + for (const msg of durableMessages) { + if (typeof msg.from !== 'string' || typeof msg.text !== 'string') continue; + if (!isLeadThoughtLike(msg)) continue; + existingTextFingerprints.add(getLeadThoughtFingerprint(msg)); + } + + const leadProcessTextFingerprints = new Set(); + const contentSeen = new Map(); + const merged: InboxMessage[] = []; + const seen = new Set(); + + for (const msg of [...durableMessages, ...liveMessages]) { + if (msg.source === 'lead_process' && !msg.to) { + const fp = getLeadThoughtFingerprint(msg); + if (existingTextFingerprints.has(fp) || leadProcessTextFingerprints.has(fp)) { + continue; + } + leadProcessTextFingerprints.add(fp); + } + + if (typeof msg.to === 'string' && msg.to.trim().length > 0) { + const contentFp = `${msg.from}\0${msg.to}\0${(msg.text ?? '').replace(/\s+/g, ' ').slice(0, 100)}`; + const msgMs = Date.parse(msg.timestamp); + const existingMs = contentSeen.get(contentFp); + if (existingMs !== undefined && Math.abs(msgMs - existingMs) <= 5000) { + continue; + } + contentSeen.set(contentFp, msgMs); + } + + const key = getLiveLeadProcessMessageKey(msg); + if (seen.has(key)) { + continue; + } + seen.add(key); + merged.push(msg); + } + + merged.sort((left, right) => Date.parse(right.timestamp) - Date.parse(left.timestamp)); + return merged; +} diff --git a/src/main/standalone.ts b/src/main/standalone.ts index 0a3e1083..d1186be3 100644 --- a/src/main/standalone.ts +++ b/src/main/standalone.ts @@ -1,5 +1,5 @@ /** - * Standalone (non-Electron) entry point for Claude Agent Teams UI. + * Standalone (non-Electron) entry point for Agent Teams UI. * * Runs the HTTP server + API without Electron, suitable for Docker * or any headless/remote environment. The renderer is served as diff --git a/src/main/types/chunks.ts b/src/main/types/chunks.ts index d24d3913..fcd9e54c 100644 --- a/src/main/types/chunks.ts +++ b/src/main/types/chunks.ts @@ -1,5 +1,5 @@ /** - * Chunk and visualization types for Claude Agent Teams UI. + * Chunk and visualization types for Agent Teams UI. * * This module contains: * - Chunk types (UserChunk, AIChunk, SystemChunk, CompactChunk) diff --git a/src/main/types/domain.ts b/src/main/types/domain.ts index 85a67e06..1c2595ab 100644 --- a/src/main/types/domain.ts +++ b/src/main/types/domain.ts @@ -1,5 +1,5 @@ /** - * Domain/business entity types for Claude Agent Teams UI. + * Domain/business entity types for Agent Teams UI. * * These types represent the application's domain model: * - Projects and sessions diff --git a/src/main/types/messages.ts b/src/main/types/messages.ts index 79f6652e..1b496d9c 100644 --- a/src/main/types/messages.ts +++ b/src/main/types/messages.ts @@ -1,5 +1,5 @@ /** - * Parsed message types and type guards for Claude Agent Teams UI. + * Parsed message types and type guards for Agent Teams UI. * * ParsedMessage is the application's internal representation after parsing * raw JSONL entries. This module also contains type guards for classifying diff --git a/src/preload/index.ts b/src/preload/index.ts index b955ba91..29fc3b1f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1583,7 +1583,8 @@ const electronAPI: ElectronAPI = { invokeIpcWithResult(MCP_REGISTRY_GET_BY_ID, registryId), getInstalled: (projectPath?: string) => invokeIpcWithResult(MCP_REGISTRY_GET_INSTALLED, projectPath), - diagnose: () => invokeIpcWithResult(MCP_REGISTRY_DIAGNOSE), + diagnose: (projectPath?: string) => + invokeIpcWithResult(MCP_REGISTRY_DIAGNOSE, projectPath), install: (request: McpInstallRequest) => invokeIpcWithResult(MCP_REGISTRY_INSTALL, request), installCustom: (request: McpCustomInstallRequest) => @@ -1627,8 +1628,8 @@ const electronAPI: ElectronAPI = { list: () => invokeIpcWithResult(API_KEYS_LIST), save: (request: ApiKeySaveRequest) => invokeIpcWithResult(API_KEYS_SAVE, request), delete: (id: string) => invokeIpcWithResult(API_KEYS_DELETE, id), - lookup: (envVarNames: string[]) => - invokeIpcWithResult(API_KEYS_LOOKUP, envVarNames), + lookup: (envVarNames: string[], projectPath?: string) => + invokeIpcWithResult(API_KEYS_LOOKUP, envVarNames, projectPath), getStorageStatus: () => invokeIpcWithResult(API_KEYS_STORAGE_STATUS), }, diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index 84d14d47..05fc5569 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -33,6 +33,7 @@ import { useStore } from '@renderer/store'; import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice'; import { formatBytes } from '@renderer/utils/formatters'; import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze'; +import { isMultimodelRuntimeStatus } from '@renderer/utils/multimodelProviderVisibility'; import { AlertTriangle, CheckCircle, @@ -321,7 +322,11 @@ function formatRuntimeAuthSummary( cliStatus: NonNullable['cliStatus']>, visibleProviders: readonly CliProviderStatus[] ): string | null { - if (cliStatus.flavor === 'agent_teams_orchestrator' && visibleProviders.length > 0) { + if (isMultimodelRuntimeStatus(cliStatus)) { + if (visibleProviders.length === 0) { + return null; + } + if ( visibleProviders.every( (provider) => provider.statusMessage === 'Checking...' && !provider.authenticated @@ -351,7 +356,7 @@ function isCheckingMultimodelStatus( visibleProviders: readonly CliProviderStatus[] ): boolean { return ( - cliStatus.flavor === 'agent_teams_orchestrator' && + isMultimodelRuntimeStatus(cliStatus) && visibleProviders.length > 0 && visibleProviders.every( (provider) => provider.statusMessage === 'Checking...' && !provider.authenticated @@ -359,6 +364,12 @@ function isCheckingMultimodelStatus( ); } +function hasVisibleAuthenticatedMultimodelProvider( + visibleProviders: readonly CliProviderStatus[] +): boolean { + return visibleProviders.some((provider) => provider.authenticated); +} + const InstalledBanner = ({ cliStatus, cliStatusLoading, @@ -382,6 +393,7 @@ const InstalledBanner = ({ () => filterMainScreenCliProviders(cliStatus.providers), [cliStatus.providers] ); + const canOpenExtensions = cliStatus.installed; const runtimeLabel = formatRuntimeLabel(cliStatus); const runtimeAuthSummary = formatRuntimeAuthSummary(cliStatus, visibleProviders); @@ -466,8 +478,8 @@ const InstalledBanner = ({ disabled={isBusy || cliStatusLoading || multimodelBusy} /> - {/* Extensions button — only when installed + authenticated */} - {cliStatus.authLoggedIn && ( + {/* Extensions button — available whenever the runtime is installed */} + {canOpenExtensions && ( + + + + + + + {mcpMutationDisableReason && ( + {mcpMutationDisableReason} + )} + )} @@ -352,7 +543,7 @@ export const ExtensionStoreView = (): React.JSX.Element => { - + diff --git a/src/renderer/components/extensions/apikeys/ApiKeyCard.tsx b/src/renderer/components/extensions/apikeys/ApiKeyCard.tsx index 653f0fa2..a946adec 100644 --- a/src/renderer/components/extensions/apikeys/ApiKeyCard.tsx +++ b/src/renderer/components/extensions/apikeys/ApiKeyCard.tsx @@ -66,6 +66,12 @@ export const ApiKeyCard = ({ apiKey, onEdit }: ApiKeyCardProps): React.JSX.Eleme + {apiKey.scope === 'project' && apiKey.projectPath && ( +

+ {apiKey.projectPath} +

+ )} + {/* Env var name */}
diff --git a/src/renderer/components/extensions/apikeys/ApiKeyFormDialog.tsx b/src/renderer/components/extensions/apikeys/ApiKeyFormDialog.tsx index 72e07225..f2b53396 100644 --- a/src/renderer/components/extensions/apikeys/ApiKeyFormDialog.tsx +++ b/src/renderer/components/extensions/apikeys/ApiKeyFormDialog.tsx @@ -32,6 +32,8 @@ const ENV_KEY_RE = /^[A-Z_][A-Z0-9_]{0,100}$/i; interface ApiKeyFormDialogProps { open: boolean; editingKey: ApiKeyEntry | null; + currentProjectPath: string | null; + currentProjectLabel: string | null; onClose: () => void; } @@ -45,6 +47,8 @@ const SCOPE_OPTIONS: { value: Scope; label: string }[] = [ export const ApiKeyFormDialog = ({ open, editingKey, + currentProjectPath, + currentProjectLabel, onClose, }: ApiKeyFormDialogProps): React.JSX.Element => { const saveApiKey = useStore((s) => s.saveApiKey); @@ -57,6 +61,14 @@ export const ApiKeyFormDialog = ({ const [scope, setScope] = useState('user'); const [error, setError] = useState(null); const [envVarError, setEnvVarError] = useState(null); + const editingProjectPath = + editingKey?.scope === 'project' ? (editingKey.projectPath ?? null) : null; + const effectiveProjectPath = editingProjectPath ?? currentProjectPath; + const effectiveProjectLabel = + effectiveProjectPath && effectiveProjectPath === currentProjectPath + ? currentProjectLabel + : effectiveProjectPath; + const canUseProjectScope = Boolean(effectiveProjectPath); // Reset form when dialog opens/closes or editing key changes useEffect(() => { @@ -77,6 +89,12 @@ export const ApiKeyFormDialog = ({ } }, [open, editingKey]); + useEffect(() => { + if (open && scope === 'project' && !canUseProjectScope) { + setScope('user'); + } + }, [canUseProjectScope, open, scope]); + const validateEnvVar = (v: string) => { if (!v.trim()) { setEnvVarError(null); @@ -109,6 +127,10 @@ export const ApiKeyFormDialog = ({ setError('Key value is required'); return; } + if (scope === 'project' && !effectiveProjectPath) { + setError('Project-scoped API keys require an active project'); + return; + } try { await saveApiKey({ @@ -117,6 +139,7 @@ export const ApiKeyFormDialog = ({ envVarName: envVarName.trim(), value, scope, + projectPath: scope === 'project' ? (effectiveProjectPath ?? undefined) : undefined, }); onClose(); } catch (err) { @@ -125,7 +148,13 @@ export const ApiKeyFormDialog = ({ }; const isEdit = editingKey !== null; - const canSubmit = name.trim() && envVarName.trim() && value && !envVarError && !apiKeySaving; + const canSubmit = + name.trim() && + envVarName.trim() && + value && + !envVarError && + !apiKeySaving && + (scope !== 'project' || canUseProjectScope); return ( !o && onClose()}> @@ -212,12 +241,23 @@ export const ApiKeyFormDialog = ({ {SCOPE_OPTIONS.map((opt) => ( - - {opt.label} + + {opt.value === 'project' + ? effectiveProjectPath + ? `Project: ${effectiveProjectLabel}` + : 'Project unavailable' + : opt.label} ))} + {scope === 'project' && effectiveProjectPath && ( +

Bound to {effectiveProjectPath}

+ )}
{/* Error display */} diff --git a/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx b/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx index 951aecb0..3352fb3c 100644 --- a/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx +++ b/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx @@ -2,7 +2,7 @@ * ApiKeysPanel — grid of saved API keys with add button and empty state. */ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Button } from '@renderer/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; @@ -15,16 +15,26 @@ import { ApiKeyFormDialog } from './ApiKeyFormDialog'; import type { ApiKeyEntry } from '@shared/types/extensions'; -export const ApiKeysPanel = (): React.JSX.Element => { - const { apiKeys, apiKeysLoading, apiKeysError, storageStatus, fetchStorageStatus } = useStore( - useShallow((s) => ({ - apiKeys: s.apiKeys, - apiKeysLoading: s.apiKeysLoading, - apiKeysError: s.apiKeysError, - storageStatus: s.apiKeyStorageStatus, - fetchStorageStatus: s.fetchApiKeyStorageStatus, - })) - ); +interface ApiKeysPanelProps { + projectPath: string | null; + projectLabel: string | null; +} + +export const ApiKeysPanel = ({ + projectPath, + projectLabel, +}: ApiKeysPanelProps): React.JSX.Element => { + const { apiKeys, apiKeysLoading, apiKeysError, storageStatus, fetchStorageStatus, cliStatus } = + useStore( + useShallow((s) => ({ + apiKeys: s.apiKeys, + apiKeysLoading: s.apiKeysLoading, + apiKeysError: s.apiKeysError, + storageStatus: s.apiKeyStorageStatus, + fetchStorageStatus: s.fetchApiKeyStorageStatus, + cliStatus: s.cliStatus, + })) + ); const [dialogOpen, setDialogOpen] = useState(false); const [editingKey, setEditingKey] = useState(null); @@ -49,9 +59,82 @@ export const ApiKeysPanel = (): React.JSX.Element => { }; const isOsKeychain = storageStatus?.encryptionMethod === 'os-keychain'; + const providerKeyCards = useMemo(() => { + if (!cliStatus?.providers?.length) { + return []; + } + + return ( + [ + { + providerId: 'anthropic', + label: 'Anthropic runtime', + envVar: 'ANTHROPIC_API_KEY', + }, + { + providerId: 'codex', + label: 'Codex runtime', + envVar: 'OPENAI_API_KEY', + }, + ] as const + ).flatMap((item) => { + const provider = cliStatus.providers.find((entry) => entry.providerId === item.providerId); + if (!provider) { + return []; + } + + return [ + { + ...item, + authenticated: provider.authenticated, + apiKeyConfigured: provider.connection?.apiKeyConfigured ?? false, + sourceLabel: provider.connection?.apiKeySourceLabel ?? null, + statusMessage: provider.statusMessage ?? null, + }, + ]; + }); + }, [cliStatus]); return (
+ {providerKeyCards.length > 0 && ( +
+ {providerKeyCards.map((provider) => ( +
+
+
+

{provider.label}

+

{provider.envVar}

+
+ + {provider.authenticated + ? 'Connected' + : provider.apiKeyConfigured + ? 'Key configured' + : 'Key missing'} + +
+

+ {provider.sourceLabel + ? `Current source: ${provider.sourceLabel}.` + : 'No stored or environment key detected for this provider.'} + {provider.statusMessage ? ` ${provider.statusMessage}` : ''} +

+
+ ))} +
+ )} {/* Header row */}

@@ -138,7 +221,13 @@ export const ApiKeysPanel = (): React.JSX.Element => { )} {/* Form dialog */} - +

); }; diff --git a/src/renderer/components/extensions/common/InstallButton.tsx b/src/renderer/components/extensions/common/InstallButton.tsx index 2bc48112..78930ad7 100644 --- a/src/renderer/components/extensions/common/InstallButton.tsx +++ b/src/renderer/components/extensions/common/InstallButton.tsx @@ -24,6 +24,7 @@ interface InstallButtonProps { isInstalled: boolean; onInstall: () => void; onUninstall: () => void; + section?: 'plugins' | 'mcp'; disabled?: boolean; size?: 'sm' | 'default'; errorMessage?: string; @@ -34,6 +35,7 @@ export const InstallButton = ({ isInstalled, onInstall, onUninstall, + section = 'plugins', disabled, size = 'sm', errorMessage, @@ -48,6 +50,7 @@ export const InstallButton = ({ isInstalled, cliStatus, cliStatusLoading, + section, }); const isDisabled = disabled || Boolean(disableReason); const [lastAction, setLastAction] = useState<'install' | 'uninstall' | null>(null); diff --git a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx index 7cb8e740..727d2603 100644 --- a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx +++ b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx @@ -3,7 +3,7 @@ * Supports stdio (npm package) and HTTP/SSE transports. */ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { Button } from '@renderer/components/ui/button'; @@ -24,6 +24,13 @@ import { SelectValue, } from '@renderer/components/ui/select'; import { useStore } from '@renderer/store'; +import { getExtensionActionDisableReason } from '@shared/utils/extensionNormalizers'; +import { + getDefaultMcpSharedScope, + getMcpScopeLabel, + isProjectScopedMcpScope, + isSharedMcpScope, +} from '@shared/utils/mcpScopes'; import { Plus, Server, Trash2 } from 'lucide-react'; import type { @@ -42,13 +49,7 @@ interface CustomMcpServerDialogProps { type TransportMode = 'stdio' | 'http'; type HttpTransport = 'streamable-http' | 'sse' | 'http'; -type Scope = 'local' | 'user' | 'project'; - -const SCOPE_OPTIONS: { value: Scope; label: string }[] = [ - { value: 'user', label: 'User (global)' }, - { value: 'project', label: 'Project' }, - { value: 'local', label: 'Local' }, -]; +type Scope = 'local' | 'user' | 'project' | 'global'; const HTTP_TRANSPORT_OPTIONS: { value: HttpTransport; label: string }[] = [ { value: 'streamable-http', label: 'Streamable HTTP' }, @@ -67,11 +68,19 @@ export const CustomMcpServerDialog = ({ projectPath, }: CustomMcpServerDialogProps): React.JSX.Element => { const installCustomMcpServer = useStore((s) => s.installCustomMcpServer); + const cliStatus = useStore((s) => s.cliStatus); + const cliStatusLoading = useStore((s) => s.cliStatusLoading); + const defaultSharedScope = getDefaultMcpSharedScope(cliStatus?.flavor); + const scopeOptions: { value: Scope; label: string }[] = [ + { value: defaultSharedScope, label: getMcpScopeLabel(defaultSharedScope, cliStatus?.flavor) }, + { value: 'project', label: 'Project' }, + { value: 'local', label: 'Local' }, + ]; // Form state const [serverName, setServerName] = useState(''); const [transportMode, setTransportMode] = useState('stdio'); - const [scope, setScope] = useState('user'); + const [scope, setScope] = useState(defaultSharedScope); // Stdio fields const [npmPackage, setNpmPackage] = useState(''); @@ -86,13 +95,31 @@ export const CustomMcpServerDialog = ({ const [envVars, setEnvVars] = useState([]); const [error, setError] = useState(null); const [installing, setInstalling] = useState(false); + const autoFilledValuesRef = useRef>({}); + const wasOpenRef = useRef(false); + const previousDefaultSharedScopeRef = useRef(defaultSharedScope); + const envVarLookupNames = envVars + .map((entry) => entry.key.trim()) + .filter(Boolean) + .sort() + .join('\0'); + const apiKeyLookupProjectPath = isProjectScopedMcpScope(scope) + ? (projectPath ?? undefined) + : undefined; + const mutationDisableReason = getExtensionActionDisableReason({ + isInstalled: false, + cliStatus, + cliStatusLoading, + section: 'mcp', + }); // Reset on open useEffect(() => { - if (open) { + const justOpened = open && !wasOpenRef.current; + if (justOpened) { setServerName(''); setTransportMode('stdio'); - setScope('user'); + setScope(defaultSharedScope); setNpmPackage(''); setNpmVersion(''); setHttpUrl(''); @@ -101,39 +128,98 @@ export const CustomMcpServerDialog = ({ setEnvVars([]); setError(null); setInstalling(false); + autoFilledValuesRef.current = {}; } - }, [open]); + wasOpenRef.current = open; + if (!open) { + previousDefaultSharedScopeRef.current = defaultSharedScope; + } + }, [defaultSharedScope, open]); useEffect(() => { - if (open && scope !== 'user' && !projectPath) { - setScope('user'); + if (!open) { + previousDefaultSharedScopeRef.current = defaultSharedScope; + return; } - }, [open, projectPath, scope]); + + const previousDefaultSharedScope = previousDefaultSharedScopeRef.current; + if ( + previousDefaultSharedScope !== defaultSharedScope && + scope === previousDefaultSharedScope && + isSharedMcpScope(scope) + ) { + setScope(defaultSharedScope); + } + + previousDefaultSharedScopeRef.current = defaultSharedScope; + }, [defaultSharedScope, open, scope]); + + useEffect(() => { + if (open && isProjectScopedMcpScope(scope) && !projectPath) { + setScope(defaultSharedScope); + } + }, [defaultSharedScope, open, projectPath, scope]); // Auto-fill env vars from saved API keys useEffect(() => { if (!open || envVars.length === 0 || !api.apiKeys) return; - const envVarNames = envVars.map((e) => e.key).filter(Boolean); + const envVarNames = envVars.map((e) => e.key.trim()).filter(Boolean); if (envVarNames.length === 0) return; - void api.apiKeys.lookup(envVarNames).then( + void api.apiKeys.lookup(envVarNames, apiKeyLookupProjectPath).then( (results) => { - if (results.length === 0) return; - const lookup = new Map(results.map((r) => [r.envVarName, r.value])); - setEnvVars((prev) => - prev.map((e) => (lookup.has(e.key) && !e.value ? { ...e, value: lookup.get(e.key)! } : e)) + const previousAutoFilledValues = autoFilledValuesRef.current; + const nextAutoFilledValues = Object.fromEntries( + results.map((result) => [result.envVarName, result.value]) ); + setEnvVars((prev) => { + let changed = false; + const next = prev.map((entry) => { + const envVarName = entry.key.trim(); + if (!envVarName) { + return entry; + } + + const previousValue = previousAutoFilledValues[envVarName]; + const nextValue = nextAutoFilledValues[envVarName]; + + if (!nextValue) { + if (previousValue && entry.value === previousValue) { + changed = true; + return { ...entry, value: '' }; + } + return entry; + } + + if (!entry.value || entry.value === previousValue) { + if (entry.value !== nextValue) { + changed = true; + return { ...entry, value: nextValue }; + } + } + + return entry; + }); + + return changed ? next : prev; + }); + autoFilledValuesRef.current = nextAutoFilledValues; }, () => { // Silently fail } ); - }, [open, envVars.length]); // eslint-disable-line react-hooks/exhaustive-deps + }, [apiKeyLookupProjectPath, envVarLookupNames, open]); // eslint-disable-line react-hooks/exhaustive-deps const handleInstall = async () => { setError(null); + if (mutationDisableReason) { + setError(mutationDisableReason); + return; + } + if (!serverName.trim()) { setError('Server name is required'); return; @@ -177,7 +263,7 @@ export const CustomMcpServerDialog = ({ const request: McpCustomInstallRequest = { serverName, scope, - projectPath: scope !== 'user' ? (projectPath ?? undefined) : undefined, + projectPath: isProjectScopedMcpScope(scope) ? (projectPath ?? undefined) : undefined, installSpec, envValues, headers: headers.filter((h) => h.key.trim() && h.value.trim()), @@ -207,7 +293,8 @@ export const CustomMcpServerDialog = ({ const canSubmit = serverName.trim() && (transportMode === 'stdio' ? npmPackage.trim() : httpUrl.trim()) && - !(scope !== 'user' && !projectPath) && + !(isProjectScopedMcpScope(scope) && !projectPath) && + !mutationDisableReason && !installing; return ( @@ -382,11 +469,11 @@ export const CustomMcpServerDialog = ({ - {SCOPE_OPTIONS.map((opt) => ( + {scopeOptions.map((opt) => ( {opt.label} @@ -436,6 +523,11 @@ export const CustomMcpServerDialog = ({
{/* Error */} + {mutationDisableReason && ( +
+ {mutationDisableReason} +
+ )} {error && (
{error} diff --git a/src/renderer/components/extensions/mcp/McpServerCard.tsx b/src/renderer/components/extensions/mcp/McpServerCard.tsx index afae2142..10844f74 100644 --- a/src/renderer/components/extensions/mcp/McpServerCard.tsx +++ b/src/renderer/components/extensions/mcp/McpServerCard.tsx @@ -16,6 +16,7 @@ import { getMcpOperationKey, sanitizeMcpServerName, } from '@shared/utils/extensionNormalizers'; +import { getDefaultMcpSharedScope } from '@shared/utils/mcpScopes'; import { Clock, Cloud, Globe, KeyRound, Lock, Monitor, Star, Tag, Wrench } from 'lucide-react'; import { Github as GithubIcon } from 'lucide-react'; @@ -47,7 +48,9 @@ export const McpServerCard = ({ diagnosticsLoading, onClick, }: McpServerCardProps): React.JSX.Element => { - const operationKey = getMcpOperationKey(server.id, 'user'); + const cliStatus = useStore((s) => s.cliStatus); + const sharedScope = getDefaultMcpSharedScope(cliStatus?.flavor); + const operationKey = getMcpOperationKey(server.id, sharedScope); const installProgress = useStore((s) => s.mcpInstallProgress[operationKey] ?? 'idle'); const installMcpServer = useStore((s) => s.installMcpServer); const uninstallMcpServer = useStore((s) => s.uninstallMcpServer); @@ -67,13 +70,13 @@ export const McpServerCard = ({ server.requiresAuth || (server.authHeaders?.length ?? 0) > 0; const defaultServerName = sanitizeMcpServerName(server.name); - const userInstallEntry = - normalizedInstalledEntries.find((entry) => entry.scope === 'user') ?? null; + const sharedInstallEntry = + normalizedInstalledEntries.find((entry) => entry.scope === sharedScope) ?? null; const installSummaryLabel = getMcpInstallationSummaryLabel(normalizedInstalledEntries); const supportsDirectInstalledAction = isInstalled && normalizedInstalledEntries.length === 1 && - userInstallEntry?.name === defaultServerName && + sharedInstallEntry?.name === defaultServerName && !requiresConfiguration; const shouldShowDirectInstallButton = canAutoInstall && (!isInstalled ? !requiresConfiguration : supportsDirectInstalledAction); @@ -258,17 +261,22 @@ export const McpServerCard = ({ installMcpServer({ registryId: server.id, serverName: defaultServerName, - scope: 'user', + scope: sharedScope, envValues: {}, headers: [], }) } onUninstall={() => - uninstallMcpServer(server.id, userInstallEntry?.name ?? defaultServerName, 'user') + uninstallMcpServer( + server.id, + sharedInstallEntry?.name ?? defaultServerName, + sharedScope + ) } size="sm" errorMessage={installError} diff --git a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx index d16e0885..97845c01 100644 --- a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +++ b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx @@ -3,7 +3,7 @@ * Uses Radix UI Kit for all form elements. */ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { Badge } from '@renderer/components/ui/badge'; @@ -31,6 +31,12 @@ import { getPreferredMcpInstallationEntry, sanitizeMcpServerName, } from '@shared/utils/extensionNormalizers'; +import { + getDefaultMcpSharedScope, + getMcpScopeLabel, + isProjectScopedMcpScope, + isSharedMcpScope, +} from '@shared/utils/mcpScopes'; import { ExternalLink, Lock, Plus, Star, Trash2, Wrench } from 'lucide-react'; import { InstallButton } from '../common/InstallButton'; @@ -55,13 +61,7 @@ interface McpServerDetailDialogProps { onClose: () => void; } -type Scope = 'local' | 'user' | 'project'; - -const SCOPE_OPTIONS: { value: Scope; label: string }[] = [ - { value: 'user', label: 'User (global)' }, - { value: 'project', label: 'Project' }, - { value: 'local', label: 'Local' }, -]; +type Scope = 'local' | 'user' | 'project' | 'global'; export const McpServerDetailDialog = ({ server, @@ -74,8 +74,10 @@ export const McpServerDetailDialog = ({ open, onClose, }: McpServerDetailDialogProps): React.JSX.Element => { - const [scope, setScope] = useState('user'); - const operationKey = server ? getMcpOperationKey(server.id, scope) : null; + const cliStatus = useStore((s) => s.cliStatus); + const defaultSharedScope = getDefaultMcpSharedScope(cliStatus?.flavor); + const [scope, setScope] = useState(defaultSharedScope); + const operationKey = server ? getMcpOperationKey(server.id, scope, projectPath) : null; const installProgress = useStore( (s) => (operationKey ? s.mcpInstallProgress[operationKey] : undefined) ?? 'idle' ); @@ -91,15 +93,36 @@ export const McpServerDetailDialog = ({ const [headers, setHeaders] = useState([]); const [imgError, setImgError] = useState(false); const [autoFilledFields, setAutoFilledFields] = useState>(new Set()); + const autoFilledValuesRef = useRef>({}); + const previousDefaultSharedScopeRef = useRef(defaultSharedScope); const normalizedInstalledEntries = installedEntries.length ? installedEntries : installedEntry ? [installedEntry] : []; + const scopeOptions: { value: Scope; label: string }[] = [ + { value: defaultSharedScope, label: getMcpScopeLabel(defaultSharedScope, cliStatus?.flavor) }, + ...(defaultSharedScope !== 'user' && + normalizedInstalledEntries.some((entry) => entry.scope === 'user') + ? [{ value: 'user' as const, label: getMcpScopeLabel('user', cliStatus?.flavor) }] + : []), + { value: 'project', label: 'Project' }, + { value: 'local', label: 'Local' }, + ]; const preferredInstalledEntry = getPreferredMcpInstallationEntry(normalizedInstalledEntries); const selectedInstalledEntry = normalizedInstalledEntries.find((entry) => entry.scope === scope) ?? null; const installSummaryLabel = getMcpInstallationSummaryLabel(normalizedInstalledEntries); + const envVarLookupNames = + server?.envVars + .map((entry) => entry.name) + .sort() + .join('\0') ?? ''; + const statusSectionLabel = + cliStatus?.flavor === 'agent_teams_orchestrator' ? 'Runtime Status' : 'Claude Status'; + const apiKeyLookupProjectPath = isProjectScopedMcpScope(scope) + ? (projectPath ?? undefined) + : undefined; // Initialize form when dialog opens or server changes useEffect(() => { @@ -120,47 +143,82 @@ export const McpServerDetailDialog = ({ })) ); setServerName(preferredInstalledEntry?.name ?? sanitizeMcpServerName(server.name)); - setScope(preferredInstalledEntry?.scope ?? 'user'); + setScope((preferredInstalledEntry?.scope as Scope | undefined) ?? defaultSharedScope); setImgError(false); setAutoFilledFields(new Set()); + autoFilledValuesRef.current = {}; }, [open, preferredInstalledEntry?.name, preferredInstalledEntry?.scope, server?.id]); useEffect(() => { - if (!server || !open) { + if (!open) { + previousDefaultSharedScopeRef.current = defaultSharedScope; return; } - setServerName(selectedInstalledEntry?.name ?? sanitizeMcpServerName(server.name)); - }, [open, scope, selectedInstalledEntry?.name, server]); + const previousDefaultSharedScope = previousDefaultSharedScopeRef.current; + if ( + previousDefaultSharedScope !== defaultSharedScope && + !preferredInstalledEntry && + scope === previousDefaultSharedScope && + isSharedMcpScope(scope) + ) { + setScope(defaultSharedScope); + } + + previousDefaultSharedScopeRef.current = defaultSharedScope; + }, [defaultSharedScope, open, preferredInstalledEntry, scope]); useEffect(() => { - if (open && scope !== 'user' && !projectPath) { - setScope('user'); + if (!server || !open || !selectedInstalledEntry) { + return; } - }, [open, projectPath, scope]); + + setServerName(selectedInstalledEntry.name); + }, [open, selectedInstalledEntry, server]); + + useEffect(() => { + if (open && isProjectScopedMcpScope(scope) && !projectPath) { + setScope(defaultSharedScope); + } + }, [defaultSharedScope, open, projectPath, scope]); // Auto-fill env values from saved API keys useEffect(() => { if (!server || !open || server.envVars.length === 0 || !api.apiKeys) return; const envVarNames = server.envVars.map((e) => e.name); - void api.apiKeys.lookup(envVarNames).then( + void api.apiKeys.lookup(envVarNames, apiKeyLookupProjectPath).then( (results) => { - if (results.length === 0) return; - const filled = new Set(); - const values: Record = {}; + const previousAutoFilledValues = autoFilledValuesRef.current; + const nextAutoFilledValues: Record = {}; for (const r of results) { - values[r.envVarName] = r.value; - filled.add(r.envVarName); + nextAutoFilledValues[r.envVarName] = r.value; } - setEnvValues((prev) => ({ ...prev, ...values })); - setAutoFilledFields(filled); + setEnvValues((prev) => { + const next = { ...prev }; + + for (const [envVarName, previousValue] of Object.entries(previousAutoFilledValues)) { + if (!(envVarName in nextAutoFilledValues) && next[envVarName] === previousValue) { + next[envVarName] = ''; + } + } + + for (const [envVarName, nextValue] of Object.entries(nextAutoFilledValues)) { + if (!next[envVarName] || next[envVarName] === previousAutoFilledValues[envVarName]) { + next[envVarName] = nextValue; + } + } + + return next; + }); + setAutoFilledFields(new Set(Object.keys(nextAutoFilledValues))); + autoFilledValuesRef.current = nextAutoFilledValues; }, () => { // Silently fail — auto-fill is supplementary } ); - }, [server?.id, open]); // eslint-disable-line react-hooks/exhaustive-deps + }, [apiKeyLookupProjectPath, envVarLookupNames, open, server?.id]); // eslint-disable-line react-hooks/exhaustive-deps if (!server) return <>; @@ -181,7 +239,7 @@ export const McpServerDetailDialog = ({ const isInstalledForScope = selectedInstalledEntry !== null; const uninstallServerName = selectedInstalledEntry?.name ?? serverName; const uninstallScope = selectedInstalledEntry?.scope ?? scope; - const scopeRequiresProjectPath = scope !== 'user' && !projectPath; + const scopeRequiresProjectPath = isProjectScopedMcpScope(scope) && !projectPath; const installDisabled = !serverName.trim() || missingRequiredEnvVars || @@ -201,7 +259,7 @@ export const McpServerDetailDialog = ({ registryId: server.id, serverName, scope, - projectPath: scope !== 'user' ? (projectPath ?? undefined) : undefined, + projectPath: isProjectScopedMcpScope(scope) ? (projectPath ?? undefined) : undefined, envValues, headers, }); @@ -212,7 +270,7 @@ export const McpServerDetailDialog = ({ server.id, uninstallServerName, uninstallScope, - uninstallScope !== 'user' ? (projectPath ?? undefined) : undefined + isProjectScopedMcpScope(uninstallScope) ? (projectPath ?? undefined) : undefined ); }; @@ -353,7 +411,7 @@ export const McpServerDetailDialog = ({ {isInstalledForScope && (
- Claude Status + {statusSectionLabel} {diagnosticsLoading && !diagnostic ? ( - {SCOPE_OPTIONS.map((opt) => ( + {scopeOptions.map((opt) => ( {opt.label} @@ -528,6 +586,7 @@ export const McpServerDetailDialog = ({ { + const projectStateKey = getMcpProjectStateKey(projectPath); const { browseCatalog, browseNextCursor, browseLoading, browseError, mcpBrowse, - installedServers, + installedServersByProjectPath, + installedServersFallback, fetchMcpGitHubStars, - mcpDiagnostics, - mcpDiagnosticsLoading, - mcpDiagnosticsError, - mcpDiagnosticsLastCheckedAt, + mcpDiagnosticsByProjectPath, + mcpDiagnosticsFallback, + mcpDiagnosticsLoadingByProjectPath, + mcpDiagnosticsLoadingFallback, + mcpDiagnosticsErrorByProjectPath, + mcpDiagnosticsErrorFallback, + mcpDiagnosticsLastCheckedAtByProjectPath, + mcpDiagnosticsLastCheckedAtFallback, runMcpDiagnostics, + cliStatus, + cliStatusLoading, } = useStore( useShallow((s) => ({ browseCatalog: s.mcpBrowseCatalog, @@ -98,15 +108,34 @@ export const McpServersPanel = ({ browseLoading: s.mcpBrowseLoading, browseError: s.mcpBrowseError, mcpBrowse: s.mcpBrowse, - installedServers: s.mcpInstalledServers, + installedServersByProjectPath: s.mcpInstalledServersByProjectPath, + installedServersFallback: s.mcpInstalledServers, fetchMcpGitHubStars: s.fetchMcpGitHubStars, - mcpDiagnostics: s.mcpDiagnostics, - mcpDiagnosticsLoading: s.mcpDiagnosticsLoading, - mcpDiagnosticsError: s.mcpDiagnosticsError, - mcpDiagnosticsLastCheckedAt: s.mcpDiagnosticsLastCheckedAt, + mcpDiagnosticsByProjectPath: s.mcpDiagnosticsByProjectPath, + mcpDiagnosticsFallback: s.mcpDiagnostics, + mcpDiagnosticsLoadingByProjectPath: s.mcpDiagnosticsLoadingByProjectPath, + mcpDiagnosticsLoadingFallback: s.mcpDiagnosticsLoading, + mcpDiagnosticsErrorByProjectPath: s.mcpDiagnosticsErrorByProjectPath, + mcpDiagnosticsErrorFallback: s.mcpDiagnosticsError, + mcpDiagnosticsLastCheckedAtByProjectPath: s.mcpDiagnosticsLastCheckedAtByProjectPath, + mcpDiagnosticsLastCheckedAtFallback: s.mcpDiagnosticsLastCheckedAt, runMcpDiagnostics: s.runMcpDiagnostics, + cliStatus: s.cliStatus, + cliStatusLoading: s.cliStatusLoading, })) ); + const installedServers = + installedServersByProjectPath?.[projectStateKey] ?? installedServersFallback ?? []; + const mcpDiagnostics = + mcpDiagnosticsByProjectPath?.[projectStateKey] ?? mcpDiagnosticsFallback ?? {}; + const mcpDiagnosticsLoading = + mcpDiagnosticsLoadingByProjectPath?.[projectStateKey] ?? mcpDiagnosticsLoadingFallback ?? false; + const mcpDiagnosticsError = + mcpDiagnosticsErrorByProjectPath?.[projectStateKey] ?? mcpDiagnosticsErrorFallback ?? null; + const mcpDiagnosticsLastCheckedAt = + mcpDiagnosticsLastCheckedAtByProjectPath?.[projectStateKey] ?? + mcpDiagnosticsLastCheckedAtFallback ?? + null; const [mcpSort, setMcpSort] = useState('name-asc'); @@ -117,9 +146,31 @@ export const McpServersPanel = ({ } }, [browseCatalog.length, browseError, browseLoading, mcpBrowse]); + const diagnosticsDisableReason = useMemo(() => { + if (cliStatusLoading) { + return 'Checking runtime status...'; + } + + if (cliStatus === null || typeof cliStatus === 'undefined') { + return 'Checking runtime availability...'; + } + + if (cliStatus?.installed === false) { + if (cliStatus.binaryPath && cliStatus.launchError) { + return 'The configured runtime was found but failed to start. Open the Dashboard to repair or reinstall it.'; + } + return 'The configured runtime is required. Install or repair it from the Dashboard.'; + } + + return null; + }, [cliStatus, cliStatusLoading]); + useEffect(() => { - void runMcpDiagnostics(); - }, [runMcpDiagnostics]); + if (diagnosticsDisableReason) { + return; + } + void runMcpDiagnostics(projectPath ?? undefined); + }, [diagnosticsDisableReason, projectPath, runMcpDiagnostics]); // Fetch GitHub stars after catalog loads (fire-and-forget) useEffect(() => { @@ -162,7 +213,12 @@ export const McpServersPanel = ({ const getDiagnostic = (server: McpCatalogItem): McpServerDiagnostic | null => { const installedEntry = getInstalledEntry(server); - return installedEntry ? (mcpDiagnostics[installedEntry.name] ?? null) : null; + return installedEntry + ? (mcpDiagnostics[getMcpDiagnosticKey(installedEntry.name, installedEntry.scope)] ?? + mcpDiagnostics[getMcpDiagnosticKey(installedEntry.name)] ?? + mcpDiagnostics[installedEntry.name] ?? + null) + : null; }; const allDiagnostics = useMemo( @@ -185,6 +241,8 @@ export const McpServersPanel = ({ // Sort displayed servers const displayServers = useMemo(() => sortMcpServers(rawServers, mcpSort), [rawServers, mcpSort]); + const runtimeLabel = + cliStatus?.flavor === 'agent_teams_orchestrator' ? 'multimodel runtime' : 'Claude CLI'; // Find selected server (search in both lists to avoid losing selection during search toggle) const selectedServer = useMemo(() => { @@ -205,24 +263,21 @@ export const McpServersPanel = ({

MCP Health Status

{mcpDiagnosticsLoading ? ( - <> - Checking installed MCP servers via Claude CLI (claude mcp list) ... - + <>Checking installed MCP servers via {runtimeLabel} ... + ) : diagnosticsDisableReason ? ( + diagnosticsDisableReason ) : mcpDiagnosticsLastCheckedAt ? ( `Last checked ${formatRelativeTime(new Date(mcpDiagnosticsLastCheckedAt).toISOString())}` ) : ( - <> - Run diagnostics (claude mcp list) to verify installed MCP - connectivity. - + <>Run diagnostics from this page to verify installed MCP connectivity. )}