chore(merge): sync dev into spike/team-snapshot-split-plan
This commit is contained in:
commit
51376e2620
130 changed files with 10173 additions and 1039 deletions
|
|
@ -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.
|
||||
|
||||
<table align="center">
|
||||
<tr>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -213,7 +213,7 @@
|
|||
},
|
||||
"build": {
|
||||
"appId": "com.agent-teams.app",
|
||||
"productName": "Claude Agent Teams UI",
|
||||
"productName": "Agent Teams UI",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { DashboardRecentProject, DashboardRecentProjectsPayload } from './d
|
|||
export type DashboardRecentProjectsPayloadLike =
|
||||
| DashboardRecentProjectsPayload
|
||||
| DashboardRecentProject[]
|
||||
| { degraded?: unknown; projects?: unknown }
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
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<void> {
|
|||
);
|
||||
|
||||
// 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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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` };
|
||||
|
|
|
|||
|
|
@ -239,8 +239,13 @@ function getMcpHealthDiagnostics(): McpHealthDiagnosticsService {
|
|||
return mcpHealthDiagnostics;
|
||||
}
|
||||
|
||||
async function handleMcpDiagnose(): Promise<IpcResult<McpServerDiagnostic[]>> {
|
||||
return wrapHandler('mcpDiagnose', () => getMcpHealthDiagnostics().diagnose());
|
||||
async function handleMcpDiagnose(
|
||||
_event: IpcMainInvokeEvent,
|
||||
projectPath?: string
|
||||
): Promise<IpcResult<McpServerDiagnostic[]>> {
|
||||
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<IpcResult<ApiKeyLookupResult[]>> {
|
||||
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
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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!);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ApiKeyLookupResult[]> {
|
||||
async lookup(envVarNames: string[], projectPath?: string): Promise<ApiKeyLookupResult[]> {
|
||||
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<ApiKeyLookupResult | null> {
|
||||
async lookupPreferred(
|
||||
envVarName: string,
|
||||
projectPath?: string
|
||||
): Promise<ApiKeyLookupResult | null> {
|
||||
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 ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<OperationResult> {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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<OperationResult> {
|
||||
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) {
|
||||
|
|
|
|||
134
src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts
Normal file
134
src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts
Normal file
|
|
@ -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<NodeJS.ProcessEnv> {
|
||||
const { env } = await buildProviderAwareCliEnv({
|
||||
binaryPath,
|
||||
connectionMode: 'augment',
|
||||
});
|
||||
return env;
|
||||
}
|
||||
|
||||
export interface ExtensionsRuntimeAdapter {
|
||||
readonly flavor: CliFlavor;
|
||||
buildManagementCliEnv(binaryPath: string): Promise<NodeJS.ProcessEnv>;
|
||||
getInstalledMcp(projectPath?: string): Promise<InstalledMcpEntry[]>;
|
||||
diagnoseMcp(projectPath?: string): Promise<McpServerDiagnostic[]>;
|
||||
}
|
||||
|
||||
export class ClaudeExtensionsAdapter implements ExtensionsRuntimeAdapter {
|
||||
readonly flavor = 'claude' as const;
|
||||
|
||||
constructor(private readonly stateReader = new McpConfigStateReader()) {}
|
||||
|
||||
async buildManagementCliEnv(binaryPath: string): Promise<NodeJS.ProcessEnv> {
|
||||
return buildManagementCliEnvForBinary(binaryPath);
|
||||
}
|
||||
|
||||
async getInstalledMcp(projectPath?: string): Promise<InstalledMcpEntry[]> {
|
||||
return this.stateReader.readInstalled(projectPath);
|
||||
}
|
||||
|
||||
async diagnoseMcp(projectPath?: string): Promise<McpServerDiagnostic[]> {
|
||||
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<NodeJS.ProcessEnv> {
|
||||
return buildManagementCliEnvForBinary(binaryPath);
|
||||
}
|
||||
|
||||
async getInstalledMcp(projectPath?: string): Promise<InstalledMcpEntry[]> {
|
||||
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<McpServerDiagnostic[]> {
|
||||
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<NodeJS.ProcessEnv> {
|
||||
return this.getActiveAdapter().buildManagementCliEnv(binaryPath);
|
||||
}
|
||||
|
||||
getInstalledMcp(projectPath?: string): Promise<InstalledMcpEntry[]> {
|
||||
return this.getActiveAdapter().getInstalledMcp(projectPath);
|
||||
}
|
||||
|
||||
diagnoseMcp(projectPath?: string): Promise<McpServerDiagnostic[]> {
|
||||
return this.getActiveAdapter().diagnoseMcp(projectPath);
|
||||
}
|
||||
}
|
||||
|
||||
export function createExtensionsRuntimeAdapter(): ExtensionsRuntimeAdapter {
|
||||
return new RuntimeSwitchingExtensionsAdapter(
|
||||
new ClaudeExtensionsAdapter(),
|
||||
new MultimodelExtensionsAdapter()
|
||||
);
|
||||
}
|
||||
101
src/main/services/extensions/runtime/McpConfigStateReader.ts
Normal file
101
src/main/services/extensions/runtime/McpConfigStateReader.ts
Normal file
|
|
@ -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<InstalledMcpEntry[]> {
|
||||
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<Record<string, unknown> | null> {
|
||||
const configPath = path.join(getHomeDir(), '.claude.json');
|
||||
try {
|
||||
const raw = await fs.readFile(configPath, 'utf-8');
|
||||
return JSON.parse(raw) as Record<string, unknown>;
|
||||
} 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<string, unknown> | null): InstalledMcpEntry[] {
|
||||
return this.readMcpServersFromConfig(config?.mcpServers, 'user');
|
||||
}
|
||||
|
||||
private readLocalMcpServers(
|
||||
config: Record<string, unknown> | null,
|
||||
projectPath: string
|
||||
): InstalledMcpEntry[] {
|
||||
const projects =
|
||||
config && typeof config.projects === 'object' && config.projects
|
||||
? (config.projects as Record<string, unknown>)
|
||||
: null;
|
||||
const projectConfig =
|
||||
projects && typeof projects[projectPath] === 'object' && projects[projectPath]
|
||||
? (projects[projectPath] as Record<string, unknown>)
|
||||
: null;
|
||||
return this.readMcpServersFromConfig(projectConfig?.mcpServers, 'local');
|
||||
}
|
||||
|
||||
private async readProjectMcpServers(projectPath: string): Promise<InstalledMcpEntry[]> {
|
||||
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<string, { command?: string; url?: string }>)
|
||||
: 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<InstalledMcpEntry[]> {
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, 'utf-8');
|
||||
const json = JSON.parse(raw) as Record<string, unknown>;
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
215
src/main/services/extensions/runtime/mcpDiagnosticsParser.ts
Normal file
215
src/main/services/extensions/runtime/mcpDiagnosticsParser.ts
Normal file
|
|
@ -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<T>(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<McpDiagnoseJsonPayload>(output);
|
||||
const checkedAtValue = parsed.checkedAt ? Date.parse(parsed.checkedAt) : Number.NaN;
|
||||
const checkedAt = Number.isFinite(checkedAtValue) ? checkedAtValue : Date.now();
|
||||
|
||||
return (parsed.diagnostics ?? []).flatMap<McpServerDiagnostic>((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] : [];
|
||||
});
|
||||
}
|
||||
45
src/main/services/extensions/runtime/mcpRuntimeJson.ts
Normal file
45
src/main/services/extensions/runtime/mcpRuntimeJson.ts
Normal file
|
|
@ -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<T>(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<McpListJsonPayload>(output);
|
||||
|
||||
return (parsed.servers ?? []).flatMap<InstalledMcpEntry>((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,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
|
@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { formatSkillRootKind, getSkillAudience } from '@shared/utils/skillRoots';
|
||||
|
||||
import type { SkillCatalogItem } from '@shared/types/extensions';
|
||||
|
||||
const ROOT_PRECEDENCE: Record<SkillCatalogItem['rootKind'], number> = {
|
||||
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<string, SkillCatalogItem[]>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<McpServerDiagnostic[]> {
|
||||
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<McpServerDiagnostic[]> {
|
||||
return this.runtimeAdapter.diagnoseMcp(projectPath);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<T> {
|
||||
|
|
@ -29,113 +20,23 @@ interface TimedCache<T> {
|
|||
export class McpInstallationStateService {
|
||||
private cache = new Map<string, TimedCache<InstalledMcpEntry[]>>();
|
||||
|
||||
/**
|
||||
* Get all installed MCP servers across user, local, and project scopes.
|
||||
*/
|
||||
constructor(
|
||||
private readonly runtimeAdapter: ExtensionsRuntimeAdapter = createExtensionsRuntimeAdapter()
|
||||
) {}
|
||||
|
||||
async getInstalled(projectPath?: string): Promise<InstalledMcpEntry[]> {
|
||||
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<Record<string, unknown> | null> {
|
||||
const configPath = path.join(getHomeDir(), '.claude.json');
|
||||
try {
|
||||
const raw = await fs.readFile(configPath, 'utf-8');
|
||||
return JSON.parse(raw) as Record<string, unknown>;
|
||||
} 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<string, unknown> | null): InstalledMcpEntry[] {
|
||||
return this.readMcpServersFromConfig(config?.mcpServers, 'user');
|
||||
}
|
||||
|
||||
private readLocalMcpServers(
|
||||
config: Record<string, unknown> | null,
|
||||
projectPath: string
|
||||
): InstalledMcpEntry[] {
|
||||
const projects =
|
||||
config && typeof config.projects === 'object' && config.projects
|
||||
? (config.projects as Record<string, unknown>)
|
||||
: null;
|
||||
const projectConfig =
|
||||
projects && typeof projects[projectPath] === 'object' && projects[projectPath]
|
||||
? (projects[projectPath] as Record<string, unknown>)
|
||||
: null;
|
||||
return this.readMcpServersFromConfig(projectConfig?.mcpServers, 'local');
|
||||
}
|
||||
|
||||
private async readProjectMcpServers(projectPath: string): Promise<InstalledMcpEntry[]> {
|
||||
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<string, { command?: string; url?: string }>)
|
||||
: 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<InstalledMcpEntry[]> {
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, 'utf-8');
|
||||
const json = JSON.parse(raw) as Record<string, unknown>;
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
? {
|
||||
|
|
|
|||
209
src/main/services/team/AutoResumeService.ts
Normal file
209
src/main/services/team/AutoResumeService.ts
Normal file
|
|
@ -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<ConfigManager, 'getConfig'>;
|
||||
|
||||
export class AutoResumeService {
|
||||
private readonly pendingTimers = new Map<string, PendingAutoResumeEntry>();
|
||||
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
|
|
@ -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<MessagesPage> {
|
||||
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<Map<string, string>> {
|
||||
private async getLeadSessionJsonlPaths(projectDir: string): Promise<Map<string, string>> {
|
||||
const jsonlPaths = new Map<string, string>();
|
||||
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<InboxMessage[]> {
|
||||
if (!config.projectPath) {
|
||||
private async extractLeadSessionTexts(
|
||||
teamName: string,
|
||||
config: TeamConfig
|
||||
): Promise<InboxMessage[]> {
|
||||
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 [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000;
|
|||
interface TeamMessageFeedDeps {
|
||||
getConfig: (teamName: string) => Promise<TeamConfig | null>;
|
||||
getInboxMessages: (teamName: string) => Promise<InboxMessage[]>;
|
||||
getLeadSessionMessages: (config: TeamConfig) => Promise<InboxMessage[]>;
|
||||
getLeadSessionMessages: (teamName: string, config: TeamConfig) => Promise<InboxMessage[]>;
|
||||
getSentMessages: (teamName: string) => Promise<InboxMessage[]>;
|
||||
}
|
||||
|
||||
|
|
@ -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[]),
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<string, unknown>[] = [{ 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<number> {
|
||||
|
|
@ -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<string>();
|
||||
|
||||
|
|
@ -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 <teammate-message> 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()) {
|
||||
|
|
|
|||
|
|
@ -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<ProjectEvidenceSource, 'projectsScan'>;
|
||||
}
|
||||
|
||||
interface ProjectDirCandidate {
|
||||
projectPath: string;
|
||||
projectDir: string;
|
||||
projectId: string;
|
||||
source: ProjectEvidenceSource;
|
||||
}
|
||||
|
||||
interface SessionProjectMatch extends ProjectDirCandidate {
|
||||
matchedSessionId: string;
|
||||
}
|
||||
|
||||
type ScannedSessionProjectMatch = Omit<SessionProjectMatch, 'projectPath'> & {
|
||||
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, unknown>): 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<string, unknown>;
|
||||
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<string>();
|
||||
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<ProjectDirCandidate, 'projectDir' | 'projectId'> & { 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<string>();
|
||||
const push = (value: unknown, source: Exclude<ProjectEvidenceSource, 'projectsScan'>): 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<string>();
|
||||
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<SessionProjectMatch | null> {
|
||||
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<SessionProjectMatch[]> {
|
||||
const matches: SessionProjectMatch[] = [];
|
||||
const seenProjectDirs = new Set<string>();
|
||||
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<string | null> {
|
||||
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<ScannedSessionProjectMatch[]> {
|
||||
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<void> => {
|
||||
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<string, ScannedSessionProjectMatch>();
|
||||
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<void> {
|
||||
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<string, unknown>;
|
||||
const rawProjectPath =
|
||||
normalizeProjectPathCandidate(parsed.projectPath) ?? currentProjectPath ?? null;
|
||||
|
||||
parsed.projectPath = normalizedNextPath;
|
||||
|
||||
const history: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
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<string>();
|
||||
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<string>();
|
||||
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<boolean> {
|
||||
try {
|
||||
const stat = await fs.stat(projectDir);
|
||||
return stat.isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async listSessionDirIds(projectDir: string): Promise<string[]> {
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
73
src/main/services/team/mergeLiveLeadProcessMessages.ts
Normal file
73
src/main/services/team/mergeLiveLeadProcessMessages.ts
Normal file
|
|
@ -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<string>();
|
||||
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<string>();
|
||||
const contentSeen = new Map<string, number>();
|
||||
const merged: InboxMessage[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1583,7 +1583,8 @@ const electronAPI: ElectronAPI = {
|
|||
invokeIpcWithResult<McpCatalogItem | null>(MCP_REGISTRY_GET_BY_ID, registryId),
|
||||
getInstalled: (projectPath?: string) =>
|
||||
invokeIpcWithResult<InstalledMcpEntry[]>(MCP_REGISTRY_GET_INSTALLED, projectPath),
|
||||
diagnose: () => invokeIpcWithResult<McpServerDiagnostic[]>(MCP_REGISTRY_DIAGNOSE),
|
||||
diagnose: (projectPath?: string) =>
|
||||
invokeIpcWithResult<McpServerDiagnostic[]>(MCP_REGISTRY_DIAGNOSE, projectPath),
|
||||
install: (request: McpInstallRequest) =>
|
||||
invokeIpcWithResult<OperationResult>(MCP_REGISTRY_INSTALL, request),
|
||||
installCustom: (request: McpCustomInstallRequest) =>
|
||||
|
|
@ -1627,8 +1628,8 @@ const electronAPI: ElectronAPI = {
|
|||
list: () => invokeIpcWithResult<ApiKeyEntry[]>(API_KEYS_LIST),
|
||||
save: (request: ApiKeySaveRequest) => invokeIpcWithResult<ApiKeyEntry>(API_KEYS_SAVE, request),
|
||||
delete: (id: string) => invokeIpcWithResult<void>(API_KEYS_DELETE, id),
|
||||
lookup: (envVarNames: string[]) =>
|
||||
invokeIpcWithResult<ApiKeyLookupResult[]>(API_KEYS_LOOKUP, envVarNames),
|
||||
lookup: (envVarNames: string[], projectPath?: string) =>
|
||||
invokeIpcWithResult<ApiKeyLookupResult[]>(API_KEYS_LOOKUP, envVarNames, projectPath),
|
||||
getStorageStatus: () => invokeIpcWithResult<ApiKeyStorageStatus>(API_KEYS_STORAGE_STATUS),
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ReturnType<typeof useCliInstaller>['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}
|
||||
/>
|
||||
</div>
|
||||
{/* Extensions button — only when installed + authenticated */}
|
||||
{cliStatus.authLoggedIn && (
|
||||
{/* Extensions button — available whenever the runtime is installed */}
|
||||
{canOpenExtensions && (
|
||||
<button
|
||||
onClick={openExtensionsTab}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/5"
|
||||
|
|
@ -844,6 +856,16 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
if (isCheckingMultimodelStatus(cliStatus, visibleCliProviders)) return 'info';
|
||||
if (cliStatus.authStatusChecking) return 'info';
|
||||
if (!cliStatus.installed) return 'error';
|
||||
if (isMultimodelRuntimeStatus(cliStatus) && visibleCliProviders.length === 0) {
|
||||
return 'warning';
|
||||
}
|
||||
if (
|
||||
isMultimodelRuntimeStatus(cliStatus) &&
|
||||
visibleCliProviders.length > 0 &&
|
||||
!hasVisibleAuthenticatedMultimodelProvider(visibleCliProviders)
|
||||
) {
|
||||
return 'warning';
|
||||
}
|
||||
if (cliStatus.installed && !cliStatus.authLoggedIn) return 'warning';
|
||||
if (cliStatus.updateAvailable) return 'info';
|
||||
return 'success';
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Tabs, TabsContent, TabsList } from '@renderer/components/ui/tabs';
|
||||
import {
|
||||
|
|
@ -18,8 +20,25 @@ import {
|
|||
import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
|
||||
import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState';
|
||||
import { useStore } from '@renderer/store';
|
||||
import {
|
||||
formatCliExtensionCapabilityStatus,
|
||||
getVisibleMultimodelProviders,
|
||||
isMultimodelRuntimeStatus,
|
||||
} from '@renderer/utils/multimodelProviderVisibility';
|
||||
import { resolveProjectPathById } from '@renderer/utils/projectLookup';
|
||||
import { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react';
|
||||
import { getExtensionActionDisableReason } from '@shared/utils/extensionNormalizers';
|
||||
import { getCliProviderExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
||||
import {
|
||||
AlertTriangle,
|
||||
BookOpen,
|
||||
Info,
|
||||
Key,
|
||||
Loader2,
|
||||
Plus,
|
||||
Puzzle,
|
||||
RefreshCw,
|
||||
Server,
|
||||
} from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { ApiKeysPanel } from './apikeys/ApiKeysPanel';
|
||||
|
|
@ -29,6 +48,55 @@ import { PluginsPanel } from './plugins/PluginsPanel';
|
|||
import { SkillsPanel } from './skills/SkillsPanel';
|
||||
import { ExtensionsSubTabTrigger } from './ExtensionsSubTabTrigger';
|
||||
|
||||
import type { CliProviderStatus } from '@shared/types';
|
||||
|
||||
const ProviderCapabilityCardSkeleton = ({
|
||||
providerId,
|
||||
displayName,
|
||||
}: {
|
||||
providerId: 'anthropic' | 'codex' | 'gemini';
|
||||
displayName: string;
|
||||
}): React.JSX.Element => (
|
||||
<div className="rounded-md border border-border bg-surface-raised px-3 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="inline-flex items-center gap-2 text-sm font-medium text-text">
|
||||
<ProviderBrandLogo providerId={providerId} className="size-4 shrink-0" />
|
||||
<span>{displayName}</span>
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-2 text-[11px] text-text-muted">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
<span>Checking provider status...</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="shrink-0 text-text-muted">
|
||||
Loading...
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{Array.from({ length: 3 }, (_, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="h-7 w-28 animate-pulse rounded-md border border-border bg-surface"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
function isProviderCapabilityCardLoading(
|
||||
provider: CliProviderStatus,
|
||||
providerLoading: boolean
|
||||
): boolean {
|
||||
return (
|
||||
providerLoading ||
|
||||
(!provider.authenticated &&
|
||||
provider.statusMessage === 'Checking...' &&
|
||||
provider.models.length === 0 &&
|
||||
provider.backend == null)
|
||||
);
|
||||
}
|
||||
|
||||
export const ExtensionStoreView = (): React.JSX.Element => {
|
||||
const tabId = useTabIdOptional();
|
||||
const {
|
||||
|
|
@ -44,6 +112,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
skillsLoading,
|
||||
cliStatus,
|
||||
cliStatusLoading,
|
||||
cliProviderStatusLoading,
|
||||
openDashboard,
|
||||
sessions,
|
||||
projects,
|
||||
|
|
@ -62,6 +131,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
skillsLoading: s.skillsLoading,
|
||||
cliStatus: s.cliStatus,
|
||||
cliStatusLoading: s.cliStatusLoading,
|
||||
cliProviderStatusLoading: s.cliProviderStatusLoading,
|
||||
openDashboard: s.openDashboard,
|
||||
sessions: s.sessions,
|
||||
projects: s.projects,
|
||||
|
|
@ -92,21 +162,21 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
label: 'Plugins',
|
||||
icon: Puzzle,
|
||||
description:
|
||||
'Small add-ons for Claude. They give the app extra features and integrations you can install when you need them.',
|
||||
'Small add-ons for the runtime. In multimodel mode they currently apply to Anthropic sessions when supported. Broader provider support is in development.',
|
||||
},
|
||||
{
|
||||
value: 'mcp-servers' as const,
|
||||
label: 'MCP Servers',
|
||||
icon: Server,
|
||||
description:
|
||||
'Connections to outside tools and apps. They let Claude read data or do actions beyond this app.',
|
||||
'Connections to outside tools and apps. They let the runtime read data or do actions beyond this app.',
|
||||
},
|
||||
{
|
||||
value: 'skills' as const,
|
||||
label: 'Skills',
|
||||
icon: BookOpen,
|
||||
description:
|
||||
'Ready-made instructions for common jobs. They help Claude do specific tasks better and more consistently.',
|
||||
'Ready-made instructions for common jobs. They help the runtime handle repeatable tasks more consistently.',
|
||||
},
|
||||
{
|
||||
value: 'api-keys' as const,
|
||||
|
|
@ -163,15 +233,34 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
|
||||
const isRefreshing =
|
||||
cliStatusLoading || apiKeysLoading || pluginCatalogLoading || mcpBrowseLoading || skillsLoading;
|
||||
const mcpMutationDisableReason = useMemo(
|
||||
() =>
|
||||
getExtensionActionDisableReason({
|
||||
isInstalled: false,
|
||||
cliStatus,
|
||||
cliStatusLoading,
|
||||
section: 'mcp',
|
||||
}),
|
||||
[cliStatus, cliStatusLoading]
|
||||
);
|
||||
const cliStatusBanner = useMemo(() => {
|
||||
if (cliStatusLoading || cliStatus === null) {
|
||||
const providers = cliStatus?.providers ?? [];
|
||||
const visibleProviders = getVisibleMultimodelProviders(providers);
|
||||
const isMultimodel = isMultimodelRuntimeStatus(cliStatus);
|
||||
const shouldShowMultimodelProviderCards =
|
||||
isMultimodel && visibleProviders.length > 0 && cliStatus !== null;
|
||||
|
||||
if ((cliStatusLoading || cliStatus === null) && !shouldShowMultimodelProviderCards) {
|
||||
return (
|
||||
<div className="bg-surface/70 mx-4 mt-3 flex items-start gap-3 rounded-md border border-border px-4 py-3">
|
||||
<Info className="mt-0.5 size-4 shrink-0 text-text-secondary" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text">Checking Claude CLI availability</p>
|
||||
<p className="text-sm font-medium text-text">
|
||||
Checking extensions runtime availability
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-text-muted">
|
||||
Extensions need Claude CLI to install plugins, run MCP servers, and validate auth.
|
||||
Extensions need the configured runtime to manage plugins, MCP servers, skills, and
|
||||
provider connections.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -186,13 +275,13 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-amber-300">
|
||||
{cliLaunchIssue
|
||||
? 'Claude CLI was found but failed to start'
|
||||
: 'Claude CLI is not available'}
|
||||
? 'The configured runtime was found but failed to start'
|
||||
: 'The configured runtime is not available'}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-text-muted">
|
||||
{cliLaunchIssue
|
||||
? 'Plugin installs are disabled until Claude CLI passes its startup health check. Open the Dashboard to repair or reinstall it.'
|
||||
: 'Plugin installs are disabled until Claude CLI is installed. Open the Dashboard to install it and retry.'}
|
||||
? 'Extensions are disabled until the runtime passes its startup health check. Open the Dashboard to repair or reinstall it.'
|
||||
: 'Extensions are disabled until the runtime is installed. Open the Dashboard to install it and retry.'}
|
||||
</p>
|
||||
{cliLaunchIssue && cliStatus.launchError && (
|
||||
<p className="mt-2 break-all font-mono text-[11px] text-text-muted">
|
||||
|
|
@ -207,7 +296,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
);
|
||||
}
|
||||
|
||||
if (!cliStatus.authLoggedIn) {
|
||||
if (!isMultimodel && !cliStatus.authLoggedIn) {
|
||||
return (
|
||||
<div className="mx-4 mt-3 flex items-start gap-3 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
|
||||
|
|
@ -226,6 +315,97 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
);
|
||||
}
|
||||
|
||||
if (isMultimodel) {
|
||||
return (
|
||||
<div className="bg-surface/70 mx-4 mt-3 rounded-md border border-border px-4 py-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="mt-0.5 size-4 shrink-0 text-text-secondary" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-text">Multimodel runtime capabilities</p>
|
||||
<p className="mt-0.5 text-xs text-text-muted">
|
||||
Provider support can differ by section. Plugins are shown only where the runtime
|
||||
explicitly declares support.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{visibleProviders.length > 0 && (
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-2">
|
||||
{visibleProviders.map((provider) => {
|
||||
const providerLoading = cliProviderStatusLoading[provider.providerId] === true;
|
||||
if (isProviderCapabilityCardLoading(provider, providerLoading)) {
|
||||
return (
|
||||
<ProviderCapabilityCardSkeleton
|
||||
key={provider.providerId}
|
||||
providerId={provider.providerId}
|
||||
displayName={provider.displayName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const statusTone = provider.authenticated
|
||||
? 'border-emerald-500/30 bg-emerald-500/5 text-emerald-300'
|
||||
: provider.supported
|
||||
? 'border-amber-500/30 bg-amber-500/5 text-amber-300'
|
||||
: 'border-border bg-surface-raised text-text-muted';
|
||||
const statusLabel = provider.authenticated
|
||||
? 'Connected'
|
||||
: provider.supported
|
||||
? 'Needs setup'
|
||||
: 'Unsupported';
|
||||
const extensionCapabilities = getCliProviderExtensionCapabilities(provider);
|
||||
const pluginStatus = extensionCapabilities.plugins.status;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={provider.providerId}
|
||||
className={`rounded-md border px-3 py-2 ${statusTone}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="inline-flex items-center gap-2 text-sm font-medium">
|
||||
<ProviderBrandLogo
|
||||
providerId={provider.providerId}
|
||||
className="size-4 shrink-0"
|
||||
/>
|
||||
<span>{provider.displayName}</span>
|
||||
</p>
|
||||
<p className="truncate text-[11px] text-text-muted">
|
||||
{provider.statusMessage ??
|
||||
provider.backend?.label ??
|
||||
'Ready to configure'}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5 text-[11px]">
|
||||
<Badge
|
||||
variant={pluginStatus === 'unsupported' ? 'outline' : 'secondary'}
|
||||
className={
|
||||
pluginStatus === 'unsupported'
|
||||
? 'border-amber-500/30 bg-amber-500/10 text-amber-300'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Plugins: {formatCliExtensionCapabilityStatus(pluginStatus)}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
MCP: {formatCliExtensionCapabilityStatus(extensionCapabilities.mcp.status)}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
Skills: {extensionCapabilities.skills.ownership}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-4 mt-3 flex items-start gap-3 rounded-md border border-emerald-500/30 bg-emerald-500/5 px-4 py-3">
|
||||
<Info className="mt-0.5 size-4 shrink-0 text-emerald-300" />
|
||||
|
|
@ -238,7 +418,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [cliStatus, cliStatusLoading, openDashboard]);
|
||||
}, [cliProviderStatusLoading, cliStatus, cliStatusLoading, openDashboard]);
|
||||
|
||||
// Browser mode guard
|
||||
if (!api.plugins && !api.mcpRegistry && !api.skills) {
|
||||
|
|
@ -280,7 +460,8 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
{!cliInstalled && (
|
||||
<div className="mb-4 flex items-center gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-400">
|
||||
<AlertTriangle className="size-4 shrink-0" />
|
||||
Claude CLI is required to install or uninstall extensions. Install it from Settings.
|
||||
The configured runtime is required to install or uninstall extensions. Install or
|
||||
repair it from the Dashboard.
|
||||
</div>
|
||||
)}
|
||||
{/* Active sessions warning */}
|
||||
|
|
@ -309,15 +490,25 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
))}
|
||||
</TabsList>
|
||||
{tabState.activeSubTab === 'mcp-servers' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCustomMcpDialogOpen(true)}
|
||||
className="mb-1 whitespace-nowrap"
|
||||
>
|
||||
<Plus className="mr-1 size-3.5" />
|
||||
Add Custom
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span tabIndex={mcpMutationDisableReason ? 0 : -1}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCustomMcpDialogOpen(true)}
|
||||
className="mb-1 whitespace-nowrap"
|
||||
disabled={Boolean(mcpMutationDisableReason)}
|
||||
>
|
||||
<Plus className="mr-1 size-3.5" />
|
||||
Add Custom
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{mcpMutationDisableReason && (
|
||||
<TooltipContent>{mcpMutationDisableReason}</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -352,7 +543,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
</TabsContent>
|
||||
|
||||
<TabsContent value="api-keys" className="mt-0 pt-4">
|
||||
<ApiKeysPanel />
|
||||
<ApiKeysPanel projectPath={projectPath} projectLabel={projectLabel} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="skills" className="mt-0 pt-4">
|
||||
|
|
|
|||
|
|
@ -66,6 +66,12 @@ export const ApiKeyCard = ({ apiKey, onEdit }: ApiKeyCardProps): React.JSX.Eleme
|
|||
</Badge>
|
||||
</div>
|
||||
|
||||
{apiKey.scope === 'project' && apiKey.projectPath && (
|
||||
<p className="truncate text-xs text-text-muted" title={apiKey.projectPath}>
|
||||
{apiKey.projectPath}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Env var name */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<code className="rounded bg-surface-raised px-1.5 py-0.5 text-xs text-blue-400">
|
||||
|
|
|
|||
|
|
@ -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<Scope>('user');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [envVarError, setEnvVarError] = useState<string | null>(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 (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
|
|
@ -212,12 +241,23 @@ export const ApiKeyFormDialog = ({
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SCOPE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
<SelectItem
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
disabled={opt.value === 'project' && !canUseProjectScope}
|
||||
>
|
||||
{opt.value === 'project'
|
||||
? effectiveProjectPath
|
||||
? `Project: ${effectiveProjectLabel}`
|
||||
: 'Project unavailable'
|
||||
: opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{scope === 'project' && effectiveProjectPath && (
|
||||
<p className="text-xs text-text-muted">Bound to {effectiveProjectPath}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
|
|
|
|||
|
|
@ -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<ApiKeyEntry | null>(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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
{providerKeyCards.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
{providerKeyCards.map((provider) => (
|
||||
<div
|
||||
key={provider.providerId}
|
||||
className="bg-surface-raised/30 rounded-lg border border-border p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text">{provider.label}</p>
|
||||
<p className="mt-0.5 font-mono text-[11px] text-text-muted">{provider.envVar}</p>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[11px] ${
|
||||
provider.authenticated
|
||||
? 'bg-emerald-500/10 text-emerald-300'
|
||||
: provider.apiKeyConfigured
|
||||
? 'bg-blue-500/10 text-blue-300'
|
||||
: 'bg-amber-500/10 text-amber-300'
|
||||
}`}
|
||||
>
|
||||
{provider.authenticated
|
||||
? 'Connected'
|
||||
: provider.apiKeyConfigured
|
||||
? 'Key configured'
|
||||
: 'Key missing'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-text-muted">
|
||||
{provider.sourceLabel
|
||||
? `Current source: ${provider.sourceLabel}.`
|
||||
: 'No stored or environment key detected for this provider.'}
|
||||
{provider.statusMessage ? ` ${provider.statusMessage}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="flex items-center gap-1.5 text-sm text-text-secondary">
|
||||
|
|
@ -138,7 +221,13 @@ export const ApiKeysPanel = (): React.JSX.Element => {
|
|||
)}
|
||||
|
||||
{/* Form dialog */}
|
||||
<ApiKeyFormDialog open={dialogOpen} editingKey={editingKey} onClose={handleDialogClose} />
|
||||
<ApiKeyFormDialog
|
||||
open={dialogOpen}
|
||||
editingKey={editingKey}
|
||||
currentProjectPath={projectPath}
|
||||
currentProjectLabel={projectLabel}
|
||||
onClose={handleDialogClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<TransportMode>('stdio');
|
||||
const [scope, setScope] = useState<Scope>('user');
|
||||
const [scope, setScope] = useState<Scope>(defaultSharedScope);
|
||||
|
||||
// Stdio fields
|
||||
const [npmPackage, setNpmPackage] = useState('');
|
||||
|
|
@ -86,13 +95,31 @@ export const CustomMcpServerDialog = ({
|
|||
const [envVars, setEnvVars] = useState<EnvEntry[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const autoFilledValuesRef = useRef<Record<string, string>>({});
|
||||
const wasOpenRef = useRef(false);
|
||||
const previousDefaultSharedScopeRef = useRef<Scope>(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 = ({
|
|||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SCOPE_OPTIONS.map((opt) => (
|
||||
{scopeOptions.map((opt) => (
|
||||
<SelectItem
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
disabled={opt.value !== 'user' && !projectPath}
|
||||
disabled={isProjectScopedMcpScope(opt.value) && !projectPath}
|
||||
>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
|
|
@ -436,6 +523,11 @@ export const CustomMcpServerDialog = ({
|
|||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{mutationDisableReason && (
|
||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-300">
|
||||
{mutationDisableReason}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
|
||||
{error}
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
|||
<InstallButton
|
||||
state={installProgress}
|
||||
isInstalled={isInstalled}
|
||||
section="mcp"
|
||||
onInstall={() =>
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -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<Scope>('user');
|
||||
const operationKey = server ? getMcpOperationKey(server.id, scope) : null;
|
||||
const cliStatus = useStore((s) => s.cliStatus);
|
||||
const defaultSharedScope = getDefaultMcpSharedScope(cliStatus?.flavor);
|
||||
const [scope, setScope] = useState<Scope>(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<McpHeaderDef[]>([]);
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const [autoFilledFields, setAutoFilledFields] = useState<Set<string>>(new Set());
|
||||
const autoFilledValuesRef = useRef<Record<string, string>>({});
|
||||
const previousDefaultSharedScopeRef = useRef<Scope>(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<string>();
|
||||
const values: Record<string, string> = {};
|
||||
const previousAutoFilledValues = autoFilledValuesRef.current;
|
||||
const nextAutoFilledValues: Record<string, string> = {};
|
||||
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 && (
|
||||
<div className="space-y-2 rounded-md border border-border bg-surface-raised px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-medium text-text">Claude Status</span>
|
||||
<span className="text-sm font-medium text-text">{statusSectionLabel}</span>
|
||||
{diagnosticsLoading && !diagnostic ? (
|
||||
<Badge
|
||||
className="border-border bg-surface-raised text-text-muted"
|
||||
|
|
@ -415,11 +473,11 @@ export const McpServerDetailDialog = ({
|
|||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SCOPE_OPTIONS.map((opt) => (
|
||||
{scopeOptions.map((opt) => (
|
||||
<SelectItem
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
disabled={opt.value !== 'user' && !projectPath}
|
||||
disabled={isProjectScopedMcpScope(opt.value) && !projectPath}
|
||||
>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
|
|
@ -528,6 +586,7 @@ export const McpServerDetailDialog = ({
|
|||
<InstallButton
|
||||
state={installProgress}
|
||||
isInstalled={isInstalledForScope}
|
||||
section="mcp"
|
||||
onInstall={handleInstall}
|
||||
onUninstall={handleUninstall}
|
||||
disabled={installDisabled}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import { useStore } from '@renderer/store';
|
|||
import { formatRelativeTime } from '@renderer/utils/formatters';
|
||||
import { CLI_NOT_FOUND_MARKER } from '@shared/constants/cli';
|
||||
import {
|
||||
getMcpDiagnosticKey,
|
||||
getMcpProjectStateKey,
|
||||
getPreferredMcpInstallationEntry,
|
||||
sanitizeMcpServerName,
|
||||
} from '@shared/utils/extensionNormalizers';
|
||||
|
|
@ -78,19 +80,27 @@ export const McpServersPanel = ({
|
|||
selectedMcpServerId,
|
||||
setSelectedMcpServerId,
|
||||
}: McpServersPanelProps): React.JSX.Element => {
|
||||
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<McpSortValue>('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 = ({
|
|||
<p className="text-sm font-medium text-text">MCP Health Status</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
{mcpDiagnosticsLoading ? (
|
||||
<>
|
||||
Checking installed MCP servers via Claude CLI (<code>claude mcp list</code>) ...
|
||||
</>
|
||||
<>Checking installed MCP servers via {runtimeLabel} ...</>
|
||||
) : diagnosticsDisableReason ? (
|
||||
diagnosticsDisableReason
|
||||
) : mcpDiagnosticsLastCheckedAt ? (
|
||||
`Last checked ${formatRelativeTime(new Date(mcpDiagnosticsLastCheckedAt).toISOString())}`
|
||||
) : (
|
||||
<>
|
||||
Run diagnostics (<code>claude mcp list</code>) to verify installed MCP
|
||||
connectivity.
|
||||
</>
|
||||
<>Run diagnostics from this page to verify installed MCP connectivity.</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void runMcpDiagnostics()}
|
||||
disabled={mcpDiagnosticsLoading}
|
||||
onClick={() => void runMcpDiagnostics(projectPath ?? undefined)}
|
||||
disabled={mcpDiagnosticsLoading || Boolean(diagnosticsDisableReason)}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
<RefreshCw
|
||||
|
|
@ -235,7 +290,7 @@ export const McpServersPanel = ({
|
|||
{(mcpDiagnosticsLoading || allDiagnostics.length > 0) && (
|
||||
<div className="mt-4 border-t border-black/10 pt-4 dark:border-white/10">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<p className="text-sm font-medium text-text">Claude MCP List Results</p>
|
||||
<p className="text-sm font-medium text-text">Runtime MCP Diagnostics</p>
|
||||
{allDiagnostics.length > 0 && (
|
||||
<span className="text-xs text-text-muted">{allDiagnostics.length} servers</span>
|
||||
)}
|
||||
|
|
@ -244,11 +299,18 @@ export const McpServersPanel = ({
|
|||
<div className="mcp-diagnostics-list max-h-[18.5rem] space-y-2 overflow-y-auto pr-1">
|
||||
{allDiagnostics.map((diagnostic) => (
|
||||
<div
|
||||
key={diagnostic.name}
|
||||
key={getMcpDiagnosticKey(diagnostic.name, diagnostic.scope)}
|
||||
className="flex items-start justify-between gap-3 rounded-md border border-black/10 px-3 py-2 dark:border-white/10"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-text">{diagnostic.name}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm text-text">{diagnostic.name}</p>
|
||||
{diagnostic.scope && (
|
||||
<span className="rounded-full border border-border bg-surface-raised px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-text-muted">
|
||||
{diagnostic.scope}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className="truncate font-mono text-[11px] text-text-muted"
|
||||
title={diagnostic.target}
|
||||
|
|
@ -263,7 +325,7 @@ export const McpServersPanel = ({
|
|||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-text-muted">Waiting for `claude mcp list` results...</p>
|
||||
<p className="text-xs text-text-muted">Waiting for diagnostics results...</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -347,10 +409,15 @@ export const McpServersPanel = ({
|
|||
<div className="flex items-start gap-3 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-300">Claude CLI not installed</p>
|
||||
<p className="text-sm font-medium text-amber-300">
|
||||
{cliStatus?.flavor === 'agent_teams_orchestrator'
|
||||
? 'Configured runtime not available'
|
||||
: 'Claude CLI not installed'}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-text-muted">
|
||||
MCP health checks require Claude CLI. Go to the Dashboard to install it
|
||||
automatically.
|
||||
{cliStatus?.flavor === 'agent_teams_orchestrator'
|
||||
? 'MCP health checks require the configured runtime. Go to the Dashboard to install or repair it.'
|
||||
: 'MCP health checks require Claude CLI. Go to the Dashboard to install or repair it.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J
|
|||
<InstallButton
|
||||
state={installProgress}
|
||||
isInstalled={isUserInstalled}
|
||||
section="plugins"
|
||||
onInstall={() => installPlugin({ pluginId: plugin.pluginId, scope: 'user' })}
|
||||
onUninstall={() => uninstallPlugin(plugin.pluginId, 'user')}
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -91,7 +91,9 @@ export const PluginDetailDialog = ({
|
|||
}
|
||||
}, [projectScopeAvailable, scope]);
|
||||
|
||||
const operationKey = plugin ? getPluginOperationKey(plugin.pluginId, scope) : null;
|
||||
const operationKey = plugin
|
||||
? getPluginOperationKey(plugin.pluginId, scope, scope !== 'user' ? projectPath : undefined)
|
||||
: null;
|
||||
const installProgress = useStore(
|
||||
(s) => (operationKey ? s.pluginInstallProgress[operationKey] : undefined) ?? 'idle'
|
||||
);
|
||||
|
|
@ -195,6 +197,7 @@ export const PluginDetailDialog = ({
|
|||
<InstallButton
|
||||
state={installProgress}
|
||||
isInstalled={isInstalledForScope}
|
||||
section="plugins"
|
||||
onInstall={() =>
|
||||
installPlugin({
|
||||
pluginId: plugin.pluginId,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
} from '@renderer/components/ui/select';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { inferCapabilities, normalizeCategory } from '@shared/utils/extensionNormalizers';
|
||||
import { getCliProviderExtensionCapability } from '@shared/utils/providerExtensionCapabilities';
|
||||
import { ArrowUpDown, Filter, Puzzle, Search } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
|
|
@ -125,11 +126,12 @@ export const PluginsPanel = ({
|
|||
hasActiveFilters,
|
||||
setPluginSort,
|
||||
}: PluginsPanelProps): React.JSX.Element => {
|
||||
const { catalog, loading, error } = useStore(
|
||||
const { catalog, loading, error, cliStatus } = useStore(
|
||||
useShallow((s) => ({
|
||||
catalog: s.pluginCatalog,
|
||||
loading: s.pluginCatalogLoading,
|
||||
error: s.pluginCatalogError,
|
||||
cliStatus: s.cliStatus,
|
||||
}))
|
||||
);
|
||||
|
||||
|
|
@ -175,9 +177,27 @@ export const PluginsPanel = ({
|
|||
}
|
||||
return counts.size;
|
||||
}, [catalog]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{cliStatus?.flavor === 'agent_teams_orchestrator' &&
|
||||
(() => {
|
||||
const codexProvider = cliStatus.providers.find(
|
||||
(provider) => provider.providerId === 'codex'
|
||||
);
|
||||
if (!codexProvider) return null;
|
||||
const capability = getCliProviderExtensionCapability(codexProvider, 'plugins');
|
||||
if (capability.status === 'supported') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-300">
|
||||
In the multimodel runtime, plugins currently apply only to Anthropic sessions. Broader
|
||||
plugin support across providers is in development.
|
||||
{capability.reason ? ` ${capability.reason}` : ''}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{/* Search + Sort + Installed only row */}
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||
<div className="flex-1">
|
||||
|
|
|
|||
|
|
@ -23,11 +23,14 @@ import {
|
|||
DialogTitle,
|
||||
} from '@renderer/components/ui/dialog';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { AlertTriangle, ExternalLink, FolderOpen, Pencil, Trash2 } from 'lucide-react';
|
||||
import { formatSkillRootKind, getSkillAudienceLabel } from '@shared/utils/skillRoots';
|
||||
import { AlertTriangle, ExternalLink, FolderOpen, Info, Pencil, Trash2 } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { resolveSkillProjectPath } from './skillProjectUtils';
|
||||
|
||||
import type { SkillValidationIssue } from '@shared/types';
|
||||
|
||||
interface SkillDetailDialogProps {
|
||||
skillId: string | null;
|
||||
open: boolean;
|
||||
|
|
@ -80,10 +83,7 @@ export const SkillDetailDialog = ({
|
|||
const effectiveProjectPath = item
|
||||
? resolveSkillProjectPath(item.scope, projectPath, item.projectRoot)
|
||||
: (projectPath ?? undefined);
|
||||
|
||||
function formatRootKind(rootKind: 'claude' | 'cursor' | 'agents'): string {
|
||||
return `.${rootKind}`;
|
||||
}
|
||||
const issuesTone = item?.issues.length ? getIssuesTone(item.issues) : null;
|
||||
|
||||
function formatScopeLabel(scope: 'user' | 'project'): string {
|
||||
return scope === 'project' ? 'This project only' : 'Your personal skills';
|
||||
|
|
@ -91,8 +91,29 @@ export const SkillDetailDialog = ({
|
|||
|
||||
function formatInvocationLabel(invocationMode: 'auto' | 'manual-only'): string {
|
||||
return invocationMode === 'manual-only'
|
||||
? 'Claude will only use this when you explicitly ask for it.'
|
||||
: 'Claude can pick this automatically when it matches the task.';
|
||||
? 'Only runs when you explicitly ask for it.'
|
||||
: 'Runs automatically when it matches the task.';
|
||||
}
|
||||
|
||||
function getIssuesTone(issues: SkillValidationIssue[]): {
|
||||
className: string;
|
||||
title: string;
|
||||
Icon: typeof AlertTriangle;
|
||||
} {
|
||||
const informationalOnly = issues.every((issue) => issue.severity === 'info');
|
||||
if (informationalOnly) {
|
||||
return {
|
||||
className: 'border-blue-500/30 bg-blue-500/5',
|
||||
title: 'This skill includes bundled scripts',
|
||||
Icon: Info,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
className: 'border-amber-500/30 bg-amber-500/5',
|
||||
title: 'Review this skill carefully before using it',
|
||||
Icon: AlertTriangle,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleDelete(): Promise<void> {
|
||||
|
|
@ -159,7 +180,8 @@ export const SkillDetailDialog = ({
|
|||
)}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{formatScopeLabel(item.scope)}</Badge>
|
||||
<Badge variant="outline">Stored in {formatRootKind(item.rootKind)}</Badge>
|
||||
<Badge variant="outline">Stored in {formatSkillRootKind(item.rootKind)}</Badge>
|
||||
<Badge variant="outline">{getSkillAudienceLabel(item.rootKind)}</Badge>
|
||||
<Badge variant="secondary">
|
||||
{item.invocationMode === 'manual-only' ? 'Manual use' : 'Auto use'}
|
||||
</Badge>
|
||||
|
|
@ -169,16 +191,30 @@ export const SkillDetailDialog = ({
|
|||
</div>
|
||||
|
||||
{item.issues.length > 0 && (
|
||||
<div className="space-y-2 rounded-md border border-amber-500/30 bg-amber-500/5 p-4">
|
||||
<p className="text-sm font-medium text-amber-700 dark:text-amber-300">
|
||||
Review this skill carefully before using it
|
||||
<div className={`space-y-2 rounded-md border p-4 ${issuesTone?.className ?? ''}`}>
|
||||
<p
|
||||
className={`text-sm font-medium ${
|
||||
issuesTone?.Icon === Info
|
||||
? 'text-blue-700 dark:text-blue-300'
|
||||
: 'text-amber-700 dark:text-amber-300'
|
||||
}`}
|
||||
>
|
||||
{issuesTone?.title}
|
||||
</p>
|
||||
{item.issues.map((issue, index) => (
|
||||
<div
|
||||
key={`${issue.code}-${index}`}
|
||||
className="flex gap-2 text-sm text-amber-700 dark:text-amber-300"
|
||||
className={`flex gap-2 text-sm ${
|
||||
issue.severity === 'info'
|
||||
? 'text-blue-700 dark:text-blue-300'
|
||||
: 'text-amber-700 dark:text-amber-300'
|
||||
}`}
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||
{issue.severity === 'info' ? (
|
||||
<Info className="mt-0.5 size-4 shrink-0" />
|
||||
) : (
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||
)}
|
||||
<span>{issue.message}</span>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -194,7 +230,7 @@ export const SkillDetailDialog = ({
|
|||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-text-muted">
|
||||
How Claude uses it
|
||||
How it is used
|
||||
</p>
|
||||
<p className="text-sm text-text">{formatInvocationLabel(item.invocationMode)}</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
import { Textarea } from '@renderer/components/ui/textarea';
|
||||
import { useMarkdownScrollSync } from '@renderer/hooks/useMarkdownScrollSync';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { SKILL_ROOT_DEFINITIONS } from '@shared/utils/skillRoots';
|
||||
import { FileSearch, RotateCcw, X } from 'lucide-react';
|
||||
|
||||
import { SkillCodeEditor } from './SkillCodeEditor';
|
||||
|
|
@ -41,6 +42,7 @@ import type {
|
|||
SkillDetail,
|
||||
SkillInvocationMode,
|
||||
SkillReviewPreview,
|
||||
SkillRootKind,
|
||||
} from '@shared/types/extensions';
|
||||
|
||||
type EditorMode = 'create' | 'edit';
|
||||
|
|
@ -50,6 +52,7 @@ interface SkillEditorDialogProps {
|
|||
mode: EditorMode;
|
||||
projectPath: string | null;
|
||||
projectLabel: string | null;
|
||||
allowCodexRootKind: boolean;
|
||||
detail: SkillDetail | null;
|
||||
onClose: () => void;
|
||||
onSaved: (skillId: string | null) => void;
|
||||
|
|
@ -68,6 +71,7 @@ export const SkillEditorDialog = ({
|
|||
mode,
|
||||
projectPath,
|
||||
projectLabel,
|
||||
allowCodexRootKind,
|
||||
detail,
|
||||
onClose,
|
||||
onSaved,
|
||||
|
|
@ -79,7 +83,7 @@ export const SkillEditorDialog = ({
|
|||
const applySkillUpsert = useStore((s) => s.applySkillUpsert);
|
||||
|
||||
const [scope, setScope] = useState<'user' | 'project'>('user');
|
||||
const [rootKind, setRootKind] = useState<'claude' | 'cursor' | 'agents'>('claude');
|
||||
const [rootKind, setRootKind] = useState<SkillRootKind>('claude');
|
||||
const [folderName, setFolderName] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
|
|
@ -218,7 +222,7 @@ export const SkillEditorDialog = ({
|
|||
setReviewLoading(false);
|
||||
setSaveLoading(false);
|
||||
setMutationError(null);
|
||||
}, [detail, mode, open, projectPath]);
|
||||
}, [allowCodexRootKind, detail, mode, open, projectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
|
|
@ -238,6 +242,12 @@ export const SkillEditorDialog = ({
|
|||
}
|
||||
}, [mode, open, projectPath, scope]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && mode === 'create' && rootKind === 'codex' && !allowCodexRootKind) {
|
||||
setRootKind('claude');
|
||||
}
|
||||
}, [allowCodexRootKind, mode, open, rootKind]);
|
||||
|
||||
useEffect(() => {
|
||||
rawContentRef.current = rawContent;
|
||||
}, [rawContent]);
|
||||
|
|
@ -289,6 +299,14 @@ export const SkillEditorDialog = ({
|
|||
);
|
||||
|
||||
const canUseProjectScope = Boolean(projectPath);
|
||||
const visibleRootDefinitions = useMemo(
|
||||
() =>
|
||||
SKILL_ROOT_DEFINITIONS.filter(
|
||||
(definition) =>
|
||||
definition.rootKind !== 'codex' || allowCodexRootKind || detail?.item.rootKind === 'codex'
|
||||
),
|
||||
[allowCodexRootKind, detail?.item.rootKind]
|
||||
);
|
||||
const instructionsLocked = manualRawEdit || customMarkdownDetected;
|
||||
const title = mode === 'create' ? 'Create skill' : 'Edit skill';
|
||||
const descriptionText =
|
||||
|
|
@ -427,18 +445,19 @@ export const SkillEditorDialog = ({
|
|||
<Label htmlFor="skill-root">Where to store it</Label>
|
||||
<Select
|
||||
value={rootKind}
|
||||
onValueChange={(value) =>
|
||||
setRootKind(value as 'claude' | 'cursor' | 'agents')
|
||||
}
|
||||
onValueChange={(value) => setRootKind(value as SkillRootKind)}
|
||||
disabled={mode === 'edit'}
|
||||
>
|
||||
<SelectTrigger id="skill-root">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="claude">.claude</SelectItem>
|
||||
<SelectItem value="cursor">.cursor</SelectItem>
|
||||
<SelectItem value="agents">.agents</SelectItem>
|
||||
{visibleRootDefinitions.map((definition) => (
|
||||
<SelectItem key={definition.rootKind} value={definition.rootKind}>
|
||||
{definition.directoryName}
|
||||
{definition.audience === 'codex' ? ' - Codex only' : ' - Shared'}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
@ -463,7 +482,7 @@ export const SkillEditorDialog = ({
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="skill-invocation">How Claude should use it</Label>
|
||||
<Label htmlFor="skill-invocation">How it should be used</Label>
|
||||
<Select
|
||||
value={invocationMode}
|
||||
onValueChange={(value) => {
|
||||
|
|
@ -476,7 +495,7 @@ export const SkillEditorDialog = ({
|
|||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Claude can use it automatically</SelectItem>
|
||||
<SelectItem value="auto">Can be used automatically</SelectItem>
|
||||
<SelectItem value="manual-only">Only when you ask for it</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
|
@ -556,7 +575,7 @@ export const SkillEditorDialog = ({
|
|||
|
||||
<div className="grid gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="skill-when-to-use">When Claude should reach for this</Label>
|
||||
<Label htmlFor="skill-when-to-use">When to reach for this</Label>
|
||||
<Textarea
|
||||
id="skill-when-to-use"
|
||||
value={whenToUse}
|
||||
|
|
@ -572,7 +591,7 @@ export const SkillEditorDialog = ({
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="skill-steps">Main steps Claude should follow</Label>
|
||||
<Label htmlFor="skill-steps">Main steps to follow</Label>
|
||||
<Textarea
|
||||
id="skill-steps"
|
||||
value={steps}
|
||||
|
|
@ -647,7 +666,7 @@ export const SkillEditorDialog = ({
|
|||
<div>
|
||||
<p className="font-medium text-text">References</p>
|
||||
<p className="mt-1 text-xs text-text-muted">
|
||||
Add supporting docs, links, or examples that Claude can look at.
|
||||
Add supporting docs, links, or examples the runtime can look at.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
|
|
|||
|
|
@ -20,14 +20,15 @@ import {
|
|||
SelectValue,
|
||||
} from '@renderer/components/ui/select';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { SKILL_ROOT_DEFINITIONS } from '@shared/utils/skillRoots';
|
||||
import { FileSearch, FolderOpen, X } from 'lucide-react';
|
||||
|
||||
import { getSuggestedSkillFolderNameFromPath } from './skillFolderNameUtils';
|
||||
import { SkillReviewDialog } from './SkillReviewDialog';
|
||||
import { resolveSkillProjectPath } from './skillProjectUtils';
|
||||
import { SkillReviewDialog } from './SkillReviewDialog';
|
||||
import { validateSkillFolderName, validateSkillImportSourceDir } from './skillValidationUtils';
|
||||
|
||||
import type { SkillReviewPreview } from '@shared/types/extensions';
|
||||
import type { SkillReviewPreview, SkillRootKind } from '@shared/types/extensions';
|
||||
|
||||
function getFriendlyImportError(message: string): string {
|
||||
if (message.includes('valid skill file')) {
|
||||
|
|
@ -55,6 +56,7 @@ interface SkillImportDialogProps {
|
|||
open: boolean;
|
||||
projectPath: string | null;
|
||||
projectLabel: string | null;
|
||||
allowCodexRootKind: boolean;
|
||||
onClose: () => void;
|
||||
onImported: (skillId: string | null) => void;
|
||||
}
|
||||
|
|
@ -63,6 +65,7 @@ export const SkillImportDialog = ({
|
|||
open,
|
||||
projectPath,
|
||||
projectLabel,
|
||||
allowCodexRootKind,
|
||||
onClose,
|
||||
onImported,
|
||||
}: SkillImportDialogProps): React.JSX.Element => {
|
||||
|
|
@ -73,7 +76,7 @@ export const SkillImportDialog = ({
|
|||
const [folderName, setFolderName] = useState('');
|
||||
const [folderNameEdited, setFolderNameEdited] = useState(false);
|
||||
const [scope, setScope] = useState<'user' | 'project'>('user');
|
||||
const [rootKind, setRootKind] = useState<'claude' | 'cursor' | 'agents'>('claude');
|
||||
const [rootKind, setRootKind] = useState<SkillRootKind>('claude');
|
||||
const [preview, setPreview] = useState<SkillReviewPreview | null>(null);
|
||||
const [reviewOpen, setReviewOpen] = useState(false);
|
||||
const [reviewLoading, setReviewLoading] = useState(false);
|
||||
|
|
@ -119,6 +122,16 @@ export const SkillImportDialog = ({
|
|||
}
|
||||
}, [open, projectPath, scope]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && rootKind === 'codex' && !allowCodexRootKind) {
|
||||
setRootKind('claude');
|
||||
}
|
||||
}, [allowCodexRootKind, open, rootKind]);
|
||||
|
||||
const visibleRootDefinitions = SKILL_ROOT_DEFINITIONS.filter(
|
||||
(definition) => definition.rootKind !== 'codex' || allowCodexRootKind
|
||||
);
|
||||
|
||||
async function handleChooseFolder(): Promise<void> {
|
||||
const selected = await api.config.selectFolders();
|
||||
const first = selected[0];
|
||||
|
|
@ -273,17 +286,18 @@ export const SkillImportDialog = ({
|
|||
<Label htmlFor="skill-import-root">Where to store it</Label>
|
||||
<Select
|
||||
value={rootKind}
|
||||
onValueChange={(value) =>
|
||||
setRootKind(value as 'claude' | 'cursor' | 'agents')
|
||||
}
|
||||
onValueChange={(value) => setRootKind(value as SkillRootKind)}
|
||||
>
|
||||
<SelectTrigger id="skill-import-root">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="claude">.claude</SelectItem>
|
||||
<SelectItem value="cursor">.cursor</SelectItem>
|
||||
<SelectItem value="agents">.agents</SelectItem>
|
||||
{visibleRootDefinitions.map((definition) => (
|
||||
<SelectItem key={definition.rootKind} value={definition.rootKind}>
|
||||
{definition.directoryName}
|
||||
{definition.audience === 'codex' ? ' - Codex only' : ' - Shared'}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,17 @@ import { Button } from '@renderer/components/ui/button';
|
|||
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { getVisibleMultimodelProviders } from '@renderer/utils/multimodelProviderVisibility';
|
||||
import {
|
||||
getCliProviderExtensionCapability,
|
||||
isCliExtensionCapabilityAvailable,
|
||||
} from '@shared/utils/providerExtensionCapabilities';
|
||||
import {
|
||||
formatSkillRootKind,
|
||||
getSkillAudience,
|
||||
getSkillAudienceLabel,
|
||||
isCodexSkillOverlayAvailable,
|
||||
} from '@shared/utils/skillRoots';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowUpAZ,
|
||||
|
|
@ -15,6 +26,7 @@ import {
|
|||
CheckCircle2,
|
||||
Clock3,
|
||||
Download,
|
||||
Info,
|
||||
Plus,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
|
|
@ -28,12 +40,19 @@ import { SkillImportDialog } from './SkillImportDialog';
|
|||
import { resolveSkillProjectPath } from './skillProjectUtils';
|
||||
|
||||
import type { SkillsSortState } from '@renderer/hooks/useExtensionsTabState';
|
||||
import type { SkillCatalogItem, SkillDetail } from '@shared/types/extensions';
|
||||
import type { SkillCatalogItem, SkillDetail, SkillValidationIssue } from '@shared/types/extensions';
|
||||
|
||||
const SUCCESS_BANNER_MS = 2500;
|
||||
const NEW_SKILL_HIGHLIGHT_MS = 4000;
|
||||
const USER_SKILLS_CATALOG_KEY = '__user__';
|
||||
type SkillsQuickFilter = 'all' | 'project' | 'personal' | 'needs-attention' | 'has-scripts';
|
||||
type SkillsQuickFilter =
|
||||
| 'all'
|
||||
| 'project'
|
||||
| 'personal'
|
||||
| 'shared'
|
||||
| 'codex-only'
|
||||
| 'needs-attention'
|
||||
| 'has-scripts';
|
||||
|
||||
interface SkillsPanelProps {
|
||||
projectPath: string | null;
|
||||
|
|
@ -57,10 +76,6 @@ function sortSkills(skills: SkillCatalogItem[], sort: SkillsSortState): SkillCat
|
|||
return next;
|
||||
}
|
||||
|
||||
function formatRootKind(rootKind: SkillCatalogItem['rootKind']): string {
|
||||
return `.${rootKind}`;
|
||||
}
|
||||
|
||||
function getScopeLabel(skill: SkillCatalogItem): string {
|
||||
return skill.scope === 'project' ? 'This project' : 'Personal';
|
||||
}
|
||||
|
|
@ -68,7 +83,7 @@ function getScopeLabel(skill: SkillCatalogItem): string {
|
|||
function getInvocationLabel(skill: SkillCatalogItem): string {
|
||||
return skill.invocationMode === 'manual-only'
|
||||
? 'Only runs when you explicitly ask for it'
|
||||
: 'Claude can use this automatically when it fits';
|
||||
: 'Runs automatically when it fits';
|
||||
}
|
||||
|
||||
function getSkillStatus(skill: SkillCatalogItem): string {
|
||||
|
|
@ -81,6 +96,45 @@ function getSkillStatus(skill: SkillCatalogItem): string {
|
|||
return 'Ready to use';
|
||||
}
|
||||
|
||||
function getPrimarySkillIssue(skill: SkillCatalogItem): SkillValidationIssue | null {
|
||||
return (
|
||||
skill.issues.find((issue) => issue.severity === 'error') ??
|
||||
skill.issues.find((issue) => issue.severity === 'warning') ??
|
||||
skill.issues[0] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function getSkillIssueTone(issue: SkillValidationIssue | null): {
|
||||
className: string;
|
||||
Icon: typeof AlertTriangle;
|
||||
} {
|
||||
if (issue?.severity === 'info') {
|
||||
return {
|
||||
className: 'border-blue-500/20 bg-blue-500/10 text-blue-700 dark:text-blue-300',
|
||||
Icon: Info,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
className: 'border-amber-500/20 bg-amber-500/5 text-amber-700 dark:text-amber-300',
|
||||
Icon: AlertTriangle,
|
||||
};
|
||||
}
|
||||
|
||||
function formatRuntimeAudienceLabel(providerNames: readonly string[]): string {
|
||||
if (providerNames.length === 0) {
|
||||
return 'the configured runtime';
|
||||
}
|
||||
if (providerNames.length === 1) {
|
||||
return providerNames[0];
|
||||
}
|
||||
if (providerNames.length === 2) {
|
||||
return `${providerNames[0]} and ${providerNames[1]}`;
|
||||
}
|
||||
return `${providerNames.slice(0, -1).join(', ')}, and ${providerNames.at(-1)}`;
|
||||
}
|
||||
|
||||
export const SkillsPanel = ({
|
||||
projectPath,
|
||||
projectLabel,
|
||||
|
|
@ -94,6 +148,7 @@ export const SkillsPanel = ({
|
|||
const catalogKey = projectPath ?? USER_SKILLS_CATALOG_KEY;
|
||||
const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog);
|
||||
const fetchSkillDetail = useStore((s) => s.fetchSkillDetail);
|
||||
const cliStatus = useStore((s) => s.cliStatus);
|
||||
const skillsLoading = useStore((s) => s.skillsCatalogLoadingByProjectPath[catalogKey] ?? false);
|
||||
const skillsError = useStore((s) => s.skillsCatalogErrorByProjectPath[catalogKey] ?? null);
|
||||
const detailById = useStore(useShallow((s) => s.skillsDetailsById));
|
||||
|
|
@ -110,18 +165,47 @@ export const SkillsPanel = ({
|
|||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [highlightedSkillId, setHighlightedSkillId] = useState<string | null>(null);
|
||||
const selectedSkillIdRef = useRef<string | null>(selectedSkillId);
|
||||
const selectedSkillItemRef = useRef<SkillCatalogItem | SkillDetail['item'] | null>(null);
|
||||
const selectedSkillItemRef = useRef<SkillCatalogItem | null>(null);
|
||||
selectedSkillIdRef.current = selectedSkillId;
|
||||
|
||||
const mergedSkills = useMemo(
|
||||
() => [...projectSkills, ...userSkills],
|
||||
[projectSkills, userSkills]
|
||||
);
|
||||
const codexSkillOverlayAvailable = useMemo(
|
||||
() => isCodexSkillOverlayAvailable(cliStatus),
|
||||
[cliStatus]
|
||||
);
|
||||
const skillsAudienceLabel = useMemo(() => {
|
||||
if (cliStatus?.flavor !== 'agent_teams_orchestrator') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const providerNames = getVisibleMultimodelProviders(cliStatus.providers ?? [])
|
||||
.filter((provider) =>
|
||||
isCliExtensionCapabilityAvailable(getCliProviderExtensionCapability(provider, 'skills'))
|
||||
)
|
||||
.map((provider) => provider.displayName);
|
||||
|
||||
return formatRuntimeAudienceLabel(providerNames);
|
||||
}, [cliStatus]);
|
||||
const codexOnlySkillsCount = useMemo(
|
||||
() => mergedSkills.filter((skill) => getSkillAudience(skill.rootKind) === 'codex').length,
|
||||
[mergedSkills]
|
||||
);
|
||||
const sharedSkillsCount = mergedSkills.length - codexOnlySkillsCount;
|
||||
const showCodexOnlyUi = codexSkillOverlayAvailable || codexOnlySkillsCount > 0;
|
||||
const selectedDetail = selectedSkillId ? (detailById[selectedSkillId] ?? null) : null;
|
||||
selectedSkillItemRef.current = selectedSkillId
|
||||
? (selectedDetail?.item ?? mergedSkills.find((skill) => skill.id === selectedSkillId) ?? null)
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (quickFilter === 'codex-only' && !showCodexOnlyUi) {
|
||||
setQuickFilter('all');
|
||||
}
|
||||
}, [quickFilter, showCodexOnlyUi]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSkillId) return;
|
||||
if (mergedSkills.some((skill) => skill.id === selectedSkillId)) return;
|
||||
|
|
@ -204,6 +288,10 @@ export const SkillsPanel = ({
|
|||
return skill.scope === 'project';
|
||||
case 'personal':
|
||||
return skill.scope === 'user';
|
||||
case 'shared':
|
||||
return getSkillAudience(skill.rootKind) === 'shared';
|
||||
case 'codex-only':
|
||||
return getSkillAudience(skill.rootKind) === 'codex';
|
||||
case 'needs-attention':
|
||||
return !skill.isValid;
|
||||
case 'has-scripts':
|
||||
|
|
@ -226,16 +314,23 @@ export const SkillsPanel = ({
|
|||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{cliStatus?.flavor === 'agent_teams_orchestrator' && (
|
||||
<div className="rounded-md border border-blue-500/30 bg-blue-500/5 px-4 py-3 text-sm text-blue-300">
|
||||
Shared skills in `.claude`, `.cursor`, and `.agents` are available to{' '}
|
||||
{skillsAudienceLabel ?? 'the configured runtime'}. Skills stored in `.codex` stay
|
||||
Codex-only when Codex support is available.
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-surface-raised/20 rounded-xl border border-border p-4">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div className="min-w-0 flex-1 space-y-1 xl:max-w-2xl">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="size-4 text-text-muted" />
|
||||
<h2 className="text-sm font-semibold text-text">Teach Claude repeatable work</h2>
|
||||
<h2 className="text-sm font-semibold text-text">Teach repeatable work</h2>
|
||||
</div>
|
||||
<p className="max-w-2xl text-sm leading-5 text-text-muted">
|
||||
Skills are reusable instructions that help Claude handle the same kind of task more
|
||||
consistently.{' '}
|
||||
Skills are reusable instructions that help the runtime handle the same kind of task
|
||||
more consistently.{' '}
|
||||
{projectPath
|
||||
? `You are seeing skills for ${projectLabel ?? projectPath} plus your personal skills.`
|
||||
: 'You are seeing only your personal skills right now.'}
|
||||
|
|
@ -243,6 +338,9 @@ export const SkillsPanel = ({
|
|||
<p className="max-w-2xl text-xs leading-5 text-text-muted">
|
||||
Use personal skills for habits you want everywhere. Use project skills for workflows
|
||||
that only make sense inside one codebase.
|
||||
{codexSkillOverlayAvailable
|
||||
? ' Use `.codex` when a skill should stay Codex-only.'
|
||||
: ' Existing `.codex` skills stay editable here, but new Codex-only skills need the Codex runtime enabled.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -320,6 +418,14 @@ export const SkillsPanel = ({
|
|||
<Badge variant="secondary" className="font-normal">
|
||||
{userSkills.length} personal
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{sharedSkillsCount} shared
|
||||
</Badge>
|
||||
{showCodexOnlyUi && (
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{codexOnlySkillsCount} Codex only
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -331,6 +437,10 @@ export const SkillsPanel = ({
|
|||
['all', 'All skills'],
|
||||
['project', 'Project'],
|
||||
['personal', 'Personal'],
|
||||
['shared', 'Shared'],
|
||||
...(showCodexOnlyUi
|
||||
? ([['codex-only', 'Codex only']] as [SkillsQuickFilter, string][])
|
||||
: []),
|
||||
['needs-attention', 'Needs attention'],
|
||||
['has-scripts', 'Has scripts'],
|
||||
] as [SkillsQuickFilter, string][]
|
||||
|
|
@ -383,7 +493,7 @@ export const SkillsPanel = ({
|
|||
<p className="text-xs text-text-muted">
|
||||
{skillsSearchQuery
|
||||
? 'Try a different search term or switch filters.'
|
||||
: 'Create your first skill to teach Claude a repeatable workflow, or import one you already use.'}
|
||||
: 'Create your first skill to teach a repeatable workflow, or import one you already use.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -404,71 +514,83 @@ export const SkillsPanel = ({
|
|||
</Badge>
|
||||
</div>
|
||||
<div className="skills-grid grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
{visibleProjectSkills.map((skill) => (
|
||||
<button
|
||||
key={skill.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedSkillId(skill.id)}
|
||||
className={`rounded-xl border p-4 text-left transition-colors ${
|
||||
highlightedSkillId === skill.id
|
||||
? 'border-green-500/50 bg-green-500/10 shadow-[0_0_0_1px_rgba(34,197,94,0.18)]'
|
||||
: 'bg-surface-raised/10 border-border hover:border-border-emphasis'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="truncate text-sm font-semibold text-text">{skill.name}</h3>
|
||||
{!skill.isValid && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-500/40 text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
Needs attention
|
||||
</Badge>
|
||||
)}
|
||||
{visibleProjectSkills.map((skill) => {
|
||||
const primaryIssue = getPrimarySkillIssue(skill);
|
||||
const issueTone = getSkillIssueTone(primaryIssue);
|
||||
const IssueIcon = issueTone.Icon;
|
||||
return (
|
||||
<button
|
||||
key={skill.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedSkillId(skill.id)}
|
||||
className={`rounded-xl border p-4 text-left transition-colors ${
|
||||
highlightedSkillId === skill.id
|
||||
? 'border-green-500/50 bg-green-500/10 shadow-[0_0_0_1px_rgba(34,197,94,0.18)]'
|
||||
: 'bg-surface-raised/10 border-border hover:border-border-emphasis'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="truncate text-sm font-semibold text-text">
|
||||
{skill.name}
|
||||
</h3>
|
||||
{!skill.isValid && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-500/40 text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
Needs attention
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="line-clamp-2 text-sm text-text-secondary">
|
||||
{skill.description}
|
||||
</p>
|
||||
</div>
|
||||
<p className="line-clamp-2 text-sm text-text-secondary">
|
||||
{skill.description}
|
||||
</p>
|
||||
<Badge variant="outline">{getScopeLabel(skill)}</Badge>
|
||||
</div>
|
||||
<Badge variant="outline">{getScopeLabel(skill)}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-2 text-xs text-text-muted">
|
||||
<p>{getInvocationLabel(skill)}</p>
|
||||
<p>{getSkillStatus(skill)}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
Stored in {formatRootKind(skill.rootKind)}
|
||||
</Badge>
|
||||
{skill.flags.hasScripts && (
|
||||
<Badge variant="destructive" className="font-normal">
|
||||
Has scripts
|
||||
</Badge>
|
||||
)}
|
||||
{skill.flags.hasReferences && (
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
References
|
||||
</Badge>
|
||||
)}
|
||||
{skill.flags.hasAssets && (
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
Assets
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{skill.issues.length > 0 && (
|
||||
<div className="mt-3 flex items-start gap-2 rounded-md border border-amber-500/20 bg-amber-500/5 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{skill.issues[0]?.message}</span>
|
||||
<div className="mt-3 space-y-2 text-xs text-text-muted">
|
||||
<p>{getInvocationLabel(skill)}</p>
|
||||
<p>{getSkillStatus(skill)}</p>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
Stored in {formatSkillRootKind(skill.rootKind)}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="font-normal">
|
||||
{getSkillAudienceLabel(skill.rootKind)}
|
||||
</Badge>
|
||||
{skill.flags.hasScripts && (
|
||||
<Badge variant="destructive" className="font-normal">
|
||||
Has scripts
|
||||
</Badge>
|
||||
)}
|
||||
{skill.flags.hasReferences && (
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
References
|
||||
</Badge>
|
||||
)}
|
||||
{skill.flags.hasAssets && (
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
Assets
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{primaryIssue && (
|
||||
<div
|
||||
className={`mt-3 flex items-start gap-2 rounded-md border px-3 py-2 text-xs ${issueTone.className}`}
|
||||
>
|
||||
<IssueIcon className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{primaryIssue.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
|
@ -479,7 +601,7 @@ export const SkillsPanel = ({
|
|||
<div>
|
||||
<h3 className="text-sm font-semibold text-text">Personal skills</h3>
|
||||
<p className="text-xs text-text-muted">
|
||||
Habits and instructions you want Claude to remember everywhere.
|
||||
Habits and instructions you want available everywhere.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
|
|
@ -487,71 +609,83 @@ export const SkillsPanel = ({
|
|||
</Badge>
|
||||
</div>
|
||||
<div className="skills-grid grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
{visibleUserSkills.map((skill) => (
|
||||
<button
|
||||
key={skill.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedSkillId(skill.id)}
|
||||
className={`rounded-xl border p-4 text-left transition-colors ${
|
||||
highlightedSkillId === skill.id
|
||||
? 'border-green-500/50 bg-green-500/10 shadow-[0_0_0_1px_rgba(34,197,94,0.18)]'
|
||||
: 'bg-surface-raised/10 border-border hover:border-border-emphasis'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="truncate text-sm font-semibold text-text">{skill.name}</h3>
|
||||
{!skill.isValid && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-500/40 text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
Needs attention
|
||||
</Badge>
|
||||
)}
|
||||
{visibleUserSkills.map((skill) => {
|
||||
const primaryIssue = getPrimarySkillIssue(skill);
|
||||
const issueTone = getSkillIssueTone(primaryIssue);
|
||||
const IssueIcon = issueTone.Icon;
|
||||
return (
|
||||
<button
|
||||
key={skill.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedSkillId(skill.id)}
|
||||
className={`rounded-xl border p-4 text-left transition-colors ${
|
||||
highlightedSkillId === skill.id
|
||||
? 'border-green-500/50 bg-green-500/10 shadow-[0_0_0_1px_rgba(34,197,94,0.18)]'
|
||||
: 'bg-surface-raised/10 border-border hover:border-border-emphasis'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="truncate text-sm font-semibold text-text">
|
||||
{skill.name}
|
||||
</h3>
|
||||
{!skill.isValid && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-500/40 text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
Needs attention
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="line-clamp-2 text-sm text-text-secondary">
|
||||
{skill.description}
|
||||
</p>
|
||||
</div>
|
||||
<p className="line-clamp-2 text-sm text-text-secondary">
|
||||
{skill.description}
|
||||
</p>
|
||||
<Badge variant="outline">{getScopeLabel(skill)}</Badge>
|
||||
</div>
|
||||
<Badge variant="outline">{getScopeLabel(skill)}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-2 text-xs text-text-muted">
|
||||
<p>{getInvocationLabel(skill)}</p>
|
||||
<p>{getSkillStatus(skill)}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
Stored in {formatRootKind(skill.rootKind)}
|
||||
</Badge>
|
||||
{skill.flags.hasScripts && (
|
||||
<Badge variant="destructive" className="font-normal">
|
||||
Has scripts
|
||||
</Badge>
|
||||
)}
|
||||
{skill.flags.hasReferences && (
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
References
|
||||
</Badge>
|
||||
)}
|
||||
{skill.flags.hasAssets && (
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
Assets
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{skill.issues.length > 0 && (
|
||||
<div className="mt-3 flex items-start gap-2 rounded-md border border-amber-500/20 bg-amber-500/5 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{skill.issues[0]?.message}</span>
|
||||
<div className="mt-3 space-y-2 text-xs text-text-muted">
|
||||
<p>{getInvocationLabel(skill)}</p>
|
||||
<p>{getSkillStatus(skill)}</p>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
Stored in {formatSkillRootKind(skill.rootKind)}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="font-normal">
|
||||
{getSkillAudienceLabel(skill.rootKind)}
|
||||
</Badge>
|
||||
{skill.flags.hasScripts && (
|
||||
<Badge variant="destructive" className="font-normal">
|
||||
Has scripts
|
||||
</Badge>
|
||||
)}
|
||||
{skill.flags.hasReferences && (
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
References
|
||||
</Badge>
|
||||
)}
|
||||
{skill.flags.hasAssets && (
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
Assets
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{primaryIssue && (
|
||||
<div
|
||||
className={`mt-3 flex items-start gap-2 rounded-md border px-3 py-2 text-xs ${issueTone.className}`}
|
||||
>
|
||||
<IssueIcon className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{primaryIssue.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
|
@ -577,6 +711,7 @@ export const SkillsPanel = ({
|
|||
mode="create"
|
||||
projectPath={projectPath}
|
||||
projectLabel={projectLabel}
|
||||
allowCodexRootKind={codexSkillOverlayAvailable}
|
||||
detail={null}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onSaved={(skillId) => {
|
||||
|
|
@ -592,6 +727,7 @@ export const SkillsPanel = ({
|
|||
mode="edit"
|
||||
projectPath={projectPath}
|
||||
projectLabel={projectLabel}
|
||||
allowCodexRootKind={codexSkillOverlayAvailable}
|
||||
detail={editingDetail}
|
||||
onClose={() => {
|
||||
setEditOpen(false);
|
||||
|
|
@ -609,6 +745,7 @@ export const SkillsPanel = ({
|
|||
open={importOpen}
|
||||
projectPath={projectPath}
|
||||
projectLabel={projectLabel}
|
||||
allowCodexRootKind={codexSkillOverlayAvailable}
|
||||
onClose={() => setImportOpen(false)}
|
||||
onImported={(skillId) => {
|
||||
setImportOpen(false);
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@ export const Sidebar = (): React.JSX.Element => {
|
|||
<button
|
||||
type="button"
|
||||
aria-label="Resize sidebar"
|
||||
className={`absolute left-0 top-0 h-full w-1 cursor-col-resize border-0 bg-transparent p-0 transition-colors hover:bg-blue-500/50 ${
|
||||
className={`absolute left-0 top-0 z-20 h-full w-1 cursor-col-resize border-0 bg-transparent p-0 transition-colors hover:bg-blue-500/50 ${
|
||||
isResizing ? 'bg-blue-500/50' : ''
|
||||
}`}
|
||||
onMouseDown={handleResizeStart}
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ function isApiKeyProviderId(providerId: CliProviderId): providerId is ApiKeyProv
|
|||
|
||||
function findPreferredApiKeyEntry(apiKeys: ApiKeyEntry[], envVarName: string): ApiKeyEntry | null {
|
||||
const matches = apiKeys.filter((entry) => entry.envVarName === envVarName);
|
||||
return matches.find((entry) => entry.scope === 'user') ?? matches[0] ?? null;
|
||||
return matches.find((entry) => entry.scope === 'user') ?? null;
|
||||
}
|
||||
|
||||
function getConnectionDescription(provider: CliProviderStatus): string {
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ export interface SafeConfig {
|
|||
notifyOnCrossTeamMessage: boolean;
|
||||
notifyOnTeamLaunched: boolean;
|
||||
notifyOnToolApproval: boolean;
|
||||
autoResumeOnRateLimit: boolean;
|
||||
statusChangeOnlySolo: boolean;
|
||||
statusChangeStatuses: string[];
|
||||
triggers: AppConfig['notifications']['triggers'];
|
||||
|
|
@ -195,6 +196,7 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
|
|||
notifyOnCrossTeamMessage: displayConfig?.notifications?.notifyOnCrossTeamMessage ?? true,
|
||||
notifyOnTeamLaunched: displayConfig?.notifications?.notifyOnTeamLaunched ?? true,
|
||||
notifyOnToolApproval: displayConfig?.notifications?.notifyOnToolApproval ?? true,
|
||||
autoResumeOnRateLimit: displayConfig?.notifications?.autoResumeOnRateLimit ?? false,
|
||||
statusChangeOnlySolo: displayConfig?.notifications?.statusChangeOnlySolo ?? true,
|
||||
statusChangeStatuses: displayConfig?.notifications?.statusChangeStatuses ?? [
|
||||
'in_progress',
|
||||
|
|
|
|||
|
|
@ -311,6 +311,7 @@ export function useSettingsHandlers({
|
|||
notifyOnCrossTeamMessage: true,
|
||||
notifyOnTeamLaunched: true,
|
||||
notifyOnToolApproval: true,
|
||||
autoResumeOnRateLimit: false,
|
||||
statusChangeOnlySolo: true,
|
||||
statusChangeStatuses: ['in_progress', 'completed'],
|
||||
triggers: defaultTriggers,
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ export const AdvancedSection = ({
|
|||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
|
||||
Claude Agent Teams UI
|
||||
Agent Teams UI
|
||||
</p>
|
||||
{isElectron && (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -163,6 +163,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
!cliStatus && cliStatusLoading && multimodelEnabled
|
||||
? createLoadingMultimodelCliStatus()
|
||||
: cliStatus;
|
||||
const canOpenExtensions = effectiveCliStatus?.installed === true;
|
||||
const showInstalledControls =
|
||||
effectiveCliStatus !== null && (installerState === 'idle' || installerState === 'completed');
|
||||
|
||||
|
|
@ -396,7 +397,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
</button>
|
||||
) : null}
|
||||
{/* Extensions button — right-aligned */}
|
||||
{effectiveCliStatus.authLoggedIn && (
|
||||
{canOpenExtensions && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openExtensionsTab}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ interface NotificationsSectionProps {
|
|||
| 'notifyOnCrossTeamMessage'
|
||||
| 'notifyOnTeamLaunched'
|
||||
| 'notifyOnToolApproval'
|
||||
| 'autoResumeOnRateLimit'
|
||||
| 'statusChangeOnlySolo',
|
||||
value: boolean
|
||||
) => void;
|
||||
|
|
@ -360,6 +361,17 @@ export const NotificationsSection = ({
|
|||
disabled={saving || !safeConfig.notifications.enabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label="Auto-resume after rate limit"
|
||||
description="When Claude reports a reset time, schedule a follow-up nudge for the team lead after the limit resets"
|
||||
icon={<Clock className="size-4" />}
|
||||
>
|
||||
<SettingsToggle
|
||||
enabled={safeConfig.notifications.autoResumeOnRateLimit}
|
||||
onChange={(v) => onNotificationToggle('autoResumeOnRateLimit', v)}
|
||||
disabled={saving || !safeConfig.notifications.enabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{/* Task Status Change Notifications — nested within team card */}
|
||||
<div className="last:*:border-b-0">
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Folder,
|
||||
ListTodo,
|
||||
Pin,
|
||||
Search,
|
||||
|
|
@ -56,7 +57,7 @@ function loadGroupingMode(): TaskGroupingMode {
|
|||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return 'none';
|
||||
return 'project';
|
||||
}
|
||||
|
||||
function saveGroupingMode(mode: TaskGroupingMode): void {
|
||||
|
|
@ -625,6 +626,7 @@ export const GlobalTaskList = ({
|
|||
projectGroups.map((group) => {
|
||||
if (group.tasks.length === 0) return null;
|
||||
const isGroupCollapsed = projectCollapsed.isCollapsed(group.projectKey);
|
||||
const groupColor = projectColor(group.projectLabel);
|
||||
let lastTeam: string | null = null;
|
||||
return (
|
||||
<div key={group.projectKey}>
|
||||
|
|
@ -639,14 +641,12 @@ export const GlobalTaskList = ({
|
|||
) : (
|
||||
<ChevronDown className="size-3 shrink-0 text-text-muted" />
|
||||
)}
|
||||
<span
|
||||
className="inline-block size-1.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: projectColor(group.projectLabel).border }}
|
||||
<Folder
|
||||
className="size-3 shrink-0"
|
||||
style={{ color: groupColor.border }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
className="truncate"
|
||||
style={{ color: projectColor(group.projectLabel).text }}
|
||||
>
|
||||
<span className="truncate" style={{ color: groupColor.text }}>
|
||||
{group.projectLabel}
|
||||
</span>
|
||||
<span className="ml-auto shrink-0 text-[10px] font-normal text-text-muted">
|
||||
|
|
|
|||
|
|
@ -111,8 +111,7 @@ export const MemberCard = ({
|
|||
!isRemoved &&
|
||||
presenceLabel === 'starting' &&
|
||||
spawnLaunchState !== 'failed_to_start' &&
|
||||
!activityTask &&
|
||||
!runtimeSummary;
|
||||
!activityTask;
|
||||
const showStartingBadge = !isRemoved && presenceLabel === 'starting' && !activityTask;
|
||||
const showRuntimeAdvisoryBadge =
|
||||
!isRemoved &&
|
||||
|
|
|
|||
|
|
@ -237,7 +237,8 @@ export const MessageComposer = ({
|
|||
buildSlashCommandSuggestions(
|
||||
getSuggestedSlashCommandsForProvider(leadProviderId),
|
||||
projectSkills,
|
||||
userSkills
|
||||
userSkills,
|
||||
leadProviderId
|
||||
),
|
||||
[leadProviderId, projectSkills, userSkills]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" type="image/png" href="./favicon.png" />
|
||||
<title>Claude Agent Teams UI</title>
|
||||
<title>Agent Teams UI</title>
|
||||
<style>
|
||||
/* Splash: animated gradient background */
|
||||
#splash {
|
||||
|
|
@ -124,7 +124,7 @@
|
|||
<circle class="splash-node splash-core-fill" style="animation-delay:1s" cx="37" cy="19" r="2" fill="#ede9fe"/>
|
||||
<circle class="splash-node splash-core-fill" style="animation-delay:2s" cx="28" cy="37" r="2.2" fill="#f3e8ff"/>
|
||||
</svg>
|
||||
<div id="splash-text">Claude Agent Teams UI</div>
|
||||
<div id="splash-text">Agent Teams UI</div>
|
||||
</div>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
import { api } from '@renderer/api';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
||||
|
||||
import type { AppState } from '../types';
|
||||
import type { CliInstallationStatus, CliProviderId, CliProviderStatus } from '@shared/types';
|
||||
|
|
@ -36,6 +37,7 @@ export function createLoadingMultimodelCliStatus(): CliInstallationStatus {
|
|||
capabilities: {
|
||||
teamLaunch: false,
|
||||
oneShot: false,
|
||||
extensions: createDefaultCliExtensionCapabilities(),
|
||||
},
|
||||
backend: null,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -4,8 +4,14 @@
|
|||
*/
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
|
||||
import { getMcpOperationKey, getPluginOperationKey } from '@shared/utils/extensionNormalizers';
|
||||
import {
|
||||
getExtensionActionDisableReason,
|
||||
getMcpDiagnosticKey,
|
||||
getMcpOperationKey,
|
||||
getMcpProjectStateKey,
|
||||
getPluginOperationKey,
|
||||
} from '@shared/utils/extensionNormalizers';
|
||||
import { isProjectScopedMcpScope } from '@shared/utils/mcpScopes';
|
||||
|
||||
import { findPaneByTabId, updatePane } from '../utils/paneHelpers';
|
||||
|
||||
|
|
@ -51,11 +57,16 @@ export interface ExtensionsSlice {
|
|||
mcpBrowseLoading: boolean;
|
||||
mcpBrowseError: string | null;
|
||||
mcpInstalledServers: InstalledMcpEntry[];
|
||||
mcpInstalledServersByProjectPath: Record<string, InstalledMcpEntry[]>;
|
||||
mcpInstalledProjectPath: string | null;
|
||||
mcpDiagnostics: Record<string, McpServerDiagnostic>;
|
||||
mcpDiagnosticsByProjectPath: Record<string, Record<string, McpServerDiagnostic>>;
|
||||
mcpDiagnosticsLoading: boolean;
|
||||
mcpDiagnosticsLoadingByProjectPath: Record<string, boolean>;
|
||||
mcpDiagnosticsError: string | null;
|
||||
mcpDiagnosticsErrorByProjectPath: Record<string, string | null>;
|
||||
mcpDiagnosticsLastCheckedAt: number | null;
|
||||
mcpDiagnosticsLastCheckedAtByProjectPath: Record<string, number | null>;
|
||||
|
||||
// ── Install progress ──
|
||||
pluginInstallProgress: Record<string, ExtensionOperationState>;
|
||||
|
|
@ -90,7 +101,7 @@ export interface ExtensionsSlice {
|
|||
fetchPluginReadme: (pluginId: string) => void;
|
||||
mcpBrowse: (cursor?: string) => Promise<void>;
|
||||
mcpFetchInstalled: (projectPath?: string) => Promise<void>;
|
||||
runMcpDiagnostics: () => Promise<void>;
|
||||
runMcpDiagnostics: (projectPath?: string) => Promise<void>;
|
||||
fetchSkillsCatalog: (projectPath?: string) => Promise<void>;
|
||||
fetchSkillDetail: (skillId: string, projectPath?: string) => Promise<void>;
|
||||
previewSkillUpsert: (request: SkillUpsertRequest) => Promise<SkillReviewPreview>;
|
||||
|
|
@ -132,7 +143,7 @@ let pluginFetchInFlight: { key: string; promise: Promise<void>; token: symbol }
|
|||
let pluginCatalogRequestSeq = 0;
|
||||
const pluginSuccessResetTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const mcpSuccessResetTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let mcpDiagnosticsInFlight: Promise<void> | null = null;
|
||||
const mcpDiagnosticsInFlightByKey = new Map<string, Promise<void>>();
|
||||
let skillsCatalogRequestSeq = 0;
|
||||
let skillsDetailRequestSeq = 0;
|
||||
const latestSkillsCatalogRequestByKey = new Map<string, number>();
|
||||
|
|
@ -152,8 +163,8 @@ function buildPluginIdSet(catalog: EnrichedPlugin[]): Set<string> {
|
|||
return new Set(catalog.map((plugin) => plugin.pluginId));
|
||||
}
|
||||
|
||||
function buildPluginOperationKeys(pluginId: string): string[] {
|
||||
return PLUGIN_OPERATION_SCOPES.map((scope) => getPluginOperationKey(pluginId, scope));
|
||||
function isPluginOperationKeyForPlugin(operationKey: string, pluginId: string): boolean {
|
||||
return operationKey.startsWith(`plugin:${pluginId}:`);
|
||||
}
|
||||
|
||||
function clearPluginOperationState(
|
||||
|
|
@ -170,10 +181,16 @@ function clearPluginOperationState(
|
|||
|
||||
const nextPluginInstallProgress = { ...pluginInstallProgress };
|
||||
const nextInstallErrors = { ...installErrors };
|
||||
const pluginIdsList = Array.from(pluginIds);
|
||||
|
||||
for (const pluginId of pluginIds) {
|
||||
for (const operationKey of buildPluginOperationKeys(pluginId)) {
|
||||
for (const operationKey of Object.keys(nextPluginInstallProgress)) {
|
||||
if (pluginIdsList.some((pluginId) => isPluginOperationKeyForPlugin(operationKey, pluginId))) {
|
||||
delete nextPluginInstallProgress[operationKey];
|
||||
}
|
||||
}
|
||||
|
||||
for (const operationKey of Object.keys(nextInstallErrors)) {
|
||||
if (pluginIdsList.some((pluginId) => isPluginOperationKeyForPlugin(operationKey, pluginId))) {
|
||||
delete nextInstallErrors[operationKey];
|
||||
}
|
||||
}
|
||||
|
|
@ -195,8 +212,9 @@ function clearPluginSuccessResetTimer(operationKey: string): void {
|
|||
}
|
||||
|
||||
function clearPluginSuccessResetTimers(pluginIds: Set<string>): void {
|
||||
for (const pluginId of pluginIds) {
|
||||
for (const operationKey of buildPluginOperationKeys(pluginId)) {
|
||||
const pluginIdsList = Array.from(pluginIds);
|
||||
for (const operationKey of Array.from(pluginSuccessResetTimers.keys())) {
|
||||
if (pluginIdsList.some((pluginId) => isPluginOperationKeyForPlugin(operationKey, pluginId))) {
|
||||
clearPluginSuccessResetTimer(operationKey);
|
||||
}
|
||||
}
|
||||
|
|
@ -222,10 +240,26 @@ function schedulePluginSuccessReset(
|
|||
pluginSuccessResetTimers.set(operationKey, timer);
|
||||
}
|
||||
|
||||
function getCustomMcpOperationKey(serverName: string, scope: InstallScope): string {
|
||||
function getCustomMcpOperationKey(
|
||||
serverName: string,
|
||||
scope: InstallScope,
|
||||
projectPath?: string | null
|
||||
): string {
|
||||
if (scope === 'project' || scope === 'local') {
|
||||
return `mcp-custom:${serverName}:${scope}:${getMcpProjectStateKey(projectPath)}`;
|
||||
}
|
||||
return `mcp-custom:${serverName}:${scope}`;
|
||||
}
|
||||
|
||||
function isProjectScopedMcpOperationKey(operationKey: string): boolean {
|
||||
return (
|
||||
operationKey.includes(':project:') ||
|
||||
operationKey.endsWith(':project') ||
|
||||
operationKey.includes(':local:') ||
|
||||
operationKey.endsWith(':local')
|
||||
);
|
||||
}
|
||||
|
||||
function clearMcpSuccessResetTimer(operationKey: string): void {
|
||||
const timer = mcpSuccessResetTimers.get(operationKey);
|
||||
if (!timer) {
|
||||
|
|
@ -269,7 +303,7 @@ function clearMcpProjectScopedOperationState(
|
|||
for (const operationKey of Object.keys(nextMcpInstallProgress)) {
|
||||
if (
|
||||
(operationKey.startsWith('mcp:') || operationKey.startsWith('mcp-custom:')) &&
|
||||
(operationKey.endsWith(':project') || operationKey.endsWith(':local'))
|
||||
isProjectScopedMcpOperationKey(operationKey)
|
||||
) {
|
||||
delete nextMcpInstallProgress[operationKey];
|
||||
}
|
||||
|
|
@ -278,7 +312,7 @@ function clearMcpProjectScopedOperationState(
|
|||
for (const operationKey of Object.keys(nextInstallErrors)) {
|
||||
if (
|
||||
(operationKey.startsWith('mcp:') || operationKey.startsWith('mcp-custom:')) &&
|
||||
(operationKey.endsWith(':project') || operationKey.endsWith(':local'))
|
||||
isProjectScopedMcpOperationKey(operationKey)
|
||||
) {
|
||||
delete nextInstallErrors[operationKey];
|
||||
}
|
||||
|
|
@ -292,7 +326,7 @@ function clearMcpProjectScopedOperationState(
|
|||
|
||||
function clearMcpProjectScopedSuccessResetTimers(): void {
|
||||
for (const operationKey of Array.from(mcpSuccessResetTimers.keys())) {
|
||||
if (operationKey.endsWith(':project') || operationKey.endsWith(':local')) {
|
||||
if (isProjectScopedMcpOperationKey(operationKey)) {
|
||||
clearMcpSuccessResetTimer(operationKey);
|
||||
}
|
||||
}
|
||||
|
|
@ -302,18 +336,15 @@ function getSkillsCatalogKey(projectPath?: string): string {
|
|||
return projectPath ?? USER_SKILLS_CATALOG_KEY;
|
||||
}
|
||||
|
||||
function upsertApiKeyEntry(entries: ApiKeyEntry[], entry: ApiKeyEntry): ApiKeyEntry[] {
|
||||
const nextEntries = entries.filter((candidate) => candidate.id !== entry.id);
|
||||
return [entry, ...nextEntries];
|
||||
}
|
||||
|
||||
/** Duration to show "success" state before returning to idle */
|
||||
const SUCCESS_DISPLAY_MS = 2_000;
|
||||
const CLI_AUTH_REQUIRED_MESSAGE =
|
||||
'Claude CLI is installed but not signed in. Go to the Dashboard and sign in to enable plugin installs.';
|
||||
const CLI_HEALTHCHECK_FAILED_MESSAGE =
|
||||
'Claude CLI was found but failed its startup health check. Open the Dashboard to repair or reinstall it before retrying.';
|
||||
const CLI_STATUS_UNKNOWN_MESSAGE =
|
||||
'Unable to verify Claude CLI status. Open the Dashboard and check the CLI before retrying.';
|
||||
const PROJECT_SCOPE_REQUIRED_MESSAGE =
|
||||
'Project- and local-scoped plugins require an active project in the Extensions tab.';
|
||||
const PLUGIN_OPERATION_SCOPES: InstallScope[] = ['user', 'project', 'local'];
|
||||
|
||||
export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSlice> = (
|
||||
set,
|
||||
get
|
||||
|
|
@ -331,11 +362,16 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
mcpBrowseLoading: false,
|
||||
mcpBrowseError: null,
|
||||
mcpInstalledServers: [],
|
||||
mcpInstalledServersByProjectPath: {},
|
||||
mcpInstalledProjectPath: null,
|
||||
mcpDiagnostics: {},
|
||||
mcpDiagnosticsByProjectPath: {},
|
||||
mcpDiagnosticsLoading: false,
|
||||
mcpDiagnosticsLoadingByProjectPath: {},
|
||||
mcpDiagnosticsError: null,
|
||||
mcpDiagnosticsErrorByProjectPath: {},
|
||||
mcpDiagnosticsLastCheckedAt: null,
|
||||
mcpDiagnosticsLastCheckedAtByProjectPath: {},
|
||||
|
||||
pluginInstallProgress: {},
|
||||
mcpInstallProgress: {},
|
||||
|
|
@ -376,7 +412,8 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
const requestToken = Symbol('pluginCatalogRequest');
|
||||
set({ pluginCatalogLoading: true, pluginCatalogError: null });
|
||||
|
||||
const promise = (async () => {
|
||||
let currentPromise: Promise<void> | null = null;
|
||||
currentPromise = (async () => {
|
||||
try {
|
||||
const result = await api.plugins!.getAll(projectPath, forceRefresh);
|
||||
set((prev) => {
|
||||
|
|
@ -438,8 +475,8 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
}
|
||||
})();
|
||||
|
||||
pluginFetchInFlight = { key: requestKey, promise, token: requestToken };
|
||||
await promise;
|
||||
pluginFetchInFlight = { key: requestKey, promise: currentPromise, token: requestToken };
|
||||
await currentPromise;
|
||||
},
|
||||
|
||||
// ── Plugin README fetch ──
|
||||
|
|
@ -514,6 +551,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
const installed = await api.mcpRegistry.getInstalled(projectPath);
|
||||
set((prev) => {
|
||||
const nextProjectPath = projectPath ?? null;
|
||||
const stateKey = getMcpProjectStateKey(nextProjectPath);
|
||||
const isSameProjectContext = prev.mcpInstalledProjectPath === nextProjectPath;
|
||||
const nextOperationState = isSameProjectContext
|
||||
? {
|
||||
|
|
@ -528,6 +566,10 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
|
||||
return {
|
||||
mcpInstalledServers: installed,
|
||||
mcpInstalledServersByProjectPath: {
|
||||
...prev.mcpInstalledServersByProjectPath,
|
||||
[stateKey]: installed,
|
||||
},
|
||||
mcpInstalledProjectPath: nextProjectPath,
|
||||
mcpInstallProgress: nextOperationState.mcpInstallProgress,
|
||||
installErrors: nextOperationState.installErrors,
|
||||
|
|
@ -538,39 +580,75 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
}
|
||||
},
|
||||
|
||||
runMcpDiagnostics: async () => {
|
||||
runMcpDiagnostics: async (projectPath?: string) => {
|
||||
const mcpRegistry = api.mcpRegistry;
|
||||
if (!mcpRegistry) return;
|
||||
const projectStateKey = getMcpProjectStateKey(projectPath);
|
||||
|
||||
if (mcpDiagnosticsInFlight) {
|
||||
await mcpDiagnosticsInFlight;
|
||||
const existing = mcpDiagnosticsInFlightByKey.get(projectStateKey);
|
||||
if (existing) {
|
||||
await existing;
|
||||
return;
|
||||
}
|
||||
|
||||
set({ mcpDiagnosticsLoading: true, mcpDiagnosticsError: null });
|
||||
set((prev) => ({
|
||||
mcpDiagnosticsLoading: true,
|
||||
mcpDiagnosticsError: null,
|
||||
mcpDiagnosticsLoadingByProjectPath: {
|
||||
...prev.mcpDiagnosticsLoadingByProjectPath,
|
||||
[projectStateKey]: true,
|
||||
},
|
||||
mcpDiagnosticsErrorByProjectPath: {
|
||||
...prev.mcpDiagnosticsErrorByProjectPath,
|
||||
[projectStateKey]: null,
|
||||
},
|
||||
}));
|
||||
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const diagnostics = await mcpRegistry.diagnose();
|
||||
const diagnostics = await mcpRegistry.diagnose(projectPath);
|
||||
const diagnosticsRecord = Object.fromEntries(
|
||||
diagnostics.map((entry) => [getMcpDiagnosticKey(entry.name, entry.scope), entry] as const)
|
||||
);
|
||||
const checkedAt = Date.now();
|
||||
set({
|
||||
mcpDiagnostics: Object.fromEntries(
|
||||
diagnostics.map((entry) => [entry.name, entry] as const)
|
||||
),
|
||||
mcpDiagnostics: diagnosticsRecord,
|
||||
mcpDiagnosticsLoading: false,
|
||||
mcpDiagnosticsLastCheckedAt: Date.now(),
|
||||
mcpDiagnosticsByProjectPath: {
|
||||
...get().mcpDiagnosticsByProjectPath,
|
||||
[projectStateKey]: diagnosticsRecord,
|
||||
},
|
||||
mcpDiagnosticsLoadingByProjectPath: {
|
||||
...get().mcpDiagnosticsLoadingByProjectPath,
|
||||
[projectStateKey]: false,
|
||||
},
|
||||
mcpDiagnosticsLastCheckedAt: checkedAt,
|
||||
mcpDiagnosticsLastCheckedAtByProjectPath: {
|
||||
...get().mcpDiagnosticsLastCheckedAtByProjectPath,
|
||||
[projectStateKey]: checkedAt,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : 'Failed to check MCP server health';
|
||||
set({
|
||||
mcpDiagnosticsLoading: false,
|
||||
mcpDiagnosticsError:
|
||||
err instanceof Error ? err.message : 'Failed to check MCP server health',
|
||||
mcpDiagnosticsError: errorMessage,
|
||||
mcpDiagnosticsLoadingByProjectPath: {
|
||||
...get().mcpDiagnosticsLoadingByProjectPath,
|
||||
[projectStateKey]: false,
|
||||
},
|
||||
mcpDiagnosticsErrorByProjectPath: {
|
||||
...get().mcpDiagnosticsErrorByProjectPath,
|
||||
[projectStateKey]: errorMessage,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
mcpDiagnosticsInFlight = null;
|
||||
mcpDiagnosticsInFlightByKey.delete(projectStateKey);
|
||||
}
|
||||
})();
|
||||
|
||||
mcpDiagnosticsInFlight = promise;
|
||||
mcpDiagnosticsInFlightByKey.set(projectStateKey, promise);
|
||||
await promise;
|
||||
},
|
||||
|
||||
|
|
@ -792,11 +870,16 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
installPlugin: async (request: PluginInstallRequest) => {
|
||||
if (!api.plugins) return;
|
||||
|
||||
const catalogProjectPathAtOperationStart = get().pluginCatalogProjectPath ?? undefined;
|
||||
const effectiveProjectPath =
|
||||
request.scope !== 'user'
|
||||
? (request.projectPath ?? get().pluginCatalogProjectPath ?? undefined)
|
||||
: request.projectPath;
|
||||
const operationKey = getPluginOperationKey(request.pluginId, request.scope);
|
||||
const operationKey = getPluginOperationKey(
|
||||
request.pluginId,
|
||||
request.scope,
|
||||
effectiveProjectPath
|
||||
);
|
||||
const effectiveRequest =
|
||||
effectiveProjectPath === request.projectPath
|
||||
? request
|
||||
|
|
@ -815,15 +898,12 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
const preflightError =
|
||||
effectiveRequest.scope !== 'user' && !effectiveRequest.projectPath
|
||||
? PROJECT_SCOPE_REQUIRED_MESSAGE
|
||||
: cliStatus === null
|
||||
? CLI_STATUS_UNKNOWN_MESSAGE
|
||||
: !cliStatus.installed
|
||||
? cliStatus.binaryPath && cliStatus.launchError
|
||||
? CLI_HEALTHCHECK_FAILED_MESSAGE
|
||||
: CLI_NOT_FOUND_MESSAGE
|
||||
: !cliStatus.authLoggedIn
|
||||
? CLI_AUTH_REQUIRED_MESSAGE
|
||||
: null;
|
||||
: getExtensionActionDisableReason({
|
||||
isInstalled: false,
|
||||
cliStatus,
|
||||
cliStatusLoading: get().cliStatusLoading,
|
||||
section: 'plugins',
|
||||
});
|
||||
|
||||
if (preflightError) {
|
||||
clearPluginSuccessResetTimer(operationKey);
|
||||
|
|
@ -858,7 +938,12 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
}));
|
||||
|
||||
// Refresh catalog to pick up new installed state
|
||||
void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true);
|
||||
void get().fetchPluginCatalog(
|
||||
effectiveRequest.scope !== 'user'
|
||||
? effectiveRequest.projectPath
|
||||
: catalogProjectPathAtOperationStart,
|
||||
true
|
||||
);
|
||||
|
||||
schedulePluginSuccessReset(operationKey, set);
|
||||
} catch (err) {
|
||||
|
|
@ -875,12 +960,13 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
uninstallPlugin: async (pluginId: string, scope?: InstallScope, projectPath?: string) => {
|
||||
if (!api.plugins) return;
|
||||
|
||||
const catalogProjectPathAtOperationStart = get().pluginCatalogProjectPath ?? undefined;
|
||||
const effectiveScope = scope ?? 'user';
|
||||
const operationKey = getPluginOperationKey(pluginId, effectiveScope);
|
||||
const effectiveProjectPath =
|
||||
effectiveScope !== 'user'
|
||||
? (projectPath ?? get().pluginCatalogProjectPath ?? undefined)
|
||||
: projectPath;
|
||||
const operationKey = getPluginOperationKey(pluginId, effectiveScope, effectiveProjectPath);
|
||||
if (effectiveScope !== 'user' && !effectiveProjectPath) {
|
||||
clearPluginSuccessResetTimer(operationKey);
|
||||
set((prev) => ({
|
||||
|
|
@ -890,6 +976,30 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
return;
|
||||
}
|
||||
|
||||
const preflightState = get();
|
||||
if (preflightState.cliStatus === null || preflightState.cliStatusLoading) {
|
||||
try {
|
||||
await preflightState.fetchCliStatus();
|
||||
} catch {
|
||||
// fetchCliStatus stores the error in cliStatusError; map to a user-facing uninstall error below.
|
||||
}
|
||||
}
|
||||
|
||||
const uninstallDisableReason = getExtensionActionDisableReason({
|
||||
isInstalled: true,
|
||||
cliStatus: get().cliStatus,
|
||||
cliStatusLoading: get().cliStatusLoading,
|
||||
section: 'plugins',
|
||||
});
|
||||
if (uninstallDisableReason) {
|
||||
clearPluginSuccessResetTimer(operationKey);
|
||||
set((prev) => ({
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' },
|
||||
installErrors: { ...prev.installErrors, [operationKey]: uninstallDisableReason },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
clearPluginSuccessResetTimer(operationKey);
|
||||
set((prev) => ({
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'pending' },
|
||||
|
|
@ -913,7 +1023,10 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
}));
|
||||
|
||||
// Refresh catalog
|
||||
void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true);
|
||||
void get().fetchPluginCatalog(
|
||||
effectiveScope !== 'user' ? effectiveProjectPath : catalogProjectPathAtOperationStart,
|
||||
true
|
||||
);
|
||||
|
||||
schedulePluginSuccessReset(operationKey, set);
|
||||
} catch (err) {
|
||||
|
|
@ -928,7 +1041,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
|
||||
// ── MCP install ──
|
||||
installMcpServer: async (request: McpInstallRequest) => {
|
||||
const operationKey = getMcpOperationKey(request.registryId, request.scope);
|
||||
const operationKey = getMcpOperationKey(request.registryId, request.scope, request.projectPath);
|
||||
if (!api.mcpRegistry) {
|
||||
clearMcpSuccessResetTimer(operationKey);
|
||||
set((prev) => ({
|
||||
|
|
@ -941,6 +1054,30 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
return;
|
||||
}
|
||||
|
||||
const preflightState = get();
|
||||
if (preflightState.cliStatus === null || preflightState.cliStatusLoading) {
|
||||
try {
|
||||
await preflightState.fetchCliStatus();
|
||||
} catch {
|
||||
// fetchCliStatus stores the error in cliStatusError; map to a user-facing install error below.
|
||||
}
|
||||
}
|
||||
|
||||
const installDisableReason = getExtensionActionDisableReason({
|
||||
isInstalled: false,
|
||||
cliStatus: get().cliStatus,
|
||||
cliStatusLoading: get().cliStatusLoading,
|
||||
section: 'mcp',
|
||||
});
|
||||
if (installDisableReason) {
|
||||
clearMcpSuccessResetTimer(operationKey);
|
||||
set((prev) => ({
|
||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' },
|
||||
installErrors: { ...prev.installErrors, [operationKey]: installDisableReason },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
clearMcpSuccessResetTimer(operationKey);
|
||||
set((prev) => ({
|
||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'pending' },
|
||||
|
|
@ -960,8 +1097,8 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
}
|
||||
|
||||
await Promise.all([
|
||||
get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined),
|
||||
get().runMcpDiagnostics(),
|
||||
get().mcpFetchInstalled(request.projectPath),
|
||||
get().runMcpDiagnostics(request.projectPath),
|
||||
]);
|
||||
|
||||
set((prev) => ({
|
||||
|
|
@ -982,34 +1119,48 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
// ── MCP custom install ──
|
||||
installCustomMcpServer: async (request: McpCustomInstallRequest) => {
|
||||
const operationScope = request.scope;
|
||||
const progressKey = getCustomMcpOperationKey(request.serverName, operationScope);
|
||||
if (!api.mcpRegistry) {
|
||||
const progressKey = getCustomMcpOperationKey(
|
||||
request.serverName,
|
||||
operationScope,
|
||||
request.projectPath
|
||||
);
|
||||
try {
|
||||
if (!api.mcpRegistry) {
|
||||
throw new Error('MCP Registry not available');
|
||||
}
|
||||
|
||||
const preflightState = get();
|
||||
if (preflightState.cliStatus === null || preflightState.cliStatusLoading) {
|
||||
try {
|
||||
await preflightState.fetchCliStatus();
|
||||
} catch {
|
||||
// fetchCliStatus stores the error in cliStatusError; map to a user-facing install error below.
|
||||
}
|
||||
}
|
||||
|
||||
const installDisableReason = getExtensionActionDisableReason({
|
||||
isInstalled: false,
|
||||
cliStatus: get().cliStatus,
|
||||
cliStatusLoading: get().cliStatusLoading,
|
||||
section: 'mcp',
|
||||
});
|
||||
if (installDisableReason) {
|
||||
throw new Error(installDisableReason);
|
||||
}
|
||||
|
||||
clearMcpSuccessResetTimer(progressKey);
|
||||
set((prev) => ({
|
||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' },
|
||||
installErrors: { ...prev.installErrors, [progressKey]: 'MCP Registry not available' },
|
||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'pending' },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
clearMcpSuccessResetTimer(progressKey);
|
||||
set((prev) => ({
|
||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'pending' },
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = await api.mcpRegistry.installCustom(request);
|
||||
if (result.state === 'error') {
|
||||
set((prev) => ({
|
||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' },
|
||||
installErrors: { ...prev.installErrors, [progressKey]: result.error ?? 'Install failed' },
|
||||
}));
|
||||
return;
|
||||
throw new Error(result.error ?? 'Install failed');
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined),
|
||||
get().runMcpDiagnostics(),
|
||||
get().mcpFetchInstalled(request.projectPath),
|
||||
get().runMcpDiagnostics(request.projectPath),
|
||||
]);
|
||||
|
||||
set((prev) => ({
|
||||
|
|
@ -1024,6 +1175,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' },
|
||||
installErrors: { ...prev.installErrors, [progressKey]: message },
|
||||
}));
|
||||
throw err instanceof Error ? err : new Error(message);
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -1034,8 +1186,9 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
scope?: string,
|
||||
projectPath?: string
|
||||
) => {
|
||||
const operationScope: InstallScope = scope === 'project' || scope === 'local' ? scope : 'user';
|
||||
const operationKey = getMcpOperationKey(registryId, operationScope);
|
||||
const operationScope: InstallScope =
|
||||
scope === 'global' || scope === 'user' || isProjectScopedMcpScope(scope) ? scope : 'user';
|
||||
const operationKey = getMcpOperationKey(registryId, operationScope, projectPath);
|
||||
if (!api.mcpRegistry) {
|
||||
clearMcpSuccessResetTimer(operationKey);
|
||||
set((prev) => ({
|
||||
|
|
@ -1045,6 +1198,30 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
return;
|
||||
}
|
||||
|
||||
const preflightState = get();
|
||||
if (preflightState.cliStatus === null || preflightState.cliStatusLoading) {
|
||||
try {
|
||||
await preflightState.fetchCliStatus();
|
||||
} catch {
|
||||
// fetchCliStatus stores the error in cliStatusError; map to a user-facing uninstall error below.
|
||||
}
|
||||
}
|
||||
|
||||
const uninstallDisableReason = getExtensionActionDisableReason({
|
||||
isInstalled: true,
|
||||
cliStatus: get().cliStatus,
|
||||
cliStatusLoading: get().cliStatusLoading,
|
||||
section: 'mcp',
|
||||
});
|
||||
if (uninstallDisableReason) {
|
||||
clearMcpSuccessResetTimer(operationKey);
|
||||
set((prev) => ({
|
||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' },
|
||||
installErrors: { ...prev.installErrors, [operationKey]: uninstallDisableReason },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
clearMcpSuccessResetTimer(operationKey);
|
||||
set((prev) => ({
|
||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'pending' },
|
||||
|
|
@ -1064,8 +1241,8 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
}
|
||||
|
||||
await Promise.all([
|
||||
get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined),
|
||||
get().runMcpDiagnostics(),
|
||||
get().mcpFetchInstalled(projectPath),
|
||||
get().runMcpDiagnostics(projectPath),
|
||||
]);
|
||||
|
||||
set((prev) => ({
|
||||
|
|
@ -1115,10 +1292,29 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
|
||||
set({ apiKeySaving: true, apiKeysError: null });
|
||||
try {
|
||||
await api.apiKeys.save(request);
|
||||
// Refresh the list to get updated masked values
|
||||
const keys = await api.apiKeys.list();
|
||||
set({ apiKeys: keys, apiKeySaving: false });
|
||||
const savedKey = await api.apiKeys.save(request);
|
||||
const warnings: string[] = [];
|
||||
|
||||
try {
|
||||
const keys = await api.apiKeys.list();
|
||||
set({ apiKeys: keys });
|
||||
} catch (listError) {
|
||||
warnings.push(
|
||||
listError instanceof Error
|
||||
? `API key saved, but failed to refresh key list. ${listError.message}`
|
||||
: 'API key saved, but failed to refresh key list.'
|
||||
);
|
||||
set((prev) => ({
|
||||
apiKeys: upsertApiKeyEntry(prev.apiKeys, savedKey),
|
||||
}));
|
||||
}
|
||||
|
||||
await get().fetchCliStatus();
|
||||
const refreshError = get().cliStatusError;
|
||||
if (refreshError) {
|
||||
warnings.push(`API key saved, but failed to refresh provider status. ${refreshError}`);
|
||||
}
|
||||
set({ apiKeySaving: false, apiKeysError: warnings.length > 0 ? warnings.join(' ') : null });
|
||||
} catch (err) {
|
||||
set({
|
||||
apiKeySaving: false,
|
||||
|
|
@ -1137,6 +1333,13 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
set((prev) => ({
|
||||
apiKeys: prev.apiKeys.filter((k) => k.id !== id),
|
||||
}));
|
||||
await get().fetchCliStatus();
|
||||
const refreshError = get().cliStatusError;
|
||||
set({
|
||||
apiKeysError: refreshError
|
||||
? `API key deleted, but failed to refresh provider status. ${refreshError}`
|
||||
: null,
|
||||
});
|
||||
} catch (err) {
|
||||
set({
|
||||
apiKeysError: err instanceof Error ? err.message : 'Failed to delete API key',
|
||||
|
|
|
|||
|
|
@ -1834,8 +1834,7 @@ function areTeamGraphSlotAssignmentsEqual(
|
|||
for (const [stableOwnerId, leftAssignment] of leftEntries) {
|
||||
const rightAssignment = right?.[stableOwnerId];
|
||||
if (
|
||||
!rightAssignment ||
|
||||
rightAssignment.ringIndex !== leftAssignment.ringIndex ||
|
||||
rightAssignment?.ringIndex !== leftAssignment.ringIndex ||
|
||||
rightAssignment.sectorIndex !== leftAssignment.sectorIndex
|
||||
) {
|
||||
return false;
|
||||
|
|
@ -2872,8 +2871,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
return (
|
||||
(nextAssignment.ringIndex === assignment.ringIndex &&
|
||||
nextAssignment.sectorIndex === assignment.sectorIndex) ||
|
||||
(displacedAssignment != null &&
|
||||
nextAssignment.ringIndex === displacedAssignment.ringIndex &&
|
||||
(nextAssignment.ringIndex === displacedAssignment?.ringIndex &&
|
||||
nextAssignment.sectorIndex === displacedAssignment.sectorIndex)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* Notification and configuration types for Claude Agent Teams UI.
|
||||
* Notification and configuration types for Agent Teams UI.
|
||||
*
|
||||
* Re-exports types from shared for backwards compatibility.
|
||||
* The canonical definitions are in @shared/types/notifications.
|
||||
|
|
|
|||
32
src/renderer/utils/multimodelProviderVisibility.ts
Normal file
32
src/renderer/utils/multimodelProviderVisibility.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { filterMainScreenCliProviders } from './geminiUiFreeze';
|
||||
|
||||
import type {
|
||||
CliExtensionCapability,
|
||||
CliInstallationStatus,
|
||||
CliProviderStatus,
|
||||
} from '@shared/types';
|
||||
|
||||
export function getVisibleMultimodelProviders(
|
||||
providers: readonly CliProviderStatus[]
|
||||
): CliProviderStatus[] {
|
||||
return filterMainScreenCliProviders(providers);
|
||||
}
|
||||
|
||||
export function isMultimodelRuntimeStatus(
|
||||
cliStatus: Pick<CliInstallationStatus, 'flavor' | 'providers'> | null | undefined
|
||||
): boolean {
|
||||
return cliStatus?.flavor === 'agent_teams_orchestrator';
|
||||
}
|
||||
|
||||
export function formatCliExtensionCapabilityStatus(
|
||||
status: CliExtensionCapability['status']
|
||||
): string {
|
||||
switch (status) {
|
||||
case 'supported':
|
||||
return 'supported';
|
||||
case 'read-only':
|
||||
return 'read-only';
|
||||
default:
|
||||
return 'unsupported';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,41 @@
|
|||
import { getSkillAudienceLabel, isSkillAvailableForProvider } from '@shared/utils/skillRoots';
|
||||
import { isSupportedSlashCommandName } from '@shared/utils/slashCommands';
|
||||
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type { TeamProviderId } from '@shared/types';
|
||||
import type { SkillCatalogItem } from '@shared/types/extensions';
|
||||
import type { KnownSlashCommandDefinition } from '@shared/utils/slashCommands';
|
||||
|
||||
function orderSkillsForProvider(
|
||||
projectSkills: readonly SkillCatalogItem[],
|
||||
userSkills: readonly SkillCatalogItem[],
|
||||
providerId?: TeamProviderId
|
||||
): SkillCatalogItem[] {
|
||||
const visibleProjectSkills = projectSkills.filter((skill) =>
|
||||
isSkillAvailableForProvider(skill.rootKind, providerId)
|
||||
);
|
||||
const visibleUserSkills = userSkills.filter((skill) =>
|
||||
isSkillAvailableForProvider(skill.rootKind, providerId)
|
||||
);
|
||||
|
||||
if (providerId !== 'codex') {
|
||||
return [...visibleProjectSkills, ...visibleUserSkills];
|
||||
}
|
||||
|
||||
const isCodexOnly = (skill: SkillCatalogItem) => skill.rootKind === 'codex';
|
||||
return [
|
||||
...visibleProjectSkills.filter(isCodexOnly),
|
||||
...visibleProjectSkills.filter((skill) => !isCodexOnly(skill)),
|
||||
...visibleUserSkills.filter(isCodexOnly),
|
||||
...visibleUserSkills.filter((skill) => !isCodexOnly(skill)),
|
||||
];
|
||||
}
|
||||
|
||||
export function buildSlashCommandSuggestions(
|
||||
builtIns: readonly KnownSlashCommandDefinition[],
|
||||
projectSkills: readonly SkillCatalogItem[],
|
||||
userSkills: readonly SkillCatalogItem[]
|
||||
userSkills: readonly SkillCatalogItem[],
|
||||
providerId?: TeamProviderId
|
||||
): MentionSuggestion[] {
|
||||
const builtInNames = new Set(builtIns.map((command) => command.name.trim().toLowerCase()));
|
||||
const builtInSuggestions: MentionSuggestion[] = builtIns.map((command) => ({
|
||||
|
|
@ -21,7 +49,7 @@ export function buildSlashCommandSuggestions(
|
|||
|
||||
const seenSkillNames = new Set<string>();
|
||||
const skillSuggestions: MentionSuggestion[] = [];
|
||||
for (const skill of [...projectSkills, ...userSkills]) {
|
||||
for (const skill of orderSkillsForProvider(projectSkills, userSkills, providerId)) {
|
||||
const normalizedFolderName = skill.folderName.trim().toLowerCase();
|
||||
if (
|
||||
!skill.isValid ||
|
||||
|
|
@ -39,7 +67,7 @@ export function buildSlashCommandSuggestions(
|
|||
name: skill.folderName,
|
||||
command: `/${normalizedFolderName}`,
|
||||
description: skill.description,
|
||||
subtitle: skill.scope === 'project' ? 'Project skill' : 'Personal skill',
|
||||
subtitle: `${skill.scope === 'project' ? 'Project skill' : 'Personal skill'} - ${getSkillAudienceLabel(skill.rootKind)}`,
|
||||
searchText: `${skill.name} ${skill.folderName}`,
|
||||
type: 'skill',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -57,6 +57,22 @@ export interface CliExternalRuntimeDiagnostic {
|
|||
detailMessage?: string | null;
|
||||
}
|
||||
|
||||
export type CliExtensionCapabilityStatus = 'supported' | 'read-only' | 'unsupported';
|
||||
export type CliExtensionOwnership = 'shared' | 'provider-scoped';
|
||||
|
||||
export interface CliExtensionCapability {
|
||||
status: CliExtensionCapabilityStatus;
|
||||
ownership: CliExtensionOwnership;
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
export interface CliExtensionCapabilities {
|
||||
plugins: CliExtensionCapability;
|
||||
mcp: CliExtensionCapability;
|
||||
skills: CliExtensionCapability;
|
||||
apiKeys: CliExtensionCapability;
|
||||
}
|
||||
|
||||
export type CliProviderModelAvailabilityStatus =
|
||||
| 'checking'
|
||||
| 'available'
|
||||
|
|
@ -85,6 +101,7 @@ export interface CliProviderStatus {
|
|||
capabilities: {
|
||||
teamLaunch: boolean;
|
||||
oneShot: boolean;
|
||||
extensions: CliExtensionCapabilities;
|
||||
};
|
||||
selectedBackendId?: string | null;
|
||||
resolvedBackendId?: string | null;
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ export interface McpCatalogAPI {
|
|||
) => Promise<{ servers: McpCatalogItem[]; nextCursor?: string }>;
|
||||
getById: (registryId: string) => Promise<McpCatalogItem | null>;
|
||||
getInstalled: (projectPath?: string) => Promise<InstalledMcpEntry[]>;
|
||||
diagnose: () => Promise<McpServerDiagnostic[]>;
|
||||
diagnose: (projectPath?: string) => Promise<McpServerDiagnostic[]>;
|
||||
install: (request: McpInstallRequest) => Promise<OperationResult>;
|
||||
installCustom: (request: McpCustomInstallRequest) => Promise<OperationResult>;
|
||||
uninstall: (name: string, scope?: string, projectPath?: string) => Promise<OperationResult>;
|
||||
|
|
@ -80,6 +80,6 @@ export interface ApiKeysAPI {
|
|||
list: () => Promise<ApiKeyEntry[]>;
|
||||
save: (request: ApiKeySaveRequest) => Promise<ApiKeyEntry>;
|
||||
delete: (id: string) => Promise<void>;
|
||||
lookup: (envVarNames: string[]) => Promise<ApiKeyLookupResult[]>;
|
||||
lookup: (envVarNames: string[], projectPath?: string) => Promise<ApiKeyLookupResult[]>;
|
||||
getStorageStatus: () => Promise<ApiKeyStorageStatus>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export interface ApiKeyEntry {
|
|||
envVarName: string;
|
||||
maskedValue: string;
|
||||
scope: 'user' | 'project';
|
||||
projectPath?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
|
|
@ -19,6 +20,7 @@ export interface ApiKeySaveRequest {
|
|||
envVarName: string;
|
||||
value: string;
|
||||
scope: 'user' | 'project';
|
||||
projectPath?: string;
|
||||
}
|
||||
|
||||
/** Decrypted key lookup result (for auto-fill) */
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
export type ExtensionOperationState = 'idle' | 'pending' | 'success' | 'error';
|
||||
|
||||
/** Installation scope — where the extension is installed */
|
||||
export type InstallScope = 'local' | 'user' | 'project';
|
||||
export type InstallScope = 'local' | 'user' | 'project' | 'global';
|
||||
|
||||
/** Result of a mutation operation */
|
||||
export interface OperationResult<T = void> {
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ export interface McpHeaderDef {
|
|||
|
||||
export interface InstalledMcpEntry {
|
||||
name: string;
|
||||
scope: 'local' | 'user' | 'project';
|
||||
scope: 'local' | 'user' | 'project' | 'global';
|
||||
transport?: string;
|
||||
}
|
||||
|
||||
|
|
@ -92,6 +92,8 @@ export type McpServerHealthStatus = 'connected' | 'needs-authentication' | 'fail
|
|||
export interface McpServerDiagnostic {
|
||||
name: string;
|
||||
target: string;
|
||||
scope?: 'local' | 'user' | 'project' | 'global' | 'dynamic' | 'managed';
|
||||
transport?: string;
|
||||
status: McpServerHealthStatus;
|
||||
statusLabel: string;
|
||||
rawLine: string;
|
||||
|
|
@ -100,7 +102,7 @@ export interface McpServerDiagnostic {
|
|||
|
||||
// ── Install request (renderer → main, minimal trusted data) ────────────────
|
||||
|
||||
export type McpInstallScope = 'local' | 'user' | 'project';
|
||||
export type McpInstallScope = 'local' | 'user' | 'project' | 'global';
|
||||
|
||||
export interface McpInstallRequest {
|
||||
registryId: string; // server ID from registry (NOT full catalog item)
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@
|
|||
|
||||
export type SkillScope = 'user' | 'project';
|
||||
|
||||
export type SkillRootKind = 'claude' | 'cursor' | 'agents';
|
||||
export type SkillRootKind = 'claude' | 'cursor' | 'agents' | 'codex';
|
||||
|
||||
export type SkillSourceType = 'filesystem';
|
||||
|
||||
export type SkillInvocationMode = 'auto' | 'manual-only';
|
||||
|
||||
export type SkillIssueSeverity = 'warning' | 'error';
|
||||
export type SkillIssueSeverity = 'info' | 'warning' | 'error';
|
||||
|
||||
export interface SkillDirectoryFlags {
|
||||
hasScripts: boolean;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* Notification and configuration types for Claude Agent Teams UI.
|
||||
* Notification and configuration types for Agent Teams UI.
|
||||
*
|
||||
* These types define:
|
||||
* - Detected errors from session files
|
||||
|
|
@ -291,6 +291,8 @@ export interface AppConfig {
|
|||
notifyOnTeamLaunched: boolean;
|
||||
/** Whether to show native OS notifications when a tool needs user approval (Allow/Deny) */
|
||||
notifyOnToolApproval: boolean;
|
||||
/** Whether to automatically nudge a rate-limited team after the limit resets */
|
||||
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']) */
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* Visualization-specific types for Claude Agent Teams UI.
|
||||
* Visualization-specific types for Agent Teams UI.
|
||||
*
|
||||
* These types are used for waterfall chart visualization
|
||||
* and are shared between main and renderer processes.
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@
|
|||
* Pure-function normalizers for Extension Store data.
|
||||
*/
|
||||
|
||||
import {
|
||||
getCliProviderExtensionCapability,
|
||||
isCliExtensionCapabilityMutable,
|
||||
} from './providerExtensionCapabilities';
|
||||
|
||||
import type {
|
||||
CliInstallationStatus,
|
||||
InstalledMcpEntry,
|
||||
|
|
@ -104,17 +109,46 @@ export function buildPluginId(pluginName: string, marketplaceName: string): stri
|
|||
/**
|
||||
* Namespaced operation-state key for plugin install/uninstall UI state.
|
||||
*/
|
||||
export function getPluginOperationKey(pluginId: string, scope: InstallScope): string {
|
||||
export function getPluginOperationKey(
|
||||
pluginId: string,
|
||||
scope: InstallScope,
|
||||
projectPath?: string | null
|
||||
): string {
|
||||
if (scope === 'project' || scope === 'local') {
|
||||
return `plugin:${pluginId}:${scope}:${getMcpProjectStateKey(projectPath)}`;
|
||||
}
|
||||
return `plugin:${pluginId}:${scope}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Namespaced operation-state key for MCP install/uninstall UI state.
|
||||
*/
|
||||
export function getMcpOperationKey(registryId: string, scope: InstallScope): string {
|
||||
export function getMcpOperationKey(
|
||||
registryId: string,
|
||||
scope: InstallScope,
|
||||
projectPath?: string | null
|
||||
): string {
|
||||
if (scope === 'project' || scope === 'local') {
|
||||
return `mcp:${registryId}:${scope}:${getMcpProjectStateKey(projectPath)}`;
|
||||
}
|
||||
return `mcp:${registryId}:${scope}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Namespaced lookup key for MCP diagnostics. Scope is included when available
|
||||
* so the same server name can coexist across global/project/local installs.
|
||||
*/
|
||||
export function getMcpDiagnosticKey(name: string, scope?: string | null): string {
|
||||
return scope ? `mcp-diagnostic:${scope}:${name}` : `mcp-diagnostic:${name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable project-aware cache key for MCP installed/diagnostics state.
|
||||
*/
|
||||
export function getMcpProjectStateKey(projectPath?: string | null): string {
|
||||
return projectPath ?? '__global__';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a plugin has an installation for the selected scope.
|
||||
*/
|
||||
|
|
@ -135,6 +169,7 @@ function summarizeInstallationScopes(scopes: InstallScope[]): string | null {
|
|||
}
|
||||
|
||||
switch (scopes[0]) {
|
||||
case 'global':
|
||||
case 'user':
|
||||
return 'Installed globally';
|
||||
case 'project':
|
||||
|
|
@ -159,6 +194,7 @@ export function getInstallationSummaryLabel(
|
|||
const MCP_SCOPE_PRIORITY: Record<InstalledMcpEntry['scope'], number> = {
|
||||
local: 0,
|
||||
project: 1,
|
||||
global: 2,
|
||||
user: 2,
|
||||
};
|
||||
|
||||
|
|
@ -175,7 +211,7 @@ export function getPreferredMcpInstallationEntry(
|
|||
|
||||
return [...installations].sort(
|
||||
(left, right) => MCP_SCOPE_PRIORITY[left.scope] - MCP_SCOPE_PRIORITY[right.scope]
|
||||
)[0]!;
|
||||
)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -195,28 +231,74 @@ export function getExtensionActionDisableReason(options: {
|
|||
isInstalled: boolean;
|
||||
cliStatus: Pick<
|
||||
CliInstallationStatus,
|
||||
'installed' | 'authLoggedIn' | 'binaryPath' | 'launchError'
|
||||
'installed' | 'authLoggedIn' | 'binaryPath' | 'launchError' | 'flavor' | 'providers'
|
||||
> | null;
|
||||
cliStatusLoading: boolean;
|
||||
section?: 'plugins' | 'mcp';
|
||||
}): string | null {
|
||||
const { isInstalled, cliStatus, cliStatusLoading } = options;
|
||||
const { isInstalled, cliStatus, cliStatusLoading, section = 'plugins' } = options;
|
||||
if (cliStatusLoading) {
|
||||
return 'Checking Claude CLI status...';
|
||||
return 'Checking runtime status...';
|
||||
}
|
||||
|
||||
if (cliStatus === null) {
|
||||
return 'Checking Claude CLI availability...';
|
||||
return 'Checking runtime availability...';
|
||||
}
|
||||
|
||||
if (cliStatus.installed === false) {
|
||||
if (cliStatus.binaryPath && cliStatus.launchError) {
|
||||
return 'Claude CLI was found but failed to start. Open the Dashboard to repair or reinstall it.';
|
||||
return 'The configured runtime was found but failed to start. Open the Dashboard to repair or reinstall it.';
|
||||
}
|
||||
return 'Claude CLI required. Install it from the Dashboard.';
|
||||
return 'The configured runtime is required. Install or repair it from the Dashboard.';
|
||||
}
|
||||
|
||||
if (!isInstalled && !cliStatus.authLoggedIn) {
|
||||
return 'Claude CLI is installed but not signed in. Open the Dashboard to sign in.';
|
||||
const providers = cliStatus.providers ?? [];
|
||||
const isMultimodel = cliStatus.flavor === 'agent_teams_orchestrator';
|
||||
|
||||
if (section === 'mcp') {
|
||||
if (!isMultimodel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mutableProviders = providers.filter((provider) =>
|
||||
isCliExtensionCapabilityMutable(getCliProviderExtensionCapability(provider, 'mcp'))
|
||||
);
|
||||
if (mutableProviders.length > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const reason = providers
|
||||
.map((provider) => getCliProviderExtensionCapability(provider, 'mcp').reason)
|
||||
.find((value): value is string => typeof value === 'string' && value.trim().length > 0);
|
||||
|
||||
return reason ?? 'MCP management is not supported by the current runtime.';
|
||||
}
|
||||
|
||||
if (!isMultimodel) {
|
||||
if (!isInstalled && !cliStatus.authLoggedIn) {
|
||||
return 'Claude CLI is installed but not signed in. Open the Dashboard to sign in.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const pluginProviders = providers.filter((provider) =>
|
||||
isCliExtensionCapabilityMutable(getCliProviderExtensionCapability(provider, 'plugins'))
|
||||
);
|
||||
|
||||
if (pluginProviders.length === 0) {
|
||||
const reason = providers
|
||||
.map((provider) => getCliProviderExtensionCapability(provider, 'plugins').reason)
|
||||
.find((value): value is string => typeof value === 'string' && value.trim().length > 0);
|
||||
return reason ?? 'Plugin installs are not supported by the current runtime.';
|
||||
}
|
||||
|
||||
if (isInstalled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const authenticatedProvider = pluginProviders.find((provider) => provider.authenticated);
|
||||
if (!authenticatedProvider) {
|
||||
return `${pluginProviders[0]?.displayName ?? 'Anthropic'} is not connected. Open the Dashboard to sign in.`;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
|
|||
36
src/shared/utils/mcpScopes.ts
Normal file
36
src/shared/utils/mcpScopes.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import type { CliFlavor } from '@shared/types';
|
||||
import type { InstalledMcpEntry } from '@shared/types/extensions';
|
||||
|
||||
export type McpInstalledScope = InstalledMcpEntry['scope'];
|
||||
export type McpSharedScope = Extract<McpInstalledScope, 'user' | 'global'>;
|
||||
|
||||
export function getDefaultMcpSharedScope(flavor?: CliFlavor | null): McpSharedScope {
|
||||
return flavor === 'agent_teams_orchestrator' ? 'global' : 'user';
|
||||
}
|
||||
|
||||
export function isSharedMcpScope(scope?: string): scope is McpSharedScope {
|
||||
return scope === 'user' || scope === 'global';
|
||||
}
|
||||
|
||||
export function isProjectScopedMcpScope(scope?: string): scope is 'project' | 'local' {
|
||||
return scope === 'project' || scope === 'local';
|
||||
}
|
||||
|
||||
export function isInstalledMcpScope(scope: unknown): scope is McpInstalledScope {
|
||||
return scope === 'user' || scope === 'global' || scope === 'project' || scope === 'local';
|
||||
}
|
||||
|
||||
export function getMcpScopeLabel(scope: McpInstalledScope, flavor?: CliFlavor | null): string {
|
||||
switch (scope) {
|
||||
case 'global':
|
||||
return 'Global';
|
||||
case 'user':
|
||||
return flavor === 'agent_teams_orchestrator' ? 'User (legacy)' : 'User (global)';
|
||||
case 'project':
|
||||
return 'Project';
|
||||
case 'local':
|
||||
return 'Local';
|
||||
default:
|
||||
return scope;
|
||||
}
|
||||
}
|
||||
81
src/shared/utils/providerExtensionCapabilities.ts
Normal file
81
src/shared/utils/providerExtensionCapabilities.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import type {
|
||||
CliExtensionCapabilities,
|
||||
CliExtensionCapability,
|
||||
CliProviderStatus,
|
||||
} from '@shared/types';
|
||||
|
||||
const SUPPORTED_SHARED_CAPABILITY: CliExtensionCapability = {
|
||||
status: 'supported',
|
||||
ownership: 'shared',
|
||||
reason: null,
|
||||
};
|
||||
|
||||
const LEGACY_MULTIMODEL_FALLBACK_CAPABILITIES: CliExtensionCapabilities = {
|
||||
plugins: {
|
||||
status: 'unsupported',
|
||||
ownership: 'shared',
|
||||
reason:
|
||||
'This runtime does not declare plugin capability support. Upgrade the runtime to manage plugins here.',
|
||||
},
|
||||
mcp: {
|
||||
status: 'read-only',
|
||||
ownership: 'shared',
|
||||
reason:
|
||||
'This runtime does not declare MCP management support. Upgrade the runtime to install or remove MCP servers here.',
|
||||
},
|
||||
skills: {
|
||||
...SUPPORTED_SHARED_CAPABILITY,
|
||||
},
|
||||
apiKeys: {
|
||||
...SUPPORTED_SHARED_CAPABILITY,
|
||||
},
|
||||
};
|
||||
|
||||
export function createDefaultCliExtensionCapabilities(
|
||||
overrides?: Partial<CliExtensionCapabilities>
|
||||
): CliExtensionCapabilities {
|
||||
return {
|
||||
plugins: { ...SUPPORTED_SHARED_CAPABILITY },
|
||||
mcp: { ...SUPPORTED_SHARED_CAPABILITY },
|
||||
skills: { ...SUPPORTED_SHARED_CAPABILITY },
|
||||
apiKeys: { ...SUPPORTED_SHARED_CAPABILITY },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function createLegacyRuntimeFallbackCliExtensionCapabilities(
|
||||
overrides?: Partial<CliExtensionCapabilities>
|
||||
): CliExtensionCapabilities {
|
||||
return {
|
||||
plugins: { ...LEGACY_MULTIMODEL_FALLBACK_CAPABILITIES.plugins },
|
||||
mcp: { ...LEGACY_MULTIMODEL_FALLBACK_CAPABILITIES.mcp },
|
||||
skills: { ...LEGACY_MULTIMODEL_FALLBACK_CAPABILITIES.skills },
|
||||
apiKeys: { ...LEGACY_MULTIMODEL_FALLBACK_CAPABILITIES.apiKeys },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCliProviderExtensionCapabilities(
|
||||
provider: Pick<CliProviderStatus, 'capabilities'>
|
||||
): CliExtensionCapabilities {
|
||||
return provider.capabilities.extensions ?? createLegacyRuntimeFallbackCliExtensionCapabilities();
|
||||
}
|
||||
|
||||
export function getCliProviderExtensionCapability(
|
||||
provider: Pick<CliProviderStatus, 'capabilities'>,
|
||||
section: keyof CliExtensionCapabilities
|
||||
): CliExtensionCapability {
|
||||
return getCliProviderExtensionCapabilities(provider)[section];
|
||||
}
|
||||
|
||||
export function isCliExtensionCapabilityAvailable(
|
||||
capability: Pick<CliExtensionCapability, 'status'>
|
||||
): boolean {
|
||||
return capability.status === 'supported' || capability.status === 'read-only';
|
||||
}
|
||||
|
||||
export function isCliExtensionCapabilityMutable(
|
||||
capability: Pick<CliExtensionCapability, 'status'>
|
||||
): boolean {
|
||||
return capability.status === 'supported';
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* Detects rate limit messages from Claude.
|
||||
* Detects rate limit messages from Claude and parses reset time from them.
|
||||
*/
|
||||
|
||||
const RATE_LIMIT_SUBSTRING = "You've hit your limit";
|
||||
|
|
@ -10,3 +10,229 @@ const RATE_LIMIT_SUBSTRING = "You've hit your limit";
|
|||
export function isRateLimitMessage(text: string): boolean {
|
||||
return text.includes(RATE_LIMIT_SUBSTRING);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reset-time parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Maps known Claude timezone abbreviations to fixed UTC offsets in minutes.
|
||||
* We only include zones Claude's API has been observed to emit. When the
|
||||
* message contains an explicit parenthesized timezone that is NOT in this
|
||||
* map, the parser returns `null` rather than guessing. When no timezone is
|
||||
* present at all, the hour:minute is treated as user-local time.
|
||||
*/
|
||||
const TIMEZONE_OFFSETS_MIN: Record<string, number> = {
|
||||
UTC: 0,
|
||||
GMT: 0,
|
||||
// North America — standard times
|
||||
EST: -5 * 60,
|
||||
CST: -6 * 60,
|
||||
MST: -7 * 60,
|
||||
PST: -8 * 60,
|
||||
// North America — daylight times
|
||||
EDT: -4 * 60,
|
||||
CDT: -5 * 60,
|
||||
MDT: -6 * 60,
|
||||
PDT: -7 * 60,
|
||||
};
|
||||
|
||||
/**
|
||||
* Attempts to parse the reset time from a Claude rate-limit message.
|
||||
*
|
||||
* Supported formats (case-insensitive):
|
||||
* - "limit will reset at 3pm (PST)"
|
||||
* - "limit will reset at 3:30 pm (PST)"
|
||||
* - "limit will reset at 15:30 UTC"
|
||||
* - "resets at 3pm" (local time assumed)
|
||||
* - "resets in 2 hours"
|
||||
* - "resets in 45 minutes"
|
||||
*
|
||||
* Returns `null` when the reset time cannot be extracted reliably. Also returns
|
||||
* null for text that does not look like a rate-limit message, so the parser is
|
||||
* safe to call on arbitrary strings.
|
||||
*
|
||||
* @param text the full rate-limit message text
|
||||
* @param now reference "now" used to resolve wall-clock times and relative
|
||||
* offsets (exposed for testability; defaults to `new Date()`)
|
||||
*/
|
||||
export function parseRateLimitResetTime(text: string, now: Date = new Date()): Date | null {
|
||||
if (!text) return null;
|
||||
// Defensive gate: only parse text that actually looks like a rate-limit
|
||||
// message. Prevents false positives from unrelated prose containing
|
||||
// words like "reset" (e.g. "reset the 5pm meeting").
|
||||
if (!isRateLimitMessage(text)) return null;
|
||||
|
||||
const relative = parseRelativeResetDuration(text);
|
||||
if (relative !== null) {
|
||||
return new Date(now.getTime() + relative);
|
||||
}
|
||||
|
||||
return parseAbsoluteResetClockTime(text, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches trailing qualifiers that shift the reset to a different day.
|
||||
* When present, we can't reliably resolve the date without more context, so
|
||||
* the parser bails out. Example: "reset at 3pm (PST) next week" — the naive
|
||||
* "today or tomorrow" rollover would fire in hours instead of a week.
|
||||
*/
|
||||
const DAY_SHIFT_QUALIFIER_RE =
|
||||
/\b(?:next\s+week|next\s+month|tomorrow|yesterday|on\s+(?:mon|tue|wed|thu|fri|sat|sun)[a-z]*)\b/i;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Relative durations: "resets in 2 hours", "resets in 45 minutes"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RESET_VERB_RE = /\breset(?:s|ting)?\b/i;
|
||||
const LEADING_FILLER_RE = /^(?:about|around)\s+/i;
|
||||
const LEADING_TIME_VALUE_RE = /^(\d+(?:\.\d+)?)\s*([a-z]+)\b/i;
|
||||
|
||||
function parseRelativeResetDuration(text: string): number | null {
|
||||
const resetVerbMatch = RESET_VERB_RE.exec(text);
|
||||
if (!resetVerbMatch) return null;
|
||||
|
||||
const afterVerb = text.slice(resetVerbMatch.index + resetVerbMatch[0].length).trimStart();
|
||||
if (!afterVerb.toLowerCase().startsWith('in')) return null;
|
||||
|
||||
let tail = afterVerb.slice(2).trimStart();
|
||||
if (tail.startsWith('~')) {
|
||||
tail = tail.slice(1).trimStart();
|
||||
}
|
||||
tail = tail.replace(LEADING_FILLER_RE, '');
|
||||
|
||||
const match = LEADING_TIME_VALUE_RE.exec(tail);
|
||||
if (!match) return null;
|
||||
|
||||
const amount = Number.parseFloat(match[1]!);
|
||||
if (!Number.isFinite(amount) || amount < 0) return null;
|
||||
|
||||
const unit = match[2]!.toLowerCase();
|
||||
if (['second', 'seconds', 'sec', 'secs', 's'].includes(unit)) {
|
||||
return Math.round(amount * 1000);
|
||||
}
|
||||
if (['minute', 'minutes', 'min', 'mins', 'm'].includes(unit)) {
|
||||
return Math.round(amount * 60 * 1000);
|
||||
}
|
||||
if (['hour', 'hours', 'hr', 'hrs', 'h'].includes(unit)) {
|
||||
return Math.round(amount * 60 * 60 * 1000);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Absolute clock times: "resets at 3pm (PST)", "resets at 15:30 UTC"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Captures the clock time + optional timezone abbreviation from phrases like
|
||||
* "reset at 3pm (PST)" or "resets at 15:30 UTC".
|
||||
*/
|
||||
const LEADING_CLOCK_RE = /^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\b/i;
|
||||
const PAREN_TZ_RE = /^\(([A-Za-z]{2,5})\)/;
|
||||
const TRAILING_TZ_RE = /^([A-Za-z]{2,5})\b/;
|
||||
|
||||
function parseAbsoluteResetClockTime(text: string, now: Date): Date | null {
|
||||
const resetVerbMatch = RESET_VERB_RE.exec(text);
|
||||
if (!resetVerbMatch) return null;
|
||||
|
||||
let tail = text.slice(resetVerbMatch.index + resetVerbMatch[0].length).trimStart();
|
||||
if (tail.toLowerCase().startsWith('at ')) {
|
||||
tail = tail.slice(3).trimStart();
|
||||
}
|
||||
|
||||
const match = LEADING_CLOCK_RE.exec(tail);
|
||||
if (!match) return null;
|
||||
|
||||
tail = tail.slice(match[0].length).trimStart();
|
||||
const parenthesizedTzMatch = PAREN_TZ_RE.exec(tail);
|
||||
const bareWordMatch = parenthesizedTzMatch ? null : TRAILING_TZ_RE.exec(tail);
|
||||
const bareTzMatch =
|
||||
bareWordMatch && bareWordMatch[1].toUpperCase() in TIMEZONE_OFFSETS_MIN ? bareWordMatch : null;
|
||||
const tzTokenLength = parenthesizedTzMatch?.[0].length ?? bareTzMatch?.[0].length ?? 0;
|
||||
|
||||
// If the text contains a day-shift qualifier ("next week", "on Tuesday",
|
||||
// etc.), the "today or tomorrow" rollover below would produce a materially
|
||||
// wrong time. Bail out and let the caller fall back to no auto-resume.
|
||||
const afterMatch = tail.slice(tzTokenLength);
|
||||
if (DAY_SHIFT_QUALIFIER_RE.test(afterMatch)) return null;
|
||||
|
||||
const hourRaw = Number.parseInt(match[1]!, 10);
|
||||
const minuteRaw = match[2] ? Number.parseInt(match[2], 10) : 0;
|
||||
const ampm = match[3]?.toLowerCase() ?? null;
|
||||
const parenthesizedTz = parenthesizedTzMatch?.[1]?.toUpperCase() ?? '';
|
||||
const trailingTz = bareTzMatch?.[1]?.toUpperCase() ?? '';
|
||||
|
||||
if (!Number.isFinite(hourRaw) || !Number.isFinite(minuteRaw)) return null;
|
||||
if (minuteRaw < 0 || minuteRaw > 59) return null;
|
||||
|
||||
let hour = hourRaw;
|
||||
if (ampm === 'pm' && hour < 12) hour += 12;
|
||||
else if (ampm === 'am' && hour === 12) hour = 0;
|
||||
|
||||
if (hour < 0 || hour > 23) return null;
|
||||
|
||||
// Timezone resolution treats parenthesized vs bare tokens differently.
|
||||
//
|
||||
// "reset at 3pm (PST)" — parenthesized, authoritative. Unknown zone
|
||||
// here means the sender meant a specific zone
|
||||
// we don't model; bail out rather than guess.
|
||||
// "reset at 3pm PST" — bare known abbreviation, same effect.
|
||||
// "reset at 3pm today" — bare unknown word ("TODAY"). This is just a
|
||||
// trailing word, not a real TZ claim; fall
|
||||
// back to local time instead of suppressing.
|
||||
// "reset at 3pm" — no token. Treat as user-local.
|
||||
let tzOffset: number | null;
|
||||
if (parenthesizedTz) {
|
||||
if (!(parenthesizedTz in TIMEZONE_OFFSETS_MIN)) return null;
|
||||
tzOffset = TIMEZONE_OFFSETS_MIN[parenthesizedTz]!;
|
||||
} else if (trailingTz && trailingTz in TIMEZONE_OFFSETS_MIN) {
|
||||
tzOffset = TIMEZONE_OFFSETS_MIN[trailingTz]!;
|
||||
} else {
|
||||
tzOffset = null;
|
||||
}
|
||||
|
||||
const candidateSeed =
|
||||
tzOffset === null
|
||||
? buildLocalToday(now, hour, minuteRaw)
|
||||
: buildUtcTodayWithOffset(now, hour, minuteRaw, tzOffset);
|
||||
let candidate: Date = candidateSeed;
|
||||
|
||||
// If the computed time is materially in the past (e.g. "3pm" parsed while
|
||||
// it's already 4pm), roll forward by one day. A small tolerance prevents
|
||||
// near-present timestamps — stale messages, clock skew, sub-second drift —
|
||||
// from being bumped 24 h forward, which would then trip the scheduler's
|
||||
// 12 h ceiling and silently drop auto-resume altogether. Timestamps within
|
||||
// `ROLLOVER_TOLERANCE_MS` of now fire immediately after the scheduler's
|
||||
// own 30 s buffer and `Math.max(0, rawDelayMs)` clamp.
|
||||
if (candidate.getTime() <= now.getTime() - ROLLOVER_TOLERANCE_MS) {
|
||||
candidate = new Date(candidate.getTime() + 24 * 60 * 60 * 1000);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
const ROLLOVER_TOLERANCE_MS = 60 * 1000;
|
||||
|
||||
function buildLocalToday(now: Date, hour: number, minute: number): Date {
|
||||
const d = new Date(now);
|
||||
d.setHours(hour, minute, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
function buildUtcTodayWithOffset(
|
||||
now: Date,
|
||||
hour: number,
|
||||
minute: number,
|
||||
offsetMinutes: number
|
||||
): Date {
|
||||
// The caller's "hour:minute" is expressed in the target zone. Anchor the
|
||||
// calendar date in that zone too — not in UTC — otherwise we get a 24h
|
||||
// error when the zone-local day differs from UTC's day (e.g. 01:00 UTC is
|
||||
// still "yesterday" for any negative-offset zone like PST).
|
||||
const zoned = new Date(now.getTime() + offsetMinutes * 60 * 1000);
|
||||
const offsetMs = offsetMinutes * 60 * 1000;
|
||||
return new Date(
|
||||
Date.UTC(zoned.getUTCFullYear(), zoned.getUTCMonth(), zoned.getUTCDate(), hour, minute, 0, 0) -
|
||||
offsetMs
|
||||
);
|
||||
}
|
||||
|
|
|
|||
84
src/shared/utils/skillRoots.ts
Normal file
84
src/shared/utils/skillRoots.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import {
|
||||
getCliProviderExtensionCapability,
|
||||
isCliExtensionCapabilityAvailable,
|
||||
} from './providerExtensionCapabilities';
|
||||
|
||||
import type { TeamProviderId } from '@shared/types';
|
||||
import type { CliInstallationStatus } from '@shared/types';
|
||||
import type { SkillRootKind } from '@shared/types/extensions';
|
||||
|
||||
export type SkillAudience = 'shared' | 'codex';
|
||||
|
||||
export interface SkillRootDefinition {
|
||||
rootKind: SkillRootKind;
|
||||
directoryName: `.${string}`;
|
||||
segments: [string, 'skills'];
|
||||
audience: SkillAudience;
|
||||
}
|
||||
|
||||
export const SKILL_ROOT_DEFINITIONS: readonly SkillRootDefinition[] = [
|
||||
{
|
||||
rootKind: 'claude',
|
||||
directoryName: '.claude',
|
||||
segments: ['.claude', 'skills'],
|
||||
audience: 'shared',
|
||||
},
|
||||
{
|
||||
rootKind: 'cursor',
|
||||
directoryName: '.cursor',
|
||||
segments: ['.cursor', 'skills'],
|
||||
audience: 'shared',
|
||||
},
|
||||
{
|
||||
rootKind: 'agents',
|
||||
directoryName: '.agents',
|
||||
segments: ['.agents', 'skills'],
|
||||
audience: 'shared',
|
||||
},
|
||||
{
|
||||
rootKind: 'codex',
|
||||
directoryName: '.codex',
|
||||
segments: ['.codex', 'skills'],
|
||||
audience: 'codex',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function getSkillRootDefinition(rootKind: SkillRootKind): SkillRootDefinition {
|
||||
return SKILL_ROOT_DEFINITIONS.find((definition) => definition.rootKind === rootKind)!;
|
||||
}
|
||||
|
||||
export function formatSkillRootKind(rootKind: SkillRootKind): string {
|
||||
return getSkillRootDefinition(rootKind).directoryName;
|
||||
}
|
||||
|
||||
export function getSkillAudience(rootKind: SkillRootKind): SkillAudience {
|
||||
return getSkillRootDefinition(rootKind).audience;
|
||||
}
|
||||
|
||||
export function getSkillAudienceLabel(rootKind: SkillRootKind): string {
|
||||
return getSkillAudience(rootKind) === 'codex' ? 'Codex only' : 'Shared';
|
||||
}
|
||||
|
||||
export function isSkillAvailableForProvider(
|
||||
rootKind: SkillRootKind,
|
||||
providerId?: TeamProviderId
|
||||
): boolean {
|
||||
return getSkillAudience(rootKind) === 'shared' || providerId === 'codex';
|
||||
}
|
||||
|
||||
export function isCodexSkillOverlayAvailable(
|
||||
cliStatus: Pick<CliInstallationStatus, 'flavor' | 'providers'> | null | undefined
|
||||
): boolean {
|
||||
if (cliStatus?.flavor !== 'agent_teams_orchestrator') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const codexProvider = cliStatus.providers.find((provider) => provider.providerId === 'codex');
|
||||
if (!codexProvider?.supported) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isCliExtensionCapabilityAvailable(
|
||||
getCliProviderExtensionCapability(codexProvider, 'skills')
|
||||
);
|
||||
}
|
||||
|
|
@ -115,6 +115,7 @@ describe('configValidation', () => {
|
|||
'notifyOnClarifications',
|
||||
'notifyOnStatusChange',
|
||||
'notifyOnTeamLaunched',
|
||||
'autoResumeOnRateLimit',
|
||||
'statusChangeOnlySolo',
|
||||
] as const)('accepts boolean %s toggle', (key) => {
|
||||
const resultOn = validateConfigUpdatePayload('notifications', { [key]: true });
|
||||
|
|
@ -136,6 +137,7 @@ describe('configValidation', () => {
|
|||
'notifyOnClarifications',
|
||||
'notifyOnStatusChange',
|
||||
'notifyOnTeamLaunched',
|
||||
'autoResumeOnRateLimit',
|
||||
'statusChangeOnlySolo',
|
||||
] as const)('rejects non-boolean %s', (key) => {
|
||||
const result = validateConfigUpdatePayload('notifications', { [key]: 'yes' });
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import * as os from 'os';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type {
|
||||
BoardTaskActivityDetailResult,
|
||||
BoardTaskActivityEntry,
|
||||
|
|
@ -7,6 +7,8 @@ import type {
|
|||
BoardTaskExactLogDetailResult,
|
||||
BoardTaskExactLogSummariesResponse,
|
||||
InboxMessage,
|
||||
MessagesPage,
|
||||
TeamViewSnapshot,
|
||||
TeamCreateRequest,
|
||||
TeamProvisioningProgress,
|
||||
} from '@shared/types/team';
|
||||
|
|
@ -120,6 +122,7 @@ import {
|
|||
registerTeamHandlers,
|
||||
removeTeamHandlers,
|
||||
} from '../../../src/main/ipc/teams';
|
||||
import { ConfigManager } from '../../../src/main/services/infrastructure/ConfigManager';
|
||||
|
||||
describe('ipc teams handlers', () => {
|
||||
const handlers = new Map<string, (...args: unknown[]) => Promise<unknown>>();
|
||||
|
|
@ -134,7 +137,7 @@ describe('ipc teams handlers', () => {
|
|||
|
||||
const service = {
|
||||
listTeams: vi.fn(async () => [{ teamName: 'my-team', displayName: 'My Team' }]),
|
||||
getTeamData: vi.fn(async () => ({
|
||||
getTeamData: vi.fn(async (): Promise<TeamViewSnapshot & { messages?: InboxMessage[] }> => ({
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team' },
|
||||
tasks: [],
|
||||
|
|
@ -147,7 +150,7 @@ describe('ipc teams handlers', () => {
|
|||
feedRevision: 'rev-1',
|
||||
messages: [] as InboxMessage[],
|
||||
})),
|
||||
getMessagesPage: vi.fn(async () => ({
|
||||
getMessagesPage: vi.fn(async (..._args: unknown[]): Promise<MessagesPage> => ({
|
||||
messages: [] as InboxMessage[],
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
|
|
@ -216,10 +219,12 @@ describe('ipc teams handlers', () => {
|
|||
launchTeam: vi.fn(async () => ({ runId: 'run-2' })),
|
||||
sendMessageToTeam: vi.fn(async () => undefined),
|
||||
isTeamAlive: vi.fn(() => true),
|
||||
getCurrentRunId: vi.fn(() => 'run-2' as string | null),
|
||||
pushLiveLeadProcessMessage: vi.fn(),
|
||||
relayLeadInboxMessages: vi.fn(async () => 0),
|
||||
relayMemberInboxMessages: vi.fn(async () => 0),
|
||||
getLiveLeadProcessMessages: vi.fn(() => [] as InboxMessage[]),
|
||||
getCurrentLeadSessionId: vi.fn(() => null as string | null),
|
||||
getAliveTeams: vi.fn(() => ['my-team']),
|
||||
getLeadActivityState: vi.fn(() => 'idle'),
|
||||
stopTeam: vi.fn(() => undefined),
|
||||
|
|
@ -275,6 +280,10 @@ describe('ipc teams handlers', () => {
|
|||
registerTeamHandlers(ipcMain as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('registers all expected handlers', () => {
|
||||
expect(handlers.has(TEAM_LIST)).toBe(true);
|
||||
expect(handlers.has(TEAM_GET_DATA)).toBe(true);
|
||||
|
|
@ -828,6 +837,81 @@ describe('ipc teams handlers', () => {
|
|||
(electron.app as { isPackaged: boolean }).isPackaged = false;
|
||||
});
|
||||
|
||||
it('does not let a live duplicate of the same session rate-limit reply delay auto-resume', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-17T12:00:30.000Z'));
|
||||
const configManager = ConfigManager.getInstance();
|
||||
const actualConfig = configManager.getConfig();
|
||||
const getConfigSpy = vi.spyOn(configManager, 'getConfig').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
...actualConfig,
|
||||
notifications: {
|
||||
...actualConfig.notifications,
|
||||
autoResumeOnRateLimit: true,
|
||||
},
|
||||
}) as never
|
||||
);
|
||||
|
||||
try {
|
||||
provisioningService.isTeamAlive.mockReturnValue(true);
|
||||
provisioningService.getCurrentLeadSessionId.mockReturnValue('sess-123');
|
||||
provisioningService.sendMessageToTeam.mockResolvedValue(undefined);
|
||||
service.getTeamData.mockResolvedValueOnce({
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team' },
|
||||
tasks: [],
|
||||
members: [],
|
||||
messages: [
|
||||
{
|
||||
from: 'team-lead',
|
||||
text: "You've hit your limit. Resets in 5 minutes.",
|
||||
timestamp: '2026-04-17T12:00:00.000Z',
|
||||
read: true,
|
||||
source: 'lead_session' as const,
|
||||
messageId: 'persisted-rate-limit-1',
|
||||
leadSessionId: 'sess-123',
|
||||
},
|
||||
],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
});
|
||||
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
|
||||
{
|
||||
from: 'team-lead',
|
||||
text: "You've hit your limit. Resets in 5 minutes.",
|
||||
timestamp: '2026-04-17T12:00:02.000Z',
|
||||
read: true,
|
||||
source: 'lead_process' as const,
|
||||
messageId: 'live-rate-limit-1',
|
||||
leadSessionId: 'sess-123',
|
||||
},
|
||||
]);
|
||||
|
||||
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
||||
const result = (await getDataHandler({} as never, 'my-team')) as {
|
||||
success: boolean;
|
||||
data: { messages?: InboxMessage[] };
|
||||
};
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.messages).toEqual([
|
||||
expect.objectContaining({
|
||||
source: 'lead_session',
|
||||
messageId: 'persisted-rate-limit-1',
|
||||
}),
|
||||
]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4 * 60 * 1000 + 59 * 1000);
|
||||
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1100);
|
||||
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
getConfigSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the team-data worker for TEAM_GET_MESSAGES_PAGE when available', async () => {
|
||||
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
|
||||
mockTeamDataWorkerClient.getMessagesPage.mockResolvedValueOnce({
|
||||
|
|
@ -965,6 +1049,552 @@ describe('ipc teams handlers', () => {
|
|||
(electron.app as { isPackaged: boolean }).isPackaged = false;
|
||||
});
|
||||
|
||||
it('rebuilds only the remaining auto-resume delay from persisted rate-limit history', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-17T12:02:00.000Z'));
|
||||
const configManager = ConfigManager.getInstance();
|
||||
const actualConfig = configManager.getConfig();
|
||||
const getConfigSpy = vi.spyOn(configManager, 'getConfig').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
...actualConfig,
|
||||
notifications: {
|
||||
...actualConfig.notifications,
|
||||
autoResumeOnRateLimit: true,
|
||||
},
|
||||
}) as never
|
||||
);
|
||||
|
||||
try {
|
||||
provisioningService.isTeamAlive.mockReturnValue(true);
|
||||
provisioningService.getCurrentLeadSessionId.mockReturnValue('sess-live');
|
||||
provisioningService.sendMessageToTeam.mockResolvedValue(undefined);
|
||||
service.getTeamData.mockResolvedValueOnce({
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team' },
|
||||
tasks: [],
|
||||
members: [],
|
||||
messages: [
|
||||
{
|
||||
from: 'team-lead',
|
||||
text: "You've hit your limit. Resets in 5 minutes.",
|
||||
timestamp: '2026-04-17T12:00:00.000Z',
|
||||
read: true,
|
||||
source: 'lead_session' as const,
|
||||
leadSessionId: 'sess-live',
|
||||
messageId: 'rate-limit-1',
|
||||
},
|
||||
],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
});
|
||||
|
||||
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
||||
const result = (await getDataHandler({} as never, 'my-team')) as {
|
||||
success: boolean;
|
||||
data: { messages: { source?: string; text: string }[] };
|
||||
};
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3 * 60 * 1000 + 29 * 1000);
|
||||
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1100);
|
||||
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
getConfigSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('can schedule auto-resume when the setting is enabled after an earlier history scan', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-17T12:02:00.000Z'));
|
||||
const configManager = ConfigManager.getInstance();
|
||||
const actualConfig = configManager.getConfig();
|
||||
let autoResumeEnabled = false;
|
||||
const getConfigSpy = vi.spyOn(configManager, 'getConfig').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
...actualConfig,
|
||||
notifications: {
|
||||
...actualConfig.notifications,
|
||||
autoResumeOnRateLimit: autoResumeEnabled,
|
||||
},
|
||||
}) as never
|
||||
);
|
||||
|
||||
try {
|
||||
provisioningService.isTeamAlive.mockReturnValue(true);
|
||||
provisioningService.getCurrentLeadSessionId.mockReturnValue('sess-live');
|
||||
provisioningService.sendMessageToTeam.mockResolvedValue(undefined);
|
||||
service.getTeamData.mockResolvedValue({
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team' },
|
||||
tasks: [],
|
||||
members: [],
|
||||
messages: [
|
||||
{
|
||||
from: 'team-lead',
|
||||
text: "You've hit your limit. Resets in 5 minutes.",
|
||||
timestamp: '2026-04-17T12:00:00.000Z',
|
||||
read: true,
|
||||
source: 'lead_session' as const,
|
||||
leadSessionId: 'sess-live',
|
||||
messageId: 'rate-limit-enable-later',
|
||||
},
|
||||
],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
});
|
||||
|
||||
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
||||
|
||||
const firstResult = (await getDataHandler({} as never, 'my-team')) as {
|
||||
success: boolean;
|
||||
};
|
||||
expect(firstResult.success).toBe(true);
|
||||
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
|
||||
|
||||
autoResumeEnabled = true;
|
||||
|
||||
const secondResult = (await getDataHandler({} as never, 'my-team')) as {
|
||||
success: boolean;
|
||||
};
|
||||
expect(secondResult.success).toBe(true);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3 * 60 * 1000 + 29 * 1000);
|
||||
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1100);
|
||||
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
getConfigSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('retries a previously over-ceiling history message once it becomes schedulable', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-17T00:00:00.000Z'));
|
||||
const configManager = ConfigManager.getInstance();
|
||||
const actualConfig = configManager.getConfig();
|
||||
const getConfigSpy = vi.spyOn(configManager, 'getConfig').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
...actualConfig,
|
||||
notifications: {
|
||||
...actualConfig.notifications,
|
||||
autoResumeOnRateLimit: true,
|
||||
},
|
||||
}) as never
|
||||
);
|
||||
|
||||
try {
|
||||
provisioningService.isTeamAlive.mockReturnValue(true);
|
||||
provisioningService.getCurrentLeadSessionId.mockReturnValue('sess-live');
|
||||
provisioningService.sendMessageToTeam.mockResolvedValue(undefined);
|
||||
service.getTeamData.mockResolvedValue({
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team' },
|
||||
tasks: [],
|
||||
members: [],
|
||||
messages: [
|
||||
{
|
||||
from: 'team-lead',
|
||||
text: "You've hit your limit. Resets at 12:20 UTC.",
|
||||
timestamp: '2026-04-17T00:00:00.000Z',
|
||||
read: true,
|
||||
source: 'lead_session' as const,
|
||||
leadSessionId: 'sess-live',
|
||||
messageId: 'rate-limit-over-ceiling',
|
||||
},
|
||||
],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
});
|
||||
|
||||
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
||||
|
||||
const firstResult = (await getDataHandler({} as never, 'my-team')) as {
|
||||
success: boolean;
|
||||
};
|
||||
expect(firstResult.success).toBe(true);
|
||||
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
|
||||
|
||||
vi.setSystemTime(new Date('2026-04-17T12:20:00.000Z'));
|
||||
|
||||
const secondResult = (await getDataHandler({} as never, 'my-team')) as {
|
||||
success: boolean;
|
||||
};
|
||||
expect(secondResult.success).toBe(true);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(29 * 1000);
|
||||
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1500);
|
||||
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
getConfigSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not rebuild auto-resume from persisted history while the team is offline', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-17T12:02:00.000Z'));
|
||||
const configManager = ConfigManager.getInstance();
|
||||
const actualConfig = configManager.getConfig();
|
||||
const getConfigSpy = vi.spyOn(configManager, 'getConfig').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
...actualConfig,
|
||||
notifications: {
|
||||
...actualConfig.notifications,
|
||||
autoResumeOnRateLimit: true,
|
||||
},
|
||||
}) as never
|
||||
);
|
||||
|
||||
try {
|
||||
provisioningService.isTeamAlive.mockReturnValue(false);
|
||||
provisioningService.sendMessageToTeam.mockResolvedValue(undefined);
|
||||
service.getTeamData.mockResolvedValue({
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team' },
|
||||
tasks: [],
|
||||
members: [],
|
||||
messages: [
|
||||
{
|
||||
from: 'team-lead',
|
||||
text: "You've hit your limit. Resets in 5 minutes.",
|
||||
timestamp: '2026-04-17T12:00:00.000Z',
|
||||
read: true,
|
||||
source: 'lead_session' as const,
|
||||
messageId: 'rate-limit-offline-history',
|
||||
},
|
||||
],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
});
|
||||
|
||||
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
||||
const result = (await getDataHandler({} as never, 'my-team')) as {
|
||||
success: boolean;
|
||||
};
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Simulate the user manually starting a fresh run later; stale persisted history
|
||||
// should not have armed an auto-resume timer while the team was offline.
|
||||
provisioningService.isTeamAlive.mockReturnValue(true);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3 * 60 * 1000 + 31 * 1000);
|
||||
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
getConfigSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not rebuild auto-resume from an older lead session after the team was manually restarted', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-17T12:02:00.000Z'));
|
||||
const configManager = ConfigManager.getInstance();
|
||||
const actualConfig = configManager.getConfig();
|
||||
const getConfigSpy = vi.spyOn(configManager, 'getConfig').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
...actualConfig,
|
||||
notifications: {
|
||||
...actualConfig.notifications,
|
||||
autoResumeOnRateLimit: true,
|
||||
},
|
||||
}) as never
|
||||
);
|
||||
|
||||
try {
|
||||
provisioningService.isTeamAlive.mockReturnValue(true);
|
||||
provisioningService.getCurrentLeadSessionId.mockReturnValue('sess-new');
|
||||
provisioningService.sendMessageToTeam.mockResolvedValue(undefined);
|
||||
service.getTeamData.mockResolvedValue({
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team' },
|
||||
tasks: [],
|
||||
members: [],
|
||||
messages: [
|
||||
{
|
||||
from: 'team-lead',
|
||||
text: "You've hit your limit. Resets in 5 minutes.",
|
||||
timestamp: '2026-04-17T12:00:00.000Z',
|
||||
read: true,
|
||||
source: 'lead_session' as const,
|
||||
leadSessionId: 'sess-old',
|
||||
messageId: 'rate-limit-old-session',
|
||||
},
|
||||
],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
});
|
||||
|
||||
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
||||
const result = (await getDataHandler({} as never, 'my-team')) as {
|
||||
success: boolean;
|
||||
};
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3 * 60 * 1000 + 31 * 1000);
|
||||
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
getConfigSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not arm lead auto-resume from a teammate inbox rate-limit message', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-17T12:02:00.000Z'));
|
||||
const configManager = ConfigManager.getInstance();
|
||||
const actualConfig = configManager.getConfig();
|
||||
const getConfigSpy = vi.spyOn(configManager, 'getConfig').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
...actualConfig,
|
||||
notifications: {
|
||||
...actualConfig.notifications,
|
||||
autoResumeOnRateLimit: true,
|
||||
},
|
||||
}) as never
|
||||
);
|
||||
|
||||
try {
|
||||
provisioningService.isTeamAlive.mockReturnValue(true);
|
||||
provisioningService.getCurrentLeadSessionId.mockReturnValue('sess-live');
|
||||
provisioningService.sendMessageToTeam.mockResolvedValue(undefined);
|
||||
service.getTeamData.mockResolvedValue({
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team' },
|
||||
tasks: [],
|
||||
members: [],
|
||||
messages: [
|
||||
{
|
||||
from: 'alice',
|
||||
to: 'team-lead',
|
||||
text: "You've hit your limit. Resets in 5 minutes.",
|
||||
timestamp: '2026-04-17T12:00:00.000Z',
|
||||
read: false,
|
||||
messageId: 'member-rate-limit-1',
|
||||
},
|
||||
],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
});
|
||||
|
||||
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
||||
const result = (await getDataHandler({} as never, 'my-team')) as {
|
||||
success: boolean;
|
||||
};
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3 * 60 * 1000 + 31 * 1000);
|
||||
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
getConfigSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('rebuilds capped newest messages through getMessagesPage so live duplicates do not leak back in', async () => {
|
||||
service.getTeamData.mockResolvedValueOnce({
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team' },
|
||||
tasks: [],
|
||||
members: [],
|
||||
messages: Array.from({ length: 50 }, (_, index) => ({
|
||||
from: 'alice',
|
||||
text: `filler-${index}`,
|
||||
timestamp: `2026-02-23T10:${String(index).padStart(2, '0')}:00.000Z`,
|
||||
read: true,
|
||||
source: 'inbox' as const,
|
||||
messageId: `durable-${index}`,
|
||||
})),
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
});
|
||||
service.getMessagesPage.mockResolvedValueOnce({
|
||||
messages: [
|
||||
{
|
||||
from: 'alice',
|
||||
text: 'filler-0',
|
||||
timestamp: '2026-02-23T10:00:00.000Z',
|
||||
read: true,
|
||||
source: 'inbox' as const,
|
||||
messageId: 'durable-0',
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
feedRevision: 'rev-1',
|
||||
});
|
||||
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
|
||||
{
|
||||
from: 'team-lead',
|
||||
text: 'Already persisted thought',
|
||||
timestamp: '2026-02-23T11:00:00.000Z',
|
||||
read: true,
|
||||
source: 'lead_process' as const,
|
||||
messageId: 'live-dup',
|
||||
leadSessionId: 'lead-1',
|
||||
},
|
||||
]);
|
||||
|
||||
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
||||
const result = (await getDataHandler({} as never, 'my-team')) as {
|
||||
success: boolean;
|
||||
data: { messages?: InboxMessage[] };
|
||||
};
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(service.getMessagesPage).toHaveBeenCalledWith('my-team', {
|
||||
limit: 50,
|
||||
liveMessages: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
messageId: 'live-dup',
|
||||
source: 'lead_process',
|
||||
}),
|
||||
]),
|
||||
});
|
||||
expect(result.data.messages).toHaveLength(50);
|
||||
});
|
||||
|
||||
it('overlays live lead_process messages onto the newest messages page', async () => {
|
||||
service.getMessagesPage.mockImplementationOnce(async (...args: unknown[]) => {
|
||||
const { liveMessages = [] } = (args[1] ?? {}) as { liveMessages?: InboxMessage[] };
|
||||
return {
|
||||
messages: [
|
||||
{
|
||||
from: 'user',
|
||||
text: 'Ping',
|
||||
timestamp: '2026-02-23T10:00:00.000Z',
|
||||
read: true,
|
||||
source: 'user_sent' as const,
|
||||
messageId: 'durable-1',
|
||||
},
|
||||
...liveMessages,
|
||||
].sort((left, right) => Date.parse(right.timestamp) - Date.parse(left.timestamp)),
|
||||
nextCursor: '2026-02-23T10:00:00.000Z|durable-1',
|
||||
hasMore: true,
|
||||
feedRevision: 'rev-1',
|
||||
} satisfies MessagesPage;
|
||||
});
|
||||
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
|
||||
{
|
||||
from: 'team-lead',
|
||||
text: 'Команда поднята, приступаю к раздаче задач.',
|
||||
timestamp: '2026-02-23T10:00:01.000Z',
|
||||
read: true,
|
||||
source: 'lead_process' as const,
|
||||
messageId: 'live-1',
|
||||
},
|
||||
]);
|
||||
|
||||
const result = (await handlers.get(TEAM_GET_MESSAGES_PAGE)!({} as never, 'my-team', {
|
||||
limit: 20,
|
||||
})) as {
|
||||
success: boolean;
|
||||
data: { messages: InboxMessage[]; nextCursor: string | null; hasMore: boolean };
|
||||
};
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.messages).toHaveLength(2);
|
||||
expect(result.data.messages[0]?.source).toBe('lead_process');
|
||||
expect(result.data.messages[0]?.text).toBe('Команда поднята, приступаю к раздаче задач.');
|
||||
expect(result.data.nextCursor).toBe('2026-02-23T10:00:00.000Z|durable-1');
|
||||
expect(result.data.hasMore).toBe(true);
|
||||
expect(service.getMessagesPage).toHaveBeenCalledWith('my-team', {
|
||||
limit: 20,
|
||||
cursor: undefined,
|
||||
liveMessages: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
source: 'lead_process',
|
||||
messageId: 'live-1',
|
||||
}),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it('dedups live lead thoughts on the newest messages page when durable lead_session already exists', async () => {
|
||||
service.getMessagesPage.mockImplementationOnce(async (...args: unknown[]) => {
|
||||
const { liveMessages = [] } = (args[1] ?? {}) as { liveMessages?: InboxMessage[] };
|
||||
expect(liveMessages).toHaveLength(1);
|
||||
return {
|
||||
messages: [
|
||||
{
|
||||
from: 'team-lead',
|
||||
text: 'Hello there',
|
||||
timestamp: '2026-02-23T10:00:00.000Z',
|
||||
read: true,
|
||||
source: 'lead_session' as const,
|
||||
leadSessionId: 'lead-1',
|
||||
messageId: 'durable-1',
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
feedRevision: 'rev-1',
|
||||
} satisfies MessagesPage;
|
||||
});
|
||||
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
|
||||
{
|
||||
from: 'team-lead',
|
||||
text: 'Hello there',
|
||||
timestamp: '2026-02-23T10:00:01.000Z',
|
||||
read: true,
|
||||
source: 'lead_process' as const,
|
||||
leadSessionId: 'lead-1',
|
||||
messageId: 'live-1',
|
||||
},
|
||||
]);
|
||||
|
||||
const result = (await handlers.get(TEAM_GET_MESSAGES_PAGE)!({} as never, 'my-team', {
|
||||
limit: 20,
|
||||
})) as {
|
||||
success: boolean;
|
||||
data: { messages: InboxMessage[] };
|
||||
};
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.messages).toHaveLength(1);
|
||||
expect(result.data.messages[0]?.source).toBe('lead_session');
|
||||
});
|
||||
|
||||
it('does not overlay live lead_process messages onto older paginated pages', async () => {
|
||||
service.getMessagesPage.mockResolvedValueOnce({
|
||||
messages: [
|
||||
{
|
||||
from: 'user',
|
||||
text: 'Older durable message',
|
||||
timestamp: '2026-02-23T09:59:00.000Z',
|
||||
read: true,
|
||||
source: 'user_sent' as const,
|
||||
messageId: 'durable-older-1',
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
feedRevision: 'rev-1',
|
||||
});
|
||||
|
||||
const result = (await handlers.get(TEAM_GET_MESSAGES_PAGE)!({} as never, 'my-team', {
|
||||
limit: 20,
|
||||
cursor: '2026-02-23T10:00:00.000Z|cursor',
|
||||
})) as {
|
||||
success: boolean;
|
||||
data: { messages: InboxMessage[] };
|
||||
};
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(provisioningService.getLiveLeadProcessMessages).not.toHaveBeenCalled();
|
||||
expect(result.data.messages).toHaveLength(1);
|
||||
expect(result.data.messages[0]?.messageId).toBe('durable-older-1');
|
||||
});
|
||||
|
||||
it('keeps TEAM_GET_DATA read-only and never triggers reconcile side effects', async () => {
|
||||
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
||||
const result = (await getDataHandler({} as never, 'my-team')) as {
|
||||
|
|
|
|||
120
test/main/services/extensions/ApiKeyService.test.ts
Normal file
120
test/main/services/extensions/ApiKeyService.test.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: vi.fn(() => false),
|
||||
getSelectedStorageBackend: vi.fn(() => 'basic_text'),
|
||||
encryptString: vi.fn(),
|
||||
decryptString: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { ApiKeyService } from '@main/services/extensions/apikeys/ApiKeyService';
|
||||
|
||||
describe('ApiKeyService', () => {
|
||||
let tempDir: string;
|
||||
let service: ApiKeyService;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apikey-service-'));
|
||||
service = new ApiKeyService(tempDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('persists projectPath for project-scoped API keys', async () => {
|
||||
const saved = await service.save({
|
||||
name: 'Project Tavily',
|
||||
envVarName: 'TAVILY_API_KEY',
|
||||
value: 'secret',
|
||||
scope: 'project',
|
||||
projectPath: '/tmp/project-a',
|
||||
});
|
||||
|
||||
expect(saved.scope).toBe('project');
|
||||
expect(saved.projectPath).toBe('/tmp/project-a');
|
||||
|
||||
await expect(service.list()).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
scope: 'project',
|
||||
projectPath: '/tmp/project-a',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects project-scoped keys without a project path', async () => {
|
||||
await expect(
|
||||
service.save({
|
||||
name: 'Broken key',
|
||||
envVarName: 'TAVILY_API_KEY',
|
||||
value: 'secret',
|
||||
scope: 'project',
|
||||
})
|
||||
).rejects.toThrow('project path');
|
||||
});
|
||||
|
||||
it('prefers exact project matches over user keys during lookup', async () => {
|
||||
await service.save({
|
||||
name: 'Shared Tavily',
|
||||
envVarName: 'TAVILY_API_KEY',
|
||||
value: 'user-secret',
|
||||
scope: 'user',
|
||||
});
|
||||
await service.save({
|
||||
name: 'Project Tavily',
|
||||
envVarName: 'TAVILY_API_KEY',
|
||||
value: 'project-secret',
|
||||
scope: 'project',
|
||||
projectPath: '/tmp/project-a',
|
||||
});
|
||||
|
||||
await expect(service.lookup(['TAVILY_API_KEY'], '/tmp/project-a')).resolves.toEqual([
|
||||
{
|
||||
envVarName: 'TAVILY_API_KEY',
|
||||
value: 'project-secret',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to user keys when project-specific matches do not exist', async () => {
|
||||
await service.save({
|
||||
name: 'Shared Tavily',
|
||||
envVarName: 'TAVILY_API_KEY',
|
||||
value: 'user-secret',
|
||||
scope: 'user',
|
||||
});
|
||||
await service.save({
|
||||
name: 'Other project Tavily',
|
||||
envVarName: 'TAVILY_API_KEY',
|
||||
value: 'project-secret',
|
||||
scope: 'project',
|
||||
projectPath: '/tmp/project-b',
|
||||
});
|
||||
|
||||
await expect(service.lookup(['TAVILY_API_KEY'], '/tmp/project-a')).resolves.toEqual([
|
||||
{
|
||||
envVarName: 'TAVILY_API_KEY',
|
||||
value: 'user-secret',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not leak project-scoped keys without project context', async () => {
|
||||
await service.save({
|
||||
name: 'Project only key',
|
||||
envVarName: 'TAVILY_API_KEY',
|
||||
value: 'project-secret',
|
||||
scope: 'project',
|
||||
projectPath: '/tmp/project-a',
|
||||
});
|
||||
|
||||
await expect(service.lookup(['TAVILY_API_KEY'])).resolves.toEqual([]);
|
||||
await expect(service.lookupPreferred('TAVILY_API_KEY')).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { parseMcpDiagnosticsOutput } from '@main/services/extensions/state/McpHealthDiagnosticsService';
|
||||
import {
|
||||
McpHealthDiagnosticsService,
|
||||
parseMcpDiagnosticsJsonOutput,
|
||||
parseMcpDiagnosticsOutput,
|
||||
} from '@main/services/extensions/state/McpHealthDiagnosticsService';
|
||||
|
||||
describe('parseMcpDiagnosticsOutput', () => {
|
||||
it('parses mixed MCP health lines from claude mcp list', () => {
|
||||
|
|
@ -12,20 +16,20 @@ browsermcp: npx @browsermcp/mcp@latest - ✓ Connected
|
|||
tavily-remote-mcp: npx -y mcp-remote https://mcp.tavily.com/mcp/?tavilyApiKey=test - ✗ Failed to connect
|
||||
alpic: https://mcp.alpic.ai (HTTP) - ! Needs authentication`);
|
||||
|
||||
expect(diagnostics).toHaveLength(5);
|
||||
expect(diagnostics).toHaveLength(3);
|
||||
expect(diagnostics[0]).toMatchObject({
|
||||
name: 'plugin:context7:context7',
|
||||
target: 'npx -y @upstash/context7-mcp',
|
||||
name: 'browsermcp',
|
||||
target: 'npx @browsermcp/mcp@latest',
|
||||
status: 'connected',
|
||||
statusLabel: 'Connected',
|
||||
});
|
||||
expect(diagnostics[3]).toMatchObject({
|
||||
expect(diagnostics[1]).toMatchObject({
|
||||
name: 'tavily-remote-mcp',
|
||||
target: 'npx -y mcp-remote https://mcp.tavily.com/mcp/?tavilyApiKey=test',
|
||||
target: 'npx -y mcp-remote https://mcp.tavily.com/mcp/?tavilyApiKey=REDACTED',
|
||||
status: 'failed',
|
||||
statusLabel: 'Failed to connect',
|
||||
});
|
||||
expect(diagnostics[4]).toMatchObject({
|
||||
expect(diagnostics[2]).toMatchObject({
|
||||
name: 'alpic',
|
||||
target: 'https://mcp.alpic.ai (HTTP)',
|
||||
status: 'needs-authentication',
|
||||
|
|
@ -40,4 +44,78 @@ another log line`);
|
|||
|
||||
expect(diagnostics).toEqual([]);
|
||||
});
|
||||
|
||||
it('parses structured multimodel MCP diagnostics JSON', () => {
|
||||
const diagnostics = parseMcpDiagnosticsJsonOutput(
|
||||
JSON.stringify({
|
||||
checkedAt: '2026-04-17T10:00:00.000Z',
|
||||
diagnostics: [
|
||||
{
|
||||
name: 'context7',
|
||||
target: 'npx -y @upstash/context7-mcp',
|
||||
status: 'connected',
|
||||
statusLabel: 'Connected',
|
||||
},
|
||||
{
|
||||
name: 'tavily',
|
||||
target: 'https://mcp.tavily.com/mcp?token=secret',
|
||||
scope: 'global',
|
||||
transport: 'http',
|
||||
status: 'timeout',
|
||||
statusLabel: 'Timed out',
|
||||
},
|
||||
{
|
||||
name: 'plugin:context7:context7',
|
||||
target: 'npx -y @upstash/context7-mcp',
|
||||
scope: 'dynamic',
|
||||
transport: 'stdio',
|
||||
status: 'connected',
|
||||
statusLabel: 'Connected',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
expect(diagnostics).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'context7',
|
||||
status: 'connected',
|
||||
statusLabel: 'Connected',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: 'tavily',
|
||||
target: 'https://mcp.tavily.com/mcp?token=REDACTED',
|
||||
scope: 'global',
|
||||
transport: 'http',
|
||||
status: 'failed',
|
||||
statusLabel: 'Timed out',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('McpHealthDiagnosticsService', () => {
|
||||
it('delegates diagnostics to the active runtime adapter', async () => {
|
||||
const diagnoseMcp = vi.fn().mockResolvedValue([
|
||||
{
|
||||
name: 'context7',
|
||||
target: 'npx -y @upstash/context7-mcp',
|
||||
status: 'connected',
|
||||
statusLabel: 'Connected',
|
||||
rawLine: 'context7: npx -y @upstash/context7-mcp - Connected',
|
||||
checkedAt: 1,
|
||||
},
|
||||
]);
|
||||
const service = new McpHealthDiagnosticsService({
|
||||
flavor: 'agent_teams_orchestrator',
|
||||
buildManagementCliEnv: vi.fn(),
|
||||
getInstalledMcp: vi.fn(),
|
||||
diagnoseMcp,
|
||||
});
|
||||
|
||||
await expect(service.diagnose('/tmp/project-a')).resolves.toEqual([
|
||||
expect.objectContaining({ name: 'context7' }),
|
||||
]);
|
||||
expect(diagnoseMcp).toHaveBeenCalledWith('/tmp/project-a');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { ClaudeExtensionsAdapter } from '@main/services/extensions/runtime/ExtensionsRuntimeAdapter';
|
||||
import { McpConfigStateReader } from '@main/services/extensions/runtime/McpConfigStateReader';
|
||||
import { McpInstallationStateService } from '@main/services/extensions/state/McpInstallationStateService';
|
||||
|
||||
const TEST_ROOT = path.parse(process.cwd()).root || path.sep;
|
||||
|
|
@ -14,7 +16,21 @@ function normalizeMockPath(filePath: unknown): string {
|
|||
}
|
||||
|
||||
vi.mock('@main/utils/pathDecoder', () => ({
|
||||
getHomeDir: () => MOCK_HOME_PATH,
|
||||
getHomeDir: () => {
|
||||
const cwd = process.cwd();
|
||||
const windowsRoot = cwd.match(/^[A-Za-z]:[\\/]/)?.[0] ?? null;
|
||||
const root = windowsRoot ?? '/';
|
||||
const sep = windowsRoot ? '\\' : '/';
|
||||
return `${root}tmp${sep}mock-home`.replaceAll('//', '/');
|
||||
},
|
||||
getClaudeBasePath: () => {
|
||||
const cwd = process.cwd();
|
||||
const windowsRoot = cwd.match(/^[A-Za-z]:[\\/]/)?.[0] ?? null;
|
||||
const root = windowsRoot ?? '/';
|
||||
const sep = windowsRoot ? '\\' : '/';
|
||||
return `${root}tmp${sep}mock-home${sep}.claude`.replaceAll('//', '/');
|
||||
},
|
||||
setClaudeBasePathOverride: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises');
|
||||
|
|
@ -24,7 +40,9 @@ describe('McpInstallationStateService', () => {
|
|||
const mockedFs = vi.mocked(fs);
|
||||
|
||||
beforeEach(() => {
|
||||
service = new McpInstallationStateService();
|
||||
service = new McpInstallationStateService(
|
||||
new ClaudeExtensionsAdapter(new McpConfigStateReader())
|
||||
);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
|
|
@ -156,5 +174,28 @@ describe('McpInstallationStateService', () => {
|
|||
]);
|
||||
expect(mockedFs.readFile).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('supports multimodel MCP state through the runtime adapter contract', async () => {
|
||||
const getInstalledMcp = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([{ name: 'context7', scope: 'user', transport: 'stdio' }])
|
||||
.mockResolvedValueOnce([{ name: 'repo-mcp', scope: 'project', transport: 'http' }]);
|
||||
service = new McpInstallationStateService({
|
||||
flavor: 'agent_teams_orchestrator',
|
||||
buildManagementCliEnv: vi.fn(),
|
||||
diagnoseMcp: vi.fn(),
|
||||
getInstalledMcp,
|
||||
});
|
||||
|
||||
await expect(service.getInstalled('/tmp/project-a')).resolves.toEqual([
|
||||
{ name: 'context7', scope: 'user', transport: 'stdio' },
|
||||
]);
|
||||
await expect(service.getInstalled('/tmp/project-b')).resolves.toEqual([
|
||||
{ name: 'repo-mcp', scope: 'project', transport: 'http' },
|
||||
]);
|
||||
expect(getInstalledMcp).toHaveBeenCalledTimes(2);
|
||||
expect(getInstalledMcp).toHaveBeenNthCalledWith(1, '/tmp/project-a');
|
||||
expect(getInstalledMcp).toHaveBeenNthCalledWith(2, '/tmp/project-b');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,12 +13,16 @@ function normalizeMockPath(filePath: unknown): string {
|
|||
return String(filePath).replaceAll('\\', '/');
|
||||
}
|
||||
|
||||
// Mock pathDecoder to control ~/.claude path
|
||||
vi.mock('@main/utils/pathDecoder', () => ({
|
||||
getClaudeBasePath: () => MOCK_CLAUDE_BASE_PATH,
|
||||
getClaudeBasePath: () => {
|
||||
const cwd = process.cwd();
|
||||
const windowsRoot = cwd.match(/^[A-Za-z]:[\\/]/)?.[0] ?? null;
|
||||
const root = windowsRoot ?? '/';
|
||||
const sep = windowsRoot ? '\\' : '/';
|
||||
return `${root}tmp${sep}mock-claude`;
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock filesystem
|
||||
vi.mock('node:fs/promises');
|
||||
|
||||
describe('PluginInstallationStateService', () => {
|
||||
|
|
|
|||
|
|
@ -48,6 +48,12 @@ unknown-key: true
|
|||
message: expect.stringContaining('version'),
|
||||
})
|
||||
);
|
||||
expect(item.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: 'has-scripts',
|
||||
severity: 'info',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('marks missing frontmatter as invalid', () => {
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ describe('SkillRootsResolver', () => {
|
|||
|
||||
const roots = resolver.resolve();
|
||||
|
||||
expect(roots).toHaveLength(3);
|
||||
expect(roots).toHaveLength(4);
|
||||
expect(roots.every((root) => root.scope === 'user')).toBe(true);
|
||||
expect(roots.map((root) => root.rootKind)).toEqual(['claude', 'cursor', 'agents']);
|
||||
expect(roots.map((root) => root.rootKind)).toEqual(['claude', 'cursor', 'agents', 'codex']);
|
||||
});
|
||||
|
||||
it('returns project and user roots when project path is provided', () => {
|
||||
|
|
@ -18,8 +18,8 @@ describe('SkillRootsResolver', () => {
|
|||
|
||||
const roots = resolver.resolve('/tmp/demo-project');
|
||||
|
||||
expect(roots).toHaveLength(6);
|
||||
expect(roots.filter((root) => root.scope === 'project')).toHaveLength(3);
|
||||
expect(roots.filter((root) => root.scope === 'user')).toHaveLength(3);
|
||||
expect(roots).toHaveLength(8);
|
||||
expect(roots.filter((root) => root.scope === 'project')).toHaveLength(4);
|
||||
expect(roots.filter((root) => root.scope === 'user')).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -40,6 +40,18 @@ describe('SkillValidator', () => {
|
|||
expect(result[1].issues.map((issue) => issue.code)).toContain('duplicate-name');
|
||||
});
|
||||
|
||||
it('does not warn when shared and codex-only overlays reuse the same skill name', () => {
|
||||
const validator = new SkillValidator();
|
||||
|
||||
const result = validator.annotateCatalog([
|
||||
makeSkill({ id: '/a', scope: 'project', rootKind: 'claude' }),
|
||||
makeSkill({ id: '/b', scope: 'project', rootKind: 'codex' }),
|
||||
]);
|
||||
|
||||
expect(result[0].issues.map((issue) => issue.code)).not.toContain('duplicate-name');
|
||||
expect(result[1].issues.map((issue) => issue.code)).not.toContain('duplicate-name');
|
||||
});
|
||||
|
||||
it('sorts by validity, scope, root precedence, then name', () => {
|
||||
const validator = new SkillValidator();
|
||||
|
||||
|
|
@ -47,6 +59,7 @@ describe('SkillValidator', () => {
|
|||
makeSkill({ id: '/3', name: 'z-user', scope: 'user', rootKind: 'claude' }),
|
||||
makeSkill({ id: '/2', name: 'b-project-cursor', scope: 'project', rootKind: 'cursor' }),
|
||||
makeSkill({ id: '/1', name: 'a-project-claude', scope: 'project', rootKind: 'claude' }),
|
||||
makeSkill({ id: '/5', name: 'c-project-codex', scope: 'project', rootKind: 'codex' }),
|
||||
makeSkill({
|
||||
id: '/4',
|
||||
name: 'invalid',
|
||||
|
|
@ -55,6 +68,6 @@ describe('SkillValidator', () => {
|
|||
}),
|
||||
]);
|
||||
|
||||
expect(result.map((item) => item.id)).toEqual(['/1', '/2', '/3', '/4']);
|
||||
expect(result.map((item) => item.id)).toEqual(['/1', '/2', '/5', '/3', '/4']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -231,7 +231,7 @@ describe('CliInstallerService', () => {
|
|||
verificationState: 'verified',
|
||||
modelVerificationState: 'idle',
|
||||
statusMessage: null,
|
||||
models: ['gpt-5.4', 'gpt-5.2-codex'],
|
||||
models: ['gpt-5.4', 'gpt-5.4-mini'],
|
||||
modelAvailability: [],
|
||||
canLoginFromUi: true,
|
||||
capabilities: { teamLaunch: true, oneShot: true },
|
||||
|
|
@ -267,14 +267,12 @@ describe('CliInstallerService', () => {
|
|||
if (normalizedArgs === '--version') {
|
||||
return { stdout: '2.3.4', stderr: '' };
|
||||
}
|
||||
if (normalizedArgs.includes('--model gpt-5.4-mini')) {
|
||||
throw new Error("The 'gpt-5.4-mini' model is not supported in this Codex runtime.");
|
||||
}
|
||||
if (normalizedArgs.includes('--model gpt-5.4')) {
|
||||
return { stdout: 'PONG', stderr: '' };
|
||||
}
|
||||
if (normalizedArgs.includes('--model gpt-5.2-codex')) {
|
||||
throw new Error(
|
||||
"The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account."
|
||||
);
|
||||
}
|
||||
throw new Error(`Unexpected execCli call: ${normalizedArgs}`);
|
||||
});
|
||||
|
||||
|
|
@ -288,9 +286,12 @@ describe('CliInstallerService', () => {
|
|||
expect(status.providers.find((provider) => provider.providerId === 'codex')?.modelAvailability).toEqual([]);
|
||||
|
||||
const verifiedProvider = await service.verifyProviderModels('codex');
|
||||
expect(verifiedProvider?.modelAvailability).toEqual([
|
||||
expect.objectContaining({ modelId: 'gpt-5.4', status: 'checking' }),
|
||||
]);
|
||||
expect(verifiedProvider?.modelAvailability).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ modelId: 'gpt-5.4', status: 'checking' }),
|
||||
expect.objectContaining({ modelId: 'gpt-5.4-mini', status: 'checking' }),
|
||||
])
|
||||
);
|
||||
expect(verifiedProvider?.modelAvailability).not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ modelId: 'gpt-5.2-codex' })])
|
||||
);
|
||||
|
|
@ -302,6 +303,10 @@ describe('CliInstallerService', () => {
|
|||
|
||||
expect(latestCodexProvider?.modelAvailability).toEqual([
|
||||
expect.objectContaining({ modelId: 'gpt-5.4', status: 'available' }),
|
||||
expect.objectContaining({
|
||||
modelId: 'gpt-5.4-mini',
|
||||
status: 'unavailable',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('ConfigManager notification config shape', () => {
|
||||
let overrideRoot: string | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (overrideRoot) {
|
||||
fs.rmSync(overrideRoot, { recursive: true, force: true });
|
||||
overrideRoot = null;
|
||||
}
|
||||
vi.resetModules();
|
||||
const pathDecoder = await import('../../../../src/main/utils/pathDecoder');
|
||||
pathDecoder.setClaudeBasePathOverride(null);
|
||||
});
|
||||
|
||||
it('strips unknown notification keys while keeping autoResumeOnRateLimit', async () => {
|
||||
vi.resetModules();
|
||||
|
||||
overrideRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'config-notifications-'));
|
||||
const pathDecoder = await import('../../../../src/main/utils/pathDecoder');
|
||||
pathDecoder.setClaudeBasePathOverride(overrideRoot);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(overrideRoot, 'claude-devtools-config.json'),
|
||||
JSON.stringify({
|
||||
notifications: {
|
||||
notifyOnInboxMessages: true,
|
||||
autoResumeOnRateLimit: true,
|
||||
notifyOnTeamLaunched: false,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const { configManager } = await import(
|
||||
'../../../../src/main/services/infrastructure/ConfigManager'
|
||||
);
|
||||
const config = configManager.getConfig();
|
||||
|
||||
expect(config.notifications.autoResumeOnRateLimit).toBe(true);
|
||||
expect(config.notifications.notifyOnTeamLaunched).toBe(false);
|
||||
expect('notifyOnInboxMessages' in config.notifications).toBe(false);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue