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
|
## Installation
|
||||||
|
|
||||||
No prerequisites - the app can detect supported runtimes/providers and guide setup from the UI.
|
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">
|
<table align="center">
|
||||||
<tr>
|
<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.
|
- **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
|
- **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
|
- **Smart task-to-log/changes matching** — automatically links session logs/changes to specific tasks
|
||||||
|
|
|
||||||
|
|
@ -213,7 +213,7 @@
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.agent-teams.app",
|
"appId": "com.agent-teams.app",
|
||||||
"productName": "Claude Agent Teams UI",
|
"productName": "Agent Teams UI",
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "release"
|
"output": "release"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -311,7 +311,8 @@
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 346,
|
"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": {
|
"global.anthropic.claude-opus-4-6-v1": {
|
||||||
"cache_creation_input_token_cost": 0.00000625,
|
"cache_creation_input_token_cost": 0.00000625,
|
||||||
|
|
@ -338,7 +339,8 @@
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 346,
|
"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": {
|
"us.anthropic.claude-opus-4-6-v1": {
|
||||||
"cache_creation_input_token_cost": 0.000006875,
|
"cache_creation_input_token_cost": 0.000006875,
|
||||||
|
|
@ -365,7 +367,8 @@
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 346,
|
"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": {
|
"eu.anthropic.claude-opus-4-6-v1": {
|
||||||
"cache_creation_input_token_cost": 0.000006875,
|
"cache_creation_input_token_cost": 0.000006875,
|
||||||
|
|
@ -392,7 +395,8 @@
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 346,
|
"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": {
|
"au.anthropic.claude-opus-4-6-v1": {
|
||||||
"cache_creation_input_token_cost": 0.000006875,
|
"cache_creation_input_token_cost": 0.000006875,
|
||||||
|
|
@ -419,6 +423,147 @@
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 346,
|
"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
|
"supports_native_structured_output": true
|
||||||
},
|
},
|
||||||
"anthropic.claude-sonnet-4-6": {
|
"anthropic.claude-sonnet-4-6": {
|
||||||
|
|
@ -854,6 +999,35 @@
|
||||||
"supports_response_schema": true,
|
"supports_response_schema": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": 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
|
"tool_use_system_prompt_tokens": 159
|
||||||
},
|
},
|
||||||
"azure_ai/claude-opus-4-1": {
|
"azure_ai/claude-opus-4-1": {
|
||||||
|
|
@ -1687,7 +1861,8 @@
|
||||||
"provider_specific_entry": {
|
"provider_specific_entry": {
|
||||||
"us": 1.1,
|
"us": 1.1,
|
||||||
"fast": 6
|
"fast": 6
|
||||||
}
|
},
|
||||||
|
"supports_max_reasoning_effort": true
|
||||||
},
|
},
|
||||||
"claude-opus-4-6-20260205": {
|
"claude-opus-4-6-20260205": {
|
||||||
"cache_creation_input_token_cost": 0.00000625,
|
"cache_creation_input_token_cost": 0.00000625,
|
||||||
|
|
@ -1715,6 +1890,71 @@
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 346,
|
"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": {
|
"provider_specific_entry": {
|
||||||
"us": 1.1,
|
"us": 1.1,
|
||||||
"fast": 6
|
"fast": 6
|
||||||
|
|
@ -4148,7 +4388,8 @@
|
||||||
"supports_response_schema": true,
|
"supports_response_schema": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": 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": {
|
"vertex_ai/claude-opus-4-6@default": {
|
||||||
"cache_creation_input_token_cost": 0.00000625,
|
"cache_creation_input_token_cost": 0.00000625,
|
||||||
|
|
@ -4174,6 +4415,61 @@
|
||||||
"supports_response_schema": true,
|
"supports_response_schema": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": 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
|
"tool_use_system_prompt_tokens": 346
|
||||||
},
|
},
|
||||||
"vertex_ai/claude-sonnet-4-5": {
|
"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).
|
* 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 { GraphView } from '@claude-teams/agent-graph';
|
||||||
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
|
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 { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog';
|
||||||
import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility';
|
import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility';
|
||||||
import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter';
|
import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter';
|
||||||
|
import { useTeamGraphSlotReset } from '../hooks/useTeamGraphSlotReset';
|
||||||
import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions';
|
import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions';
|
||||||
|
|
||||||
import { GraphActivityHud } from './GraphActivityHud';
|
import { GraphActivityHud } from './GraphActivityHud';
|
||||||
|
|
@ -52,17 +53,15 @@ export const TeamGraphOverlay = ({
|
||||||
onOpenMemberProfile,
|
onOpenMemberProfile,
|
||||||
}: TeamGraphOverlayProps): React.JSX.Element => {
|
}: TeamGraphOverlayProps): React.JSX.Element => {
|
||||||
const graphData = useTeamGraphAdapter(teamName);
|
const graphData = useTeamGraphAdapter(teamName);
|
||||||
const {
|
const { openTeamPage: openTeamTab, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName);
|
||||||
openTeamPage: openTeamTab,
|
|
||||||
resetOwnerSlotAssignmentsToDefaults,
|
|
||||||
commitOwnerSlotDrop,
|
|
||||||
} = useTeamGraphSurfaceActions(teamName);
|
|
||||||
const { sidebarVisible: persistedSidebarVisible, toggleSidebarVisible } =
|
const { sidebarVisible: persistedSidebarVisible, toggleSidebarVisible } =
|
||||||
useGraphSidebarVisibility();
|
useGraphSidebarVisibility();
|
||||||
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
|
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
|
||||||
const effectiveSidebarVisible = sidebarVisible ?? persistedSidebarVisible;
|
const effectiveSidebarVisible = sidebarVisible ?? persistedSidebarVisible;
|
||||||
const handleToggleSidebar = onToggleSidebar ?? toggleSidebarVisible;
|
const handleToggleSidebar = onToggleSidebar ?? toggleSidebarVisible;
|
||||||
|
|
||||||
|
useTeamGraphSlotReset(teamName);
|
||||||
|
|
||||||
// Task action dispatchers (same pattern as TeamGraphTab)
|
// Task action dispatchers (same pattern as TeamGraphTab)
|
||||||
const dispatchTaskAction = useCallback(
|
const dispatchTaskAction = useCallback(
|
||||||
(action: string) => (taskId: string) =>
|
(action: string) => (taskId: string) =>
|
||||||
|
|
@ -89,11 +88,6 @@ export const TeamGraphOverlay = ({
|
||||||
const openCreateTask = useCallback(() => {
|
const openCreateTask = useCallback(() => {
|
||||||
openCreateTaskDialog('');
|
openCreateTaskDialog('');
|
||||||
}, [openCreateTaskDialog]);
|
}, [openCreateTaskDialog]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
resetOwnerSlotAssignmentsToDefaults();
|
|
||||||
}, [resetOwnerSlotAssignmentsToDefaults]);
|
|
||||||
|
|
||||||
const events: GraphEventPort = {
|
const events: GraphEventPort = {
|
||||||
onNodeDoubleClick: useCallback(
|
onNodeDoubleClick: useCallback(
|
||||||
(ref: GraphDomainRef) => {
|
(ref: GraphDomainRef) => {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
* Provides Fullscreen button that opens the overlay.
|
* 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 { GraphView } from '@claude-teams/agent-graph';
|
||||||
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
|
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 { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog';
|
||||||
import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility';
|
import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility';
|
||||||
import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter';
|
import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter';
|
||||||
|
import { useTeamGraphSlotReset } from '../hooks/useTeamGraphSlotReset';
|
||||||
import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions';
|
import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions';
|
||||||
|
|
||||||
import { GraphActivityHud } from './GraphActivityHud';
|
import { GraphActivityHud } from './GraphActivityHud';
|
||||||
|
|
@ -45,12 +46,13 @@ export const TeamGraphTab = ({
|
||||||
isPaneFocused = false,
|
isPaneFocused = false,
|
||||||
}: TeamGraphTabProps): React.JSX.Element => {
|
}: TeamGraphTabProps): React.JSX.Element => {
|
||||||
const graphData = useTeamGraphAdapter(teamName);
|
const graphData = useTeamGraphAdapter(teamName);
|
||||||
const { openTeamPage, resetOwnerSlotAssignmentsToDefaults, commitOwnerSlotDrop } =
|
const { openTeamPage, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName);
|
||||||
useTeamGraphSurfaceActions(teamName);
|
|
||||||
const [fullscreen, setFullscreen] = useState(false);
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility();
|
const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility();
|
||||||
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
|
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
|
||||||
|
|
||||||
|
useTeamGraphSlotReset(teamName, isActive);
|
||||||
|
|
||||||
// Typed event dispatchers (DRY — used in both events + renderOverlay)
|
// Typed event dispatchers (DRY — used in both events + renderOverlay)
|
||||||
const dispatchOpenTask = useCallback(
|
const dispatchOpenTask = useCallback(
|
||||||
(taskId: string) =>
|
(taskId: string) =>
|
||||||
|
|
@ -76,14 +78,6 @@ export const TeamGraphTab = ({
|
||||||
const openCreateTask = useCallback(() => {
|
const openCreateTask = useCallback(() => {
|
||||||
openCreateTaskDialog('');
|
openCreateTaskDialog('');
|
||||||
}, [openCreateTaskDialog]);
|
}, [openCreateTaskDialog]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (!isActive) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resetOwnerSlotAssignmentsToDefaults();
|
|
||||||
}, [isActive, resetOwnerSlotAssignmentsToDefaults]);
|
|
||||||
|
|
||||||
// Task action dispatchers
|
// Task action dispatchers
|
||||||
const dispatchTaskAction = useCallback(
|
const dispatchTaskAction = useCallback(
|
||||||
(action: string) => (taskId: string) =>
|
(action: string) => (taskId: string) =>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import type { DashboardRecentProject, DashboardRecentProjectsPayload } from './d
|
||||||
export type DashboardRecentProjectsPayloadLike =
|
export type DashboardRecentProjectsPayloadLike =
|
||||||
| DashboardRecentProjectsPayload
|
| DashboardRecentProjectsPayload
|
||||||
| DashboardRecentProject[]
|
| DashboardRecentProject[]
|
||||||
|
| { degraded?: unknown; projects?: unknown }
|
||||||
| null
|
| null
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -199,7 +199,7 @@ export class CodexAppServerClient {
|
||||||
{
|
{
|
||||||
clientInfo: {
|
clientInfo: {
|
||||||
name: 'claude-agent-teams-ui',
|
name: 'claude-agent-teams-ui',
|
||||||
title: 'Claude Agent Teams UI',
|
title: 'Agent Teams UI',
|
||||||
version: '0.1.0',
|
version: '0.1.0',
|
||||||
},
|
},
|
||||||
capabilities: {
|
capabilities: {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* Main process entry point for Claude Agent Teams UI.
|
* Main process entry point for Agent Teams UI.
|
||||||
*
|
*
|
||||||
* Responsibilities:
|
* Responsibilities:
|
||||||
* - Initialize Electron app and main window
|
* - Initialize Electron app and main window
|
||||||
|
|
@ -70,6 +70,7 @@ import { setReviewMainWindow } from './ipc/review';
|
||||||
import { setTmuxMainWindow } from './ipc/tmux';
|
import { setTmuxMainWindow } from './ipc/tmux';
|
||||||
import {
|
import {
|
||||||
ApiKeyService,
|
ApiKeyService,
|
||||||
|
createExtensionsRuntimeAdapter,
|
||||||
ExtensionFacadeService,
|
ExtensionFacadeService,
|
||||||
GlamaMcpEnrichmentService,
|
GlamaMcpEnrichmentService,
|
||||||
McpCatalogAggregator,
|
McpCatalogAggregator,
|
||||||
|
|
@ -99,6 +100,7 @@ import {
|
||||||
type TeamReconcileTrigger,
|
type TeamReconcileTrigger,
|
||||||
} from './services/team/TeamReconcileDrainScheduler';
|
} from './services/team/TeamReconcileDrainScheduler';
|
||||||
import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore';
|
import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore';
|
||||||
|
import { clearAutoResumeService } from './services/team/AutoResumeService';
|
||||||
import { getAppIconPath } from './utils/appIcon';
|
import { getAppIconPath } from './utils/appIcon';
|
||||||
import { getProjectsBasePath, getTeamsBasePath, getTodosBasePath } from './utils/pathDecoder';
|
import { getProjectsBasePath, getTeamsBasePath, getTodosBasePath } from './utils/pathDecoder';
|
||||||
import {
|
import {
|
||||||
|
|
@ -878,8 +880,9 @@ async function initializeServices(): Promise<void> {
|
||||||
const officialMcpRegistry = new OfficialMcpRegistryService();
|
const officialMcpRegistry = new OfficialMcpRegistryService();
|
||||||
const glamaMcpService = new GlamaMcpEnrichmentService();
|
const glamaMcpService = new GlamaMcpEnrichmentService();
|
||||||
const mcpAggregator = new McpCatalogAggregator(officialMcpRegistry, glamaMcpService);
|
const mcpAggregator = new McpCatalogAggregator(officialMcpRegistry, glamaMcpService);
|
||||||
const mcpStateService = new McpInstallationStateService();
|
const extensionsRuntimeAdapter = createExtensionsRuntimeAdapter();
|
||||||
const mcpHealthDiagnosticsService = new McpHealthDiagnosticsService();
|
const mcpStateService = new McpInstallationStateService(extensionsRuntimeAdapter);
|
||||||
|
const mcpHealthDiagnosticsService = new McpHealthDiagnosticsService(extensionsRuntimeAdapter);
|
||||||
const skillsCatalogService = new SkillsCatalogService();
|
const skillsCatalogService = new SkillsCatalogService();
|
||||||
const skillsMutationService = new SkillsMutationService();
|
const skillsMutationService = new SkillsMutationService();
|
||||||
skillsWatcherService = new SkillsWatcherService();
|
skillsWatcherService = new SkillsWatcherService();
|
||||||
|
|
@ -891,8 +894,11 @@ async function initializeServices(): Promise<void> {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Install services — resolve binary dynamically via ClaudeBinaryResolver
|
// Install services — resolve binary dynamically via ClaudeBinaryResolver
|
||||||
const pluginInstallService = new PluginInstallService(pluginCatalogService);
|
const pluginInstallService = new PluginInstallService(
|
||||||
const mcpInstallService = new McpInstallService(mcpAggregator);
|
pluginCatalogService,
|
||||||
|
extensionsRuntimeAdapter
|
||||||
|
);
|
||||||
|
const mcpInstallService = new McpInstallService(mcpAggregator, extensionsRuntimeAdapter);
|
||||||
const apiKeyService = new ApiKeyService();
|
const apiKeyService = new ApiKeyService();
|
||||||
await apiKeyService.syncProcessEnv(RUNTIME_MANAGED_API_KEY_ENV_VARS);
|
await apiKeyService.syncProcessEnv(RUNTIME_MANAGED_API_KEY_ENV_VARS);
|
||||||
// warmup() and ensureInstalled() are deferred to after window creation
|
// warmup() and ensureInstalled() are deferred to after window creation
|
||||||
|
|
@ -1078,6 +1084,11 @@ async function startHttpServer(
|
||||||
function shutdownServices(): void {
|
function shutdownServices(): void {
|
||||||
logger.info('Shutting down services...');
|
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.
|
// Kill all team CLI processes via SIGKILL BEFORE anything else.
|
||||||
// This must happen before the OS closes stdin pipes (on app exit),
|
// This must happen before the OS closes stdin pipes (on app exit),
|
||||||
// because stdin EOF triggers CLI's graceful shutdown which deletes team files.
|
// because stdin EOF triggers CLI's graceful shutdown which deletes team files.
|
||||||
|
|
@ -1220,7 +1231,7 @@ function createWindow(): void {
|
||||||
backgroundColor: '#1a1a1a',
|
backgroundColor: '#1a1a1a',
|
||||||
...(useNativeTitleBar ? {} : { titleBarStyle: 'hidden' as const }),
|
...(useNativeTitleBar ? {} : { titleBarStyle: 'hidden' as const }),
|
||||||
...(isMac && { trafficLightPosition: getTrafficLightPositionForZoom(1) }),
|
...(isMac && { trafficLightPosition: getTrafficLightPositionForZoom(1) }),
|
||||||
title: 'Claude Agent Teams UI',
|
title: 'Agent Teams UI',
|
||||||
});
|
});
|
||||||
markRendererUnavailable(mainWindow);
|
markRendererUnavailable(mainWindow);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,7 @@ function validateNotificationsSection(
|
||||||
'notifyOnCrossTeamMessage',
|
'notifyOnCrossTeamMessage',
|
||||||
'notifyOnTeamLaunched',
|
'notifyOnTeamLaunched',
|
||||||
'notifyOnToolApproval',
|
'notifyOnToolApproval',
|
||||||
|
'autoResumeOnRateLimit',
|
||||||
'statusChangeOnlySolo',
|
'statusChangeOnlySolo',
|
||||||
'statusChangeStatuses',
|
'statusChangeStatuses',
|
||||||
'triggers',
|
'triggers',
|
||||||
|
|
@ -219,6 +220,12 @@ function validateNotificationsSection(
|
||||||
}
|
}
|
||||||
result.notifyOnToolApproval = value;
|
result.notifyOnToolApproval = value;
|
||||||
break;
|
break;
|
||||||
|
case 'autoResumeOnRateLimit':
|
||||||
|
if (typeof value !== 'boolean') {
|
||||||
|
return { valid: false, error: `notifications.${key} must be a boolean` };
|
||||||
|
}
|
||||||
|
result.autoResumeOnRateLimit = value;
|
||||||
|
break;
|
||||||
case 'statusChangeOnlySolo':
|
case 'statusChangeOnlySolo':
|
||||||
if (typeof value !== 'boolean') {
|
if (typeof value !== 'boolean') {
|
||||||
return { valid: false, error: `notifications.${key} must be a boolean` };
|
return { valid: false, error: `notifications.${key} must be a boolean` };
|
||||||
|
|
|
||||||
|
|
@ -239,8 +239,13 @@ function getMcpHealthDiagnostics(): McpHealthDiagnosticsService {
|
||||||
return mcpHealthDiagnostics;
|
return mcpHealthDiagnostics;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleMcpDiagnose(): Promise<IpcResult<McpServerDiagnostic[]>> {
|
async function handleMcpDiagnose(
|
||||||
return wrapHandler('mcpDiagnose', () => getMcpHealthDiagnostics().diagnose());
|
_event: IpcMainInvokeEvent,
|
||||||
|
projectPath?: string
|
||||||
|
): Promise<IpcResult<McpServerDiagnostic[]>> {
|
||||||
|
return wrapHandler('mcpDiagnose', () =>
|
||||||
|
getMcpHealthDiagnostics().diagnose(typeof projectPath === 'string' ? projectPath : undefined)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Install/Uninstall Handlers ────────────────────────────────────────────
|
// ── Install/Uninstall Handlers ────────────────────────────────────────────
|
||||||
|
|
@ -416,11 +421,15 @@ async function handleApiKeysDelete(
|
||||||
|
|
||||||
async function handleApiKeysLookup(
|
async function handleApiKeysLookup(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
envVarNames?: string[]
|
envVarNames?: string[],
|
||||||
|
projectPath?: string
|
||||||
): Promise<IpcResult<ApiKeyLookupResult[]>> {
|
): Promise<IpcResult<ApiKeyLookupResult[]>> {
|
||||||
return wrapHandler('apiKeysLookup', () => {
|
return wrapHandler('apiKeysLookup', () => {
|
||||||
if (!Array.isArray(envVarNames)) throw new Error('envVarNames array is required');
|
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,
|
buildActionModeAgentBlock,
|
||||||
isAgentActionMode,
|
isAgentActionMode,
|
||||||
} from '../services/team/actionModeInstructions';
|
} from '../services/team/actionModeInstructions';
|
||||||
|
import {
|
||||||
|
getAutoResumeService,
|
||||||
|
initializeAutoResumeService,
|
||||||
|
} from '../services/team/AutoResumeService';
|
||||||
import {
|
import {
|
||||||
buildReplaceMembersDiff,
|
buildReplaceMembersDiff,
|
||||||
buildReplaceMembersSummaryMessage,
|
buildReplaceMembersSummaryMessage,
|
||||||
} from '../services/team/memberUpdateNotifications';
|
} from '../services/team/memberUpdateNotifications';
|
||||||
|
import { mergeLiveLeadProcessMessages } from '../services/team/mergeLiveLeadProcessMessages';
|
||||||
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
|
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
|
||||||
import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore';
|
import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore';
|
||||||
import { TeamMetaStore } from '../services/team/TeamMetaStore';
|
import { TeamMetaStore } from '../services/team/TeamMetaStore';
|
||||||
|
|
@ -153,11 +158,9 @@ import type {
|
||||||
IpcResult,
|
IpcResult,
|
||||||
KanbanColumnId,
|
KanbanColumnId,
|
||||||
LeadActivitySnapshot,
|
LeadActivitySnapshot,
|
||||||
LeadContextUsage,
|
|
||||||
LeadContextUsageSnapshot,
|
LeadContextUsageSnapshot,
|
||||||
MemberFullStats,
|
MemberFullStats,
|
||||||
MemberLogSummary,
|
MemberLogSummary,
|
||||||
MemberSpawnStatusEntry,
|
|
||||||
MemberSpawnStatusesSnapshot,
|
MemberSpawnStatusesSnapshot,
|
||||||
MessagesPage,
|
MessagesPage,
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
|
|
@ -313,11 +316,25 @@ const SEEN_API_ERROR_KEYS_MAX = 500;
|
||||||
* and NotificationManager dedupeKey (to prevent storage duplicates).
|
* and NotificationManager dedupeKey (to prevent storage duplicates).
|
||||||
*/
|
*/
|
||||||
function checkRateLimitMessages(
|
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,
|
teamName: string,
|
||||||
teamDisplayName: string,
|
teamDisplayName: string,
|
||||||
projectPath?: string
|
projectPath?: string,
|
||||||
|
teamIsAlive = true,
|
||||||
|
currentLeadSessionId: string | null = null
|
||||||
): void {
|
): void {
|
||||||
|
const observedAt = new Date();
|
||||||
|
const autoResumeEnabled =
|
||||||
|
ConfigManager.getInstance().getConfig().notifications.autoResumeOnRateLimit;
|
||||||
|
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
if (msg.from === 'user') continue;
|
if (msg.from === 'user') continue;
|
||||||
if (!isRateLimitMessage(msg.text)) continue;
|
if (!isRateLimitMessage(msg.text)) continue;
|
||||||
|
|
@ -325,28 +342,55 @@ function checkRateLimitMessages(
|
||||||
const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`;
|
const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`;
|
||||||
const dedupeKey = `rate-limit:${teamName}:${rawKey}`;
|
const dedupeKey = `rate-limit:${teamName}:${rawKey}`;
|
||||||
|
|
||||||
// In-memory guard: prevents resurrection after user deletes the notification
|
// In-memory guard: prevents resurrection after user deletes the notification.
|
||||||
if (seenRateLimitKeys.has(dedupeKey)) continue;
|
if (!seenRateLimitKeys.has(dedupeKey)) {
|
||||||
seenRateLimitKeys.add(dedupeKey);
|
seenRateLimitKeys.add(dedupeKey);
|
||||||
|
|
||||||
// Evict oldest entries to prevent unbounded growth
|
// Evict oldest entries to prevent unbounded growth
|
||||||
if (seenRateLimitKeys.size > SEEN_RATE_LIMIT_KEYS_MAX) {
|
if (seenRateLimitKeys.size > SEEN_RATE_LIMIT_KEYS_MAX) {
|
||||||
const first = seenRateLimitKeys.values().next().value;
|
const first = seenRateLimitKeys.values().next().value;
|
||||||
if (first) seenRateLimitKeys.delete(first);
|
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()
|
// Only schedule auto-resume while a live team run currently exists.
|
||||||
.addTeamNotification({
|
// Persisted history for an offline/stopped team may still contain the old
|
||||||
teamEventType: 'rate_limit',
|
// 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,
|
teamName,
|
||||||
teamDisplayName,
|
msg.text,
|
||||||
from: msg.from,
|
observedAt,
|
||||||
summary: `Rate limit: ${msg.from}`,
|
new Date(msg.timestamp)
|
||||||
body: msg.text.slice(0, 200),
|
);
|
||||||
dedupeKey,
|
}
|
||||||
projectPath,
|
|
||||||
})
|
|
||||||
.catch(() => undefined);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -461,6 +505,7 @@ export function initializeTeamHandlers(
|
||||||
): void {
|
): void {
|
||||||
teamDataService = service;
|
teamDataService = service;
|
||||||
teamProvisioningService = provisioningService;
|
teamProvisioningService = provisioningService;
|
||||||
|
initializeAutoResumeService(provisioningService);
|
||||||
teamMemberLogsFinder = logsFinder ?? null;
|
teamMemberLogsFinder = logsFinder ?? null;
|
||||||
memberStatsComputer = statsComputer ?? null;
|
memberStatsComputer = statsComputer ?? null;
|
||||||
teamBackupService = backupService ?? null;
|
teamBackupService = backupService ?? null;
|
||||||
|
|
@ -788,11 +833,51 @@ async function handleGetData(
|
||||||
}
|
}
|
||||||
const provisioning = getTeamProvisioningService();
|
const provisioning = getTeamProvisioningService();
|
||||||
const isAlive = provisioning.isTeamAlive(tn);
|
const isAlive = provisioning.isTeamAlive(tn);
|
||||||
|
const currentLeadSessionId = provisioning.getCurrentLeadSessionId(tn);
|
||||||
|
|
||||||
const displayName = data.config.name || tn;
|
const displayName = data.config.name || tn;
|
||||||
const projectPath = data.config.projectPath;
|
const projectPath = data.config.projectPath;
|
||||||
const live = provisioning.getLiveLeadProcessMessages(tn);
|
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 } };
|
return { success: true, data: { ...data, isAlive } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -872,6 +957,7 @@ async function handleDeleteTeam(
|
||||||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||||
}
|
}
|
||||||
return wrapTeamHandler('deleteTeam', async () => {
|
return wrapTeamHandler('deleteTeam', async () => {
|
||||||
|
getAutoResumeService().cancelPendingAutoResume(validated.value!);
|
||||||
getTeamProvisioningService().stopTeam(validated.value!);
|
getTeamProvisioningService().stopTeam(validated.value!);
|
||||||
await getTeamDataService().deleteTeam(validated.value!);
|
await getTeamDataService().deleteTeam(validated.value!);
|
||||||
});
|
});
|
||||||
|
|
@ -897,6 +983,7 @@ async function handlePermanentlyDeleteTeam(
|
||||||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||||
}
|
}
|
||||||
return wrapTeamHandler('permanentlyDeleteTeam', async () => {
|
return wrapTeamHandler('permanentlyDeleteTeam', async () => {
|
||||||
|
getAutoResumeService().cancelPendingAutoResume(validated.value!);
|
||||||
await getTeamDataService().permanentlyDeleteTeam(validated.value!);
|
await getTeamDataService().permanentlyDeleteTeam(validated.value!);
|
||||||
// Clean up app-owned data (attachments, task-attachments) that lives outside ~/.claude/
|
// Clean up app-owned data (attachments, task-attachments) that lives outside ~/.claude/
|
||||||
const appData = getAppDataPath();
|
const appData = getAppDataPath();
|
||||||
|
|
@ -1680,6 +1767,24 @@ async function handleGetMessagesPage(
|
||||||
return wrapTeamHandler('getMessagesPage', async () => {
|
return wrapTeamHandler('getMessagesPage', async () => {
|
||||||
let page: MessagesPage;
|
let page: MessagesPage;
|
||||||
const notificationContext = await getTeamDataService().getTeamNotificationContext(vTeam.value!);
|
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();
|
const worker = getTeamDataWorkerClient();
|
||||||
if (worker.isAvailable()) {
|
if (worker.isAvailable()) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -2734,6 +2839,7 @@ async function handleStopTeam(
|
||||||
}
|
}
|
||||||
return wrapTeamHandler('stop', async () => {
|
return wrapTeamHandler('stop', async () => {
|
||||||
addMainBreadcrumb('team', 'stop', { teamName: validated.value! });
|
addMainBreadcrumb('team', 'stop', { teamName: validated.value! });
|
||||||
|
getAutoResumeService().cancelPendingAutoResume(validated.value!);
|
||||||
getTeamProvisioningService().stopTeam(validated.value!);
|
getTeamProvisioningService().stopTeam(validated.value!);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ interface StoredApiKey {
|
||||||
encrypted?: boolean;
|
encrypted?: boolean;
|
||||||
encryptionMethod?: EncryptionMethod;
|
encryptionMethod?: EncryptionMethod;
|
||||||
scope: 'user' | 'project';
|
scope: 'user' | 'project';
|
||||||
|
projectPath?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -73,6 +74,7 @@ export class ApiKeyService {
|
||||||
envVarName: k.envVarName,
|
envVarName: k.envVarName,
|
||||||
maskedValue: this.mask(this.decrypt(k)),
|
maskedValue: this.mask(this.decrypt(k)),
|
||||||
scope: k.scope,
|
scope: k.scope,
|
||||||
|
projectPath: k.projectPath,
|
||||||
createdAt: k.createdAt,
|
createdAt: k.createdAt,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
@ -86,6 +88,9 @@ export class ApiKeyService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!request.value) throw new Error('Key value is required');
|
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 keys = await this.readStore();
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
@ -101,6 +106,7 @@ export class ApiKeyService {
|
||||||
encryptedValue: value,
|
encryptedValue: value,
|
||||||
encryptionMethod: method,
|
encryptionMethod: method,
|
||||||
scope: request.scope,
|
scope: request.scope,
|
||||||
|
projectPath: request.scope === 'project' ? request.projectPath?.trim() : undefined,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
delete keys[idx].encrypted;
|
delete keys[idx].encrypted;
|
||||||
|
|
@ -112,6 +118,7 @@ export class ApiKeyService {
|
||||||
encryptedValue: value,
|
encryptedValue: value,
|
||||||
encryptionMethod: method,
|
encryptionMethod: method,
|
||||||
scope: request.scope,
|
scope: request.scope,
|
||||||
|
projectPath: request.scope === 'project' ? request.projectPath?.trim() : undefined,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -124,6 +131,7 @@ export class ApiKeyService {
|
||||||
envVarName: saved.envVarName,
|
envVarName: saved.envVarName,
|
||||||
maskedValue: this.mask(request.value),
|
maskedValue: this.mask(request.value),
|
||||||
scope: saved.scope,
|
scope: saved.scope,
|
||||||
|
projectPath: saved.projectPath,
|
||||||
createdAt: saved.createdAt,
|
createdAt: saved.createdAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -135,25 +143,36 @@ export class ApiKeyService {
|
||||||
await this.writeStore(filtered);
|
await this.writeStore(filtered);
|
||||||
}
|
}
|
||||||
|
|
||||||
async lookup(envVarNames: string[]): Promise<ApiKeyLookupResult[]> {
|
async lookup(envVarNames: string[], projectPath?: string): Promise<ApiKeyLookupResult[]> {
|
||||||
if (!envVarNames.length) return [];
|
if (!envVarNames.length) return [];
|
||||||
const keys = await this.readStore();
|
const keys = await this.readStore();
|
||||||
const nameSet = new Set(envVarNames);
|
return Array.from(new Set(envVarNames)).flatMap((envVarName) => {
|
||||||
return keys
|
const preferred = this.pickPreferredKey(
|
||||||
.filter((k) => nameSet.has(k.envVarName))
|
keys.filter((key) => key.envVarName === envVarName),
|
||||||
.map((k) => ({
|
projectPath
|
||||||
envVarName: k.envVarName,
|
);
|
||||||
value: this.decrypt(k),
|
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 keys = await this.readStore();
|
||||||
const matching = keys.filter((key) => key.envVarName === envVarName);
|
const preferred = this.pickPreferredKey(
|
||||||
const preferred =
|
keys.filter((key) => key.envVarName === envVarName),
|
||||||
matching.find((key) => key.scope === 'user') ??
|
projectPath
|
||||||
matching.find((key) => key.scope === 'project') ??
|
);
|
||||||
null;
|
|
||||||
|
|
||||||
if (!preferred) {
|
if (!preferred) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -280,6 +299,20 @@ export class ApiKeyService {
|
||||||
return stored.encrypted ? 'safeStorage' : 'base64';
|
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 ───────────────────────────────────────
|
// ── AES-256-GCM local encryption ───────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,11 @@ export { PluginCatalogService } from './catalog/PluginCatalogService';
|
||||||
export { ExtensionFacadeService } from './ExtensionFacadeService';
|
export { ExtensionFacadeService } from './ExtensionFacadeService';
|
||||||
export { McpInstallService } from './install/McpInstallService';
|
export { McpInstallService } from './install/McpInstallService';
|
||||||
export { PluginInstallService } from './install/PluginInstallService';
|
export { PluginInstallService } from './install/PluginInstallService';
|
||||||
|
export {
|
||||||
|
ClaudeExtensionsAdapter,
|
||||||
|
createExtensionsRuntimeAdapter,
|
||||||
|
MultimodelExtensionsAdapter,
|
||||||
|
} from './runtime/ExtensionsRuntimeAdapter';
|
||||||
export { SkillImportService } from './skills/SkillImportService';
|
export { SkillImportService } from './skills/SkillImportService';
|
||||||
export { SkillMetadataParser } from './skills/SkillMetadataParser';
|
export { SkillMetadataParser } from './skills/SkillMetadataParser';
|
||||||
export { SkillPlanService } from './skills/SkillPlanService';
|
export { SkillPlanService } from './skills/SkillPlanService';
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,15 @@
|
||||||
|
|
||||||
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
||||||
import { execCli } from '@main/utils/childProcess';
|
import { execCli } from '@main/utils/childProcess';
|
||||||
import { buildEnrichedEnv } from '@main/utils/cliEnv';
|
|
||||||
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
|
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import { isProjectScopedMcpScope } from '@shared/utils/mcpScopes';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
|
import { createExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
|
||||||
|
|
||||||
import type { McpCatalogAggregator } from '../catalog/McpCatalogAggregator';
|
import type { McpCatalogAggregator } from '../catalog/McpCatalogAggregator';
|
||||||
|
import type { ExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
|
||||||
import type {
|
import type {
|
||||||
McpCustomInstallRequest,
|
McpCustomInstallRequest,
|
||||||
McpInstallRequest,
|
McpInstallRequest,
|
||||||
|
|
@ -27,7 +30,7 @@ const logger = createLogger('Extensions:McpInstall');
|
||||||
const SERVER_NAME_RE = /^[\w.-]{1,100}$/;
|
const SERVER_NAME_RE = /^[\w.-]{1,100}$/;
|
||||||
|
|
||||||
/** Allowed scope values (prevent command injection) */
|
/** 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 */
|
/** Env var key must be safe shell identifier */
|
||||||
const ENV_KEY_RE = /^[A-Z_][A-Z0-9_]{0,100}$/i;
|
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;
|
const TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
function scopeRequiresProjectPath(scope?: string): boolean {
|
function scopeRequiresProjectPath(scope?: string): boolean {
|
||||||
return scope === 'local' || scope === 'project';
|
return isProjectScopedMcpScope(scope);
|
||||||
}
|
}
|
||||||
|
|
||||||
export class McpInstallService {
|
export class McpInstallService {
|
||||||
constructor(private readonly aggregator: McpCatalogAggregator) {}
|
constructor(
|
||||||
|
private readonly aggregator: McpCatalogAggregator,
|
||||||
|
private readonly runtimeAdapter: ExtensionsRuntimeAdapter = createExtensionsRuntimeAdapter()
|
||||||
|
) {}
|
||||||
|
|
||||||
async install(request: McpInstallRequest): Promise<OperationResult> {
|
async install(request: McpInstallRequest): Promise<OperationResult> {
|
||||||
const { registryId, serverName, scope, projectPath, envValues, headers } = request;
|
const { registryId, serverName, scope, projectPath, envValues, headers } = request;
|
||||||
|
|
@ -59,7 +65,7 @@ export class McpInstallService {
|
||||||
if (scope && !VALID_SCOPES.has(scope)) {
|
if (scope && !VALID_SCOPES.has(scope)) {
|
||||||
return {
|
return {
|
||||||
state: 'error',
|
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,
|
error: CLI_NOT_FOUND_MESSAGE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary);
|
||||||
|
|
||||||
const { stderr } = await execCli(claudeBinary, args, {
|
const { stderr } = await execCli(claudeBinary, args, {
|
||||||
timeout: TIMEOUT_MS,
|
timeout: TIMEOUT_MS,
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
env: buildEnrichedEnv(claudeBinary),
|
env,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (stderr) {
|
if (stderr) {
|
||||||
|
|
@ -295,11 +302,12 @@ export class McpInstallService {
|
||||||
error: CLI_NOT_FOUND_MESSAGE,
|
error: CLI_NOT_FOUND_MESSAGE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary);
|
||||||
|
|
||||||
const { stderr } = await execCli(claudeBinary, args, {
|
const { stderr } = await execCli(claudeBinary, args, {
|
||||||
timeout: TIMEOUT_MS,
|
timeout: TIMEOUT_MS,
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
env: buildEnrichedEnv(claudeBinary),
|
env,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (stderr) {
|
if (stderr) {
|
||||||
|
|
@ -330,7 +338,7 @@ export class McpInstallService {
|
||||||
if (scope && !VALID_SCOPES.has(scope)) {
|
if (scope && !VALID_SCOPES.has(scope)) {
|
||||||
return {
|
return {
|
||||||
state: 'error',
|
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,
|
error: CLI_NOT_FOUND_MESSAGE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary);
|
||||||
|
|
||||||
await execCli(claudeBinary, args, {
|
await execCli(claudeBinary, args, {
|
||||||
timeout: TIMEOUT_MS,
|
timeout: TIMEOUT_MS,
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
env: buildEnrichedEnv(claudeBinary),
|
env,
|
||||||
});
|
});
|
||||||
return { state: 'success' };
|
return { state: 'success' };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,14 @@
|
||||||
|
|
||||||
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
||||||
import { execCli } from '@main/utils/childProcess';
|
import { execCli } from '@main/utils/childProcess';
|
||||||
import { buildEnrichedEnv } from '@main/utils/cliEnv';
|
|
||||||
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
|
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
|
import { createExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
|
||||||
|
|
||||||
import type { PluginCatalogService } from '../catalog/PluginCatalogService';
|
import type { PluginCatalogService } from '../catalog/PluginCatalogService';
|
||||||
|
import type { ExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
|
||||||
import type { OperationResult, PluginInstallRequest } from '@shared/types/extensions';
|
import type { OperationResult, PluginInstallRequest } from '@shared/types/extensions';
|
||||||
|
|
||||||
const logger = createLogger('Extensions:PluginInstall');
|
const logger = createLogger('Extensions:PluginInstall');
|
||||||
|
|
@ -31,7 +33,10 @@ function scopeRequiresProjectPath(scope?: string): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PluginInstallService {
|
export class PluginInstallService {
|
||||||
constructor(private readonly catalogService: PluginCatalogService) {}
|
constructor(
|
||||||
|
private readonly catalogService: PluginCatalogService,
|
||||||
|
private readonly runtimeAdapter: ExtensionsRuntimeAdapter = createExtensionsRuntimeAdapter()
|
||||||
|
) {}
|
||||||
|
|
||||||
async install(request: PluginInstallRequest): Promise<OperationResult> {
|
async install(request: PluginInstallRequest): Promise<OperationResult> {
|
||||||
const { pluginId, scope, projectPath } = request;
|
const { pluginId, scope, projectPath } = request;
|
||||||
|
|
@ -95,11 +100,12 @@ export class PluginInstallService {
|
||||||
error: CLI_NOT_FOUND_MESSAGE,
|
error: CLI_NOT_FOUND_MESSAGE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary);
|
||||||
|
|
||||||
const { stdout, stderr } = await execCli(claudeBinary, args, {
|
const { stdout, stderr } = await execCli(claudeBinary, args, {
|
||||||
timeout: INSTALL_TIMEOUT_MS,
|
timeout: INSTALL_TIMEOUT_MS,
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
env: buildEnrichedEnv(claudeBinary),
|
env,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (stderr && !stdout) {
|
if (stderr && !stdout) {
|
||||||
|
|
@ -175,11 +181,12 @@ export class PluginInstallService {
|
||||||
error: CLI_NOT_FOUND_MESSAGE,
|
error: CLI_NOT_FOUND_MESSAGE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary);
|
||||||
|
|
||||||
await execCli(claudeBinary, args, {
|
await execCli(claudeBinary, args, {
|
||||||
timeout: UNINSTALL_TIMEOUT_MS,
|
timeout: UNINSTALL_TIMEOUT_MS,
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
env: buildEnrichedEnv(claudeBinary),
|
env,
|
||||||
});
|
});
|
||||||
return { state: 'success' };
|
return { state: 'success' };
|
||||||
} catch (err) {
|
} 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',
|
code: 'has-scripts',
|
||||||
message:
|
message:
|
||||||
'This skill includes a scripts directory. Review bundled scripts before trusting it.',
|
'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 * as path from 'node:path';
|
||||||
|
|
||||||
import { getHomeDir } from '@main/utils/pathDecoder';
|
import { getHomeDir } from '@main/utils/pathDecoder';
|
||||||
|
import { SKILL_ROOT_DEFINITIONS } from '@shared/utils/skillRoots';
|
||||||
|
|
||||||
import type { SkillRootKind, SkillScope } from '@shared/types/extensions';
|
import type { SkillRootKind, SkillScope } from '@shared/types/extensions';
|
||||||
|
|
||||||
|
|
@ -11,11 +12,12 @@ export interface ResolvedSkillRoot {
|
||||||
rootPath: string;
|
rootPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const USER_ROOTS: { rootKind: SkillRootKind; segments: string[] }[] = [
|
const USER_ROOTS: { rootKind: SkillRootKind; segments: string[] }[] = SKILL_ROOT_DEFINITIONS.map(
|
||||||
{ rootKind: 'claude', segments: ['.claude', 'skills'] },
|
(definition) => ({
|
||||||
{ rootKind: 'cursor', segments: ['.cursor', 'skills'] },
|
rootKind: definition.rootKind,
|
||||||
{ rootKind: 'agents', segments: ['.agents', 'skills'] },
|
segments: [...definition.segments],
|
||||||
];
|
})
|
||||||
|
);
|
||||||
|
|
||||||
export class SkillRootsResolver {
|
export class SkillRootsResolver {
|
||||||
resolve(projectPath?: string): ResolvedSkillRoot[] {
|
resolve(projectPath?: string): ResolvedSkillRoot[] {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
|
import { formatSkillRootKind, getSkillAudience } from '@shared/utils/skillRoots';
|
||||||
|
|
||||||
import type { SkillCatalogItem } from '@shared/types/extensions';
|
import type { SkillCatalogItem } from '@shared/types/extensions';
|
||||||
|
|
||||||
const ROOT_PRECEDENCE: Record<SkillCatalogItem['rootKind'], number> = {
|
const ROOT_PRECEDENCE: Record<SkillCatalogItem['rootKind'], number> = {
|
||||||
claude: 0,
|
claude: 0,
|
||||||
cursor: 1,
|
cursor: 1,
|
||||||
agents: 2,
|
agents: 2,
|
||||||
|
codex: 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
export class SkillValidator {
|
export class SkillValidator {
|
||||||
|
|
@ -21,14 +24,14 @@ export class SkillValidator {
|
||||||
private annotateDuplicateNames(items: SkillCatalogItem[]): SkillCatalogItem[] {
|
private annotateDuplicateNames(items: SkillCatalogItem[]): SkillCatalogItem[] {
|
||||||
const itemsByName = new Map<string, SkillCatalogItem[]>();
|
const itemsByName = new Map<string, SkillCatalogItem[]>();
|
||||||
for (const item of items) {
|
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) ?? [];
|
const bucket = itemsByName.get(key) ?? [];
|
||||||
bucket.push(item);
|
bucket.push(item);
|
||||||
itemsByName.set(key, bucket);
|
itemsByName.set(key, bucket);
|
||||||
}
|
}
|
||||||
|
|
||||||
return items.map((item) => {
|
return items.map((item) => {
|
||||||
const key = item.name.trim().toLowerCase();
|
const key = `${item.name.trim().toLowerCase()}::${getSkillAudience(item.rootKind)}`;
|
||||||
const duplicates = itemsByName.get(key) ?? [];
|
const duplicates = itemsByName.get(key) ?? [];
|
||||||
if (duplicates.length <= 1) {
|
if (duplicates.length <= 1) {
|
||||||
return item;
|
return item;
|
||||||
|
|
@ -59,6 +62,7 @@ export class SkillValidator {
|
||||||
}
|
}
|
||||||
|
|
||||||
private formatRootLabel(item: SkillCatalogItem): string {
|
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 { createExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
|
||||||
import { execCli } from '@main/utils/childProcess';
|
import {
|
||||||
import { buildEnrichedEnv } from '@main/utils/cliEnv';
|
parseMcpDiagnosticsJsonOutput,
|
||||||
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
|
parseMcpDiagnosticsOutput,
|
||||||
import { createLogger } from '@shared/utils/logger';
|
} 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');
|
export { parseMcpDiagnosticsJsonOutput, parseMcpDiagnosticsOutput };
|
||||||
|
|
||||||
const TIMEOUT_MS = 30_000;
|
|
||||||
|
|
||||||
export class McpHealthDiagnosticsService {
|
export class McpHealthDiagnosticsService {
|
||||||
async diagnose(): Promise<McpServerDiagnostic[]> {
|
constructor(
|
||||||
const claudeBinary = await ClaudeBinaryResolver.resolve();
|
private readonly runtimeAdapter: ExtensionsRuntimeAdapter = createExtensionsRuntimeAdapter()
|
||||||
if (!claudeBinary) {
|
) {}
|
||||||
throw new Error(CLI_NOT_FOUND_MESSAGE);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { stdout, stderr } = await execCli(claudeBinary, ['mcp', 'list'], {
|
async diagnose(projectPath?: string): Promise<McpServerDiagnostic[]> {
|
||||||
timeout: TIMEOUT_MS,
|
return this.runtimeAdapter.diagnoseMcp(projectPath);
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,15 @@
|
||||||
/**
|
/**
|
||||||
* Reads installed MCP server state from the filesystem.
|
* Resolves installed MCP server state through the active runtime adapter.
|
||||||
*
|
*
|
||||||
* Sources:
|
* Direct Claude mode reads CLI-managed config files.
|
||||||
* - User scope: ~/.claude.json → mcpServers
|
* Multimodel mode uses the structured `mcp list --json` runtime contract.
|
||||||
* - 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.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as fs from 'node:fs/promises';
|
import { createExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
|
||||||
import * as path from 'node:path';
|
|
||||||
|
|
||||||
import { getHomeDir } from '@main/utils/pathDecoder';
|
|
||||||
import { createLogger } from '@shared/utils/logger';
|
|
||||||
|
|
||||||
|
import type { ExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
|
||||||
import type { InstalledMcpEntry } from '@shared/types/extensions';
|
import type { InstalledMcpEntry } from '@shared/types/extensions';
|
||||||
|
|
||||||
const logger = createLogger('Extensions:McpState');
|
|
||||||
|
|
||||||
const CACHE_TTL_MS = 10_000; // 10 seconds
|
const CACHE_TTL_MS = 10_000; // 10 seconds
|
||||||
|
|
||||||
interface TimedCache<T> {
|
interface TimedCache<T> {
|
||||||
|
|
@ -29,113 +20,23 @@ interface TimedCache<T> {
|
||||||
export class McpInstallationStateService {
|
export class McpInstallationStateService {
|
||||||
private cache = new Map<string, TimedCache<InstalledMcpEntry[]>>();
|
private cache = new Map<string, TimedCache<InstalledMcpEntry[]>>();
|
||||||
|
|
||||||
/**
|
constructor(
|
||||||
* Get all installed MCP servers across user, local, and project scopes.
|
private readonly runtimeAdapter: ExtensionsRuntimeAdapter = createExtensionsRuntimeAdapter()
|
||||||
*/
|
) {}
|
||||||
|
|
||||||
async getInstalled(projectPath?: string): Promise<InstalledMcpEntry[]> {
|
async getInstalled(projectPath?: string): Promise<InstalledMcpEntry[]> {
|
||||||
const cacheKey = projectPath ?? '__user__';
|
const cacheKey = `${this.runtimeAdapter.flavor}:${projectPath ?? '__user__'}`;
|
||||||
const cached = this.cache.get(cacheKey);
|
const cached = this.cache.get(cacheKey);
|
||||||
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
||||||
return cached.data;
|
return cached.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
const entries: InstalledMcpEntry[] = [];
|
const entries = await this.runtimeAdapter.getInstalledMcp(projectPath);
|
||||||
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)));
|
|
||||||
}
|
|
||||||
|
|
||||||
this.cache.set(cacheKey, { data: entries, fetchedAt: Date.now() });
|
this.cache.set(cacheKey, { data: entries, fetchedAt: Date.now() });
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Invalidate cache. Call after install/uninstall operations.
|
|
||||||
*/
|
|
||||||
invalidateCache(): void {
|
invalidateCache(): void {
|
||||||
this.cache.clear();
|
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';
|
} from '@main/utils/shellEnv';
|
||||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import { createWriteStream, existsSync, promises as fsp } from 'fs';
|
import { createWriteStream, existsSync, promises as fsp } from 'fs';
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
|
|
@ -145,7 +146,13 @@ function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallat
|
||||||
...provider,
|
...provider,
|
||||||
modelVerificationState: provider.modelVerificationState ?? 'idle',
|
modelVerificationState: provider.modelVerificationState ?? 'idle',
|
||||||
modelAvailability: provider.modelAvailability?.map((item) => ({ ...item })) ?? [],
|
modelAvailability: provider.modelAvailability?.map((item) => ({ ...item })) ?? [],
|
||||||
capabilities: { ...provider.capabilities },
|
capabilities: {
|
||||||
|
...provider.capabilities,
|
||||||
|
extensions: {
|
||||||
|
...createDefaultCliExtensionCapabilities(),
|
||||||
|
...provider.capabilities.extensions,
|
||||||
|
},
|
||||||
|
},
|
||||||
selectedBackendId: provider.selectedBackendId ?? null,
|
selectedBackendId: provider.selectedBackendId ?? null,
|
||||||
resolvedBackendId: provider.resolvedBackendId ?? null,
|
resolvedBackendId: provider.resolvedBackendId ?? null,
|
||||||
availableBackends: provider.availableBackends?.map((backend) => ({ ...backend })) ?? [],
|
availableBackends: provider.availableBackends?.map((backend) => ({ ...backend })) ?? [],
|
||||||
|
|
@ -467,6 +474,7 @@ export class CliInstallerService {
|
||||||
capabilities: {
|
capabilities: {
|
||||||
teamLaunch: false,
|
teamLaunch: false,
|
||||||
oneShot: false,
|
oneShot: false,
|
||||||
|
extensions: createDefaultCliExtensionCapabilities(),
|
||||||
},
|
},
|
||||||
backend: null,
|
backend: null,
|
||||||
}))
|
}))
|
||||||
|
|
@ -528,7 +536,13 @@ export class CliInstallerService {
|
||||||
authMethod: provider.authMethod,
|
authMethod: provider.authMethod,
|
||||||
selectedBackendId: provider.selectedBackendId ?? null,
|
selectedBackendId: provider.selectedBackendId ?? null,
|
||||||
resolvedBackendId: provider.resolvedBackendId ?? null,
|
resolvedBackendId: provider.resolvedBackendId ?? null,
|
||||||
capabilities: { ...provider.capabilities },
|
capabilities: {
|
||||||
|
...provider.capabilities,
|
||||||
|
extensions: {
|
||||||
|
...createDefaultCliExtensionCapabilities(),
|
||||||
|
...provider.capabilities.extensions,
|
||||||
|
},
|
||||||
|
},
|
||||||
backend: provider.backend ? { ...provider.backend } : null,
|
backend: provider.backend ? { ...provider.backend } : null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,12 @@ export interface NotificationConfig {
|
||||||
notifyOnTeamLaunched: boolean;
|
notifyOnTeamLaunched: boolean;
|
||||||
/** Whether to show native OS notifications when a tool needs user approval */
|
/** Whether to show native OS notifications when a tool needs user approval */
|
||||||
notifyOnToolApproval: boolean;
|
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) */
|
/** Only notify on status changes in solo teams (no teammates) */
|
||||||
statusChangeOnlySolo: boolean;
|
statusChangeOnlySolo: boolean;
|
||||||
/** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */
|
/** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */
|
||||||
|
|
@ -306,6 +312,7 @@ const DEFAULT_CONFIG: AppConfig = {
|
||||||
notifyOnCrossTeamMessage: true,
|
notifyOnCrossTeamMessage: true,
|
||||||
notifyOnTeamLaunched: true,
|
notifyOnTeamLaunched: true,
|
||||||
notifyOnToolApproval: true,
|
notifyOnToolApproval: true,
|
||||||
|
autoResumeOnRateLimit: false,
|
||||||
statusChangeOnlySolo: false,
|
statusChangeOnlySolo: false,
|
||||||
statusChangeStatuses: ['in_progress', 'completed'],
|
statusChangeStatuses: ['in_progress', 'completed'],
|
||||||
triggers: DEFAULT_TRIGGERS,
|
triggers: DEFAULT_TRIGGERS,
|
||||||
|
|
@ -502,8 +509,55 @@ export class ConfigManager {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
notifications: {
|
notifications: {
|
||||||
...DEFAULT_CONFIG.notifications,
|
enabled: loadedNotifications.enabled ?? DEFAULT_CONFIG.notifications.enabled,
|
||||||
...loadedNotifications,
|
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,
|
triggers: mergedTriggers,
|
||||||
},
|
},
|
||||||
general: mergedGeneral,
|
general: mergedGeneral,
|
||||||
|
|
|
||||||
|
|
@ -543,10 +543,10 @@ export class NotificationManager extends EventEmitter {
|
||||||
logger.debug(`[test-notification] creating Notification (platform=${process.platform})`);
|
logger.debug(`[test-notification] creating Notification (platform=${process.platform})`);
|
||||||
const notification = new NotificationClass({
|
const notification = new NotificationClass({
|
||||||
title: 'Test Notification',
|
title: 'Test Notification',
|
||||||
...(isMac ? { subtitle: 'Claude Agent Teams UI' } : {}),
|
...(isMac ? { subtitle: 'Agent Teams UI' } : {}),
|
||||||
body: isMac
|
body: isMac
|
||||||
? 'Notifications are working correctly!'
|
? 'Notifications are working correctly!'
|
||||||
: 'Claude Agent Teams UI\nNotifications are working correctly!',
|
: 'Agent Teams UI\nNotifications are working correctly!',
|
||||||
...(iconPath ? { icon: iconPath } : {}),
|
...(iconPath ? { icon: iconPath } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import { execCli } from '@main/utils/childProcess';
|
import { execCli } from '@main/utils/childProcess';
|
||||||
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import {
|
||||||
|
createDefaultCliExtensionCapabilities,
|
||||||
|
createLegacyRuntimeFallbackCliExtensionCapabilities,
|
||||||
|
} from '@shared/utils/providerExtensionCapabilities';
|
||||||
|
|
||||||
import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth';
|
import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth';
|
||||||
import { buildProviderAwareCliEnv } from './providerAwareCliEnv';
|
import { buildProviderAwareCliEnv } from './providerAwareCliEnv';
|
||||||
|
|
@ -13,6 +17,19 @@ const logger = createLogger('ClaudeMultimodelBridgeService');
|
||||||
const PROVIDER_STATUS_TIMEOUT_MS = 10_000;
|
const PROVIDER_STATUS_TIMEOUT_MS = 10_000;
|
||||||
const PROVIDER_MODELS_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 {
|
interface ProviderStatusCommandResponse {
|
||||||
schemaVersion?: number;
|
schemaVersion?: number;
|
||||||
providers?: Record<
|
providers?: Record<
|
||||||
|
|
@ -27,6 +44,7 @@ interface ProviderStatusCommandResponse {
|
||||||
capabilities?: {
|
capabilities?: {
|
||||||
teamLaunch?: boolean;
|
teamLaunch?: boolean;
|
||||||
oneShot?: boolean;
|
oneShot?: boolean;
|
||||||
|
extensions?: RuntimeExtensionCapabilitiesResponse;
|
||||||
};
|
};
|
||||||
backend?: {
|
backend?: {
|
||||||
kind?: string;
|
kind?: string;
|
||||||
|
|
@ -84,6 +102,7 @@ interface UnifiedRuntimeStatusResponse {
|
||||||
capabilities?: {
|
capabilities?: {
|
||||||
teamLaunch?: boolean;
|
teamLaunch?: boolean;
|
||||||
oneShot?: boolean;
|
oneShot?: boolean;
|
||||||
|
extensions?: RuntimeExtensionCapabilitiesResponse;
|
||||||
};
|
};
|
||||||
backend?: {
|
backend?: {
|
||||||
kind?: string;
|
kind?: string;
|
||||||
|
|
@ -129,6 +148,7 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat
|
||||||
capabilities: {
|
capabilities: {
|
||||||
teamLaunch: false,
|
teamLaunch: false,
|
||||||
oneShot: false,
|
oneShot: false,
|
||||||
|
extensions: createLegacyRuntimeFallbackCliExtensionCapabilities(),
|
||||||
},
|
},
|
||||||
selectedBackendId: null,
|
selectedBackendId: null,
|
||||||
resolvedBackendId: 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(
|
function extractModelIds(
|
||||||
models: (string | { id?: string; label?: string; description?: string })[] | undefined
|
models: (string | { id?: string; label?: string; description?: string })[] | undefined
|
||||||
): string[] {
|
): string[] {
|
||||||
|
|
@ -203,6 +258,7 @@ export class ClaudeMultimodelBridgeService {
|
||||||
capabilities: {
|
capabilities: {
|
||||||
teamLaunch: runtimeStatus.capabilities?.teamLaunch === true,
|
teamLaunch: runtimeStatus.capabilities?.teamLaunch === true,
|
||||||
oneShot: runtimeStatus.capabilities?.oneShot === true,
|
oneShot: runtimeStatus.capabilities?.oneShot === true,
|
||||||
|
extensions: mapRuntimeExtensionCapabilities(runtimeStatus.capabilities?.extensions),
|
||||||
},
|
},
|
||||||
selectedBackendId: runtimeStatus.selectedBackendId ?? null,
|
selectedBackendId: runtimeStatus.selectedBackendId ?? null,
|
||||||
resolvedBackendId: runtimeStatus.resolvedBackendId ?? null,
|
resolvedBackendId: runtimeStatus.resolvedBackendId ?? null,
|
||||||
|
|
@ -325,6 +381,7 @@ export class ClaudeMultimodelBridgeService {
|
||||||
provider.capabilities = {
|
provider.capabilities = {
|
||||||
teamLaunch: true,
|
teamLaunch: true,
|
||||||
oneShot: true,
|
oneShot: true,
|
||||||
|
extensions: createDefaultCliExtensionCapabilities(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -428,6 +485,7 @@ export class ClaudeMultimodelBridgeService {
|
||||||
capabilities: {
|
capabilities: {
|
||||||
teamLaunch: runtimeStatus.capabilities?.teamLaunch === true,
|
teamLaunch: runtimeStatus.capabilities?.teamLaunch === true,
|
||||||
oneShot: runtimeStatus.capabilities?.oneShot === true,
|
oneShot: runtimeStatus.capabilities?.oneShot === true,
|
||||||
|
extensions: mapRuntimeExtensionCapabilities(runtimeStatus.capabilities?.extensions),
|
||||||
},
|
},
|
||||||
backend: runtimeStatus.backend?.kind
|
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 { yieldToEventLoop } from '@main/utils/asyncYield';
|
||||||
import {
|
import { getClaudeBasePath, getTasksBasePath, getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||||
encodePath,
|
|
||||||
extractBaseDir,
|
|
||||||
getClaudeBasePath,
|
|
||||||
getProjectsBasePath,
|
|
||||||
getTasksBasePath,
|
|
||||||
getTeamsBasePath,
|
|
||||||
} from '@main/utils/pathDecoder';
|
|
||||||
import { killProcessByPid } from '@main/utils/processKill';
|
import { killProcessByPid } from '@main/utils/processKill';
|
||||||
import {
|
import {
|
||||||
AGENT_BLOCK_CLOSE,
|
AGENT_BLOCK_CLOSE,
|
||||||
|
|
@ -16,7 +9,7 @@ import {
|
||||||
} from '@shared/constants/agentBlocks';
|
} from '@shared/constants/agentBlocks';
|
||||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||||
import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics';
|
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 { createLogger } from '@shared/utils/logger';
|
||||||
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
|
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
|
||||||
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
||||||
|
|
@ -40,6 +33,10 @@ import {
|
||||||
import { atomicWriteAsync } from './atomicWrite';
|
import { atomicWriteAsync } from './atomicWrite';
|
||||||
import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor';
|
import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor';
|
||||||
import { MemberActivityMetaService } from './MemberActivityMetaService';
|
import { MemberActivityMetaService } from './MemberActivityMetaService';
|
||||||
|
import {
|
||||||
|
getLiveLeadProcessMessageKey,
|
||||||
|
mergeLiveLeadProcessMessages,
|
||||||
|
} from './mergeLiveLeadProcessMessages';
|
||||||
import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils';
|
import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils';
|
||||||
import { TeamConfigReader } from './TeamConfigReader';
|
import { TeamConfigReader } from './TeamConfigReader';
|
||||||
import { TeamInboxReader } from './TeamInboxReader';
|
import { TeamInboxReader } from './TeamInboxReader';
|
||||||
|
|
@ -54,6 +51,7 @@ import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
||||||
import { TeamTaskCommentNotificationJournal } from './TeamTaskCommentNotificationJournal';
|
import { TeamTaskCommentNotificationJournal } from './TeamTaskCommentNotificationJournal';
|
||||||
import { TeamTaskReader } from './TeamTaskReader';
|
import { TeamTaskReader } from './TeamTaskReader';
|
||||||
import { TeamTaskWriter } from './TeamTaskWriter';
|
import { TeamTaskWriter } from './TeamTaskWriter';
|
||||||
|
import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver';
|
||||||
|
|
||||||
import type { PersistedTaskChangePresenceIndex } from './cache/taskChangePresenceCacheTypes';
|
import type { PersistedTaskChangePresenceIndex } from './cache/taskChangePresenceCacheTypes';
|
||||||
import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository';
|
import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository';
|
||||||
|
|
@ -194,12 +192,15 @@ export class TeamDataService {
|
||||||
private readonly taskCommentNotificationJournal: TeamTaskCommentNotificationJournal = new TeamTaskCommentNotificationJournal(),
|
private readonly taskCommentNotificationJournal: TeamTaskCommentNotificationJournal = new TeamTaskCommentNotificationJournal(),
|
||||||
private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore(),
|
private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore(),
|
||||||
private memberRuntimeAdvisoryService: TeamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService(),
|
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({
|
this.messageFeedService = new TeamMessageFeedService({
|
||||||
getConfig: (teamName) => this.configReader.getConfig(teamName),
|
getConfig: (teamName) => this.configReader.getConfig(teamName),
|
||||||
getInboxMessages: (teamName) => this.inboxReader.getMessages(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),
|
getSentMessages: (teamName) => this.sentMessagesStore.readMessages(teamName),
|
||||||
});
|
});
|
||||||
this.memberActivityMetaService = new MemberActivityMetaService(this.messageFeedService);
|
this.memberActivityMetaService = new MemberActivityMetaService(this.messageFeedService);
|
||||||
|
|
@ -910,10 +911,14 @@ export class TeamDataService {
|
||||||
*/
|
*/
|
||||||
async getMessagesPage(
|
async getMessagesPage(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
options: { cursor?: string | null; limit: number }
|
options: { cursor?: string | null; limit: number; liveMessages?: InboxMessage[] }
|
||||||
): Promise<MessagesPage> {
|
): Promise<MessagesPage> {
|
||||||
const feed = await this.messageFeedService.getFeed(teamName);
|
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) {
|
if (options.cursor) {
|
||||||
const [cursorTs, cursorId] = options.cursor.split('|');
|
const [cursorTs, cursorId] = options.cursor.split('|');
|
||||||
|
|
@ -933,7 +938,61 @@ export class TeamDataService {
|
||||||
const nextCursor =
|
const nextCursor =
|
||||||
hasMore && lastMsg ? `${lastMsg.timestamp}|${requireCanonicalMessageId(lastMsg)}` : null;
|
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(
|
async getMessageFeed(
|
||||||
|
|
@ -2369,37 +2428,20 @@ export class TeamDataService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getLeadProjectDirCandidates(projectPath: string): string[] {
|
private async getLeadSessionJsonlPaths(projectDir: string): Promise<Map<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>> {
|
|
||||||
const jsonlPaths = new Map<string, string>();
|
const jsonlPaths = new Map<string, string>();
|
||||||
for (const dirPath of this.getLeadProjectDirCandidates(projectPath)) {
|
let entries: fs.Dirent[];
|
||||||
let entries: fs.Dirent[];
|
try {
|
||||||
try {
|
entries = await fs.promises.readdir(projectDir, { withFileTypes: true });
|
||||||
entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
} catch {
|
||||||
} catch {
|
return jsonlPaths;
|
||||||
continue;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
|
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
|
||||||
const sessionId = entry.name.slice(0, -'.jsonl'.length).trim();
|
const sessionId = entry.name.slice(0, -'.jsonl'.length).trim();
|
||||||
if (!sessionId || jsonlPaths.has(sessionId)) continue;
|
if (!sessionId || jsonlPaths.has(sessionId)) continue;
|
||||||
jsonlPaths.set(sessionId, path.join(dirPath, entry.name));
|
jsonlPaths.set(sessionId, path.join(projectDir, entry.name));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return jsonlPaths;
|
return jsonlPaths;
|
||||||
|
|
@ -2645,17 +2687,23 @@ export class TeamDataService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async extractLeadSessionTexts(config: TeamConfig): Promise<InboxMessage[]> {
|
private async extractLeadSessionTexts(
|
||||||
if (!config.projectPath) {
|
teamName: string,
|
||||||
|
config: TeamConfig
|
||||||
|
): Promise<InboxMessage[]> {
|
||||||
|
const transcriptContext = await this.projectResolver.getContext(teamName);
|
||||||
|
if (!transcriptContext) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
const leadName =
|
||||||
const leadName = config.members?.find((m) => isLeadMember(m))?.name ?? 'team-lead';
|
transcriptContext.config.members?.find((m) => isLeadMember(m))?.name ?? 'team-lead';
|
||||||
const sessionIds = this.getRecentLeadSessionIds(config);
|
const sessionIds = Array.from(
|
||||||
|
new Set([...this.getRecentLeadSessionIds(config), ...transcriptContext.sessionIds])
|
||||||
|
);
|
||||||
if (sessionIds.length === 0) {
|
if (sessionIds.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const availableJsonlPaths = await this.getLeadSessionJsonlPaths(config.projectPath);
|
const availableJsonlPaths = await this.getLeadSessionJsonlPaths(transcriptContext.projectDir);
|
||||||
if (availableJsonlPaths.size === 0) {
|
if (availableJsonlPaths.size === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000;
|
||||||
interface TeamMessageFeedDeps {
|
interface TeamMessageFeedDeps {
|
||||||
getConfig: (teamName: string) => Promise<TeamConfig | null>;
|
getConfig: (teamName: string) => Promise<TeamConfig | null>;
|
||||||
getInboxMessages: (teamName: string) => Promise<InboxMessage[]>;
|
getInboxMessages: (teamName: string) => Promise<InboxMessage[]>;
|
||||||
getLeadSessionMessages: (config: TeamConfig) => Promise<InboxMessage[]>;
|
getLeadSessionMessages: (teamName: string, config: TeamConfig) => Promise<InboxMessage[]>;
|
||||||
getSentMessages: (teamName: string) => Promise<InboxMessage[]>;
|
getSentMessages: (teamName: string) => Promise<InboxMessage[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -370,7 +370,7 @@ export class TeamMessageFeedService {
|
||||||
|
|
||||||
const [inboxMessages, leadTexts, sentMessages] = await Promise.all([
|
const [inboxMessages, leadTexts, sentMessages] = await Promise.all([
|
||||||
this.deps.getInboxMessages(teamName).catch(() => [] as InboxMessage[]),
|
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[]),
|
this.deps.getSentMessages(teamName).catch(() => [] as InboxMessage[]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@ import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||||
import { TeamMetaStore } from './TeamMetaStore';
|
import { TeamMetaStore } from './TeamMetaStore';
|
||||||
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
||||||
import { TeamTaskReader } from './TeamTaskReader';
|
import { TeamTaskReader } from './TeamTaskReader';
|
||||||
|
import { peekAutoResumeService } from './AutoResumeService';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kill a team CLI process using SIGKILL (uncatchable).
|
* Kill a team CLI process using SIGKILL (uncatchable).
|
||||||
|
|
@ -2413,6 +2414,40 @@ export class TeamProvisioningService {
|
||||||
return this.getProvisioningRunId(teamName) ?? this.getAliveRunId(teamName);
|
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 {
|
private appendCliLogs(run: ProvisioningRun, stream: 'stdout' | 'stderr', text: string): void {
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
run.claudeLogsUpdatedAt = new Date(nowMs).toISOString();
|
run.claudeLogsUpdatedAt = new Date(nowMs).toISOString();
|
||||||
|
|
@ -3191,7 +3226,58 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
getLiveLeadProcessMessages(teamName: string): InboxMessage[] {
|
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): {
|
getLeadActivityState(teamName: string): {
|
||||||
|
|
@ -5110,6 +5196,7 @@ export class TeamProvisioningService {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.resetTeamScopedTransientStateForNewRun(request.teamName);
|
||||||
this.runs.set(runId, run);
|
this.runs.set(runId, run);
|
||||||
this.provisioningRunByTeam.set(request.teamName, runId);
|
this.provisioningRunByTeam.set(request.teamName, runId);
|
||||||
run.onProgress(run.progress);
|
run.onProgress(run.progress);
|
||||||
|
|
@ -5655,7 +5742,7 @@ export class TeamProvisioningService {
|
||||||
pendingInboxRelayCandidates: [],
|
pendingInboxRelayCandidates: [],
|
||||||
provisioningOutputParts: [],
|
provisioningOutputParts: [],
|
||||||
provisioningOutputIndexByMessageId: new Map(),
|
provisioningOutputIndexByMessageId: new Map(),
|
||||||
detectedSessionId: null,
|
detectedSessionId: previousSessionId ?? null,
|
||||||
leadActivityState: 'active',
|
leadActivityState: 'active',
|
||||||
leadContextUsage: null,
|
leadContextUsage: null,
|
||||||
authFailureRetried: false,
|
authFailureRetried: false,
|
||||||
|
|
@ -5696,6 +5783,7 @@ export class TeamProvisioningService {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.resetTeamScopedTransientStateForNewRun(request.teamName);
|
||||||
this.runs.set(runId, run);
|
this.runs.set(runId, run);
|
||||||
this.provisioningRunByTeam.set(request.teamName, runId);
|
this.provisioningRunByTeam.set(request.teamName, runId);
|
||||||
run.onProgress(run.progress);
|
run.onProgress(run.progress);
|
||||||
|
|
@ -5954,6 +6042,21 @@ export class TeamProvisioningService {
|
||||||
throw new Error(`Team "${teamName}" process stdin is not writable`);
|
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 }];
|
const contentBlocks: Record<string, unknown>[] = [{ type: 'text', text: message }];
|
||||||
if (attachments?.length) {
|
if (attachments?.length) {
|
||||||
for (const att of attachments) {
|
for (const att of attachments) {
|
||||||
|
|
@ -6073,7 +6176,7 @@ export class TeamProvisioningService {
|
||||||
userText,
|
userText,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
await this.sendMessageToTeam(teamName, message);
|
await this.sendMessageToRun(run, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
async relayMemberInboxMessages(teamName: string, memberName: string): Promise<number> {
|
async relayMemberInboxMessages(teamName: string, memberName: string): Promise<number> {
|
||||||
|
|
@ -6095,6 +6198,8 @@ export class TeamProvisioningService {
|
||||||
const run = this.runs.get(runId);
|
const run = this.runs.get(runId);
|
||||||
if (!run?.child || run.processKilled || run.cancelRequested) return 0;
|
if (!run?.child || run.processKilled || run.cancelRequested) return 0;
|
||||||
if (!run.provisioningComplete) 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>();
|
const relayedIds = this.relayedMemberInboxMessageIds.get(relayKey) ?? new Set<string>();
|
||||||
|
|
||||||
|
|
@ -6104,6 +6209,7 @@ export class TeamProvisioningService {
|
||||||
} catch {
|
} catch {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
if (isStaleRelayRun()) return 0;
|
||||||
|
|
||||||
const unread = memberInboxMessages
|
const unread = memberInboxMessages
|
||||||
.filter((m): m is InboxMessage & { messageId: string } => {
|
.filter((m): m is InboxMessage & { messageId: string } => {
|
||||||
|
|
@ -6134,6 +6240,7 @@ export class TeamProvisioningService {
|
||||||
.map(({ message }) => message);
|
.map(({ message }) => message);
|
||||||
|
|
||||||
const readOnlyIgnoredUnread = [...silentNoiseUnread, ...passiveIdleUnread];
|
const readOnlyIgnoredUnread = [...silentNoiseUnread, ...passiveIdleUnread];
|
||||||
|
if (isStaleRelayRun()) return 0;
|
||||||
|
|
||||||
if (readOnlyIgnoredUnread.length > 0) {
|
if (readOnlyIgnoredUnread.length > 0) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -6207,7 +6314,7 @@ export class TeamProvisioningService {
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.sendMessageToTeam(teamName, message);
|
await this.sendMessageToRun(run, message);
|
||||||
} catch {
|
} catch {
|
||||||
this.forgetPendingInboxRelayCandidates(run, memberName, rememberedRelayIds);
|
this.forgetPendingInboxRelayCandidates(run, memberName, rememberedRelayIds);
|
||||||
return 0;
|
return 0;
|
||||||
|
|
@ -6263,6 +6370,8 @@ export class TeamProvisioningService {
|
||||||
if (!runId) return 0;
|
if (!runId) return 0;
|
||||||
const run = this.runs.get(runId);
|
const run = this.runs.get(runId);
|
||||||
if (!run?.child || run.processKilled || run.cancelRequested) return 0;
|
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
|
// Permission request scan runs even during provisioning — teammates may need
|
||||||
// tool approval before the lead's first turn completes. CLI marks inbox messages
|
// tool approval before the lead's first turn completes. CLI marks inbox messages
|
||||||
|
|
@ -6273,10 +6382,12 @@ export class TeamProvisioningService {
|
||||||
} catch {
|
} catch {
|
||||||
// config not ready yet during early provisioning — skip scan
|
// config not ready yet during early provisioning — skip scan
|
||||||
}
|
}
|
||||||
|
if (isStaleRelayRun()) return 0;
|
||||||
if (config) {
|
if (config) {
|
||||||
const leadName = config.members?.find((m) => isLeadMember(m))?.name?.trim() || 'team-lead';
|
const leadName = config.members?.find((m) => isLeadMember(m))?.name?.trim() || 'team-lead';
|
||||||
try {
|
try {
|
||||||
const leadInboxMessages = await this.inboxReader.getMessagesFor(teamName, leadName);
|
const leadInboxMessages = await this.inboxReader.getMessagesFor(teamName, leadName);
|
||||||
|
if (isStaleRelayRun()) return 0;
|
||||||
const permMsgsToMarkRead: { messageId: string }[] = [];
|
const permMsgsToMarkRead: { messageId: string }[] = [];
|
||||||
const runStartedAtMs = Date.parse(run.startedAt);
|
const runStartedAtMs = Date.parse(run.startedAt);
|
||||||
for (const msg of leadInboxMessages) {
|
for (const msg of leadInboxMessages) {
|
||||||
|
|
@ -6321,6 +6432,7 @@ export class TeamProvisioningService {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (isStaleRelayRun()) return 0;
|
||||||
if (!config) return 0;
|
if (!config) return 0;
|
||||||
|
|
||||||
const leadName = config.members?.find((m) => isLeadMember(m))?.name?.trim() || 'team-lead';
|
const leadName = config.members?.find((m) => isLeadMember(m))?.name?.trim() || 'team-lead';
|
||||||
|
|
@ -6330,8 +6442,10 @@ export class TeamProvisioningService {
|
||||||
} catch {
|
} catch {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
if (isStaleRelayRun()) return 0;
|
||||||
|
|
||||||
await this.refreshMemberSpawnStatusesFromLeadInbox(run);
|
await this.refreshMemberSpawnStatusesFromLeadInbox(run);
|
||||||
|
if (isStaleRelayRun()) return 0;
|
||||||
|
|
||||||
const unread = leadInboxMessages
|
const unread = leadInboxMessages
|
||||||
.filter((m): m is InboxMessage & { messageId: string } => {
|
.filter((m): m is InboxMessage & { messageId: string } => {
|
||||||
|
|
@ -6469,6 +6583,7 @@ export class TeamProvisioningService {
|
||||||
...passiveIdleUnread.map((m) => m.messageId),
|
...passiveIdleUnread.map((m) => m.messageId),
|
||||||
]);
|
]);
|
||||||
const remainingUnread = unread.filter((m) => !readOnlyIgnoredIds.has(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).
|
// Category 2: same-team native delivery confirmation (one-to-one pairing).
|
||||||
const { nativeMatchedMessageIds, persisted: sameTeamPersisted } =
|
const { nativeMatchedMessageIds, persisted: sameTeamPersisted } =
|
||||||
|
|
@ -6631,7 +6746,7 @@ export class TeamProvisioningService {
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.sendMessageToTeam(teamName, message);
|
await this.sendMessageToRun(run, message);
|
||||||
} catch {
|
} catch {
|
||||||
if (run.leadRelayCapture) {
|
if (run.leadRelayCapture) {
|
||||||
clearTimeout(run.leadRelayCapture.timeoutHandle);
|
clearTimeout(run.leadRelayCapture.timeoutHandle);
|
||||||
|
|
@ -7677,6 +7792,12 @@ export class TeamProvisioningService {
|
||||||
if (result.deduplicated) {
|
if (result.deduplicated) {
|
||||||
return;
|
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 = {
|
const msg: InboxMessage = {
|
||||||
from: leadName,
|
from: leadName,
|
||||||
to: recipient.startsWith('cross-team:')
|
to: recipient.startsWith('cross-team:')
|
||||||
|
|
@ -7904,11 +8025,18 @@ export class TeamProvisioningService {
|
||||||
private pushLiveLeadTextMessage(
|
private pushLiveLeadTextMessage(
|
||||||
run: ProvisioningRun,
|
run: ProvisioningRun,
|
||||||
cleanText: string,
|
cleanText: string,
|
||||||
stableMessageId?: string
|
stableMessageId?: string,
|
||||||
|
messageTimestamp?: string
|
||||||
): void {
|
): void {
|
||||||
run.leadMsgSeq += 1;
|
run.leadMsgSeq += 1;
|
||||||
const leadName = this.getRunLeadName(run);
|
const leadName = this.getRunLeadName(run);
|
||||||
const messageId = stableMessageId || `lead-turn-${run.runId}-${run.leadMsgSeq}`;
|
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.
|
// Attach accumulated tool call details from preceding tool_use messages, then reset.
|
||||||
const toolCalls = run.pendingToolCalls.length > 0 ? [...run.pendingToolCalls] : undefined;
|
const toolCalls = run.pendingToolCalls.length > 0 ? [...run.pendingToolCalls] : undefined;
|
||||||
const toolSummary = toolCalls ? formatToolSummaryFromCalls(toolCalls) : undefined;
|
const toolSummary = toolCalls ? formatToolSummaryFromCalls(toolCalls) : undefined;
|
||||||
|
|
@ -7916,7 +8044,7 @@ export class TeamProvisioningService {
|
||||||
const leadMsg: InboxMessage = {
|
const leadMsg: InboxMessage = {
|
||||||
from: leadName,
|
from: leadName,
|
||||||
text: cleanText,
|
text: cleanText,
|
||||||
timestamp: nowIso(),
|
timestamp,
|
||||||
read: true,
|
read: true,
|
||||||
summary: cleanText.length > 60 ? cleanText.slice(0, 57) + '...' : cleanText,
|
summary: cleanText.length > 60 ? cleanText.slice(0, 57) + '...' : cleanText,
|
||||||
messageId,
|
messageId,
|
||||||
|
|
@ -8264,6 +8392,18 @@ export class TeamProvisioningService {
|
||||||
// stream-json output has various message types:
|
// stream-json output has various message types:
|
||||||
// {"type":"assistant","content":[{"type":"text","text":"..."},...]}
|
// {"type":"assistant","content":[{"type":"text","text":"..."},...]}
|
||||||
// {"type":"result","subtype":"success",...}
|
// {"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') {
|
if (msg.type === 'user') {
|
||||||
// Check for permission_request in raw user message text BEFORE teammate-message parsing.
|
// 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,
|
// 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);
|
.map((part) => part.text as string);
|
||||||
if (textParts.length > 0) {
|
if (textParts.length > 0) {
|
||||||
const text = textParts.join('\n');
|
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")
|
// 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".
|
// rather than stderr or a result.subtype=error. Detect early to avoid false "ready".
|
||||||
this.handleAuthFailureInOutput(run, text, 'assistant');
|
this.handleAuthFailureInOutput(run, text, 'assistant');
|
||||||
|
|
@ -8348,7 +8494,8 @@ export class TeamProvisioningService {
|
||||||
this.pushLiveLeadTextMessage(
|
this.pushLiveLeadTextMessage(
|
||||||
run,
|
run,
|
||||||
cleanText,
|
cleanText,
|
||||||
this.getStableLeadThoughtMessageId(msg) ?? undefined
|
this.getStableLeadThoughtMessageId(msg) ?? undefined,
|
||||||
|
messageTimestamp
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -8361,7 +8508,8 @@ export class TeamProvisioningService {
|
||||||
this.pushLiveLeadTextMessage(
|
this.pushLiveLeadTextMessage(
|
||||||
run,
|
run,
|
||||||
cleanText,
|
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)) {
|
if (this.handleDeterministicBootstrapEvent(run, msg)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -10041,7 +10178,7 @@ export class TeamProvisioningService {
|
||||||
`Не стартовали тиммейты: ${failedSpawnMembers.map((member) => `@${member.name}`).join(', ')}.`,
|
`Не стартовали тиммейты: ${failedSpawnMembers.map((member) => `@${member.name}`).join(', ')}.`,
|
||||||
`Не считай их доступными, пока их запуск не будет повторён успешно.`,
|
`Не считай их доступными, пока их запуск не будет повторён успешно.`,
|
||||||
].join(' ');
|
].join(' ');
|
||||||
await this.sendMessageToTeam(run.teamName, failureNotice).catch((error: unknown) =>
|
await this.sendMessageToRun(run, failureNotice).catch((error: unknown) =>
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[${run.teamName}] failed to send teammate-start failure notice to lead: ${
|
`[${run.teamName}] failed to send teammate-start failure notice to lead: ${
|
||||||
error instanceof Error ? error.message : String(error)
|
error instanceof Error ? error.message : String(error)
|
||||||
|
|
@ -10089,7 +10226,7 @@ export class TeamProvisioningService {
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join('\n\n');
|
.join('\n\n');
|
||||||
|
|
||||||
await this.sendMessageToTeam(run.teamName, message);
|
await this.sendMessageToRun(run, message);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[${run.teamName}] Failed to kick off solo task resumption: ${
|
`[${run.teamName}] Failed to kick off solo task resumption: ${
|
||||||
|
|
@ -10209,7 +10346,7 @@ export class TeamProvisioningService {
|
||||||
`Не стартовали тиммейты: ${failedSpawnMembers.map((member) => `@${member.name}`).join(', ')}.`,
|
`Не стартовали тиммейты: ${failedSpawnMembers.map((member) => `@${member.name}`).join(', ')}.`,
|
||||||
`Не считай их доступными, пока их запуск не будет повторён успешно.`,
|
`Не считай их доступными, пока их запуск не будет повторён успешно.`,
|
||||||
].join(' ');
|
].join(' ');
|
||||||
await this.sendMessageToTeam(run.teamName, failureNotice).catch((error: unknown) =>
|
await this.sendMessageToRun(run, failureNotice).catch((error: unknown) =>
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[${run.teamName}] failed to send teammate-start failure notice to lead: ${
|
`[${run.teamName}] failed to send teammate-start failure notice to lead: ${
|
||||||
error instanceof Error ? error.message : String(error)
|
error instanceof Error ? error.message : String(error)
|
||||||
|
|
@ -10529,7 +10666,14 @@ export class TeamProvisioningService {
|
||||||
* Remove a run from tracking maps.
|
* Remove a run from tracking maps.
|
||||||
*/
|
*/
|
||||||
private cleanupRun(run: ProvisioningRun): void {
|
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');
|
void this.persistLaunchStateSnapshot(run, 'finished');
|
||||||
}
|
}
|
||||||
this.resetRuntimeToolActivity(run);
|
this.resetRuntimeToolActivity(run);
|
||||||
|
|
@ -10558,19 +10702,13 @@ export class TeamProvisioningService {
|
||||||
if (this.aliveRunByTeam.get(run.teamName) === run.runId) {
|
if (this.aliveRunByTeam.get(run.teamName) === run.runId) {
|
||||||
this.aliveRunByTeam.delete(run.teamName);
|
this.aliveRunByTeam.delete(run.teamName);
|
||||||
}
|
}
|
||||||
this.leadInboxRelayInFlight.delete(run.teamName);
|
if (!hasNewerTrackedRun) {
|
||||||
this.relayedLeadInboxMessageIds.delete(run.teamName);
|
this.leadInboxRelayInFlight.delete(run.teamName);
|
||||||
this.pendingCrossTeamFirstReplies.delete(run.teamName);
|
this.relayedLeadInboxMessageIds.delete(run.teamName);
|
||||||
this.recentCrossTeamLeadDeliveryMessageIds.delete(run.teamName);
|
this.pendingCrossTeamFirstReplies.delete(run.teamName);
|
||||||
this.recentSameTeamNativeFingerprints.delete(run.teamName);
|
this.recentCrossTeamLeadDeliveryMessageIds.delete(run.teamName);
|
||||||
// Clear same-team retry timers
|
this.recentSameTeamNativeFingerprints.delete(run.teamName);
|
||||||
for (const suffix of ['deferred', 'persist']) {
|
this.clearSameTeamRetryTimers(run.teamName);
|
||||||
const key = `same-team-${suffix}:${run.teamName}`;
|
|
||||||
const timer = this.pendingTimeouts.get(key);
|
|
||||||
if (timer) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
this.pendingTimeouts.delete(key);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for (const memberName of run.memberSpawnStatuses.keys()) {
|
for (const memberName of run.memberSpawnStatuses.keys()) {
|
||||||
const key = this.getMemberLaunchGraceKey(run, memberName);
|
const key = this.getMemberLaunchGraceKey(run, memberName);
|
||||||
|
|
@ -10582,17 +10720,21 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
run.activeCrossTeamReplyHints = [];
|
run.activeCrossTeamReplyHints = [];
|
||||||
run.pendingInboxRelayCandidates = [];
|
run.pendingInboxRelayCandidates = [];
|
||||||
for (const key of Array.from(this.memberInboxRelayInFlight.keys())) {
|
if (!hasNewerTrackedRun) {
|
||||||
if (key.startsWith(`${run.teamName}:`)) {
|
for (const key of Array.from(this.memberInboxRelayInFlight.keys())) {
|
||||||
this.memberInboxRelayInFlight.delete(key);
|
if (key.startsWith(`${run.teamName}:`)) {
|
||||||
|
this.memberInboxRelayInFlight.delete(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
for (const key of Array.from(this.relayedMemberInboxMessageIds.keys())) {
|
||||||
for (const key of Array.from(this.relayedMemberInboxMessageIds.keys())) {
|
if (key.startsWith(`${run.teamName}:`)) {
|
||||||
if (key.startsWith(`${run.teamName}:`)) {
|
this.relayedMemberInboxMessageIds.delete(key);
|
||||||
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
|
// Dismiss any pending tool approvals for this run
|
||||||
if (run.pendingApprovals.size > 0) {
|
if (run.pendingApprovals.size > 0) {
|
||||||
for (const requestId of run.pendingApprovals.keys()) {
|
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 { createLogger } from '@shared/utils/logger';
|
||||||
import { createReadStream, type Dirent } from 'fs';
|
import { createReadStream, type Dirent } from 'fs';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
|
|
@ -15,6 +23,33 @@ const SESSION_DISCOVERY_CACHE_TTL = 30_000;
|
||||||
const TEAM_AFFINITY_SCAN_LINES = 40;
|
const TEAM_AFFINITY_SCAN_LINES = 40;
|
||||||
const ROOT_DISCOVERY_CONCURRENCY = 12;
|
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 {
|
function trimTrailingSlashes(value: string): string {
|
||||||
let end = value.length;
|
let end = value.length;
|
||||||
while (end > 0) {
|
while (end > 0) {
|
||||||
|
|
@ -32,6 +67,17 @@ function isSessionDirectoryName(name: string): boolean {
|
||||||
return name !== 'memory' && !name.startsWith('.');
|
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 {
|
function extractTextContent(entry: Record<string, unknown>): string | null {
|
||||||
if (typeof entry.content === 'string') {
|
if (typeof entry.content === 'string') {
|
||||||
return entry.content;
|
return entry.content;
|
||||||
|
|
@ -71,6 +117,9 @@ function lineMentionsTeam(text: string, teamName: string): boolean {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return (
|
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(`on team '${normalizedTeam}'`) ||
|
normalizedText.includes(`on team '${normalizedTeam}'`) ||
|
||||||
normalizedText.includes(`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[] {
|
function collectKnownSessionIds(config: TeamConfig): string[] {
|
||||||
const knownSessionIds = new Set<string>();
|
const knownSessionIds = new Set<string>();
|
||||||
const push = (value: unknown): void => {
|
const push = (value: unknown): void => {
|
||||||
|
|
@ -93,7 +164,8 @@ function collectKnownSessionIds(config: TeamConfig): string[] {
|
||||||
|
|
||||||
push(config.leadSessionId);
|
push(config.leadSessionId);
|
||||||
if (Array.isArray(config.sessionHistory)) {
|
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);
|
push(sessionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -130,13 +202,39 @@ export class TeamTranscriptProjectResolver {
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await this.configReader.getConfig(teamName);
|
const config = await this.configReader.getConfig(teamName);
|
||||||
if (!config?.projectPath) {
|
if (!config) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { projectDir, projectId } = await this.resolveProjectDirectory(config);
|
const resolution = await this.resolveProjectDirectory(teamName, config);
|
||||||
const sessionIds = await this.discoverSessionIds(teamName, projectDir, config);
|
if (!resolution) {
|
||||||
const value = { projectDir, projectId, config, sessionIds };
|
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, {
|
this.contextCache.set(teamName, {
|
||||||
value,
|
value,
|
||||||
expiresAt: Date.now() + SESSION_DISCOVERY_CACHE_TTL,
|
expiresAt: Date.now() + SESSION_DISCOVERY_CACHE_TTL,
|
||||||
|
|
@ -145,47 +243,391 @@ export class TeamTranscriptProjectResolver {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveProjectDirectory(
|
private async resolveProjectDirectory(
|
||||||
|
teamName: string,
|
||||||
config: TeamConfig
|
config: TeamConfig
|
||||||
): Promise<{ projectDir: string; projectId: string }> {
|
): Promise<{ projectDir: string; projectId: string; effectiveProjectPath?: string } | null> {
|
||||||
const normalizedProjectPath = trimTrailingSlashes(config.projectPath ?? '');
|
const sessionIds = collectKnownSessionIds(config);
|
||||||
let projectId = encodePath(normalizedProjectPath);
|
const pathCandidates = this.collectProjectPathCandidates(config);
|
||||||
let projectDir = path.join(getProjectsBasePath(), extractBaseDir(projectId));
|
const currentCandidate = pathCandidates[0] ?? null;
|
||||||
|
if (sessionIds.length === 0) {
|
||||||
|
return this.buildFallbackResolution(teamName, pathCandidates);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
const rankBySessionId = new Map(sessionIds.map((sessionId, index) => [sessionId, index]));
|
||||||
const stat = await fs.stat(projectDir);
|
const getMatchRank = (match: { matchedSessionId: string } | null): number =>
|
||||||
if (!stat.isDirectory()) {
|
match
|
||||||
throw new Error('not a directory');
|
? (rankBySessionId.get(match.matchedSessionId) ?? Number.POSITIVE_INFINITY)
|
||||||
}
|
: Number.POSITIVE_INFINITY;
|
||||||
return { projectDir, projectId };
|
|
||||||
} catch {
|
|
||||||
const leadSessionId =
|
|
||||||
typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0
|
|
||||||
? config.leadSessionId.trim()
|
|
||||||
: null;
|
|
||||||
if (!leadSessionId) {
|
|
||||||
return { projectDir, projectId };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
const toResolution = (
|
||||||
const projectEntries = await fs.readdir(getProjectsBasePath(), { withFileTypes: true });
|
match: Pick<ProjectDirCandidate, 'projectDir' | 'projectId'> & { projectPath?: string }
|
||||||
for (const entry of projectEntries) {
|
): { projectDir: string; projectId: string; effectiveProjectPath?: string } => ({
|
||||||
if (!entry.isDirectory()) continue;
|
projectDir: match.projectDir,
|
||||||
const candidateDir = path.join(getProjectsBasePath(), entry.name);
|
projectId: match.projectId,
|
||||||
try {
|
...(match.projectPath ? { effectiveProjectPath: match.projectPath } : {}),
|
||||||
await fs.access(path.join(candidateDir, `${leadSessionId}.jsonl`));
|
});
|
||||||
projectDir = candidateDir;
|
|
||||||
projectId = entry.name;
|
let currentMatch: SessionProjectMatch | null = null;
|
||||||
break;
|
if (currentCandidate) {
|
||||||
} catch {
|
const resolvedCurrentMatch = await this.findMatchInProjectPathCandidate(
|
||||||
// not this project
|
currentCandidate,
|
||||||
}
|
sessionIds
|
||||||
}
|
);
|
||||||
} catch {
|
if (resolvedCurrentMatch && getMatchRank(resolvedCurrentMatch) === 0) {
|
||||||
// best-effort fallback
|
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(
|
private async discoverSessionIds(
|
||||||
|
|
@ -199,9 +641,58 @@ export class TeamTranscriptProjectResolver {
|
||||||
this.listSessionDirIds(projectDir),
|
this.listSessionDirIds(projectDir),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return Array.from(new Set([...knownSessionIds, ...teamRootSessionIds, ...sessionDirIds])).sort(
|
const orderedSessionIds: string[] = [];
|
||||||
(left, right) => left.localeCompare(right)
|
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[]> {
|
private async listSessionDirIds(projectDir: string): Promise<string[]> {
|
||||||
|
|
@ -272,6 +763,9 @@ export class TeamTranscriptProjectResolver {
|
||||||
if (directTeamName === normalizedTeam) {
|
if (directTeamName === normalizedTeam) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (entryContainsNestedTeamName(entry, normalizedTeam)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const textContent = extractTextContent(entry);
|
const textContent = extractTextContent(entry);
|
||||||
if (textContent && lineMentionsTeam(textContent, normalizedTeam)) {
|
if (textContent && lineMentionsTeam(textContent, normalizedTeam)) {
|
||||||
|
|
|
||||||
|
|
@ -145,7 +145,7 @@ export class TeammateToolTracker {
|
||||||
const state = this.stateByTeam.get(teamName);
|
const state = this.stateByTeam.get(teamName);
|
||||||
if (!state?.enabled || state.epoch !== expectedEpoch) return;
|
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);
|
const currentState = this.stateByTeam.get(teamName);
|
||||||
if (!currentState?.enabled || currentState.epoch !== expectedEpoch) return;
|
if (!currentState?.enabled || currentState.epoch !== expectedEpoch) return;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,12 @@ export { BoardTaskActivityService } from './taskLogs/activity/BoardTaskActivityS
|
||||||
export { BoardTaskExactLogDetailService } from './taskLogs/exact/BoardTaskExactLogDetailService';
|
export { BoardTaskExactLogDetailService } from './taskLogs/exact/BoardTaskExactLogDetailService';
|
||||||
export { BoardTaskExactLogsService } from './taskLogs/exact/BoardTaskExactLogsService';
|
export { BoardTaskExactLogsService } from './taskLogs/exact/BoardTaskExactLogsService';
|
||||||
export { BoardTaskLogStreamService } from './taskLogs/stream/BoardTaskLogStreamService';
|
export { BoardTaskLogStreamService } from './taskLogs/stream/BoardTaskLogStreamService';
|
||||||
|
export {
|
||||||
|
AutoResumeService,
|
||||||
|
clearAutoResumeService,
|
||||||
|
getAutoResumeService,
|
||||||
|
initializeAutoResumeService,
|
||||||
|
} from './AutoResumeService';
|
||||||
export { TeamAttachmentStore } from './TeamAttachmentStore';
|
export { TeamAttachmentStore } from './TeamAttachmentStore';
|
||||||
export { TeamBackupService } from './TeamBackupService';
|
export { TeamBackupService } from './TeamBackupService';
|
||||||
export { TeamConfigReader } from './TeamConfigReader';
|
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
|
* Runs the HTTP server + API without Electron, suitable for Docker
|
||||||
* or any headless/remote environment. The renderer is served as
|
* 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:
|
* This module contains:
|
||||||
* - Chunk types (UserChunk, AIChunk, SystemChunk, CompactChunk)
|
* - 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:
|
* These types represent the application's domain model:
|
||||||
* - Projects and sessions
|
* - 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
|
* ParsedMessage is the application's internal representation after parsing
|
||||||
* raw JSONL entries. This module also contains type guards for classifying
|
* 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),
|
invokeIpcWithResult<McpCatalogItem | null>(MCP_REGISTRY_GET_BY_ID, registryId),
|
||||||
getInstalled: (projectPath?: string) =>
|
getInstalled: (projectPath?: string) =>
|
||||||
invokeIpcWithResult<InstalledMcpEntry[]>(MCP_REGISTRY_GET_INSTALLED, projectPath),
|
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) =>
|
install: (request: McpInstallRequest) =>
|
||||||
invokeIpcWithResult<OperationResult>(MCP_REGISTRY_INSTALL, request),
|
invokeIpcWithResult<OperationResult>(MCP_REGISTRY_INSTALL, request),
|
||||||
installCustom: (request: McpCustomInstallRequest) =>
|
installCustom: (request: McpCustomInstallRequest) =>
|
||||||
|
|
@ -1627,8 +1628,8 @@ const electronAPI: ElectronAPI = {
|
||||||
list: () => invokeIpcWithResult<ApiKeyEntry[]>(API_KEYS_LIST),
|
list: () => invokeIpcWithResult<ApiKeyEntry[]>(API_KEYS_LIST),
|
||||||
save: (request: ApiKeySaveRequest) => invokeIpcWithResult<ApiKeyEntry>(API_KEYS_SAVE, request),
|
save: (request: ApiKeySaveRequest) => invokeIpcWithResult<ApiKeyEntry>(API_KEYS_SAVE, request),
|
||||||
delete: (id: string) => invokeIpcWithResult<void>(API_KEYS_DELETE, id),
|
delete: (id: string) => invokeIpcWithResult<void>(API_KEYS_DELETE, id),
|
||||||
lookup: (envVarNames: string[]) =>
|
lookup: (envVarNames: string[], projectPath?: string) =>
|
||||||
invokeIpcWithResult<ApiKeyLookupResult[]>(API_KEYS_LOOKUP, envVarNames),
|
invokeIpcWithResult<ApiKeyLookupResult[]>(API_KEYS_LOOKUP, envVarNames, projectPath),
|
||||||
getStorageStatus: () => invokeIpcWithResult<ApiKeyStorageStatus>(API_KEYS_STORAGE_STATUS),
|
getStorageStatus: () => invokeIpcWithResult<ApiKeyStorageStatus>(API_KEYS_STORAGE_STATUS),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import { useStore } from '@renderer/store';
|
||||||
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
|
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
|
||||||
import { formatBytes } from '@renderer/utils/formatters';
|
import { formatBytes } from '@renderer/utils/formatters';
|
||||||
import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze';
|
import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze';
|
||||||
|
import { isMultimodelRuntimeStatus } from '@renderer/utils/multimodelProviderVisibility';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
|
|
@ -321,7 +322,11 @@ function formatRuntimeAuthSummary(
|
||||||
cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']>,
|
cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']>,
|
||||||
visibleProviders: readonly CliProviderStatus[]
|
visibleProviders: readonly CliProviderStatus[]
|
||||||
): string | null {
|
): string | null {
|
||||||
if (cliStatus.flavor === 'agent_teams_orchestrator' && visibleProviders.length > 0) {
|
if (isMultimodelRuntimeStatus(cliStatus)) {
|
||||||
|
if (visibleProviders.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
visibleProviders.every(
|
visibleProviders.every(
|
||||||
(provider) => provider.statusMessage === 'Checking...' && !provider.authenticated
|
(provider) => provider.statusMessage === 'Checking...' && !provider.authenticated
|
||||||
|
|
@ -351,7 +356,7 @@ function isCheckingMultimodelStatus(
|
||||||
visibleProviders: readonly CliProviderStatus[]
|
visibleProviders: readonly CliProviderStatus[]
|
||||||
): boolean {
|
): boolean {
|
||||||
return (
|
return (
|
||||||
cliStatus.flavor === 'agent_teams_orchestrator' &&
|
isMultimodelRuntimeStatus(cliStatus) &&
|
||||||
visibleProviders.length > 0 &&
|
visibleProviders.length > 0 &&
|
||||||
visibleProviders.every(
|
visibleProviders.every(
|
||||||
(provider) => provider.statusMessage === 'Checking...' && !provider.authenticated
|
(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 = ({
|
const InstalledBanner = ({
|
||||||
cliStatus,
|
cliStatus,
|
||||||
cliStatusLoading,
|
cliStatusLoading,
|
||||||
|
|
@ -382,6 +393,7 @@ const InstalledBanner = ({
|
||||||
() => filterMainScreenCliProviders(cliStatus.providers),
|
() => filterMainScreenCliProviders(cliStatus.providers),
|
||||||
[cliStatus.providers]
|
[cliStatus.providers]
|
||||||
);
|
);
|
||||||
|
const canOpenExtensions = cliStatus.installed;
|
||||||
const runtimeLabel = formatRuntimeLabel(cliStatus);
|
const runtimeLabel = formatRuntimeLabel(cliStatus);
|
||||||
const runtimeAuthSummary = formatRuntimeAuthSummary(cliStatus, visibleProviders);
|
const runtimeAuthSummary = formatRuntimeAuthSummary(cliStatus, visibleProviders);
|
||||||
|
|
||||||
|
|
@ -466,8 +478,8 @@ const InstalledBanner = ({
|
||||||
disabled={isBusy || cliStatusLoading || multimodelBusy}
|
disabled={isBusy || cliStatusLoading || multimodelBusy}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* Extensions button — only when installed + authenticated */}
|
{/* Extensions button — available whenever the runtime is installed */}
|
||||||
{cliStatus.authLoggedIn && (
|
{canOpenExtensions && (
|
||||||
<button
|
<button
|
||||||
onClick={openExtensionsTab}
|
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"
|
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 (isCheckingMultimodelStatus(cliStatus, visibleCliProviders)) return 'info';
|
||||||
if (cliStatus.authStatusChecking) return 'info';
|
if (cliStatus.authStatusChecking) return 'info';
|
||||||
if (!cliStatus.installed) return 'error';
|
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.installed && !cliStatus.authLoggedIn) return 'warning';
|
||||||
if (cliStatus.updateAvailable) return 'info';
|
if (cliStatus.updateAvailable) return 'info';
|
||||||
return 'success';
|
return 'success';
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
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 { Button } from '@renderer/components/ui/button';
|
||||||
import { Tabs, TabsContent, TabsList } from '@renderer/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList } from '@renderer/components/ui/tabs';
|
||||||
import {
|
import {
|
||||||
|
|
@ -18,8 +20,25 @@ import {
|
||||||
import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
|
import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
|
||||||
import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState';
|
import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import {
|
||||||
|
formatCliExtensionCapabilityStatus,
|
||||||
|
getVisibleMultimodelProviders,
|
||||||
|
isMultimodelRuntimeStatus,
|
||||||
|
} from '@renderer/utils/multimodelProviderVisibility';
|
||||||
import { resolveProjectPathById } from '@renderer/utils/projectLookup';
|
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 { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { ApiKeysPanel } from './apikeys/ApiKeysPanel';
|
import { ApiKeysPanel } from './apikeys/ApiKeysPanel';
|
||||||
|
|
@ -29,6 +48,55 @@ import { PluginsPanel } from './plugins/PluginsPanel';
|
||||||
import { SkillsPanel } from './skills/SkillsPanel';
|
import { SkillsPanel } from './skills/SkillsPanel';
|
||||||
import { ExtensionsSubTabTrigger } from './ExtensionsSubTabTrigger';
|
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 => {
|
export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
const tabId = useTabIdOptional();
|
const tabId = useTabIdOptional();
|
||||||
const {
|
const {
|
||||||
|
|
@ -44,6 +112,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
skillsLoading,
|
skillsLoading,
|
||||||
cliStatus,
|
cliStatus,
|
||||||
cliStatusLoading,
|
cliStatusLoading,
|
||||||
|
cliProviderStatusLoading,
|
||||||
openDashboard,
|
openDashboard,
|
||||||
sessions,
|
sessions,
|
||||||
projects,
|
projects,
|
||||||
|
|
@ -62,6 +131,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
skillsLoading: s.skillsLoading,
|
skillsLoading: s.skillsLoading,
|
||||||
cliStatus: s.cliStatus,
|
cliStatus: s.cliStatus,
|
||||||
cliStatusLoading: s.cliStatusLoading,
|
cliStatusLoading: s.cliStatusLoading,
|
||||||
|
cliProviderStatusLoading: s.cliProviderStatusLoading,
|
||||||
openDashboard: s.openDashboard,
|
openDashboard: s.openDashboard,
|
||||||
sessions: s.sessions,
|
sessions: s.sessions,
|
||||||
projects: s.projects,
|
projects: s.projects,
|
||||||
|
|
@ -92,21 +162,21 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
label: 'Plugins',
|
label: 'Plugins',
|
||||||
icon: Puzzle,
|
icon: Puzzle,
|
||||||
description:
|
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,
|
value: 'mcp-servers' as const,
|
||||||
label: 'MCP Servers',
|
label: 'MCP Servers',
|
||||||
icon: Server,
|
icon: Server,
|
||||||
description:
|
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,
|
value: 'skills' as const,
|
||||||
label: 'Skills',
|
label: 'Skills',
|
||||||
icon: BookOpen,
|
icon: BookOpen,
|
||||||
description:
|
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,
|
value: 'api-keys' as const,
|
||||||
|
|
@ -163,15 +233,34 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
|
|
||||||
const isRefreshing =
|
const isRefreshing =
|
||||||
cliStatusLoading || apiKeysLoading || pluginCatalogLoading || mcpBrowseLoading || skillsLoading;
|
cliStatusLoading || apiKeysLoading || pluginCatalogLoading || mcpBrowseLoading || skillsLoading;
|
||||||
|
const mcpMutationDisableReason = useMemo(
|
||||||
|
() =>
|
||||||
|
getExtensionActionDisableReason({
|
||||||
|
isInstalled: false,
|
||||||
|
cliStatus,
|
||||||
|
cliStatusLoading,
|
||||||
|
section: 'mcp',
|
||||||
|
}),
|
||||||
|
[cliStatus, cliStatusLoading]
|
||||||
|
);
|
||||||
const cliStatusBanner = useMemo(() => {
|
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 (
|
return (
|
||||||
<div className="bg-surface/70 mx-4 mt-3 flex items-start gap-3 rounded-md border border-border px-4 py-3">
|
<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" />
|
<Info className="mt-0.5 size-4 shrink-0 text-text-secondary" />
|
||||||
<div>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -186,13 +275,13 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm font-medium text-amber-300">
|
<p className="text-sm font-medium text-amber-300">
|
||||||
{cliLaunchIssue
|
{cliLaunchIssue
|
||||||
? 'Claude CLI was found but failed to start'
|
? 'The configured runtime was found but failed to start'
|
||||||
: 'Claude CLI is not available'}
|
: 'The configured runtime is not available'}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 text-xs text-text-muted">
|
<p className="mt-0.5 text-xs text-text-muted">
|
||||||
{cliLaunchIssue
|
{cliLaunchIssue
|
||||||
? 'Plugin installs are disabled until Claude CLI passes its startup health check. Open the Dashboard to repair or reinstall it.'
|
? 'Extensions are disabled until the runtime 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 is installed. Open the Dashboard to install it and retry.'}
|
||||||
</p>
|
</p>
|
||||||
{cliLaunchIssue && cliStatus.launchError && (
|
{cliLaunchIssue && cliStatus.launchError && (
|
||||||
<p className="mt-2 break-all font-mono text-[11px] text-text-muted">
|
<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 (
|
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">
|
<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" />
|
<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 (
|
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">
|
<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" />
|
<Info className="mt-0.5 size-4 shrink-0 text-emerald-300" />
|
||||||
|
|
@ -238,7 +418,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}, [cliStatus, cliStatusLoading, openDashboard]);
|
}, [cliProviderStatusLoading, cliStatus, cliStatusLoading, openDashboard]);
|
||||||
|
|
||||||
// Browser mode guard
|
// Browser mode guard
|
||||||
if (!api.plugins && !api.mcpRegistry && !api.skills) {
|
if (!api.plugins && !api.mcpRegistry && !api.skills) {
|
||||||
|
|
@ -280,7 +460,8 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
{!cliInstalled && (
|
{!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">
|
<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" />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Active sessions warning */}
|
{/* Active sessions warning */}
|
||||||
|
|
@ -309,15 +490,25 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
))}
|
))}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
{tabState.activeSubTab === 'mcp-servers' && (
|
{tabState.activeSubTab === 'mcp-servers' && (
|
||||||
<Button
|
<Tooltip>
|
||||||
variant="outline"
|
<TooltipTrigger asChild>
|
||||||
size="sm"
|
<span tabIndex={mcpMutationDisableReason ? 0 : -1}>
|
||||||
onClick={() => setCustomMcpDialogOpen(true)}
|
<Button
|
||||||
className="mb-1 whitespace-nowrap"
|
variant="outline"
|
||||||
>
|
size="sm"
|
||||||
<Plus className="mr-1 size-3.5" />
|
onClick={() => setCustomMcpDialogOpen(true)}
|
||||||
Add Custom
|
className="mb-1 whitespace-nowrap"
|
||||||
</Button>
|
disabled={Boolean(mcpMutationDisableReason)}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 size-3.5" />
|
||||||
|
Add Custom
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
{mcpMutationDisableReason && (
|
||||||
|
<TooltipContent>{mcpMutationDisableReason}</TooltipContent>
|
||||||
|
)}
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -352,7 +543,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="api-keys" className="mt-0 pt-4">
|
<TabsContent value="api-keys" className="mt-0 pt-4">
|
||||||
<ApiKeysPanel />
|
<ApiKeysPanel projectPath={projectPath} projectLabel={projectLabel} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="skills" className="mt-0 pt-4">
|
<TabsContent value="skills" className="mt-0 pt-4">
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,12 @@ export const ApiKeyCard = ({ apiKey, onEdit }: ApiKeyCardProps): React.JSX.Eleme
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{apiKey.scope === 'project' && apiKey.projectPath && (
|
||||||
|
<p className="truncate text-xs text-text-muted" title={apiKey.projectPath}>
|
||||||
|
{apiKey.projectPath}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Env var name */}
|
{/* Env var name */}
|
||||||
<div className="flex items-center gap-1.5">
|
<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">
|
<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 {
|
interface ApiKeyFormDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
editingKey: ApiKeyEntry | null;
|
editingKey: ApiKeyEntry | null;
|
||||||
|
currentProjectPath: string | null;
|
||||||
|
currentProjectLabel: string | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,6 +47,8 @@ const SCOPE_OPTIONS: { value: Scope; label: string }[] = [
|
||||||
export const ApiKeyFormDialog = ({
|
export const ApiKeyFormDialog = ({
|
||||||
open,
|
open,
|
||||||
editingKey,
|
editingKey,
|
||||||
|
currentProjectPath,
|
||||||
|
currentProjectLabel,
|
||||||
onClose,
|
onClose,
|
||||||
}: ApiKeyFormDialogProps): React.JSX.Element => {
|
}: ApiKeyFormDialogProps): React.JSX.Element => {
|
||||||
const saveApiKey = useStore((s) => s.saveApiKey);
|
const saveApiKey = useStore((s) => s.saveApiKey);
|
||||||
|
|
@ -57,6 +61,14 @@ export const ApiKeyFormDialog = ({
|
||||||
const [scope, setScope] = useState<Scope>('user');
|
const [scope, setScope] = useState<Scope>('user');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [envVarError, setEnvVarError] = 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
|
// Reset form when dialog opens/closes or editing key changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -77,6 +89,12 @@ export const ApiKeyFormDialog = ({
|
||||||
}
|
}
|
||||||
}, [open, editingKey]);
|
}, [open, editingKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && scope === 'project' && !canUseProjectScope) {
|
||||||
|
setScope('user');
|
||||||
|
}
|
||||||
|
}, [canUseProjectScope, open, scope]);
|
||||||
|
|
||||||
const validateEnvVar = (v: string) => {
|
const validateEnvVar = (v: string) => {
|
||||||
if (!v.trim()) {
|
if (!v.trim()) {
|
||||||
setEnvVarError(null);
|
setEnvVarError(null);
|
||||||
|
|
@ -109,6 +127,10 @@ export const ApiKeyFormDialog = ({
|
||||||
setError('Key value is required');
|
setError('Key value is required');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (scope === 'project' && !effectiveProjectPath) {
|
||||||
|
setError('Project-scoped API keys require an active project');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await saveApiKey({
|
await saveApiKey({
|
||||||
|
|
@ -117,6 +139,7 @@ export const ApiKeyFormDialog = ({
|
||||||
envVarName: envVarName.trim(),
|
envVarName: envVarName.trim(),
|
||||||
value,
|
value,
|
||||||
scope,
|
scope,
|
||||||
|
projectPath: scope === 'project' ? (effectiveProjectPath ?? undefined) : undefined,
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -125,7 +148,13 @@ export const ApiKeyFormDialog = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const isEdit = editingKey !== null;
|
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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
|
@ -212,12 +241,23 @@ export const ApiKeyFormDialog = ({
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{SCOPE_OPTIONS.map((opt) => (
|
{SCOPE_OPTIONS.map((opt) => (
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
<SelectItem
|
||||||
{opt.label}
|
key={opt.value}
|
||||||
|
value={opt.value}
|
||||||
|
disabled={opt.value === 'project' && !canUseProjectScope}
|
||||||
|
>
|
||||||
|
{opt.value === 'project'
|
||||||
|
? effectiveProjectPath
|
||||||
|
? `Project: ${effectiveProjectLabel}`
|
||||||
|
: 'Project unavailable'
|
||||||
|
: opt.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
{scope === 'project' && effectiveProjectPath && (
|
||||||
|
<p className="text-xs text-text-muted">Bound to {effectiveProjectPath}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error display */}
|
{/* Error display */}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* ApiKeysPanel — grid of saved API keys with add button and empty state.
|
* 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 { Button } from '@renderer/components/ui/button';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
|
|
@ -15,16 +15,26 @@ import { ApiKeyFormDialog } from './ApiKeyFormDialog';
|
||||||
|
|
||||||
import type { ApiKeyEntry } from '@shared/types/extensions';
|
import type { ApiKeyEntry } from '@shared/types/extensions';
|
||||||
|
|
||||||
export const ApiKeysPanel = (): React.JSX.Element => {
|
interface ApiKeysPanelProps {
|
||||||
const { apiKeys, apiKeysLoading, apiKeysError, storageStatus, fetchStorageStatus } = useStore(
|
projectPath: string | null;
|
||||||
useShallow((s) => ({
|
projectLabel: string | null;
|
||||||
apiKeys: s.apiKeys,
|
}
|
||||||
apiKeysLoading: s.apiKeysLoading,
|
|
||||||
apiKeysError: s.apiKeysError,
|
export const ApiKeysPanel = ({
|
||||||
storageStatus: s.apiKeyStorageStatus,
|
projectPath,
|
||||||
fetchStorageStatus: s.fetchApiKeyStorageStatus,
|
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 [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [editingKey, setEditingKey] = useState<ApiKeyEntry | null>(null);
|
const [editingKey, setEditingKey] = useState<ApiKeyEntry | null>(null);
|
||||||
|
|
@ -49,9 +59,82 @@ export const ApiKeysPanel = (): React.JSX.Element => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const isOsKeychain = storageStatus?.encryptionMethod === 'os-keychain';
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<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 */}
|
{/* Header row */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="flex items-center gap-1.5 text-sm text-text-secondary">
|
<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 */}
|
{/* Form dialog */}
|
||||||
<ApiKeyFormDialog open={dialogOpen} editingKey={editingKey} onClose={handleDialogClose} />
|
<ApiKeyFormDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
editingKey={editingKey}
|
||||||
|
currentProjectPath={projectPath}
|
||||||
|
currentProjectLabel={projectLabel}
|
||||||
|
onClose={handleDialogClose}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ interface InstallButtonProps {
|
||||||
isInstalled: boolean;
|
isInstalled: boolean;
|
||||||
onInstall: () => void;
|
onInstall: () => void;
|
||||||
onUninstall: () => void;
|
onUninstall: () => void;
|
||||||
|
section?: 'plugins' | 'mcp';
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
size?: 'sm' | 'default';
|
size?: 'sm' | 'default';
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
|
|
@ -34,6 +35,7 @@ export const InstallButton = ({
|
||||||
isInstalled,
|
isInstalled,
|
||||||
onInstall,
|
onInstall,
|
||||||
onUninstall,
|
onUninstall,
|
||||||
|
section = 'plugins',
|
||||||
disabled,
|
disabled,
|
||||||
size = 'sm',
|
size = 'sm',
|
||||||
errorMessage,
|
errorMessage,
|
||||||
|
|
@ -48,6 +50,7 @@ export const InstallButton = ({
|
||||||
isInstalled,
|
isInstalled,
|
||||||
cliStatus,
|
cliStatus,
|
||||||
cliStatusLoading,
|
cliStatusLoading,
|
||||||
|
section,
|
||||||
});
|
});
|
||||||
const isDisabled = disabled || Boolean(disableReason);
|
const isDisabled = disabled || Boolean(disableReason);
|
||||||
const [lastAction, setLastAction] = useState<'install' | 'uninstall' | null>(null);
|
const [lastAction, setLastAction] = useState<'install' | 'uninstall' | null>(null);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
* Supports stdio (npm package) and HTTP/SSE transports.
|
* 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 { api } from '@renderer/api';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
|
|
@ -24,6 +24,13 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@renderer/components/ui/select';
|
} from '@renderer/components/ui/select';
|
||||||
import { useStore } from '@renderer/store';
|
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 { Plus, Server, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -42,13 +49,7 @@ interface CustomMcpServerDialogProps {
|
||||||
|
|
||||||
type TransportMode = 'stdio' | 'http';
|
type TransportMode = 'stdio' | 'http';
|
||||||
type HttpTransport = 'streamable-http' | 'sse' | 'http';
|
type HttpTransport = 'streamable-http' | 'sse' | 'http';
|
||||||
type Scope = 'local' | 'user' | 'project';
|
type Scope = 'local' | 'user' | 'project' | 'global';
|
||||||
|
|
||||||
const SCOPE_OPTIONS: { value: Scope; label: string }[] = [
|
|
||||||
{ value: 'user', label: 'User (global)' },
|
|
||||||
{ value: 'project', label: 'Project' },
|
|
||||||
{ value: 'local', label: 'Local' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const HTTP_TRANSPORT_OPTIONS: { value: HttpTransport; label: string }[] = [
|
const HTTP_TRANSPORT_OPTIONS: { value: HttpTransport; label: string }[] = [
|
||||||
{ value: 'streamable-http', label: 'Streamable HTTP' },
|
{ value: 'streamable-http', label: 'Streamable HTTP' },
|
||||||
|
|
@ -67,11 +68,19 @@ export const CustomMcpServerDialog = ({
|
||||||
projectPath,
|
projectPath,
|
||||||
}: CustomMcpServerDialogProps): React.JSX.Element => {
|
}: CustomMcpServerDialogProps): React.JSX.Element => {
|
||||||
const installCustomMcpServer = useStore((s) => s.installCustomMcpServer);
|
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
|
// Form state
|
||||||
const [serverName, setServerName] = useState('');
|
const [serverName, setServerName] = useState('');
|
||||||
const [transportMode, setTransportMode] = useState<TransportMode>('stdio');
|
const [transportMode, setTransportMode] = useState<TransportMode>('stdio');
|
||||||
const [scope, setScope] = useState<Scope>('user');
|
const [scope, setScope] = useState<Scope>(defaultSharedScope);
|
||||||
|
|
||||||
// Stdio fields
|
// Stdio fields
|
||||||
const [npmPackage, setNpmPackage] = useState('');
|
const [npmPackage, setNpmPackage] = useState('');
|
||||||
|
|
@ -86,13 +95,31 @@ export const CustomMcpServerDialog = ({
|
||||||
const [envVars, setEnvVars] = useState<EnvEntry[]>([]);
|
const [envVars, setEnvVars] = useState<EnvEntry[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [installing, setInstalling] = useState(false);
|
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
|
// Reset on open
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
const justOpened = open && !wasOpenRef.current;
|
||||||
|
if (justOpened) {
|
||||||
setServerName('');
|
setServerName('');
|
||||||
setTransportMode('stdio');
|
setTransportMode('stdio');
|
||||||
setScope('user');
|
setScope(defaultSharedScope);
|
||||||
setNpmPackage('');
|
setNpmPackage('');
|
||||||
setNpmVersion('');
|
setNpmVersion('');
|
||||||
setHttpUrl('');
|
setHttpUrl('');
|
||||||
|
|
@ -101,39 +128,98 @@ export const CustomMcpServerDialog = ({
|
||||||
setEnvVars([]);
|
setEnvVars([]);
|
||||||
setError(null);
|
setError(null);
|
||||||
setInstalling(false);
|
setInstalling(false);
|
||||||
|
autoFilledValuesRef.current = {};
|
||||||
}
|
}
|
||||||
}, [open]);
|
wasOpenRef.current = open;
|
||||||
|
if (!open) {
|
||||||
|
previousDefaultSharedScopeRef.current = defaultSharedScope;
|
||||||
|
}
|
||||||
|
}, [defaultSharedScope, open]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && scope !== 'user' && !projectPath) {
|
if (!open) {
|
||||||
setScope('user');
|
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
|
// Auto-fill env vars from saved API keys
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || envVars.length === 0 || !api.apiKeys) return;
|
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;
|
if (envVarNames.length === 0) return;
|
||||||
|
|
||||||
void api.apiKeys.lookup(envVarNames).then(
|
void api.apiKeys.lookup(envVarNames, apiKeyLookupProjectPath).then(
|
||||||
(results) => {
|
(results) => {
|
||||||
if (results.length === 0) return;
|
const previousAutoFilledValues = autoFilledValuesRef.current;
|
||||||
const lookup = new Map(results.map((r) => [r.envVarName, r.value]));
|
const nextAutoFilledValues = Object.fromEntries(
|
||||||
setEnvVars((prev) =>
|
results.map((result) => [result.envVarName, result.value])
|
||||||
prev.map((e) => (lookup.has(e.key) && !e.value ? { ...e, value: lookup.get(e.key)! } : e))
|
|
||||||
);
|
);
|
||||||
|
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
|
// 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 () => {
|
const handleInstall = async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
if (mutationDisableReason) {
|
||||||
|
setError(mutationDisableReason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!serverName.trim()) {
|
if (!serverName.trim()) {
|
||||||
setError('Server name is required');
|
setError('Server name is required');
|
||||||
return;
|
return;
|
||||||
|
|
@ -177,7 +263,7 @@ export const CustomMcpServerDialog = ({
|
||||||
const request: McpCustomInstallRequest = {
|
const request: McpCustomInstallRequest = {
|
||||||
serverName,
|
serverName,
|
||||||
scope,
|
scope,
|
||||||
projectPath: scope !== 'user' ? (projectPath ?? undefined) : undefined,
|
projectPath: isProjectScopedMcpScope(scope) ? (projectPath ?? undefined) : undefined,
|
||||||
installSpec,
|
installSpec,
|
||||||
envValues,
|
envValues,
|
||||||
headers: headers.filter((h) => h.key.trim() && h.value.trim()),
|
headers: headers.filter((h) => h.key.trim() && h.value.trim()),
|
||||||
|
|
@ -207,7 +293,8 @@ export const CustomMcpServerDialog = ({
|
||||||
const canSubmit =
|
const canSubmit =
|
||||||
serverName.trim() &&
|
serverName.trim() &&
|
||||||
(transportMode === 'stdio' ? npmPackage.trim() : httpUrl.trim()) &&
|
(transportMode === 'stdio' ? npmPackage.trim() : httpUrl.trim()) &&
|
||||||
!(scope !== 'user' && !projectPath) &&
|
!(isProjectScopedMcpScope(scope) && !projectPath) &&
|
||||||
|
!mutationDisableReason &&
|
||||||
!installing;
|
!installing;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -382,11 +469,11 @@ export const CustomMcpServerDialog = ({
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{SCOPE_OPTIONS.map((opt) => (
|
{scopeOptions.map((opt) => (
|
||||||
<SelectItem
|
<SelectItem
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
value={opt.value}
|
value={opt.value}
|
||||||
disabled={opt.value !== 'user' && !projectPath}
|
disabled={isProjectScopedMcpScope(opt.value) && !projectPath}
|
||||||
>
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
@ -436,6 +523,11 @@ export const CustomMcpServerDialog = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error */}
|
{/* 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 && (
|
{error && (
|
||||||
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
|
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
|
||||||
{error}
|
{error}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
getMcpOperationKey,
|
getMcpOperationKey,
|
||||||
sanitizeMcpServerName,
|
sanitizeMcpServerName,
|
||||||
} from '@shared/utils/extensionNormalizers';
|
} from '@shared/utils/extensionNormalizers';
|
||||||
|
import { getDefaultMcpSharedScope } from '@shared/utils/mcpScopes';
|
||||||
import { Clock, Cloud, Globe, KeyRound, Lock, Monitor, Star, Tag, Wrench } from 'lucide-react';
|
import { Clock, Cloud, Globe, KeyRound, Lock, Monitor, Star, Tag, Wrench } from 'lucide-react';
|
||||||
import { Github as GithubIcon } from 'lucide-react';
|
import { Github as GithubIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -47,7 +48,9 @@ export const McpServerCard = ({
|
||||||
diagnosticsLoading,
|
diagnosticsLoading,
|
||||||
onClick,
|
onClick,
|
||||||
}: McpServerCardProps): React.JSX.Element => {
|
}: 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 installProgress = useStore((s) => s.mcpInstallProgress[operationKey] ?? 'idle');
|
||||||
const installMcpServer = useStore((s) => s.installMcpServer);
|
const installMcpServer = useStore((s) => s.installMcpServer);
|
||||||
const uninstallMcpServer = useStore((s) => s.uninstallMcpServer);
|
const uninstallMcpServer = useStore((s) => s.uninstallMcpServer);
|
||||||
|
|
@ -67,13 +70,13 @@ export const McpServerCard = ({
|
||||||
server.requiresAuth ||
|
server.requiresAuth ||
|
||||||
(server.authHeaders?.length ?? 0) > 0;
|
(server.authHeaders?.length ?? 0) > 0;
|
||||||
const defaultServerName = sanitizeMcpServerName(server.name);
|
const defaultServerName = sanitizeMcpServerName(server.name);
|
||||||
const userInstallEntry =
|
const sharedInstallEntry =
|
||||||
normalizedInstalledEntries.find((entry) => entry.scope === 'user') ?? null;
|
normalizedInstalledEntries.find((entry) => entry.scope === sharedScope) ?? null;
|
||||||
const installSummaryLabel = getMcpInstallationSummaryLabel(normalizedInstalledEntries);
|
const installSummaryLabel = getMcpInstallationSummaryLabel(normalizedInstalledEntries);
|
||||||
const supportsDirectInstalledAction =
|
const supportsDirectInstalledAction =
|
||||||
isInstalled &&
|
isInstalled &&
|
||||||
normalizedInstalledEntries.length === 1 &&
|
normalizedInstalledEntries.length === 1 &&
|
||||||
userInstallEntry?.name === defaultServerName &&
|
sharedInstallEntry?.name === defaultServerName &&
|
||||||
!requiresConfiguration;
|
!requiresConfiguration;
|
||||||
const shouldShowDirectInstallButton =
|
const shouldShowDirectInstallButton =
|
||||||
canAutoInstall && (!isInstalled ? !requiresConfiguration : supportsDirectInstalledAction);
|
canAutoInstall && (!isInstalled ? !requiresConfiguration : supportsDirectInstalledAction);
|
||||||
|
|
@ -258,17 +261,22 @@ export const McpServerCard = ({
|
||||||
<InstallButton
|
<InstallButton
|
||||||
state={installProgress}
|
state={installProgress}
|
||||||
isInstalled={isInstalled}
|
isInstalled={isInstalled}
|
||||||
|
section="mcp"
|
||||||
onInstall={() =>
|
onInstall={() =>
|
||||||
installMcpServer({
|
installMcpServer({
|
||||||
registryId: server.id,
|
registryId: server.id,
|
||||||
serverName: defaultServerName,
|
serverName: defaultServerName,
|
||||||
scope: 'user',
|
scope: sharedScope,
|
||||||
envValues: {},
|
envValues: {},
|
||||||
headers: [],
|
headers: [],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
onUninstall={() =>
|
onUninstall={() =>
|
||||||
uninstallMcpServer(server.id, userInstallEntry?.name ?? defaultServerName, 'user')
|
uninstallMcpServer(
|
||||||
|
server.id,
|
||||||
|
sharedInstallEntry?.name ?? defaultServerName,
|
||||||
|
sharedScope
|
||||||
|
)
|
||||||
}
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
errorMessage={installError}
|
errorMessage={installError}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
* Uses Radix UI Kit for all form elements.
|
* 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 { api } from '@renderer/api';
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
|
|
@ -31,6 +31,12 @@ import {
|
||||||
getPreferredMcpInstallationEntry,
|
getPreferredMcpInstallationEntry,
|
||||||
sanitizeMcpServerName,
|
sanitizeMcpServerName,
|
||||||
} from '@shared/utils/extensionNormalizers';
|
} from '@shared/utils/extensionNormalizers';
|
||||||
|
import {
|
||||||
|
getDefaultMcpSharedScope,
|
||||||
|
getMcpScopeLabel,
|
||||||
|
isProjectScopedMcpScope,
|
||||||
|
isSharedMcpScope,
|
||||||
|
} from '@shared/utils/mcpScopes';
|
||||||
import { ExternalLink, Lock, Plus, Star, Trash2, Wrench } from 'lucide-react';
|
import { ExternalLink, Lock, Plus, Star, Trash2, Wrench } from 'lucide-react';
|
||||||
|
|
||||||
import { InstallButton } from '../common/InstallButton';
|
import { InstallButton } from '../common/InstallButton';
|
||||||
|
|
@ -55,13 +61,7 @@ interface McpServerDetailDialogProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Scope = 'local' | 'user' | 'project';
|
type Scope = 'local' | 'user' | 'project' | 'global';
|
||||||
|
|
||||||
const SCOPE_OPTIONS: { value: Scope; label: string }[] = [
|
|
||||||
{ value: 'user', label: 'User (global)' },
|
|
||||||
{ value: 'project', label: 'Project' },
|
|
||||||
{ value: 'local', label: 'Local' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const McpServerDetailDialog = ({
|
export const McpServerDetailDialog = ({
|
||||||
server,
|
server,
|
||||||
|
|
@ -74,8 +74,10 @@ export const McpServerDetailDialog = ({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
}: McpServerDetailDialogProps): React.JSX.Element => {
|
}: McpServerDetailDialogProps): React.JSX.Element => {
|
||||||
const [scope, setScope] = useState<Scope>('user');
|
const cliStatus = useStore((s) => s.cliStatus);
|
||||||
const operationKey = server ? getMcpOperationKey(server.id, scope) : null;
|
const defaultSharedScope = getDefaultMcpSharedScope(cliStatus?.flavor);
|
||||||
|
const [scope, setScope] = useState<Scope>(defaultSharedScope);
|
||||||
|
const operationKey = server ? getMcpOperationKey(server.id, scope, projectPath) : null;
|
||||||
const installProgress = useStore(
|
const installProgress = useStore(
|
||||||
(s) => (operationKey ? s.mcpInstallProgress[operationKey] : undefined) ?? 'idle'
|
(s) => (operationKey ? s.mcpInstallProgress[operationKey] : undefined) ?? 'idle'
|
||||||
);
|
);
|
||||||
|
|
@ -91,15 +93,36 @@ export const McpServerDetailDialog = ({
|
||||||
const [headers, setHeaders] = useState<McpHeaderDef[]>([]);
|
const [headers, setHeaders] = useState<McpHeaderDef[]>([]);
|
||||||
const [imgError, setImgError] = useState(false);
|
const [imgError, setImgError] = useState(false);
|
||||||
const [autoFilledFields, setAutoFilledFields] = useState<Set<string>>(new Set());
|
const [autoFilledFields, setAutoFilledFields] = useState<Set<string>>(new Set());
|
||||||
|
const autoFilledValuesRef = useRef<Record<string, string>>({});
|
||||||
|
const previousDefaultSharedScopeRef = useRef<Scope>(defaultSharedScope);
|
||||||
const normalizedInstalledEntries = installedEntries.length
|
const normalizedInstalledEntries = installedEntries.length
|
||||||
? installedEntries
|
? installedEntries
|
||||||
: installedEntry
|
: installedEntry
|
||||||
? [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 preferredInstalledEntry = getPreferredMcpInstallationEntry(normalizedInstalledEntries);
|
||||||
const selectedInstalledEntry =
|
const selectedInstalledEntry =
|
||||||
normalizedInstalledEntries.find((entry) => entry.scope === scope) ?? null;
|
normalizedInstalledEntries.find((entry) => entry.scope === scope) ?? null;
|
||||||
const installSummaryLabel = getMcpInstallationSummaryLabel(normalizedInstalledEntries);
|
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
|
// Initialize form when dialog opens or server changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -120,47 +143,82 @@ export const McpServerDetailDialog = ({
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
setServerName(preferredInstalledEntry?.name ?? sanitizeMcpServerName(server.name));
|
setServerName(preferredInstalledEntry?.name ?? sanitizeMcpServerName(server.name));
|
||||||
setScope(preferredInstalledEntry?.scope ?? 'user');
|
setScope((preferredInstalledEntry?.scope as Scope | undefined) ?? defaultSharedScope);
|
||||||
setImgError(false);
|
setImgError(false);
|
||||||
setAutoFilledFields(new Set());
|
setAutoFilledFields(new Set());
|
||||||
|
autoFilledValuesRef.current = {};
|
||||||
}, [open, preferredInstalledEntry?.name, preferredInstalledEntry?.scope, server?.id]);
|
}, [open, preferredInstalledEntry?.name, preferredInstalledEntry?.scope, server?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!server || !open) {
|
if (!open) {
|
||||||
|
previousDefaultSharedScopeRef.current = defaultSharedScope;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setServerName(selectedInstalledEntry?.name ?? sanitizeMcpServerName(server.name));
|
const previousDefaultSharedScope = previousDefaultSharedScopeRef.current;
|
||||||
}, [open, scope, selectedInstalledEntry?.name, server]);
|
if (
|
||||||
|
previousDefaultSharedScope !== defaultSharedScope &&
|
||||||
|
!preferredInstalledEntry &&
|
||||||
|
scope === previousDefaultSharedScope &&
|
||||||
|
isSharedMcpScope(scope)
|
||||||
|
) {
|
||||||
|
setScope(defaultSharedScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
previousDefaultSharedScopeRef.current = defaultSharedScope;
|
||||||
|
}, [defaultSharedScope, open, preferredInstalledEntry, scope]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && scope !== 'user' && !projectPath) {
|
if (!server || !open || !selectedInstalledEntry) {
|
||||||
setScope('user');
|
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
|
// Auto-fill env values from saved API keys
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!server || !open || server.envVars.length === 0 || !api.apiKeys) return;
|
if (!server || !open || server.envVars.length === 0 || !api.apiKeys) return;
|
||||||
|
|
||||||
const envVarNames = server.envVars.map((e) => e.name);
|
const envVarNames = server.envVars.map((e) => e.name);
|
||||||
void api.apiKeys.lookup(envVarNames).then(
|
void api.apiKeys.lookup(envVarNames, apiKeyLookupProjectPath).then(
|
||||||
(results) => {
|
(results) => {
|
||||||
if (results.length === 0) return;
|
const previousAutoFilledValues = autoFilledValuesRef.current;
|
||||||
const filled = new Set<string>();
|
const nextAutoFilledValues: Record<string, string> = {};
|
||||||
const values: Record<string, string> = {};
|
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
values[r.envVarName] = r.value;
|
nextAutoFilledValues[r.envVarName] = r.value;
|
||||||
filled.add(r.envVarName);
|
|
||||||
}
|
}
|
||||||
setEnvValues((prev) => ({ ...prev, ...values }));
|
setEnvValues((prev) => {
|
||||||
setAutoFilledFields(filled);
|
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
|
// 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 <></>;
|
if (!server) return <></>;
|
||||||
|
|
||||||
|
|
@ -181,7 +239,7 @@ export const McpServerDetailDialog = ({
|
||||||
const isInstalledForScope = selectedInstalledEntry !== null;
|
const isInstalledForScope = selectedInstalledEntry !== null;
|
||||||
const uninstallServerName = selectedInstalledEntry?.name ?? serverName;
|
const uninstallServerName = selectedInstalledEntry?.name ?? serverName;
|
||||||
const uninstallScope = selectedInstalledEntry?.scope ?? scope;
|
const uninstallScope = selectedInstalledEntry?.scope ?? scope;
|
||||||
const scopeRequiresProjectPath = scope !== 'user' && !projectPath;
|
const scopeRequiresProjectPath = isProjectScopedMcpScope(scope) && !projectPath;
|
||||||
const installDisabled =
|
const installDisabled =
|
||||||
!serverName.trim() ||
|
!serverName.trim() ||
|
||||||
missingRequiredEnvVars ||
|
missingRequiredEnvVars ||
|
||||||
|
|
@ -201,7 +259,7 @@ export const McpServerDetailDialog = ({
|
||||||
registryId: server.id,
|
registryId: server.id,
|
||||||
serverName,
|
serverName,
|
||||||
scope,
|
scope,
|
||||||
projectPath: scope !== 'user' ? (projectPath ?? undefined) : undefined,
|
projectPath: isProjectScopedMcpScope(scope) ? (projectPath ?? undefined) : undefined,
|
||||||
envValues,
|
envValues,
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
|
|
@ -212,7 +270,7 @@ export const McpServerDetailDialog = ({
|
||||||
server.id,
|
server.id,
|
||||||
uninstallServerName,
|
uninstallServerName,
|
||||||
uninstallScope,
|
uninstallScope,
|
||||||
uninstallScope !== 'user' ? (projectPath ?? undefined) : undefined
|
isProjectScopedMcpScope(uninstallScope) ? (projectPath ?? undefined) : undefined
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -353,7 +411,7 @@ export const McpServerDetailDialog = ({
|
||||||
{isInstalledForScope && (
|
{isInstalledForScope && (
|
||||||
<div className="space-y-2 rounded-md border border-border bg-surface-raised px-4 py-3">
|
<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">
|
<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 ? (
|
{diagnosticsLoading && !diagnostic ? (
|
||||||
<Badge
|
<Badge
|
||||||
className="border-border bg-surface-raised text-text-muted"
|
className="border-border bg-surface-raised text-text-muted"
|
||||||
|
|
@ -415,11 +473,11 @@ export const McpServerDetailDialog = ({
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{SCOPE_OPTIONS.map((opt) => (
|
{scopeOptions.map((opt) => (
|
||||||
<SelectItem
|
<SelectItem
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
value={opt.value}
|
value={opt.value}
|
||||||
disabled={opt.value !== 'user' && !projectPath}
|
disabled={isProjectScopedMcpScope(opt.value) && !projectPath}
|
||||||
>
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
@ -528,6 +586,7 @@ export const McpServerDetailDialog = ({
|
||||||
<InstallButton
|
<InstallButton
|
||||||
state={installProgress}
|
state={installProgress}
|
||||||
isInstalled={isInstalledForScope}
|
isInstalled={isInstalledForScope}
|
||||||
|
section="mcp"
|
||||||
onInstall={handleInstall}
|
onInstall={handleInstall}
|
||||||
onUninstall={handleUninstall}
|
onUninstall={handleUninstall}
|
||||||
disabled={installDisabled}
|
disabled={installDisabled}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ import { useStore } from '@renderer/store';
|
||||||
import { formatRelativeTime } from '@renderer/utils/formatters';
|
import { formatRelativeTime } from '@renderer/utils/formatters';
|
||||||
import { CLI_NOT_FOUND_MARKER } from '@shared/constants/cli';
|
import { CLI_NOT_FOUND_MARKER } from '@shared/constants/cli';
|
||||||
import {
|
import {
|
||||||
|
getMcpDiagnosticKey,
|
||||||
|
getMcpProjectStateKey,
|
||||||
getPreferredMcpInstallationEntry,
|
getPreferredMcpInstallationEntry,
|
||||||
sanitizeMcpServerName,
|
sanitizeMcpServerName,
|
||||||
} from '@shared/utils/extensionNormalizers';
|
} from '@shared/utils/extensionNormalizers';
|
||||||
|
|
@ -78,19 +80,27 @@ export const McpServersPanel = ({
|
||||||
selectedMcpServerId,
|
selectedMcpServerId,
|
||||||
setSelectedMcpServerId,
|
setSelectedMcpServerId,
|
||||||
}: McpServersPanelProps): React.JSX.Element => {
|
}: McpServersPanelProps): React.JSX.Element => {
|
||||||
|
const projectStateKey = getMcpProjectStateKey(projectPath);
|
||||||
const {
|
const {
|
||||||
browseCatalog,
|
browseCatalog,
|
||||||
browseNextCursor,
|
browseNextCursor,
|
||||||
browseLoading,
|
browseLoading,
|
||||||
browseError,
|
browseError,
|
||||||
mcpBrowse,
|
mcpBrowse,
|
||||||
installedServers,
|
installedServersByProjectPath,
|
||||||
|
installedServersFallback,
|
||||||
fetchMcpGitHubStars,
|
fetchMcpGitHubStars,
|
||||||
mcpDiagnostics,
|
mcpDiagnosticsByProjectPath,
|
||||||
mcpDiagnosticsLoading,
|
mcpDiagnosticsFallback,
|
||||||
mcpDiagnosticsError,
|
mcpDiagnosticsLoadingByProjectPath,
|
||||||
mcpDiagnosticsLastCheckedAt,
|
mcpDiagnosticsLoadingFallback,
|
||||||
|
mcpDiagnosticsErrorByProjectPath,
|
||||||
|
mcpDiagnosticsErrorFallback,
|
||||||
|
mcpDiagnosticsLastCheckedAtByProjectPath,
|
||||||
|
mcpDiagnosticsLastCheckedAtFallback,
|
||||||
runMcpDiagnostics,
|
runMcpDiagnostics,
|
||||||
|
cliStatus,
|
||||||
|
cliStatusLoading,
|
||||||
} = useStore(
|
} = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
browseCatalog: s.mcpBrowseCatalog,
|
browseCatalog: s.mcpBrowseCatalog,
|
||||||
|
|
@ -98,15 +108,34 @@ export const McpServersPanel = ({
|
||||||
browseLoading: s.mcpBrowseLoading,
|
browseLoading: s.mcpBrowseLoading,
|
||||||
browseError: s.mcpBrowseError,
|
browseError: s.mcpBrowseError,
|
||||||
mcpBrowse: s.mcpBrowse,
|
mcpBrowse: s.mcpBrowse,
|
||||||
installedServers: s.mcpInstalledServers,
|
installedServersByProjectPath: s.mcpInstalledServersByProjectPath,
|
||||||
|
installedServersFallback: s.mcpInstalledServers,
|
||||||
fetchMcpGitHubStars: s.fetchMcpGitHubStars,
|
fetchMcpGitHubStars: s.fetchMcpGitHubStars,
|
||||||
mcpDiagnostics: s.mcpDiagnostics,
|
mcpDiagnosticsByProjectPath: s.mcpDiagnosticsByProjectPath,
|
||||||
mcpDiagnosticsLoading: s.mcpDiagnosticsLoading,
|
mcpDiagnosticsFallback: s.mcpDiagnostics,
|
||||||
mcpDiagnosticsError: s.mcpDiagnosticsError,
|
mcpDiagnosticsLoadingByProjectPath: s.mcpDiagnosticsLoadingByProjectPath,
|
||||||
mcpDiagnosticsLastCheckedAt: s.mcpDiagnosticsLastCheckedAt,
|
mcpDiagnosticsLoadingFallback: s.mcpDiagnosticsLoading,
|
||||||
|
mcpDiagnosticsErrorByProjectPath: s.mcpDiagnosticsErrorByProjectPath,
|
||||||
|
mcpDiagnosticsErrorFallback: s.mcpDiagnosticsError,
|
||||||
|
mcpDiagnosticsLastCheckedAtByProjectPath: s.mcpDiagnosticsLastCheckedAtByProjectPath,
|
||||||
|
mcpDiagnosticsLastCheckedAtFallback: s.mcpDiagnosticsLastCheckedAt,
|
||||||
runMcpDiagnostics: s.runMcpDiagnostics,
|
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');
|
const [mcpSort, setMcpSort] = useState<McpSortValue>('name-asc');
|
||||||
|
|
||||||
|
|
@ -117,9 +146,31 @@ export const McpServersPanel = ({
|
||||||
}
|
}
|
||||||
}, [browseCatalog.length, browseError, browseLoading, mcpBrowse]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
void runMcpDiagnostics();
|
if (diagnosticsDisableReason) {
|
||||||
}, [runMcpDiagnostics]);
|
return;
|
||||||
|
}
|
||||||
|
void runMcpDiagnostics(projectPath ?? undefined);
|
||||||
|
}, [diagnosticsDisableReason, projectPath, runMcpDiagnostics]);
|
||||||
|
|
||||||
// Fetch GitHub stars after catalog loads (fire-and-forget)
|
// Fetch GitHub stars after catalog loads (fire-and-forget)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -162,7 +213,12 @@ export const McpServersPanel = ({
|
||||||
|
|
||||||
const getDiagnostic = (server: McpCatalogItem): McpServerDiagnostic | null => {
|
const getDiagnostic = (server: McpCatalogItem): McpServerDiagnostic | null => {
|
||||||
const installedEntry = getInstalledEntry(server);
|
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(
|
const allDiagnostics = useMemo(
|
||||||
|
|
@ -185,6 +241,8 @@ export const McpServersPanel = ({
|
||||||
|
|
||||||
// Sort displayed servers
|
// Sort displayed servers
|
||||||
const displayServers = useMemo(() => sortMcpServers(rawServers, mcpSort), [rawServers, mcpSort]);
|
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)
|
// Find selected server (search in both lists to avoid losing selection during search toggle)
|
||||||
const selectedServer = useMemo(() => {
|
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-sm font-medium text-text">MCP Health Status</p>
|
||||||
<p className="text-xs text-text-muted">
|
<p className="text-xs text-text-muted">
|
||||||
{mcpDiagnosticsLoading ? (
|
{mcpDiagnosticsLoading ? (
|
||||||
<>
|
<>Checking installed MCP servers via {runtimeLabel} ...</>
|
||||||
Checking installed MCP servers via Claude CLI (<code>claude mcp list</code>) ...
|
) : diagnosticsDisableReason ? (
|
||||||
</>
|
diagnosticsDisableReason
|
||||||
) : mcpDiagnosticsLastCheckedAt ? (
|
) : mcpDiagnosticsLastCheckedAt ? (
|
||||||
`Last checked ${formatRelativeTime(new Date(mcpDiagnosticsLastCheckedAt).toISOString())}`
|
`Last checked ${formatRelativeTime(new Date(mcpDiagnosticsLastCheckedAt).toISOString())}`
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>Run diagnostics from this page to verify installed MCP connectivity.</>
|
||||||
Run diagnostics (<code>claude mcp list</code>) to verify installed MCP
|
|
||||||
connectivity.
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => void runMcpDiagnostics()}
|
onClick={() => void runMcpDiagnostics(projectPath ?? undefined)}
|
||||||
disabled={mcpDiagnosticsLoading}
|
disabled={mcpDiagnosticsLoading || Boolean(diagnosticsDisableReason)}
|
||||||
className="whitespace-nowrap"
|
className="whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<RefreshCw
|
<RefreshCw
|
||||||
|
|
@ -235,7 +290,7 @@ export const McpServersPanel = ({
|
||||||
{(mcpDiagnosticsLoading || allDiagnostics.length > 0) && (
|
{(mcpDiagnosticsLoading || allDiagnostics.length > 0) && (
|
||||||
<div className="mt-4 border-t border-black/10 pt-4 dark:border-white/10">
|
<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">
|
<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 && (
|
{allDiagnostics.length > 0 && (
|
||||||
<span className="text-xs text-text-muted">{allDiagnostics.length} servers</span>
|
<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">
|
<div className="mcp-diagnostics-list max-h-[18.5rem] space-y-2 overflow-y-auto pr-1">
|
||||||
{allDiagnostics.map((diagnostic) => (
|
{allDiagnostics.map((diagnostic) => (
|
||||||
<div
|
<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"
|
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">
|
<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
|
<p
|
||||||
className="truncate font-mono text-[11px] text-text-muted"
|
className="truncate font-mono text-[11px] text-text-muted"
|
||||||
title={diagnostic.target}
|
title={diagnostic.target}
|
||||||
|
|
@ -263,7 +325,7 @@ export const McpServersPanel = ({
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</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">
|
<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" />
|
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
|
||||||
<div>
|
<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">
|
<p className="mt-0.5 text-xs text-text-muted">
|
||||||
MCP health checks require Claude CLI. Go to the Dashboard to install it
|
{cliStatus?.flavor === 'agent_teams_orchestrator'
|
||||||
automatically.
|
? '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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,7 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J
|
||||||
<InstallButton
|
<InstallButton
|
||||||
state={installProgress}
|
state={installProgress}
|
||||||
isInstalled={isUserInstalled}
|
isInstalled={isUserInstalled}
|
||||||
|
section="plugins"
|
||||||
onInstall={() => installPlugin({ pluginId: plugin.pluginId, scope: 'user' })}
|
onInstall={() => installPlugin({ pluginId: plugin.pluginId, scope: 'user' })}
|
||||||
onUninstall={() => uninstallPlugin(plugin.pluginId, 'user')}
|
onUninstall={() => uninstallPlugin(plugin.pluginId, 'user')}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,9 @@ export const PluginDetailDialog = ({
|
||||||
}
|
}
|
||||||
}, [projectScopeAvailable, scope]);
|
}, [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(
|
const installProgress = useStore(
|
||||||
(s) => (operationKey ? s.pluginInstallProgress[operationKey] : undefined) ?? 'idle'
|
(s) => (operationKey ? s.pluginInstallProgress[operationKey] : undefined) ?? 'idle'
|
||||||
);
|
);
|
||||||
|
|
@ -195,6 +197,7 @@ export const PluginDetailDialog = ({
|
||||||
<InstallButton
|
<InstallButton
|
||||||
state={installProgress}
|
state={installProgress}
|
||||||
isInstalled={isInstalledForScope}
|
isInstalled={isInstalledForScope}
|
||||||
|
section="plugins"
|
||||||
onInstall={() =>
|
onInstall={() =>
|
||||||
installPlugin({
|
installPlugin({
|
||||||
pluginId: plugin.pluginId,
|
pluginId: plugin.pluginId,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
} from '@renderer/components/ui/select';
|
} from '@renderer/components/ui/select';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { inferCapabilities, normalizeCategory } from '@shared/utils/extensionNormalizers';
|
import { inferCapabilities, normalizeCategory } from '@shared/utils/extensionNormalizers';
|
||||||
|
import { getCliProviderExtensionCapability } from '@shared/utils/providerExtensionCapabilities';
|
||||||
import { ArrowUpDown, Filter, Puzzle, Search } from 'lucide-react';
|
import { ArrowUpDown, Filter, Puzzle, Search } from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
|
@ -125,11 +126,12 @@ export const PluginsPanel = ({
|
||||||
hasActiveFilters,
|
hasActiveFilters,
|
||||||
setPluginSort,
|
setPluginSort,
|
||||||
}: PluginsPanelProps): React.JSX.Element => {
|
}: PluginsPanelProps): React.JSX.Element => {
|
||||||
const { catalog, loading, error } = useStore(
|
const { catalog, loading, error, cliStatus } = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
catalog: s.pluginCatalog,
|
catalog: s.pluginCatalog,
|
||||||
loading: s.pluginCatalogLoading,
|
loading: s.pluginCatalogLoading,
|
||||||
error: s.pluginCatalogError,
|
error: s.pluginCatalogError,
|
||||||
|
cliStatus: s.cliStatus,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -175,9 +177,27 @@ export const PluginsPanel = ({
|
||||||
}
|
}
|
||||||
return counts.size;
|
return counts.size;
|
||||||
}, [catalog]);
|
}, [catalog]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<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 */}
|
{/* Search + Sort + Installed only row */}
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,14 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@renderer/components/ui/dialog';
|
} from '@renderer/components/ui/dialog';
|
||||||
import { useStore } from '@renderer/store';
|
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 { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { resolveSkillProjectPath } from './skillProjectUtils';
|
import { resolveSkillProjectPath } from './skillProjectUtils';
|
||||||
|
|
||||||
|
import type { SkillValidationIssue } from '@shared/types';
|
||||||
|
|
||||||
interface SkillDetailDialogProps {
|
interface SkillDetailDialogProps {
|
||||||
skillId: string | null;
|
skillId: string | null;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -80,10 +83,7 @@ export const SkillDetailDialog = ({
|
||||||
const effectiveProjectPath = item
|
const effectiveProjectPath = item
|
||||||
? resolveSkillProjectPath(item.scope, projectPath, item.projectRoot)
|
? resolveSkillProjectPath(item.scope, projectPath, item.projectRoot)
|
||||||
: (projectPath ?? undefined);
|
: (projectPath ?? undefined);
|
||||||
|
const issuesTone = item?.issues.length ? getIssuesTone(item.issues) : null;
|
||||||
function formatRootKind(rootKind: 'claude' | 'cursor' | 'agents'): string {
|
|
||||||
return `.${rootKind}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatScopeLabel(scope: 'user' | 'project'): string {
|
function formatScopeLabel(scope: 'user' | 'project'): string {
|
||||||
return scope === 'project' ? 'This project only' : 'Your personal skills';
|
return scope === 'project' ? 'This project only' : 'Your personal skills';
|
||||||
|
|
@ -91,8 +91,29 @@ export const SkillDetailDialog = ({
|
||||||
|
|
||||||
function formatInvocationLabel(invocationMode: 'auto' | 'manual-only'): string {
|
function formatInvocationLabel(invocationMode: 'auto' | 'manual-only'): string {
|
||||||
return invocationMode === 'manual-only'
|
return invocationMode === 'manual-only'
|
||||||
? 'Claude will only use this when you explicitly ask for it.'
|
? 'Only runs when you explicitly ask for it.'
|
||||||
: 'Claude can pick this automatically when it matches the task.';
|
: '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> {
|
async function handleDelete(): Promise<void> {
|
||||||
|
|
@ -159,7 +180,8 @@ export const SkillDetailDialog = ({
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Badge variant="outline">{formatScopeLabel(item.scope)}</Badge>
|
<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">
|
<Badge variant="secondary">
|
||||||
{item.invocationMode === 'manual-only' ? 'Manual use' : 'Auto use'}
|
{item.invocationMode === 'manual-only' ? 'Manual use' : 'Auto use'}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
@ -169,16 +191,30 @@ export const SkillDetailDialog = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{item.issues.length > 0 && (
|
{item.issues.length > 0 && (
|
||||||
<div className="space-y-2 rounded-md border border-amber-500/30 bg-amber-500/5 p-4">
|
<div className={`space-y-2 rounded-md border p-4 ${issuesTone?.className ?? ''}`}>
|
||||||
<p className="text-sm font-medium text-amber-700 dark:text-amber-300">
|
<p
|
||||||
Review this skill carefully before using it
|
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>
|
</p>
|
||||||
{item.issues.map((issue, index) => (
|
{item.issues.map((issue, index) => (
|
||||||
<div
|
<div
|
||||||
key={`${issue.code}-${index}`}
|
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>
|
<span>{issue.message}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -194,7 +230,7 @@ export const SkillDetailDialog = ({
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-text-muted">
|
<p className="text-xs font-medium uppercase tracking-wide text-text-muted">
|
||||||
How Claude uses it
|
How it is used
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-text">{formatInvocationLabel(item.invocationMode)}</p>
|
<p className="text-sm text-text">{formatInvocationLabel(item.invocationMode)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import {
|
||||||
import { Textarea } from '@renderer/components/ui/textarea';
|
import { Textarea } from '@renderer/components/ui/textarea';
|
||||||
import { useMarkdownScrollSync } from '@renderer/hooks/useMarkdownScrollSync';
|
import { useMarkdownScrollSync } from '@renderer/hooks/useMarkdownScrollSync';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { SKILL_ROOT_DEFINITIONS } from '@shared/utils/skillRoots';
|
||||||
import { FileSearch, RotateCcw, X } from 'lucide-react';
|
import { FileSearch, RotateCcw, X } from 'lucide-react';
|
||||||
|
|
||||||
import { SkillCodeEditor } from './SkillCodeEditor';
|
import { SkillCodeEditor } from './SkillCodeEditor';
|
||||||
|
|
@ -41,6 +42,7 @@ import type {
|
||||||
SkillDetail,
|
SkillDetail,
|
||||||
SkillInvocationMode,
|
SkillInvocationMode,
|
||||||
SkillReviewPreview,
|
SkillReviewPreview,
|
||||||
|
SkillRootKind,
|
||||||
} from '@shared/types/extensions';
|
} from '@shared/types/extensions';
|
||||||
|
|
||||||
type EditorMode = 'create' | 'edit';
|
type EditorMode = 'create' | 'edit';
|
||||||
|
|
@ -50,6 +52,7 @@ interface SkillEditorDialogProps {
|
||||||
mode: EditorMode;
|
mode: EditorMode;
|
||||||
projectPath: string | null;
|
projectPath: string | null;
|
||||||
projectLabel: string | null;
|
projectLabel: string | null;
|
||||||
|
allowCodexRootKind: boolean;
|
||||||
detail: SkillDetail | null;
|
detail: SkillDetail | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSaved: (skillId: string | null) => void;
|
onSaved: (skillId: string | null) => void;
|
||||||
|
|
@ -68,6 +71,7 @@ export const SkillEditorDialog = ({
|
||||||
mode,
|
mode,
|
||||||
projectPath,
|
projectPath,
|
||||||
projectLabel,
|
projectLabel,
|
||||||
|
allowCodexRootKind,
|
||||||
detail,
|
detail,
|
||||||
onClose,
|
onClose,
|
||||||
onSaved,
|
onSaved,
|
||||||
|
|
@ -79,7 +83,7 @@ export const SkillEditorDialog = ({
|
||||||
const applySkillUpsert = useStore((s) => s.applySkillUpsert);
|
const applySkillUpsert = useStore((s) => s.applySkillUpsert);
|
||||||
|
|
||||||
const [scope, setScope] = useState<'user' | 'project'>('user');
|
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 [folderName, setFolderName] = useState('');
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
|
|
@ -218,7 +222,7 @@ export const SkillEditorDialog = ({
|
||||||
setReviewLoading(false);
|
setReviewLoading(false);
|
||||||
setSaveLoading(false);
|
setSaveLoading(false);
|
||||||
setMutationError(null);
|
setMutationError(null);
|
||||||
}, [detail, mode, open, projectPath]);
|
}, [allowCodexRootKind, detail, mode, open, projectPath]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
|
|
@ -238,6 +242,12 @@ export const SkillEditorDialog = ({
|
||||||
}
|
}
|
||||||
}, [mode, open, projectPath, scope]);
|
}, [mode, open, projectPath, scope]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && mode === 'create' && rootKind === 'codex' && !allowCodexRootKind) {
|
||||||
|
setRootKind('claude');
|
||||||
|
}
|
||||||
|
}, [allowCodexRootKind, mode, open, rootKind]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
rawContentRef.current = rawContent;
|
rawContentRef.current = rawContent;
|
||||||
}, [rawContent]);
|
}, [rawContent]);
|
||||||
|
|
@ -289,6 +299,14 @@ export const SkillEditorDialog = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
const canUseProjectScope = Boolean(projectPath);
|
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 instructionsLocked = manualRawEdit || customMarkdownDetected;
|
||||||
const title = mode === 'create' ? 'Create skill' : 'Edit skill';
|
const title = mode === 'create' ? 'Create skill' : 'Edit skill';
|
||||||
const descriptionText =
|
const descriptionText =
|
||||||
|
|
@ -427,18 +445,19 @@ export const SkillEditorDialog = ({
|
||||||
<Label htmlFor="skill-root">Where to store it</Label>
|
<Label htmlFor="skill-root">Where to store it</Label>
|
||||||
<Select
|
<Select
|
||||||
value={rootKind}
|
value={rootKind}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) => setRootKind(value as SkillRootKind)}
|
||||||
setRootKind(value as 'claude' | 'cursor' | 'agents')
|
|
||||||
}
|
|
||||||
disabled={mode === 'edit'}
|
disabled={mode === 'edit'}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="skill-root">
|
<SelectTrigger id="skill-root">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="claude">.claude</SelectItem>
|
{visibleRootDefinitions.map((definition) => (
|
||||||
<SelectItem value="cursor">.cursor</SelectItem>
|
<SelectItem key={definition.rootKind} value={definition.rootKind}>
|
||||||
<SelectItem value="agents">.agents</SelectItem>
|
{definition.directoryName}
|
||||||
|
{definition.audience === 'codex' ? ' - Codex only' : ' - Shared'}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -463,7 +482,7 @@ export const SkillEditorDialog = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<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
|
<Select
|
||||||
value={invocationMode}
|
value={invocationMode}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
|
|
@ -476,7 +495,7 @@ export const SkillEditorDialog = ({
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<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>
|
<SelectItem value="manual-only">Only when you ask for it</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
@ -556,7 +575,7 @@ export const SkillEditorDialog = ({
|
||||||
|
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<div className="space-y-2">
|
<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
|
<Textarea
|
||||||
id="skill-when-to-use"
|
id="skill-when-to-use"
|
||||||
value={whenToUse}
|
value={whenToUse}
|
||||||
|
|
@ -572,7 +591,7 @@ export const SkillEditorDialog = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<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
|
<Textarea
|
||||||
id="skill-steps"
|
id="skill-steps"
|
||||||
value={steps}
|
value={steps}
|
||||||
|
|
@ -647,7 +666,7 @@ export const SkillEditorDialog = ({
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-text">References</p>
|
<p className="font-medium text-text">References</p>
|
||||||
<p className="mt-1 text-xs text-text-muted">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
|
||||||
|
|
@ -20,14 +20,15 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@renderer/components/ui/select';
|
} from '@renderer/components/ui/select';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { SKILL_ROOT_DEFINITIONS } from '@shared/utils/skillRoots';
|
||||||
import { FileSearch, FolderOpen, X } from 'lucide-react';
|
import { FileSearch, FolderOpen, X } from 'lucide-react';
|
||||||
|
|
||||||
import { getSuggestedSkillFolderNameFromPath } from './skillFolderNameUtils';
|
import { getSuggestedSkillFolderNameFromPath } from './skillFolderNameUtils';
|
||||||
import { SkillReviewDialog } from './SkillReviewDialog';
|
|
||||||
import { resolveSkillProjectPath } from './skillProjectUtils';
|
import { resolveSkillProjectPath } from './skillProjectUtils';
|
||||||
|
import { SkillReviewDialog } from './SkillReviewDialog';
|
||||||
import { validateSkillFolderName, validateSkillImportSourceDir } from './skillValidationUtils';
|
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 {
|
function getFriendlyImportError(message: string): string {
|
||||||
if (message.includes('valid skill file')) {
|
if (message.includes('valid skill file')) {
|
||||||
|
|
@ -55,6 +56,7 @@ interface SkillImportDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
projectPath: string | null;
|
projectPath: string | null;
|
||||||
projectLabel: string | null;
|
projectLabel: string | null;
|
||||||
|
allowCodexRootKind: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onImported: (skillId: string | null) => void;
|
onImported: (skillId: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -63,6 +65,7 @@ export const SkillImportDialog = ({
|
||||||
open,
|
open,
|
||||||
projectPath,
|
projectPath,
|
||||||
projectLabel,
|
projectLabel,
|
||||||
|
allowCodexRootKind,
|
||||||
onClose,
|
onClose,
|
||||||
onImported,
|
onImported,
|
||||||
}: SkillImportDialogProps): React.JSX.Element => {
|
}: SkillImportDialogProps): React.JSX.Element => {
|
||||||
|
|
@ -73,7 +76,7 @@ export const SkillImportDialog = ({
|
||||||
const [folderName, setFolderName] = useState('');
|
const [folderName, setFolderName] = useState('');
|
||||||
const [folderNameEdited, setFolderNameEdited] = useState(false);
|
const [folderNameEdited, setFolderNameEdited] = useState(false);
|
||||||
const [scope, setScope] = useState<'user' | 'project'>('user');
|
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 [preview, setPreview] = useState<SkillReviewPreview | null>(null);
|
||||||
const [reviewOpen, setReviewOpen] = useState(false);
|
const [reviewOpen, setReviewOpen] = useState(false);
|
||||||
const [reviewLoading, setReviewLoading] = useState(false);
|
const [reviewLoading, setReviewLoading] = useState(false);
|
||||||
|
|
@ -119,6 +122,16 @@ export const SkillImportDialog = ({
|
||||||
}
|
}
|
||||||
}, [open, projectPath, scope]);
|
}, [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> {
|
async function handleChooseFolder(): Promise<void> {
|
||||||
const selected = await api.config.selectFolders();
|
const selected = await api.config.selectFolders();
|
||||||
const first = selected[0];
|
const first = selected[0];
|
||||||
|
|
@ -273,17 +286,18 @@ export const SkillImportDialog = ({
|
||||||
<Label htmlFor="skill-import-root">Where to store it</Label>
|
<Label htmlFor="skill-import-root">Where to store it</Label>
|
||||||
<Select
|
<Select
|
||||||
value={rootKind}
|
value={rootKind}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) => setRootKind(value as SkillRootKind)}
|
||||||
setRootKind(value as 'claude' | 'cursor' | 'agents')
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger id="skill-import-root">
|
<SelectTrigger id="skill-import-root">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="claude">.claude</SelectItem>
|
{visibleRootDefinitions.map((definition) => (
|
||||||
<SelectItem value="cursor">.cursor</SelectItem>
|
<SelectItem key={definition.rootKind} value={definition.rootKind}>
|
||||||
<SelectItem value="agents">.agents</SelectItem>
|
{definition.directoryName}
|
||||||
|
{definition.audience === 'codex' ? ' - Codex only' : ' - Shared'}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,17 @@ import { Button } from '@renderer/components/ui/button';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { useStore } from '@renderer/store';
|
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 {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowUpAZ,
|
ArrowUpAZ,
|
||||||
|
|
@ -15,6 +26,7 @@ import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock3,
|
Clock3,
|
||||||
Download,
|
Download,
|
||||||
|
Info,
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
@ -28,12 +40,19 @@ import { SkillImportDialog } from './SkillImportDialog';
|
||||||
import { resolveSkillProjectPath } from './skillProjectUtils';
|
import { resolveSkillProjectPath } from './skillProjectUtils';
|
||||||
|
|
||||||
import type { SkillsSortState } from '@renderer/hooks/useExtensionsTabState';
|
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 SUCCESS_BANNER_MS = 2500;
|
||||||
const NEW_SKILL_HIGHLIGHT_MS = 4000;
|
const NEW_SKILL_HIGHLIGHT_MS = 4000;
|
||||||
const USER_SKILLS_CATALOG_KEY = '__user__';
|
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 {
|
interface SkillsPanelProps {
|
||||||
projectPath: string | null;
|
projectPath: string | null;
|
||||||
|
|
@ -57,10 +76,6 @@ function sortSkills(skills: SkillCatalogItem[], sort: SkillsSortState): SkillCat
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRootKind(rootKind: SkillCatalogItem['rootKind']): string {
|
|
||||||
return `.${rootKind}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getScopeLabel(skill: SkillCatalogItem): string {
|
function getScopeLabel(skill: SkillCatalogItem): string {
|
||||||
return skill.scope === 'project' ? 'This project' : 'Personal';
|
return skill.scope === 'project' ? 'This project' : 'Personal';
|
||||||
}
|
}
|
||||||
|
|
@ -68,7 +83,7 @@ function getScopeLabel(skill: SkillCatalogItem): string {
|
||||||
function getInvocationLabel(skill: SkillCatalogItem): string {
|
function getInvocationLabel(skill: SkillCatalogItem): string {
|
||||||
return skill.invocationMode === 'manual-only'
|
return skill.invocationMode === 'manual-only'
|
||||||
? 'Only runs when you explicitly ask for it'
|
? '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 {
|
function getSkillStatus(skill: SkillCatalogItem): string {
|
||||||
|
|
@ -81,6 +96,45 @@ function getSkillStatus(skill: SkillCatalogItem): string {
|
||||||
return 'Ready to use';
|
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 = ({
|
export const SkillsPanel = ({
|
||||||
projectPath,
|
projectPath,
|
||||||
projectLabel,
|
projectLabel,
|
||||||
|
|
@ -94,6 +148,7 @@ export const SkillsPanel = ({
|
||||||
const catalogKey = projectPath ?? USER_SKILLS_CATALOG_KEY;
|
const catalogKey = projectPath ?? USER_SKILLS_CATALOG_KEY;
|
||||||
const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog);
|
const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog);
|
||||||
const fetchSkillDetail = useStore((s) => s.fetchSkillDetail);
|
const fetchSkillDetail = useStore((s) => s.fetchSkillDetail);
|
||||||
|
const cliStatus = useStore((s) => s.cliStatus);
|
||||||
const skillsLoading = useStore((s) => s.skillsCatalogLoadingByProjectPath[catalogKey] ?? false);
|
const skillsLoading = useStore((s) => s.skillsCatalogLoadingByProjectPath[catalogKey] ?? false);
|
||||||
const skillsError = useStore((s) => s.skillsCatalogErrorByProjectPath[catalogKey] ?? null);
|
const skillsError = useStore((s) => s.skillsCatalogErrorByProjectPath[catalogKey] ?? null);
|
||||||
const detailById = useStore(useShallow((s) => s.skillsDetailsById));
|
const detailById = useStore(useShallow((s) => s.skillsDetailsById));
|
||||||
|
|
@ -110,18 +165,47 @@ export const SkillsPanel = ({
|
||||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
const [highlightedSkillId, setHighlightedSkillId] = useState<string | null>(null);
|
const [highlightedSkillId, setHighlightedSkillId] = useState<string | null>(null);
|
||||||
const selectedSkillIdRef = useRef<string | null>(selectedSkillId);
|
const selectedSkillIdRef = useRef<string | null>(selectedSkillId);
|
||||||
const selectedSkillItemRef = useRef<SkillCatalogItem | SkillDetail['item'] | null>(null);
|
const selectedSkillItemRef = useRef<SkillCatalogItem | null>(null);
|
||||||
selectedSkillIdRef.current = selectedSkillId;
|
selectedSkillIdRef.current = selectedSkillId;
|
||||||
|
|
||||||
const mergedSkills = useMemo(
|
const mergedSkills = useMemo(
|
||||||
() => [...projectSkills, ...userSkills],
|
() => [...projectSkills, ...userSkills],
|
||||||
[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;
|
const selectedDetail = selectedSkillId ? (detailById[selectedSkillId] ?? null) : null;
|
||||||
selectedSkillItemRef.current = selectedSkillId
|
selectedSkillItemRef.current = selectedSkillId
|
||||||
? (selectedDetail?.item ?? mergedSkills.find((skill) => skill.id === selectedSkillId) ?? null)
|
? (selectedDetail?.item ?? mergedSkills.find((skill) => skill.id === selectedSkillId) ?? null)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (quickFilter === 'codex-only' && !showCodexOnlyUi) {
|
||||||
|
setQuickFilter('all');
|
||||||
|
}
|
||||||
|
}, [quickFilter, showCodexOnlyUi]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedSkillId) return;
|
if (!selectedSkillId) return;
|
||||||
if (mergedSkills.some((skill) => skill.id === selectedSkillId)) return;
|
if (mergedSkills.some((skill) => skill.id === selectedSkillId)) return;
|
||||||
|
|
@ -204,6 +288,10 @@ export const SkillsPanel = ({
|
||||||
return skill.scope === 'project';
|
return skill.scope === 'project';
|
||||||
case 'personal':
|
case 'personal':
|
||||||
return skill.scope === 'user';
|
return skill.scope === 'user';
|
||||||
|
case 'shared':
|
||||||
|
return getSkillAudience(skill.rootKind) === 'shared';
|
||||||
|
case 'codex-only':
|
||||||
|
return getSkillAudience(skill.rootKind) === 'codex';
|
||||||
case 'needs-attention':
|
case 'needs-attention':
|
||||||
return !skill.isValid;
|
return !skill.isValid;
|
||||||
case 'has-scripts':
|
case 'has-scripts':
|
||||||
|
|
@ -226,16 +314,23 @@ export const SkillsPanel = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<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="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="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="min-w-0 flex-1 space-y-1 xl:max-w-2xl">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BookOpen className="size-4 text-text-muted" />
|
<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>
|
</div>
|
||||||
<p className="max-w-2xl text-sm leading-5 text-text-muted">
|
<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
|
Skills are reusable instructions that help the runtime handle the same kind of task
|
||||||
consistently.{' '}
|
more consistently.{' '}
|
||||||
{projectPath
|
{projectPath
|
||||||
? `You are seeing skills for ${projectLabel ?? projectPath} plus your personal skills.`
|
? `You are seeing skills for ${projectLabel ?? projectPath} plus your personal skills.`
|
||||||
: 'You are seeing only your personal skills right now.'}
|
: '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">
|
<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
|
Use personal skills for habits you want everywhere. Use project skills for workflows
|
||||||
that only make sense inside one codebase.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -320,6 +418,14 @@ export const SkillsPanel = ({
|
||||||
<Badge variant="secondary" className="font-normal">
|
<Badge variant="secondary" className="font-normal">
|
||||||
{userSkills.length} personal
|
{userSkills.length} personal
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<Badge variant="secondary" className="font-normal">
|
||||||
|
{sharedSkillsCount} shared
|
||||||
|
</Badge>
|
||||||
|
{showCodexOnlyUi && (
|
||||||
|
<Badge variant="secondary" className="font-normal">
|
||||||
|
{codexOnlySkillsCount} Codex only
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -331,6 +437,10 @@ export const SkillsPanel = ({
|
||||||
['all', 'All skills'],
|
['all', 'All skills'],
|
||||||
['project', 'Project'],
|
['project', 'Project'],
|
||||||
['personal', 'Personal'],
|
['personal', 'Personal'],
|
||||||
|
['shared', 'Shared'],
|
||||||
|
...(showCodexOnlyUi
|
||||||
|
? ([['codex-only', 'Codex only']] as [SkillsQuickFilter, string][])
|
||||||
|
: []),
|
||||||
['needs-attention', 'Needs attention'],
|
['needs-attention', 'Needs attention'],
|
||||||
['has-scripts', 'Has scripts'],
|
['has-scripts', 'Has scripts'],
|
||||||
] as [SkillsQuickFilter, string][]
|
] as [SkillsQuickFilter, string][]
|
||||||
|
|
@ -383,7 +493,7 @@ export const SkillsPanel = ({
|
||||||
<p className="text-xs text-text-muted">
|
<p className="text-xs text-text-muted">
|
||||||
{skillsSearchQuery
|
{skillsSearchQuery
|
||||||
? 'Try a different search term or switch filters.'
|
? '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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -404,71 +514,83 @@ export const SkillsPanel = ({
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="skills-grid grid grid-cols-1 gap-3 xl:grid-cols-2">
|
<div className="skills-grid grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||||
{visibleProjectSkills.map((skill) => (
|
{visibleProjectSkills.map((skill) => {
|
||||||
<button
|
const primaryIssue = getPrimarySkillIssue(skill);
|
||||||
key={skill.id}
|
const issueTone = getSkillIssueTone(primaryIssue);
|
||||||
type="button"
|
const IssueIcon = issueTone.Icon;
|
||||||
onClick={() => setSelectedSkillId(skill.id)}
|
return (
|
||||||
className={`rounded-xl border p-4 text-left transition-colors ${
|
<button
|
||||||
highlightedSkillId === skill.id
|
key={skill.id}
|
||||||
? 'border-green-500/50 bg-green-500/10 shadow-[0_0_0_1px_rgba(34,197,94,0.18)]'
|
type="button"
|
||||||
: 'bg-surface-raised/10 border-border hover:border-border-emphasis'
|
onClick={() => setSelectedSkillId(skill.id)}
|
||||||
}`}
|
className={`rounded-xl border p-4 text-left transition-colors ${
|
||||||
>
|
highlightedSkillId === skill.id
|
||||||
<div className="flex items-start justify-between gap-3">
|
? 'border-green-500/50 bg-green-500/10 shadow-[0_0_0_1px_rgba(34,197,94,0.18)]'
|
||||||
<div className="min-w-0 space-y-1">
|
: 'bg-surface-raised/10 border-border hover:border-border-emphasis'
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
}`}
|
||||||
<h3 className="truncate text-sm font-semibold text-text">{skill.name}</h3>
|
>
|
||||||
{!skill.isValid && (
|
<div className="flex items-start justify-between gap-3">
|
||||||
<Badge
|
<div className="min-w-0 space-y-1">
|
||||||
variant="outline"
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
className="border-amber-500/40 text-amber-700 dark:text-amber-300"
|
<h3 className="truncate text-sm font-semibold text-text">
|
||||||
>
|
{skill.name}
|
||||||
Needs attention
|
</h3>
|
||||||
</Badge>
|
{!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>
|
</div>
|
||||||
<p className="line-clamp-2 text-sm text-text-secondary">
|
<Badge variant="outline">{getScopeLabel(skill)}</Badge>
|
||||||
{skill.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline">{getScopeLabel(skill)}</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3 space-y-2 text-xs text-text-muted">
|
<div className="mt-3 space-y-2 text-xs text-text-muted">
|
||||||
<p>{getInvocationLabel(skill)}</p>
|
<p>{getInvocationLabel(skill)}</p>
|
||||||
<p>{getSkillStatus(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>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
@ -479,7 +601,7 @@ export const SkillsPanel = ({
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-text">Personal skills</h3>
|
<h3 className="text-sm font-semibold text-text">Personal skills</h3>
|
||||||
<p className="text-xs text-text-muted">
|
<p className="text-xs text-text-muted">
|
||||||
Habits and instructions you want Claude to remember everywhere.
|
Habits and instructions you want available everywhere.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="secondary" className="font-normal">
|
<Badge variant="secondary" className="font-normal">
|
||||||
|
|
@ -487,71 +609,83 @@ export const SkillsPanel = ({
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="skills-grid grid grid-cols-1 gap-3 xl:grid-cols-2">
|
<div className="skills-grid grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||||
{visibleUserSkills.map((skill) => (
|
{visibleUserSkills.map((skill) => {
|
||||||
<button
|
const primaryIssue = getPrimarySkillIssue(skill);
|
||||||
key={skill.id}
|
const issueTone = getSkillIssueTone(primaryIssue);
|
||||||
type="button"
|
const IssueIcon = issueTone.Icon;
|
||||||
onClick={() => setSelectedSkillId(skill.id)}
|
return (
|
||||||
className={`rounded-xl border p-4 text-left transition-colors ${
|
<button
|
||||||
highlightedSkillId === skill.id
|
key={skill.id}
|
||||||
? 'border-green-500/50 bg-green-500/10 shadow-[0_0_0_1px_rgba(34,197,94,0.18)]'
|
type="button"
|
||||||
: 'bg-surface-raised/10 border-border hover:border-border-emphasis'
|
onClick={() => setSelectedSkillId(skill.id)}
|
||||||
}`}
|
className={`rounded-xl border p-4 text-left transition-colors ${
|
||||||
>
|
highlightedSkillId === skill.id
|
||||||
<div className="flex items-start justify-between gap-3">
|
? 'border-green-500/50 bg-green-500/10 shadow-[0_0_0_1px_rgba(34,197,94,0.18)]'
|
||||||
<div className="min-w-0 space-y-1">
|
: 'bg-surface-raised/10 border-border hover:border-border-emphasis'
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
}`}
|
||||||
<h3 className="truncate text-sm font-semibold text-text">{skill.name}</h3>
|
>
|
||||||
{!skill.isValid && (
|
<div className="flex items-start justify-between gap-3">
|
||||||
<Badge
|
<div className="min-w-0 space-y-1">
|
||||||
variant="outline"
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
className="border-amber-500/40 text-amber-700 dark:text-amber-300"
|
<h3 className="truncate text-sm font-semibold text-text">
|
||||||
>
|
{skill.name}
|
||||||
Needs attention
|
</h3>
|
||||||
</Badge>
|
{!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>
|
</div>
|
||||||
<p className="line-clamp-2 text-sm text-text-secondary">
|
<Badge variant="outline">{getScopeLabel(skill)}</Badge>
|
||||||
{skill.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline">{getScopeLabel(skill)}</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3 space-y-2 text-xs text-text-muted">
|
<div className="mt-3 space-y-2 text-xs text-text-muted">
|
||||||
<p>{getInvocationLabel(skill)}</p>
|
<p>{getInvocationLabel(skill)}</p>
|
||||||
<p>{getSkillStatus(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>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
@ -577,6 +711,7 @@ export const SkillsPanel = ({
|
||||||
mode="create"
|
mode="create"
|
||||||
projectPath={projectPath}
|
projectPath={projectPath}
|
||||||
projectLabel={projectLabel}
|
projectLabel={projectLabel}
|
||||||
|
allowCodexRootKind={codexSkillOverlayAvailable}
|
||||||
detail={null}
|
detail={null}
|
||||||
onClose={() => setCreateOpen(false)}
|
onClose={() => setCreateOpen(false)}
|
||||||
onSaved={(skillId) => {
|
onSaved={(skillId) => {
|
||||||
|
|
@ -592,6 +727,7 @@ export const SkillsPanel = ({
|
||||||
mode="edit"
|
mode="edit"
|
||||||
projectPath={projectPath}
|
projectPath={projectPath}
|
||||||
projectLabel={projectLabel}
|
projectLabel={projectLabel}
|
||||||
|
allowCodexRootKind={codexSkillOverlayAvailable}
|
||||||
detail={editingDetail}
|
detail={editingDetail}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setEditOpen(false);
|
setEditOpen(false);
|
||||||
|
|
@ -609,6 +745,7 @@ export const SkillsPanel = ({
|
||||||
open={importOpen}
|
open={importOpen}
|
||||||
projectPath={projectPath}
|
projectPath={projectPath}
|
||||||
projectLabel={projectLabel}
|
projectLabel={projectLabel}
|
||||||
|
allowCodexRootKind={codexSkillOverlayAvailable}
|
||||||
onClose={() => setImportOpen(false)}
|
onClose={() => setImportOpen(false)}
|
||||||
onImported={(skillId) => {
|
onImported={(skillId) => {
|
||||||
setImportOpen(false);
|
setImportOpen(false);
|
||||||
|
|
|
||||||
|
|
@ -204,7 +204,7 @@ export const Sidebar = (): React.JSX.Element => {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Resize sidebar"
|
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' : ''
|
isResizing ? 'bg-blue-500/50' : ''
|
||||||
}`}
|
}`}
|
||||||
onMouseDown={handleResizeStart}
|
onMouseDown={handleResizeStart}
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ function isApiKeyProviderId(providerId: CliProviderId): providerId is ApiKeyProv
|
||||||
|
|
||||||
function findPreferredApiKeyEntry(apiKeys: ApiKeyEntry[], envVarName: string): ApiKeyEntry | null {
|
function findPreferredApiKeyEntry(apiKeys: ApiKeyEntry[], envVarName: string): ApiKeyEntry | null {
|
||||||
const matches = apiKeys.filter((entry) => entry.envVarName === envVarName);
|
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 {
|
function getConnectionDescription(provider: CliProviderStatus): string {
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ export interface SafeConfig {
|
||||||
notifyOnCrossTeamMessage: boolean;
|
notifyOnCrossTeamMessage: boolean;
|
||||||
notifyOnTeamLaunched: boolean;
|
notifyOnTeamLaunched: boolean;
|
||||||
notifyOnToolApproval: boolean;
|
notifyOnToolApproval: boolean;
|
||||||
|
autoResumeOnRateLimit: boolean;
|
||||||
statusChangeOnlySolo: boolean;
|
statusChangeOnlySolo: boolean;
|
||||||
statusChangeStatuses: string[];
|
statusChangeStatuses: string[];
|
||||||
triggers: AppConfig['notifications']['triggers'];
|
triggers: AppConfig['notifications']['triggers'];
|
||||||
|
|
@ -195,6 +196,7 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
|
||||||
notifyOnCrossTeamMessage: displayConfig?.notifications?.notifyOnCrossTeamMessage ?? true,
|
notifyOnCrossTeamMessage: displayConfig?.notifications?.notifyOnCrossTeamMessage ?? true,
|
||||||
notifyOnTeamLaunched: displayConfig?.notifications?.notifyOnTeamLaunched ?? true,
|
notifyOnTeamLaunched: displayConfig?.notifications?.notifyOnTeamLaunched ?? true,
|
||||||
notifyOnToolApproval: displayConfig?.notifications?.notifyOnToolApproval ?? true,
|
notifyOnToolApproval: displayConfig?.notifications?.notifyOnToolApproval ?? true,
|
||||||
|
autoResumeOnRateLimit: displayConfig?.notifications?.autoResumeOnRateLimit ?? false,
|
||||||
statusChangeOnlySolo: displayConfig?.notifications?.statusChangeOnlySolo ?? true,
|
statusChangeOnlySolo: displayConfig?.notifications?.statusChangeOnlySolo ?? true,
|
||||||
statusChangeStatuses: displayConfig?.notifications?.statusChangeStatuses ?? [
|
statusChangeStatuses: displayConfig?.notifications?.statusChangeStatuses ?? [
|
||||||
'in_progress',
|
'in_progress',
|
||||||
|
|
|
||||||
|
|
@ -311,6 +311,7 @@ export function useSettingsHandlers({
|
||||||
notifyOnCrossTeamMessage: true,
|
notifyOnCrossTeamMessage: true,
|
||||||
notifyOnTeamLaunched: true,
|
notifyOnTeamLaunched: true,
|
||||||
notifyOnToolApproval: true,
|
notifyOnToolApproval: true,
|
||||||
|
autoResumeOnRateLimit: false,
|
||||||
statusChangeOnlySolo: true,
|
statusChangeOnlySolo: true,
|
||||||
statusChangeStatuses: ['in_progress', 'completed'],
|
statusChangeStatuses: ['in_progress', 'completed'],
|
||||||
triggers: defaultTriggers,
|
triggers: defaultTriggers,
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,7 @@ export const AdvancedSection = ({
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<p className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
|
<p className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
|
||||||
Claude Agent Teams UI
|
Agent Teams UI
|
||||||
</p>
|
</p>
|
||||||
{isElectron && (
|
{isElectron && (
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
!cliStatus && cliStatusLoading && multimodelEnabled
|
!cliStatus && cliStatusLoading && multimodelEnabled
|
||||||
? createLoadingMultimodelCliStatus()
|
? createLoadingMultimodelCliStatus()
|
||||||
: cliStatus;
|
: cliStatus;
|
||||||
|
const canOpenExtensions = effectiveCliStatus?.installed === true;
|
||||||
const showInstalledControls =
|
const showInstalledControls =
|
||||||
effectiveCliStatus !== null && (installerState === 'idle' || installerState === 'completed');
|
effectiveCliStatus !== null && (installerState === 'idle' || installerState === 'completed');
|
||||||
|
|
||||||
|
|
@ -396,7 +397,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
{/* Extensions button — right-aligned */}
|
{/* Extensions button — right-aligned */}
|
||||||
{effectiveCliStatus.authLoggedIn && (
|
{canOpenExtensions && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openExtensionsTab}
|
onClick={openExtensionsTab}
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@ interface NotificationsSectionProps {
|
||||||
| 'notifyOnCrossTeamMessage'
|
| 'notifyOnCrossTeamMessage'
|
||||||
| 'notifyOnTeamLaunched'
|
| 'notifyOnTeamLaunched'
|
||||||
| 'notifyOnToolApproval'
|
| 'notifyOnToolApproval'
|
||||||
|
| 'autoResumeOnRateLimit'
|
||||||
| 'statusChangeOnlySolo',
|
| 'statusChangeOnlySolo',
|
||||||
value: boolean
|
value: boolean
|
||||||
) => void;
|
) => void;
|
||||||
|
|
@ -360,6 +361,17 @@ export const NotificationsSection = ({
|
||||||
disabled={saving || !safeConfig.notifications.enabled}
|
disabled={saving || !safeConfig.notifications.enabled}
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</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 */}
|
{/* Task Status Change Notifications — nested within team card */}
|
||||||
<div className="last:*:border-b-0">
|
<div className="last:*:border-b-0">
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Folder,
|
||||||
ListTodo,
|
ListTodo,
|
||||||
Pin,
|
Pin,
|
||||||
Search,
|
Search,
|
||||||
|
|
@ -56,7 +57,7 @@ function loadGroupingMode(): TaskGroupingMode {
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
return 'none';
|
return 'project';
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveGroupingMode(mode: TaskGroupingMode): void {
|
function saveGroupingMode(mode: TaskGroupingMode): void {
|
||||||
|
|
@ -625,6 +626,7 @@ export const GlobalTaskList = ({
|
||||||
projectGroups.map((group) => {
|
projectGroups.map((group) => {
|
||||||
if (group.tasks.length === 0) return null;
|
if (group.tasks.length === 0) return null;
|
||||||
const isGroupCollapsed = projectCollapsed.isCollapsed(group.projectKey);
|
const isGroupCollapsed = projectCollapsed.isCollapsed(group.projectKey);
|
||||||
|
const groupColor = projectColor(group.projectLabel);
|
||||||
let lastTeam: string | null = null;
|
let lastTeam: string | null = null;
|
||||||
return (
|
return (
|
||||||
<div key={group.projectKey}>
|
<div key={group.projectKey}>
|
||||||
|
|
@ -639,14 +641,12 @@ export const GlobalTaskList = ({
|
||||||
) : (
|
) : (
|
||||||
<ChevronDown className="size-3 shrink-0 text-text-muted" />
|
<ChevronDown className="size-3 shrink-0 text-text-muted" />
|
||||||
)}
|
)}
|
||||||
<span
|
<Folder
|
||||||
className="inline-block size-1.5 shrink-0 rounded-full"
|
className="size-3 shrink-0"
|
||||||
style={{ backgroundColor: projectColor(group.projectLabel).border }}
|
style={{ color: groupColor.border }}
|
||||||
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<span
|
<span className="truncate" style={{ color: groupColor.text }}>
|
||||||
className="truncate"
|
|
||||||
style={{ color: projectColor(group.projectLabel).text }}
|
|
||||||
>
|
|
||||||
{group.projectLabel}
|
{group.projectLabel}
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-auto shrink-0 text-[10px] font-normal text-text-muted">
|
<span className="ml-auto shrink-0 text-[10px] font-normal text-text-muted">
|
||||||
|
|
|
||||||
|
|
@ -111,8 +111,7 @@ export const MemberCard = ({
|
||||||
!isRemoved &&
|
!isRemoved &&
|
||||||
presenceLabel === 'starting' &&
|
presenceLabel === 'starting' &&
|
||||||
spawnLaunchState !== 'failed_to_start' &&
|
spawnLaunchState !== 'failed_to_start' &&
|
||||||
!activityTask &&
|
!activityTask;
|
||||||
!runtimeSummary;
|
|
||||||
const showStartingBadge = !isRemoved && presenceLabel === 'starting' && !activityTask;
|
const showStartingBadge = !isRemoved && presenceLabel === 'starting' && !activityTask;
|
||||||
const showRuntimeAdvisoryBadge =
|
const showRuntimeAdvisoryBadge =
|
||||||
!isRemoved &&
|
!isRemoved &&
|
||||||
|
|
|
||||||
|
|
@ -237,7 +237,8 @@ export const MessageComposer = ({
|
||||||
buildSlashCommandSuggestions(
|
buildSlashCommandSuggestions(
|
||||||
getSuggestedSlashCommandsForProvider(leadProviderId),
|
getSuggestedSlashCommandsForProvider(leadProviderId),
|
||||||
projectSkills,
|
projectSkills,
|
||||||
userSkills
|
userSkills,
|
||||||
|
leadProviderId
|
||||||
),
|
),
|
||||||
[leadProviderId, projectSkills, userSkills]
|
[leadProviderId, projectSkills, userSkills]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<link rel="icon" type="image/png" href="./favicon.png" />
|
<link rel="icon" type="image/png" href="./favicon.png" />
|
||||||
<title>Claude Agent Teams UI</title>
|
<title>Agent Teams UI</title>
|
||||||
<style>
|
<style>
|
||||||
/* Splash: animated gradient background */
|
/* Splash: animated gradient background */
|
||||||
#splash {
|
#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: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"/>
|
<circle class="splash-node splash-core-fill" style="animation-delay:2s" cx="28" cy="37" r="2.2" fill="#f3e8ff"/>
|
||||||
</svg>
|
</svg>
|
||||||
<div id="splash-text">Claude Agent Teams UI</div>
|
<div id="splash-text">Agent Teams UI</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="./main.tsx"></script>
|
<script type="module" src="./main.tsx"></script>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
||||||
|
|
||||||
import type { AppState } from '../types';
|
import type { AppState } from '../types';
|
||||||
import type { CliInstallationStatus, CliProviderId, CliProviderStatus } from '@shared/types';
|
import type { CliInstallationStatus, CliProviderId, CliProviderStatus } from '@shared/types';
|
||||||
|
|
@ -36,6 +37,7 @@ export function createLoadingMultimodelCliStatus(): CliInstallationStatus {
|
||||||
capabilities: {
|
capabilities: {
|
||||||
teamLaunch: false,
|
teamLaunch: false,
|
||||||
oneShot: false,
|
oneShot: false,
|
||||||
|
extensions: createDefaultCliExtensionCapabilities(),
|
||||||
},
|
},
|
||||||
backend: null,
|
backend: null,
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,14 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
|
import {
|
||||||
import { getMcpOperationKey, getPluginOperationKey } from '@shared/utils/extensionNormalizers';
|
getExtensionActionDisableReason,
|
||||||
|
getMcpDiagnosticKey,
|
||||||
|
getMcpOperationKey,
|
||||||
|
getMcpProjectStateKey,
|
||||||
|
getPluginOperationKey,
|
||||||
|
} from '@shared/utils/extensionNormalizers';
|
||||||
|
import { isProjectScopedMcpScope } from '@shared/utils/mcpScopes';
|
||||||
|
|
||||||
import { findPaneByTabId, updatePane } from '../utils/paneHelpers';
|
import { findPaneByTabId, updatePane } from '../utils/paneHelpers';
|
||||||
|
|
||||||
|
|
@ -51,11 +57,16 @@ export interface ExtensionsSlice {
|
||||||
mcpBrowseLoading: boolean;
|
mcpBrowseLoading: boolean;
|
||||||
mcpBrowseError: string | null;
|
mcpBrowseError: string | null;
|
||||||
mcpInstalledServers: InstalledMcpEntry[];
|
mcpInstalledServers: InstalledMcpEntry[];
|
||||||
|
mcpInstalledServersByProjectPath: Record<string, InstalledMcpEntry[]>;
|
||||||
mcpInstalledProjectPath: string | null;
|
mcpInstalledProjectPath: string | null;
|
||||||
mcpDiagnostics: Record<string, McpServerDiagnostic>;
|
mcpDiagnostics: Record<string, McpServerDiagnostic>;
|
||||||
|
mcpDiagnosticsByProjectPath: Record<string, Record<string, McpServerDiagnostic>>;
|
||||||
mcpDiagnosticsLoading: boolean;
|
mcpDiagnosticsLoading: boolean;
|
||||||
|
mcpDiagnosticsLoadingByProjectPath: Record<string, boolean>;
|
||||||
mcpDiagnosticsError: string | null;
|
mcpDiagnosticsError: string | null;
|
||||||
|
mcpDiagnosticsErrorByProjectPath: Record<string, string | null>;
|
||||||
mcpDiagnosticsLastCheckedAt: number | null;
|
mcpDiagnosticsLastCheckedAt: number | null;
|
||||||
|
mcpDiagnosticsLastCheckedAtByProjectPath: Record<string, number | null>;
|
||||||
|
|
||||||
// ── Install progress ──
|
// ── Install progress ──
|
||||||
pluginInstallProgress: Record<string, ExtensionOperationState>;
|
pluginInstallProgress: Record<string, ExtensionOperationState>;
|
||||||
|
|
@ -90,7 +101,7 @@ export interface ExtensionsSlice {
|
||||||
fetchPluginReadme: (pluginId: string) => void;
|
fetchPluginReadme: (pluginId: string) => void;
|
||||||
mcpBrowse: (cursor?: string) => Promise<void>;
|
mcpBrowse: (cursor?: string) => Promise<void>;
|
||||||
mcpFetchInstalled: (projectPath?: string) => Promise<void>;
|
mcpFetchInstalled: (projectPath?: string) => Promise<void>;
|
||||||
runMcpDiagnostics: () => Promise<void>;
|
runMcpDiagnostics: (projectPath?: string) => Promise<void>;
|
||||||
fetchSkillsCatalog: (projectPath?: string) => Promise<void>;
|
fetchSkillsCatalog: (projectPath?: string) => Promise<void>;
|
||||||
fetchSkillDetail: (skillId: string, projectPath?: string) => Promise<void>;
|
fetchSkillDetail: (skillId: string, projectPath?: string) => Promise<void>;
|
||||||
previewSkillUpsert: (request: SkillUpsertRequest) => Promise<SkillReviewPreview>;
|
previewSkillUpsert: (request: SkillUpsertRequest) => Promise<SkillReviewPreview>;
|
||||||
|
|
@ -132,7 +143,7 @@ let pluginFetchInFlight: { key: string; promise: Promise<void>; token: symbol }
|
||||||
let pluginCatalogRequestSeq = 0;
|
let pluginCatalogRequestSeq = 0;
|
||||||
const pluginSuccessResetTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
const pluginSuccessResetTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
const mcpSuccessResetTimers = 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 skillsCatalogRequestSeq = 0;
|
||||||
let skillsDetailRequestSeq = 0;
|
let skillsDetailRequestSeq = 0;
|
||||||
const latestSkillsCatalogRequestByKey = new Map<string, number>();
|
const latestSkillsCatalogRequestByKey = new Map<string, number>();
|
||||||
|
|
@ -152,8 +163,8 @@ function buildPluginIdSet(catalog: EnrichedPlugin[]): Set<string> {
|
||||||
return new Set(catalog.map((plugin) => plugin.pluginId));
|
return new Set(catalog.map((plugin) => plugin.pluginId));
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPluginOperationKeys(pluginId: string): string[] {
|
function isPluginOperationKeyForPlugin(operationKey: string, pluginId: string): boolean {
|
||||||
return PLUGIN_OPERATION_SCOPES.map((scope) => getPluginOperationKey(pluginId, scope));
|
return operationKey.startsWith(`plugin:${pluginId}:`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearPluginOperationState(
|
function clearPluginOperationState(
|
||||||
|
|
@ -170,10 +181,16 @@ function clearPluginOperationState(
|
||||||
|
|
||||||
const nextPluginInstallProgress = { ...pluginInstallProgress };
|
const nextPluginInstallProgress = { ...pluginInstallProgress };
|
||||||
const nextInstallErrors = { ...installErrors };
|
const nextInstallErrors = { ...installErrors };
|
||||||
|
const pluginIdsList = Array.from(pluginIds);
|
||||||
|
|
||||||
for (const pluginId of pluginIds) {
|
for (const operationKey of Object.keys(nextPluginInstallProgress)) {
|
||||||
for (const operationKey of buildPluginOperationKeys(pluginId)) {
|
if (pluginIdsList.some((pluginId) => isPluginOperationKeyForPlugin(operationKey, pluginId))) {
|
||||||
delete nextPluginInstallProgress[operationKey];
|
delete nextPluginInstallProgress[operationKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const operationKey of Object.keys(nextInstallErrors)) {
|
||||||
|
if (pluginIdsList.some((pluginId) => isPluginOperationKeyForPlugin(operationKey, pluginId))) {
|
||||||
delete nextInstallErrors[operationKey];
|
delete nextInstallErrors[operationKey];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -195,8 +212,9 @@ function clearPluginSuccessResetTimer(operationKey: string): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearPluginSuccessResetTimers(pluginIds: Set<string>): void {
|
function clearPluginSuccessResetTimers(pluginIds: Set<string>): void {
|
||||||
for (const pluginId of pluginIds) {
|
const pluginIdsList = Array.from(pluginIds);
|
||||||
for (const operationKey of buildPluginOperationKeys(pluginId)) {
|
for (const operationKey of Array.from(pluginSuccessResetTimers.keys())) {
|
||||||
|
if (pluginIdsList.some((pluginId) => isPluginOperationKeyForPlugin(operationKey, pluginId))) {
|
||||||
clearPluginSuccessResetTimer(operationKey);
|
clearPluginSuccessResetTimer(operationKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -222,10 +240,26 @@ function schedulePluginSuccessReset(
|
||||||
pluginSuccessResetTimers.set(operationKey, timer);
|
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}`;
|
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 {
|
function clearMcpSuccessResetTimer(operationKey: string): void {
|
||||||
const timer = mcpSuccessResetTimers.get(operationKey);
|
const timer = mcpSuccessResetTimers.get(operationKey);
|
||||||
if (!timer) {
|
if (!timer) {
|
||||||
|
|
@ -269,7 +303,7 @@ function clearMcpProjectScopedOperationState(
|
||||||
for (const operationKey of Object.keys(nextMcpInstallProgress)) {
|
for (const operationKey of Object.keys(nextMcpInstallProgress)) {
|
||||||
if (
|
if (
|
||||||
(operationKey.startsWith('mcp:') || operationKey.startsWith('mcp-custom:')) &&
|
(operationKey.startsWith('mcp:') || operationKey.startsWith('mcp-custom:')) &&
|
||||||
(operationKey.endsWith(':project') || operationKey.endsWith(':local'))
|
isProjectScopedMcpOperationKey(operationKey)
|
||||||
) {
|
) {
|
||||||
delete nextMcpInstallProgress[operationKey];
|
delete nextMcpInstallProgress[operationKey];
|
||||||
}
|
}
|
||||||
|
|
@ -278,7 +312,7 @@ function clearMcpProjectScopedOperationState(
|
||||||
for (const operationKey of Object.keys(nextInstallErrors)) {
|
for (const operationKey of Object.keys(nextInstallErrors)) {
|
||||||
if (
|
if (
|
||||||
(operationKey.startsWith('mcp:') || operationKey.startsWith('mcp-custom:')) &&
|
(operationKey.startsWith('mcp:') || operationKey.startsWith('mcp-custom:')) &&
|
||||||
(operationKey.endsWith(':project') || operationKey.endsWith(':local'))
|
isProjectScopedMcpOperationKey(operationKey)
|
||||||
) {
|
) {
|
||||||
delete nextInstallErrors[operationKey];
|
delete nextInstallErrors[operationKey];
|
||||||
}
|
}
|
||||||
|
|
@ -292,7 +326,7 @@ function clearMcpProjectScopedOperationState(
|
||||||
|
|
||||||
function clearMcpProjectScopedSuccessResetTimers(): void {
|
function clearMcpProjectScopedSuccessResetTimers(): void {
|
||||||
for (const operationKey of Array.from(mcpSuccessResetTimers.keys())) {
|
for (const operationKey of Array.from(mcpSuccessResetTimers.keys())) {
|
||||||
if (operationKey.endsWith(':project') || operationKey.endsWith(':local')) {
|
if (isProjectScopedMcpOperationKey(operationKey)) {
|
||||||
clearMcpSuccessResetTimer(operationKey);
|
clearMcpSuccessResetTimer(operationKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -302,18 +336,15 @@ function getSkillsCatalogKey(projectPath?: string): string {
|
||||||
return projectPath ?? USER_SKILLS_CATALOG_KEY;
|
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 */
|
/** Duration to show "success" state before returning to idle */
|
||||||
const SUCCESS_DISPLAY_MS = 2_000;
|
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 =
|
const PROJECT_SCOPE_REQUIRED_MESSAGE =
|
||||||
'Project- and local-scoped plugins require an active project in the Extensions tab.';
|
'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> = (
|
export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSlice> = (
|
||||||
set,
|
set,
|
||||||
get
|
get
|
||||||
|
|
@ -331,11 +362,16 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
mcpBrowseLoading: false,
|
mcpBrowseLoading: false,
|
||||||
mcpBrowseError: null,
|
mcpBrowseError: null,
|
||||||
mcpInstalledServers: [],
|
mcpInstalledServers: [],
|
||||||
|
mcpInstalledServersByProjectPath: {},
|
||||||
mcpInstalledProjectPath: null,
|
mcpInstalledProjectPath: null,
|
||||||
mcpDiagnostics: {},
|
mcpDiagnostics: {},
|
||||||
|
mcpDiagnosticsByProjectPath: {},
|
||||||
mcpDiagnosticsLoading: false,
|
mcpDiagnosticsLoading: false,
|
||||||
|
mcpDiagnosticsLoadingByProjectPath: {},
|
||||||
mcpDiagnosticsError: null,
|
mcpDiagnosticsError: null,
|
||||||
|
mcpDiagnosticsErrorByProjectPath: {},
|
||||||
mcpDiagnosticsLastCheckedAt: null,
|
mcpDiagnosticsLastCheckedAt: null,
|
||||||
|
mcpDiagnosticsLastCheckedAtByProjectPath: {},
|
||||||
|
|
||||||
pluginInstallProgress: {},
|
pluginInstallProgress: {},
|
||||||
mcpInstallProgress: {},
|
mcpInstallProgress: {},
|
||||||
|
|
@ -376,7 +412,8 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
const requestToken = Symbol('pluginCatalogRequest');
|
const requestToken = Symbol('pluginCatalogRequest');
|
||||||
set({ pluginCatalogLoading: true, pluginCatalogError: null });
|
set({ pluginCatalogLoading: true, pluginCatalogError: null });
|
||||||
|
|
||||||
const promise = (async () => {
|
let currentPromise: Promise<void> | null = null;
|
||||||
|
currentPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
const result = await api.plugins!.getAll(projectPath, forceRefresh);
|
const result = await api.plugins!.getAll(projectPath, forceRefresh);
|
||||||
set((prev) => {
|
set((prev) => {
|
||||||
|
|
@ -438,8 +475,8 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
pluginFetchInFlight = { key: requestKey, promise, token: requestToken };
|
pluginFetchInFlight = { key: requestKey, promise: currentPromise, token: requestToken };
|
||||||
await promise;
|
await currentPromise;
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Plugin README fetch ──
|
// ── Plugin README fetch ──
|
||||||
|
|
@ -514,6 +551,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
const installed = await api.mcpRegistry.getInstalled(projectPath);
|
const installed = await api.mcpRegistry.getInstalled(projectPath);
|
||||||
set((prev) => {
|
set((prev) => {
|
||||||
const nextProjectPath = projectPath ?? null;
|
const nextProjectPath = projectPath ?? null;
|
||||||
|
const stateKey = getMcpProjectStateKey(nextProjectPath);
|
||||||
const isSameProjectContext = prev.mcpInstalledProjectPath === nextProjectPath;
|
const isSameProjectContext = prev.mcpInstalledProjectPath === nextProjectPath;
|
||||||
const nextOperationState = isSameProjectContext
|
const nextOperationState = isSameProjectContext
|
||||||
? {
|
? {
|
||||||
|
|
@ -528,6 +566,10 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mcpInstalledServers: installed,
|
mcpInstalledServers: installed,
|
||||||
|
mcpInstalledServersByProjectPath: {
|
||||||
|
...prev.mcpInstalledServersByProjectPath,
|
||||||
|
[stateKey]: installed,
|
||||||
|
},
|
||||||
mcpInstalledProjectPath: nextProjectPath,
|
mcpInstalledProjectPath: nextProjectPath,
|
||||||
mcpInstallProgress: nextOperationState.mcpInstallProgress,
|
mcpInstallProgress: nextOperationState.mcpInstallProgress,
|
||||||
installErrors: nextOperationState.installErrors,
|
installErrors: nextOperationState.installErrors,
|
||||||
|
|
@ -538,39 +580,75 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
runMcpDiagnostics: async () => {
|
runMcpDiagnostics: async (projectPath?: string) => {
|
||||||
const mcpRegistry = api.mcpRegistry;
|
const mcpRegistry = api.mcpRegistry;
|
||||||
if (!mcpRegistry) return;
|
if (!mcpRegistry) return;
|
||||||
|
const projectStateKey = getMcpProjectStateKey(projectPath);
|
||||||
|
|
||||||
if (mcpDiagnosticsInFlight) {
|
const existing = mcpDiagnosticsInFlightByKey.get(projectStateKey);
|
||||||
await mcpDiagnosticsInFlight;
|
if (existing) {
|
||||||
|
await existing;
|
||||||
return;
|
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 () => {
|
const promise = (async () => {
|
||||||
try {
|
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({
|
set({
|
||||||
mcpDiagnostics: Object.fromEntries(
|
mcpDiagnostics: diagnosticsRecord,
|
||||||
diagnostics.map((entry) => [entry.name, entry] as const)
|
|
||||||
),
|
|
||||||
mcpDiagnosticsLoading: false,
|
mcpDiagnosticsLoading: false,
|
||||||
mcpDiagnosticsLastCheckedAt: Date.now(),
|
mcpDiagnosticsByProjectPath: {
|
||||||
|
...get().mcpDiagnosticsByProjectPath,
|
||||||
|
[projectStateKey]: diagnosticsRecord,
|
||||||
|
},
|
||||||
|
mcpDiagnosticsLoadingByProjectPath: {
|
||||||
|
...get().mcpDiagnosticsLoadingByProjectPath,
|
||||||
|
[projectStateKey]: false,
|
||||||
|
},
|
||||||
|
mcpDiagnosticsLastCheckedAt: checkedAt,
|
||||||
|
mcpDiagnosticsLastCheckedAtByProjectPath: {
|
||||||
|
...get().mcpDiagnosticsLastCheckedAtByProjectPath,
|
||||||
|
[projectStateKey]: checkedAt,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const errorMessage =
|
||||||
|
err instanceof Error ? err.message : 'Failed to check MCP server health';
|
||||||
set({
|
set({
|
||||||
mcpDiagnosticsLoading: false,
|
mcpDiagnosticsLoading: false,
|
||||||
mcpDiagnosticsError:
|
mcpDiagnosticsError: errorMessage,
|
||||||
err instanceof Error ? err.message : 'Failed to check MCP server health',
|
mcpDiagnosticsLoadingByProjectPath: {
|
||||||
|
...get().mcpDiagnosticsLoadingByProjectPath,
|
||||||
|
[projectStateKey]: false,
|
||||||
|
},
|
||||||
|
mcpDiagnosticsErrorByProjectPath: {
|
||||||
|
...get().mcpDiagnosticsErrorByProjectPath,
|
||||||
|
[projectStateKey]: errorMessage,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
mcpDiagnosticsInFlight = null;
|
mcpDiagnosticsInFlightByKey.delete(projectStateKey);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
mcpDiagnosticsInFlight = promise;
|
mcpDiagnosticsInFlightByKey.set(projectStateKey, promise);
|
||||||
await promise;
|
await promise;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -792,11 +870,16 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
installPlugin: async (request: PluginInstallRequest) => {
|
installPlugin: async (request: PluginInstallRequest) => {
|
||||||
if (!api.plugins) return;
|
if (!api.plugins) return;
|
||||||
|
|
||||||
|
const catalogProjectPathAtOperationStart = get().pluginCatalogProjectPath ?? undefined;
|
||||||
const effectiveProjectPath =
|
const effectiveProjectPath =
|
||||||
request.scope !== 'user'
|
request.scope !== 'user'
|
||||||
? (request.projectPath ?? get().pluginCatalogProjectPath ?? undefined)
|
? (request.projectPath ?? get().pluginCatalogProjectPath ?? undefined)
|
||||||
: request.projectPath;
|
: request.projectPath;
|
||||||
const operationKey = getPluginOperationKey(request.pluginId, request.scope);
|
const operationKey = getPluginOperationKey(
|
||||||
|
request.pluginId,
|
||||||
|
request.scope,
|
||||||
|
effectiveProjectPath
|
||||||
|
);
|
||||||
const effectiveRequest =
|
const effectiveRequest =
|
||||||
effectiveProjectPath === request.projectPath
|
effectiveProjectPath === request.projectPath
|
||||||
? request
|
? request
|
||||||
|
|
@ -815,15 +898,12 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
const preflightError =
|
const preflightError =
|
||||||
effectiveRequest.scope !== 'user' && !effectiveRequest.projectPath
|
effectiveRequest.scope !== 'user' && !effectiveRequest.projectPath
|
||||||
? PROJECT_SCOPE_REQUIRED_MESSAGE
|
? PROJECT_SCOPE_REQUIRED_MESSAGE
|
||||||
: cliStatus === null
|
: getExtensionActionDisableReason({
|
||||||
? CLI_STATUS_UNKNOWN_MESSAGE
|
isInstalled: false,
|
||||||
: !cliStatus.installed
|
cliStatus,
|
||||||
? cliStatus.binaryPath && cliStatus.launchError
|
cliStatusLoading: get().cliStatusLoading,
|
||||||
? CLI_HEALTHCHECK_FAILED_MESSAGE
|
section: 'plugins',
|
||||||
: CLI_NOT_FOUND_MESSAGE
|
});
|
||||||
: !cliStatus.authLoggedIn
|
|
||||||
? CLI_AUTH_REQUIRED_MESSAGE
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (preflightError) {
|
if (preflightError) {
|
||||||
clearPluginSuccessResetTimer(operationKey);
|
clearPluginSuccessResetTimer(operationKey);
|
||||||
|
|
@ -858,7 +938,12 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Refresh catalog to pick up new installed state
|
// 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);
|
schedulePluginSuccessReset(operationKey, set);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -875,12 +960,13 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
uninstallPlugin: async (pluginId: string, scope?: InstallScope, projectPath?: string) => {
|
uninstallPlugin: async (pluginId: string, scope?: InstallScope, projectPath?: string) => {
|
||||||
if (!api.plugins) return;
|
if (!api.plugins) return;
|
||||||
|
|
||||||
|
const catalogProjectPathAtOperationStart = get().pluginCatalogProjectPath ?? undefined;
|
||||||
const effectiveScope = scope ?? 'user';
|
const effectiveScope = scope ?? 'user';
|
||||||
const operationKey = getPluginOperationKey(pluginId, effectiveScope);
|
|
||||||
const effectiveProjectPath =
|
const effectiveProjectPath =
|
||||||
effectiveScope !== 'user'
|
effectiveScope !== 'user'
|
||||||
? (projectPath ?? get().pluginCatalogProjectPath ?? undefined)
|
? (projectPath ?? get().pluginCatalogProjectPath ?? undefined)
|
||||||
: projectPath;
|
: projectPath;
|
||||||
|
const operationKey = getPluginOperationKey(pluginId, effectiveScope, effectiveProjectPath);
|
||||||
if (effectiveScope !== 'user' && !effectiveProjectPath) {
|
if (effectiveScope !== 'user' && !effectiveProjectPath) {
|
||||||
clearPluginSuccessResetTimer(operationKey);
|
clearPluginSuccessResetTimer(operationKey);
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
|
|
@ -890,6 +976,30 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
return;
|
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);
|
clearPluginSuccessResetTimer(operationKey);
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'pending' },
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'pending' },
|
||||||
|
|
@ -913,7 +1023,10 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Refresh catalog
|
// Refresh catalog
|
||||||
void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true);
|
void get().fetchPluginCatalog(
|
||||||
|
effectiveScope !== 'user' ? effectiveProjectPath : catalogProjectPathAtOperationStart,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
schedulePluginSuccessReset(operationKey, set);
|
schedulePluginSuccessReset(operationKey, set);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -928,7 +1041,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
|
|
||||||
// ── MCP install ──
|
// ── MCP install ──
|
||||||
installMcpServer: async (request: McpInstallRequest) => {
|
installMcpServer: async (request: McpInstallRequest) => {
|
||||||
const operationKey = getMcpOperationKey(request.registryId, request.scope);
|
const operationKey = getMcpOperationKey(request.registryId, request.scope, request.projectPath);
|
||||||
if (!api.mcpRegistry) {
|
if (!api.mcpRegistry) {
|
||||||
clearMcpSuccessResetTimer(operationKey);
|
clearMcpSuccessResetTimer(operationKey);
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
|
|
@ -941,6 +1054,30 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
return;
|
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);
|
clearMcpSuccessResetTimer(operationKey);
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'pending' },
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'pending' },
|
||||||
|
|
@ -960,8 +1097,8 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined),
|
get().mcpFetchInstalled(request.projectPath),
|
||||||
get().runMcpDiagnostics(),
|
get().runMcpDiagnostics(request.projectPath),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
|
|
@ -982,34 +1119,48 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
// ── MCP custom install ──
|
// ── MCP custom install ──
|
||||||
installCustomMcpServer: async (request: McpCustomInstallRequest) => {
|
installCustomMcpServer: async (request: McpCustomInstallRequest) => {
|
||||||
const operationScope = request.scope;
|
const operationScope = request.scope;
|
||||||
const progressKey = getCustomMcpOperationKey(request.serverName, operationScope);
|
const progressKey = getCustomMcpOperationKey(
|
||||||
if (!api.mcpRegistry) {
|
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);
|
clearMcpSuccessResetTimer(progressKey);
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' },
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'pending' },
|
||||||
installErrors: { ...prev.installErrors, [progressKey]: 'MCP Registry not available' },
|
|
||||||
}));
|
}));
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearMcpSuccessResetTimer(progressKey);
|
|
||||||
set((prev) => ({
|
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'pending' },
|
|
||||||
}));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await api.mcpRegistry.installCustom(request);
|
const result = await api.mcpRegistry.installCustom(request);
|
||||||
if (result.state === 'error') {
|
if (result.state === 'error') {
|
||||||
set((prev) => ({
|
throw new Error(result.error ?? 'Install failed');
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' },
|
|
||||||
installErrors: { ...prev.installErrors, [progressKey]: result.error ?? 'Install failed' },
|
|
||||||
}));
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined),
|
get().mcpFetchInstalled(request.projectPath),
|
||||||
get().runMcpDiagnostics(),
|
get().runMcpDiagnostics(request.projectPath),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
|
|
@ -1024,6 +1175,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' },
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' },
|
||||||
installErrors: { ...prev.installErrors, [progressKey]: message },
|
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,
|
scope?: string,
|
||||||
projectPath?: string
|
projectPath?: string
|
||||||
) => {
|
) => {
|
||||||
const operationScope: InstallScope = scope === 'project' || scope === 'local' ? scope : 'user';
|
const operationScope: InstallScope =
|
||||||
const operationKey = getMcpOperationKey(registryId, operationScope);
|
scope === 'global' || scope === 'user' || isProjectScopedMcpScope(scope) ? scope : 'user';
|
||||||
|
const operationKey = getMcpOperationKey(registryId, operationScope, projectPath);
|
||||||
if (!api.mcpRegistry) {
|
if (!api.mcpRegistry) {
|
||||||
clearMcpSuccessResetTimer(operationKey);
|
clearMcpSuccessResetTimer(operationKey);
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
|
|
@ -1045,6 +1198,30 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
return;
|
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);
|
clearMcpSuccessResetTimer(operationKey);
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'pending' },
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'pending' },
|
||||||
|
|
@ -1064,8 +1241,8 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined),
|
get().mcpFetchInstalled(projectPath),
|
||||||
get().runMcpDiagnostics(),
|
get().runMcpDiagnostics(projectPath),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
|
|
@ -1115,10 +1292,29 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
|
|
||||||
set({ apiKeySaving: true, apiKeysError: null });
|
set({ apiKeySaving: true, apiKeysError: null });
|
||||||
try {
|
try {
|
||||||
await api.apiKeys.save(request);
|
const savedKey = await api.apiKeys.save(request);
|
||||||
// Refresh the list to get updated masked values
|
const warnings: string[] = [];
|
||||||
const keys = await api.apiKeys.list();
|
|
||||||
set({ apiKeys: keys, apiKeySaving: false });
|
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) {
|
} catch (err) {
|
||||||
set({
|
set({
|
||||||
apiKeySaving: false,
|
apiKeySaving: false,
|
||||||
|
|
@ -1137,6 +1333,13 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
apiKeys: prev.apiKeys.filter((k) => k.id !== id),
|
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) {
|
} catch (err) {
|
||||||
set({
|
set({
|
||||||
apiKeysError: err instanceof Error ? err.message : 'Failed to delete API key',
|
apiKeysError: err instanceof Error ? err.message : 'Failed to delete API key',
|
||||||
|
|
|
||||||
|
|
@ -1834,8 +1834,7 @@ function areTeamGraphSlotAssignmentsEqual(
|
||||||
for (const [stableOwnerId, leftAssignment] of leftEntries) {
|
for (const [stableOwnerId, leftAssignment] of leftEntries) {
|
||||||
const rightAssignment = right?.[stableOwnerId];
|
const rightAssignment = right?.[stableOwnerId];
|
||||||
if (
|
if (
|
||||||
!rightAssignment ||
|
rightAssignment?.ringIndex !== leftAssignment.ringIndex ||
|
||||||
rightAssignment.ringIndex !== leftAssignment.ringIndex ||
|
|
||||||
rightAssignment.sectorIndex !== leftAssignment.sectorIndex
|
rightAssignment.sectorIndex !== leftAssignment.sectorIndex
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -2872,8 +2871,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
return (
|
return (
|
||||||
(nextAssignment.ringIndex === assignment.ringIndex &&
|
(nextAssignment.ringIndex === assignment.ringIndex &&
|
||||||
nextAssignment.sectorIndex === assignment.sectorIndex) ||
|
nextAssignment.sectorIndex === assignment.sectorIndex) ||
|
||||||
(displacedAssignment != null &&
|
(nextAssignment.ringIndex === displacedAssignment?.ringIndex &&
|
||||||
nextAssignment.ringIndex === displacedAssignment.ringIndex &&
|
|
||||||
nextAssignment.sectorIndex === displacedAssignment.sectorIndex)
|
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.
|
* Re-exports types from shared for backwards compatibility.
|
||||||
* The canonical definitions are in @shared/types/notifications.
|
* 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 { isSupportedSlashCommandName } from '@shared/utils/slashCommands';
|
||||||
|
|
||||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
|
import type { TeamProviderId } from '@shared/types';
|
||||||
import type { SkillCatalogItem } from '@shared/types/extensions';
|
import type { SkillCatalogItem } from '@shared/types/extensions';
|
||||||
import type { KnownSlashCommandDefinition } from '@shared/utils/slashCommands';
|
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(
|
export function buildSlashCommandSuggestions(
|
||||||
builtIns: readonly KnownSlashCommandDefinition[],
|
builtIns: readonly KnownSlashCommandDefinition[],
|
||||||
projectSkills: readonly SkillCatalogItem[],
|
projectSkills: readonly SkillCatalogItem[],
|
||||||
userSkills: readonly SkillCatalogItem[]
|
userSkills: readonly SkillCatalogItem[],
|
||||||
|
providerId?: TeamProviderId
|
||||||
): MentionSuggestion[] {
|
): MentionSuggestion[] {
|
||||||
const builtInNames = new Set(builtIns.map((command) => command.name.trim().toLowerCase()));
|
const builtInNames = new Set(builtIns.map((command) => command.name.trim().toLowerCase()));
|
||||||
const builtInSuggestions: MentionSuggestion[] = builtIns.map((command) => ({
|
const builtInSuggestions: MentionSuggestion[] = builtIns.map((command) => ({
|
||||||
|
|
@ -21,7 +49,7 @@ export function buildSlashCommandSuggestions(
|
||||||
|
|
||||||
const seenSkillNames = new Set<string>();
|
const seenSkillNames = new Set<string>();
|
||||||
const skillSuggestions: MentionSuggestion[] = [];
|
const skillSuggestions: MentionSuggestion[] = [];
|
||||||
for (const skill of [...projectSkills, ...userSkills]) {
|
for (const skill of orderSkillsForProvider(projectSkills, userSkills, providerId)) {
|
||||||
const normalizedFolderName = skill.folderName.trim().toLowerCase();
|
const normalizedFolderName = skill.folderName.trim().toLowerCase();
|
||||||
if (
|
if (
|
||||||
!skill.isValid ||
|
!skill.isValid ||
|
||||||
|
|
@ -39,7 +67,7 @@ export function buildSlashCommandSuggestions(
|
||||||
name: skill.folderName,
|
name: skill.folderName,
|
||||||
command: `/${normalizedFolderName}`,
|
command: `/${normalizedFolderName}`,
|
||||||
description: skill.description,
|
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}`,
|
searchText: `${skill.name} ${skill.folderName}`,
|
||||||
type: 'skill',
|
type: 'skill',
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,22 @@ export interface CliExternalRuntimeDiagnostic {
|
||||||
detailMessage?: string | null;
|
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 =
|
export type CliProviderModelAvailabilityStatus =
|
||||||
| 'checking'
|
| 'checking'
|
||||||
| 'available'
|
| 'available'
|
||||||
|
|
@ -85,6 +101,7 @@ export interface CliProviderStatus {
|
||||||
capabilities: {
|
capabilities: {
|
||||||
teamLaunch: boolean;
|
teamLaunch: boolean;
|
||||||
oneShot: boolean;
|
oneShot: boolean;
|
||||||
|
extensions: CliExtensionCapabilities;
|
||||||
};
|
};
|
||||||
selectedBackendId?: string | null;
|
selectedBackendId?: string | null;
|
||||||
resolvedBackendId?: string | null;
|
resolvedBackendId?: string | null;
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ export interface McpCatalogAPI {
|
||||||
) => Promise<{ servers: McpCatalogItem[]; nextCursor?: string }>;
|
) => Promise<{ servers: McpCatalogItem[]; nextCursor?: string }>;
|
||||||
getById: (registryId: string) => Promise<McpCatalogItem | null>;
|
getById: (registryId: string) => Promise<McpCatalogItem | null>;
|
||||||
getInstalled: (projectPath?: string) => Promise<InstalledMcpEntry[]>;
|
getInstalled: (projectPath?: string) => Promise<InstalledMcpEntry[]>;
|
||||||
diagnose: () => Promise<McpServerDiagnostic[]>;
|
diagnose: (projectPath?: string) => Promise<McpServerDiagnostic[]>;
|
||||||
install: (request: McpInstallRequest) => Promise<OperationResult>;
|
install: (request: McpInstallRequest) => Promise<OperationResult>;
|
||||||
installCustom: (request: McpCustomInstallRequest) => Promise<OperationResult>;
|
installCustom: (request: McpCustomInstallRequest) => Promise<OperationResult>;
|
||||||
uninstall: (name: string, scope?: string, projectPath?: string) => Promise<OperationResult>;
|
uninstall: (name: string, scope?: string, projectPath?: string) => Promise<OperationResult>;
|
||||||
|
|
@ -80,6 +80,6 @@ export interface ApiKeysAPI {
|
||||||
list: () => Promise<ApiKeyEntry[]>;
|
list: () => Promise<ApiKeyEntry[]>;
|
||||||
save: (request: ApiKeySaveRequest) => Promise<ApiKeyEntry>;
|
save: (request: ApiKeySaveRequest) => Promise<ApiKeyEntry>;
|
||||||
delete: (id: string) => Promise<void>;
|
delete: (id: string) => Promise<void>;
|
||||||
lookup: (envVarNames: string[]) => Promise<ApiKeyLookupResult[]>;
|
lookup: (envVarNames: string[], projectPath?: string) => Promise<ApiKeyLookupResult[]>;
|
||||||
getStorageStatus: () => Promise<ApiKeyStorageStatus>;
|
getStorageStatus: () => Promise<ApiKeyStorageStatus>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ export interface ApiKeyEntry {
|
||||||
envVarName: string;
|
envVarName: string;
|
||||||
maskedValue: string;
|
maskedValue: string;
|
||||||
scope: 'user' | 'project';
|
scope: 'user' | 'project';
|
||||||
|
projectPath?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -19,6 +20,7 @@ export interface ApiKeySaveRequest {
|
||||||
envVarName: string;
|
envVarName: string;
|
||||||
value: string;
|
value: string;
|
||||||
scope: 'user' | 'project';
|
scope: 'user' | 'project';
|
||||||
|
projectPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Decrypted key lookup result (for auto-fill) */
|
/** Decrypted key lookup result (for auto-fill) */
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
export type ExtensionOperationState = 'idle' | 'pending' | 'success' | 'error';
|
export type ExtensionOperationState = 'idle' | 'pending' | 'success' | 'error';
|
||||||
|
|
||||||
/** Installation scope — where the extension is installed */
|
/** 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 */
|
/** Result of a mutation operation */
|
||||||
export interface OperationResult<T = void> {
|
export interface OperationResult<T = void> {
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ export interface McpHeaderDef {
|
||||||
|
|
||||||
export interface InstalledMcpEntry {
|
export interface InstalledMcpEntry {
|
||||||
name: string;
|
name: string;
|
||||||
scope: 'local' | 'user' | 'project';
|
scope: 'local' | 'user' | 'project' | 'global';
|
||||||
transport?: string;
|
transport?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,6 +92,8 @@ export type McpServerHealthStatus = 'connected' | 'needs-authentication' | 'fail
|
||||||
export interface McpServerDiagnostic {
|
export interface McpServerDiagnostic {
|
||||||
name: string;
|
name: string;
|
||||||
target: string;
|
target: string;
|
||||||
|
scope?: 'local' | 'user' | 'project' | 'global' | 'dynamic' | 'managed';
|
||||||
|
transport?: string;
|
||||||
status: McpServerHealthStatus;
|
status: McpServerHealthStatus;
|
||||||
statusLabel: string;
|
statusLabel: string;
|
||||||
rawLine: string;
|
rawLine: string;
|
||||||
|
|
@ -100,7 +102,7 @@ export interface McpServerDiagnostic {
|
||||||
|
|
||||||
// ── Install request (renderer → main, minimal trusted data) ────────────────
|
// ── Install request (renderer → main, minimal trusted data) ────────────────
|
||||||
|
|
||||||
export type McpInstallScope = 'local' | 'user' | 'project';
|
export type McpInstallScope = 'local' | 'user' | 'project' | 'global';
|
||||||
|
|
||||||
export interface McpInstallRequest {
|
export interface McpInstallRequest {
|
||||||
registryId: string; // server ID from registry (NOT full catalog item)
|
registryId: string; // server ID from registry (NOT full catalog item)
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,13 @@
|
||||||
|
|
||||||
export type SkillScope = 'user' | 'project';
|
export type SkillScope = 'user' | 'project';
|
||||||
|
|
||||||
export type SkillRootKind = 'claude' | 'cursor' | 'agents';
|
export type SkillRootKind = 'claude' | 'cursor' | 'agents' | 'codex';
|
||||||
|
|
||||||
export type SkillSourceType = 'filesystem';
|
export type SkillSourceType = 'filesystem';
|
||||||
|
|
||||||
export type SkillInvocationMode = 'auto' | 'manual-only';
|
export type SkillInvocationMode = 'auto' | 'manual-only';
|
||||||
|
|
||||||
export type SkillIssueSeverity = 'warning' | 'error';
|
export type SkillIssueSeverity = 'info' | 'warning' | 'error';
|
||||||
|
|
||||||
export interface SkillDirectoryFlags {
|
export interface SkillDirectoryFlags {
|
||||||
hasScripts: boolean;
|
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:
|
* These types define:
|
||||||
* - Detected errors from session files
|
* - Detected errors from session files
|
||||||
|
|
@ -291,6 +291,8 @@ export interface AppConfig {
|
||||||
notifyOnTeamLaunched: boolean;
|
notifyOnTeamLaunched: boolean;
|
||||||
/** Whether to show native OS notifications when a tool needs user approval (Allow/Deny) */
|
/** Whether to show native OS notifications when a tool needs user approval (Allow/Deny) */
|
||||||
notifyOnToolApproval: boolean;
|
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) */
|
/** Only notify on status changes in solo teams (no teammates) */
|
||||||
statusChangeOnlySolo: boolean;
|
statusChangeOnlySolo: boolean;
|
||||||
/** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */
|
/** 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
|
* These types are used for waterfall chart visualization
|
||||||
* and are shared between main and renderer processes.
|
* and are shared between main and renderer processes.
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,11 @@
|
||||||
* Pure-function normalizers for Extension Store data.
|
* Pure-function normalizers for Extension Store data.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
getCliProviderExtensionCapability,
|
||||||
|
isCliExtensionCapabilityMutable,
|
||||||
|
} from './providerExtensionCapabilities';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
CliInstallationStatus,
|
CliInstallationStatus,
|
||||||
InstalledMcpEntry,
|
InstalledMcpEntry,
|
||||||
|
|
@ -104,17 +109,46 @@ export function buildPluginId(pluginName: string, marketplaceName: string): stri
|
||||||
/**
|
/**
|
||||||
* Namespaced operation-state key for plugin install/uninstall UI state.
|
* 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}`;
|
return `plugin:${pluginId}:${scope}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Namespaced operation-state key for MCP install/uninstall UI state.
|
* 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}`;
|
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.
|
* Check whether a plugin has an installation for the selected scope.
|
||||||
*/
|
*/
|
||||||
|
|
@ -135,6 +169,7 @@ function summarizeInstallationScopes(scopes: InstallScope[]): string | null {
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (scopes[0]) {
|
switch (scopes[0]) {
|
||||||
|
case 'global':
|
||||||
case 'user':
|
case 'user':
|
||||||
return 'Installed globally';
|
return 'Installed globally';
|
||||||
case 'project':
|
case 'project':
|
||||||
|
|
@ -159,6 +194,7 @@ export function getInstallationSummaryLabel(
|
||||||
const MCP_SCOPE_PRIORITY: Record<InstalledMcpEntry['scope'], number> = {
|
const MCP_SCOPE_PRIORITY: Record<InstalledMcpEntry['scope'], number> = {
|
||||||
local: 0,
|
local: 0,
|
||||||
project: 1,
|
project: 1,
|
||||||
|
global: 2,
|
||||||
user: 2,
|
user: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -175,7 +211,7 @@ export function getPreferredMcpInstallationEntry(
|
||||||
|
|
||||||
return [...installations].sort(
|
return [...installations].sort(
|
||||||
(left, right) => MCP_SCOPE_PRIORITY[left.scope] - MCP_SCOPE_PRIORITY[right.scope]
|
(left, right) => MCP_SCOPE_PRIORITY[left.scope] - MCP_SCOPE_PRIORITY[right.scope]
|
||||||
)[0]!;
|
)[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -195,28 +231,74 @@ export function getExtensionActionDisableReason(options: {
|
||||||
isInstalled: boolean;
|
isInstalled: boolean;
|
||||||
cliStatus: Pick<
|
cliStatus: Pick<
|
||||||
CliInstallationStatus,
|
CliInstallationStatus,
|
||||||
'installed' | 'authLoggedIn' | 'binaryPath' | 'launchError'
|
'installed' | 'authLoggedIn' | 'binaryPath' | 'launchError' | 'flavor' | 'providers'
|
||||||
> | null;
|
> | null;
|
||||||
cliStatusLoading: boolean;
|
cliStatusLoading: boolean;
|
||||||
|
section?: 'plugins' | 'mcp';
|
||||||
}): string | null {
|
}): string | null {
|
||||||
const { isInstalled, cliStatus, cliStatusLoading } = options;
|
const { isInstalled, cliStatus, cliStatusLoading, section = 'plugins' } = options;
|
||||||
if (cliStatusLoading) {
|
if (cliStatusLoading) {
|
||||||
return 'Checking Claude CLI status...';
|
return 'Checking runtime status...';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cliStatus === null) {
|
if (cliStatus === null) {
|
||||||
return 'Checking Claude CLI availability...';
|
return 'Checking runtime availability...';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cliStatus.installed === false) {
|
if (cliStatus.installed === false) {
|
||||||
if (cliStatus.binaryPath && cliStatus.launchError) {
|
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) {
|
const providers = cliStatus.providers ?? [];
|
||||||
return 'Claude CLI is installed but not signed in. Open the Dashboard to sign in.';
|
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;
|
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";
|
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 {
|
export function isRateLimitMessage(text: string): boolean {
|
||||||
return text.includes(RATE_LIMIT_SUBSTRING);
|
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',
|
'notifyOnClarifications',
|
||||||
'notifyOnStatusChange',
|
'notifyOnStatusChange',
|
||||||
'notifyOnTeamLaunched',
|
'notifyOnTeamLaunched',
|
||||||
|
'autoResumeOnRateLimit',
|
||||||
'statusChangeOnlySolo',
|
'statusChangeOnlySolo',
|
||||||
] as const)('accepts boolean %s toggle', (key) => {
|
] as const)('accepts boolean %s toggle', (key) => {
|
||||||
const resultOn = validateConfigUpdatePayload('notifications', { [key]: true });
|
const resultOn = validateConfigUpdatePayload('notifications', { [key]: true });
|
||||||
|
|
@ -136,6 +137,7 @@ describe('configValidation', () => {
|
||||||
'notifyOnClarifications',
|
'notifyOnClarifications',
|
||||||
'notifyOnStatusChange',
|
'notifyOnStatusChange',
|
||||||
'notifyOnTeamLaunched',
|
'notifyOnTeamLaunched',
|
||||||
|
'autoResumeOnRateLimit',
|
||||||
'statusChangeOnlySolo',
|
'statusChangeOnlySolo',
|
||||||
] as const)('rejects non-boolean %s', (key) => {
|
] as const)('rejects non-boolean %s', (key) => {
|
||||||
const result = validateConfigUpdatePayload('notifications', { [key]: 'yes' });
|
const result = validateConfigUpdatePayload('notifications', { [key]: 'yes' });
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import type {
|
import type {
|
||||||
BoardTaskActivityDetailResult,
|
BoardTaskActivityDetailResult,
|
||||||
BoardTaskActivityEntry,
|
BoardTaskActivityEntry,
|
||||||
|
|
@ -7,6 +7,8 @@ import type {
|
||||||
BoardTaskExactLogDetailResult,
|
BoardTaskExactLogDetailResult,
|
||||||
BoardTaskExactLogSummariesResponse,
|
BoardTaskExactLogSummariesResponse,
|
||||||
InboxMessage,
|
InboxMessage,
|
||||||
|
MessagesPage,
|
||||||
|
TeamViewSnapshot,
|
||||||
TeamCreateRequest,
|
TeamCreateRequest,
|
||||||
TeamProvisioningProgress,
|
TeamProvisioningProgress,
|
||||||
} from '@shared/types/team';
|
} from '@shared/types/team';
|
||||||
|
|
@ -120,6 +122,7 @@ import {
|
||||||
registerTeamHandlers,
|
registerTeamHandlers,
|
||||||
removeTeamHandlers,
|
removeTeamHandlers,
|
||||||
} from '../../../src/main/ipc/teams';
|
} from '../../../src/main/ipc/teams';
|
||||||
|
import { ConfigManager } from '../../../src/main/services/infrastructure/ConfigManager';
|
||||||
|
|
||||||
describe('ipc teams handlers', () => {
|
describe('ipc teams handlers', () => {
|
||||||
const handlers = new Map<string, (...args: unknown[]) => Promise<unknown>>();
|
const handlers = new Map<string, (...args: unknown[]) => Promise<unknown>>();
|
||||||
|
|
@ -134,7 +137,7 @@ describe('ipc teams handlers', () => {
|
||||||
|
|
||||||
const service = {
|
const service = {
|
||||||
listTeams: vi.fn(async () => [{ teamName: 'my-team', displayName: 'My Team' }]),
|
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',
|
teamName: 'my-team',
|
||||||
config: { name: 'My Team' },
|
config: { name: 'My Team' },
|
||||||
tasks: [],
|
tasks: [],
|
||||||
|
|
@ -147,7 +150,7 @@ describe('ipc teams handlers', () => {
|
||||||
feedRevision: 'rev-1',
|
feedRevision: 'rev-1',
|
||||||
messages: [] as InboxMessage[],
|
messages: [] as InboxMessage[],
|
||||||
})),
|
})),
|
||||||
getMessagesPage: vi.fn(async () => ({
|
getMessagesPage: vi.fn(async (..._args: unknown[]): Promise<MessagesPage> => ({
|
||||||
messages: [] as InboxMessage[],
|
messages: [] as InboxMessage[],
|
||||||
nextCursor: null,
|
nextCursor: null,
|
||||||
hasMore: false,
|
hasMore: false,
|
||||||
|
|
@ -216,10 +219,12 @@ describe('ipc teams handlers', () => {
|
||||||
launchTeam: vi.fn(async () => ({ runId: 'run-2' })),
|
launchTeam: vi.fn(async () => ({ runId: 'run-2' })),
|
||||||
sendMessageToTeam: vi.fn(async () => undefined),
|
sendMessageToTeam: vi.fn(async () => undefined),
|
||||||
isTeamAlive: vi.fn(() => true),
|
isTeamAlive: vi.fn(() => true),
|
||||||
|
getCurrentRunId: vi.fn(() => 'run-2' as string | null),
|
||||||
pushLiveLeadProcessMessage: vi.fn(),
|
pushLiveLeadProcessMessage: vi.fn(),
|
||||||
relayLeadInboxMessages: vi.fn(async () => 0),
|
relayLeadInboxMessages: vi.fn(async () => 0),
|
||||||
relayMemberInboxMessages: vi.fn(async () => 0),
|
relayMemberInboxMessages: vi.fn(async () => 0),
|
||||||
getLiveLeadProcessMessages: vi.fn(() => [] as InboxMessage[]),
|
getLiveLeadProcessMessages: vi.fn(() => [] as InboxMessage[]),
|
||||||
|
getCurrentLeadSessionId: vi.fn(() => null as string | null),
|
||||||
getAliveTeams: vi.fn(() => ['my-team']),
|
getAliveTeams: vi.fn(() => ['my-team']),
|
||||||
getLeadActivityState: vi.fn(() => 'idle'),
|
getLeadActivityState: vi.fn(() => 'idle'),
|
||||||
stopTeam: vi.fn(() => undefined),
|
stopTeam: vi.fn(() => undefined),
|
||||||
|
|
@ -275,6 +280,10 @@ describe('ipc teams handlers', () => {
|
||||||
registerTeamHandlers(ipcMain as never);
|
registerTeamHandlers(ipcMain as never);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
it('registers all expected handlers', () => {
|
it('registers all expected handlers', () => {
|
||||||
expect(handlers.has(TEAM_LIST)).toBe(true);
|
expect(handlers.has(TEAM_LIST)).toBe(true);
|
||||||
expect(handlers.has(TEAM_GET_DATA)).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;
|
(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 () => {
|
it('uses the team-data worker for TEAM_GET_MESSAGES_PAGE when available', async () => {
|
||||||
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
|
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
|
||||||
mockTeamDataWorkerClient.getMessagesPage.mockResolvedValueOnce({
|
mockTeamDataWorkerClient.getMessagesPage.mockResolvedValueOnce({
|
||||||
|
|
@ -965,6 +1049,552 @@ describe('ipc teams handlers', () => {
|
||||||
(electron.app as { isPackaged: boolean }).isPackaged = false;
|
(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 () => {
|
it('keeps TEAM_GET_DATA read-only and never triggers reconcile side effects', async () => {
|
||||||
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
||||||
const result = (await getDataHandler({} as never, 'my-team')) as {
|
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', () => {
|
describe('parseMcpDiagnosticsOutput', () => {
|
||||||
it('parses mixed MCP health lines from claude mcp list', () => {
|
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
|
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`);
|
alpic: https://mcp.alpic.ai (HTTP) - ! Needs authentication`);
|
||||||
|
|
||||||
expect(diagnostics).toHaveLength(5);
|
expect(diagnostics).toHaveLength(3);
|
||||||
expect(diagnostics[0]).toMatchObject({
|
expect(diagnostics[0]).toMatchObject({
|
||||||
name: 'plugin:context7:context7',
|
name: 'browsermcp',
|
||||||
target: 'npx -y @upstash/context7-mcp',
|
target: 'npx @browsermcp/mcp@latest',
|
||||||
status: 'connected',
|
status: 'connected',
|
||||||
statusLabel: 'Connected',
|
statusLabel: 'Connected',
|
||||||
});
|
});
|
||||||
expect(diagnostics[3]).toMatchObject({
|
expect(diagnostics[1]).toMatchObject({
|
||||||
name: 'tavily-remote-mcp',
|
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',
|
status: 'failed',
|
||||||
statusLabel: 'Failed to connect',
|
statusLabel: 'Failed to connect',
|
||||||
});
|
});
|
||||||
expect(diagnostics[4]).toMatchObject({
|
expect(diagnostics[2]).toMatchObject({
|
||||||
name: 'alpic',
|
name: 'alpic',
|
||||||
target: 'https://mcp.alpic.ai (HTTP)',
|
target: 'https://mcp.alpic.ai (HTTP)',
|
||||||
status: 'needs-authentication',
|
status: 'needs-authentication',
|
||||||
|
|
@ -40,4 +44,78 @@ another log line`);
|
||||||
|
|
||||||
expect(diagnostics).toEqual([]);
|
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 fs from 'node:fs/promises';
|
||||||
import * as path from 'node:path';
|
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';
|
import { McpInstallationStateService } from '@main/services/extensions/state/McpInstallationStateService';
|
||||||
|
|
||||||
const TEST_ROOT = path.parse(process.cwd()).root || path.sep;
|
const TEST_ROOT = path.parse(process.cwd()).root || path.sep;
|
||||||
|
|
@ -14,7 +16,21 @@ function normalizeMockPath(filePath: unknown): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
vi.mock('@main/utils/pathDecoder', () => ({
|
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');
|
vi.mock('node:fs/promises');
|
||||||
|
|
@ -24,7 +40,9 @@ describe('McpInstallationStateService', () => {
|
||||||
const mockedFs = vi.mocked(fs);
|
const mockedFs = vi.mocked(fs);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
service = new McpInstallationStateService();
|
service = new McpInstallationStateService(
|
||||||
|
new ClaudeExtensionsAdapter(new McpConfigStateReader())
|
||||||
|
);
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -156,5 +174,28 @@ describe('McpInstallationStateService', () => {
|
||||||
]);
|
]);
|
||||||
expect(mockedFs.readFile).toHaveBeenCalledTimes(4);
|
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('\\', '/');
|
return String(filePath).replaceAll('\\', '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock pathDecoder to control ~/.claude path
|
|
||||||
vi.mock('@main/utils/pathDecoder', () => ({
|
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');
|
vi.mock('node:fs/promises');
|
||||||
|
|
||||||
describe('PluginInstallationStateService', () => {
|
describe('PluginInstallationStateService', () => {
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,12 @@ unknown-key: true
|
||||||
message: expect.stringContaining('version'),
|
message: expect.stringContaining('version'),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
expect(item.issues).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
code: 'has-scripts',
|
||||||
|
severity: 'info',
|
||||||
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks missing frontmatter as invalid', () => {
|
it('marks missing frontmatter as invalid', () => {
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@ describe('SkillRootsResolver', () => {
|
||||||
|
|
||||||
const roots = resolver.resolve();
|
const roots = resolver.resolve();
|
||||||
|
|
||||||
expect(roots).toHaveLength(3);
|
expect(roots).toHaveLength(4);
|
||||||
expect(roots.every((root) => root.scope === 'user')).toBe(true);
|
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', () => {
|
it('returns project and user roots when project path is provided', () => {
|
||||||
|
|
@ -18,8 +18,8 @@ describe('SkillRootsResolver', () => {
|
||||||
|
|
||||||
const roots = resolver.resolve('/tmp/demo-project');
|
const roots = resolver.resolve('/tmp/demo-project');
|
||||||
|
|
||||||
expect(roots).toHaveLength(6);
|
expect(roots).toHaveLength(8);
|
||||||
expect(roots.filter((root) => root.scope === 'project')).toHaveLength(3);
|
expect(roots.filter((root) => root.scope === 'project')).toHaveLength(4);
|
||||||
expect(roots.filter((root) => root.scope === 'user')).toHaveLength(3);
|
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');
|
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', () => {
|
it('sorts by validity, scope, root precedence, then name', () => {
|
||||||
const validator = new SkillValidator();
|
const validator = new SkillValidator();
|
||||||
|
|
||||||
|
|
@ -47,6 +59,7 @@ describe('SkillValidator', () => {
|
||||||
makeSkill({ id: '/3', name: 'z-user', scope: 'user', rootKind: 'claude' }),
|
makeSkill({ id: '/3', name: 'z-user', scope: 'user', rootKind: 'claude' }),
|
||||||
makeSkill({ id: '/2', name: 'b-project-cursor', scope: 'project', rootKind: 'cursor' }),
|
makeSkill({ id: '/2', name: 'b-project-cursor', scope: 'project', rootKind: 'cursor' }),
|
||||||
makeSkill({ id: '/1', name: 'a-project-claude', scope: 'project', rootKind: 'claude' }),
|
makeSkill({ id: '/1', name: 'a-project-claude', scope: 'project', rootKind: 'claude' }),
|
||||||
|
makeSkill({ id: '/5', name: 'c-project-codex', scope: 'project', rootKind: 'codex' }),
|
||||||
makeSkill({
|
makeSkill({
|
||||||
id: '/4',
|
id: '/4',
|
||||||
name: 'invalid',
|
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',
|
verificationState: 'verified',
|
||||||
modelVerificationState: 'idle',
|
modelVerificationState: 'idle',
|
||||||
statusMessage: null,
|
statusMessage: null,
|
||||||
models: ['gpt-5.4', 'gpt-5.2-codex'],
|
models: ['gpt-5.4', 'gpt-5.4-mini'],
|
||||||
modelAvailability: [],
|
modelAvailability: [],
|
||||||
canLoginFromUi: true,
|
canLoginFromUi: true,
|
||||||
capabilities: { teamLaunch: true, oneShot: true },
|
capabilities: { teamLaunch: true, oneShot: true },
|
||||||
|
|
@ -267,14 +267,12 @@ describe('CliInstallerService', () => {
|
||||||
if (normalizedArgs === '--version') {
|
if (normalizedArgs === '--version') {
|
||||||
return { stdout: '2.3.4', stderr: '' };
|
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')) {
|
if (normalizedArgs.includes('--model gpt-5.4')) {
|
||||||
return { stdout: 'PONG', stderr: '' };
|
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}`);
|
throw new Error(`Unexpected execCli call: ${normalizedArgs}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -288,9 +286,12 @@ describe('CliInstallerService', () => {
|
||||||
expect(status.providers.find((provider) => provider.providerId === 'codex')?.modelAvailability).toEqual([]);
|
expect(status.providers.find((provider) => provider.providerId === 'codex')?.modelAvailability).toEqual([]);
|
||||||
|
|
||||||
const verifiedProvider = await service.verifyProviderModels('codex');
|
const verifiedProvider = await service.verifyProviderModels('codex');
|
||||||
expect(verifiedProvider?.modelAvailability).toEqual([
|
expect(verifiedProvider?.modelAvailability).toEqual(
|
||||||
expect.objectContaining({ modelId: 'gpt-5.4', status: 'checking' }),
|
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(verifiedProvider?.modelAvailability).not.toEqual(
|
||||||
expect.arrayContaining([expect.objectContaining({ modelId: 'gpt-5.2-codex' })])
|
expect.arrayContaining([expect.objectContaining({ modelId: 'gpt-5.2-codex' })])
|
||||||
);
|
);
|
||||||
|
|
@ -302,6 +303,10 @@ describe('CliInstallerService', () => {
|
||||||
|
|
||||||
expect(latestCodexProvider?.modelAvailability).toEqual([
|
expect(latestCodexProvider?.modelAvailability).toEqual([
|
||||||
expect.objectContaining({ modelId: 'gpt-5.4', status: 'available' }),
|
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