chore(merge): sync dev into spike/team-snapshot-split-plan

This commit is contained in:
777genius 2026-04-18 11:32:29 +03:00
commit 51376e2620
130 changed files with 10173 additions and 1039 deletions

View file

@ -49,6 +49,7 @@ https://github.com/user-attachments/assets/35e27989-726d-4059-8662-bae610e46b42
## Installation
No prerequisites - the app can detect supported runtimes/providers and guide setup from the UI.
If you want the freshest version, clone the repo and run it from the `dev` branch.
<table align="center">
<tr>
@ -126,6 +127,8 @@ A local orchestration layer for AI agent teams across Claude and Codex.
- **Task creation with attachments** — send a message to the team lead with any attached images. The lead will automatically create a fully described task and attach your files directly to the task for complete context.
- **Auto-resume after rate limits** — when the lead hits a Claude rate limit and the reset time is known, the app can automatically nudge the lead to continue once the cooldown has passed
- **Deep session analysis** — detailed breakdown of what happened in each agent session: bash commands, reasoning, subprocesses
- **Smart task-to-log/changes matching** — automatically links session logs/changes to specific tasks

View file

@ -213,7 +213,7 @@
},
"build": {
"appId": "com.agent-teams.app",
"productName": "Claude Agent Teams UI",
"productName": "Agent Teams UI",
"directories": {
"output": "release"
},

View file

@ -311,7 +311,8 @@
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true
"supports_native_structured_output": true,
"supports_max_reasoning_effort": true
},
"global.anthropic.claude-opus-4-6-v1": {
"cache_creation_input_token_cost": 0.00000625,
@ -338,7 +339,8 @@
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true
"supports_native_structured_output": true,
"supports_max_reasoning_effort": true
},
"us.anthropic.claude-opus-4-6-v1": {
"cache_creation_input_token_cost": 0.000006875,
@ -365,7 +367,8 @@
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true
"supports_native_structured_output": true,
"supports_max_reasoning_effort": true
},
"eu.anthropic.claude-opus-4-6-v1": {
"cache_creation_input_token_cost": 0.000006875,
@ -392,7 +395,8 @@
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true
"supports_native_structured_output": true,
"supports_max_reasoning_effort": true
},
"au.anthropic.claude-opus-4-6-v1": {
"cache_creation_input_token_cost": 0.000006875,
@ -419,6 +423,147 @@
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true,
"supports_max_reasoning_effort": true
},
"anthropic.claude-opus-4-7": {
"cache_creation_input_token_cost": 0.00000625,
"cache_read_input_token_cost": 5e-7,
"input_cost_per_token": 0.000005,
"litellm_provider": "bedrock_converse",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 0.000025,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true
},
"global.anthropic.claude-opus-4-7": {
"cache_creation_input_token_cost": 0.00000625,
"cache_read_input_token_cost": 5e-7,
"input_cost_per_token": 0.000005,
"litellm_provider": "bedrock_converse",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 0.000025,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true
},
"us.anthropic.claude-opus-4-7": {
"cache_creation_input_token_cost": 0.000006875,
"cache_read_input_token_cost": 5.5e-7,
"input_cost_per_token": 0.0000055,
"litellm_provider": "bedrock_converse",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 0.0000275,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true
},
"eu.anthropic.claude-opus-4-7": {
"cache_creation_input_token_cost": 0.000006875,
"cache_read_input_token_cost": 5.5e-7,
"input_cost_per_token": 0.0000055,
"litellm_provider": "bedrock_converse",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 0.0000275,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true
},
"au.anthropic.claude-opus-4-7": {
"cache_creation_input_token_cost": 0.000006875,
"cache_read_input_token_cost": 5.5e-7,
"input_cost_per_token": 0.0000055,
"litellm_provider": "bedrock_converse",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 0.0000275,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true
},
"anthropic.claude-sonnet-4-6": {
@ -854,6 +999,35 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 159,
"supports_max_reasoning_effort": true
},
"azure_ai/claude-opus-4-7": {
"input_cost_per_token": 0.000005,
"output_cost_per_token": 0.000025,
"litellm_provider": "azure_ai",
"max_input_tokens": 200000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"cache_creation_input_token_cost": 0.00000625,
"cache_creation_input_token_cost_above_1hr": 0.00001,
"cache_read_input_token_cost": 5e-7,
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"tool_use_system_prompt_tokens": 159
},
"azure_ai/claude-opus-4-1": {
@ -1687,7 +1861,8 @@
"provider_specific_entry": {
"us": 1.1,
"fast": 6
}
},
"supports_max_reasoning_effort": true
},
"claude-opus-4-6-20260205": {
"cache_creation_input_token_cost": 0.00000625,
@ -1715,6 +1890,71 @@
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
"provider_specific_entry": {
"us": 1.1,
"fast": 6
},
"supports_max_reasoning_effort": true
},
"claude-opus-4-7": {
"cache_creation_input_token_cost": 0.00000625,
"cache_creation_input_token_cost_above_1hr": 0.00001,
"cache_read_input_token_cost": 5e-7,
"input_cost_per_token": 0.000005,
"litellm_provider": "anthropic",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 0.000025,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"tool_use_system_prompt_tokens": 346,
"provider_specific_entry": {
"us": 1.1,
"fast": 6
}
},
"claude-opus-4-7-20260416": {
"cache_creation_input_token_cost": 0.00000625,
"cache_creation_input_token_cost_above_1hr": 0.00001,
"cache_read_input_token_cost": 5e-7,
"input_cost_per_token": 0.000005,
"litellm_provider": "anthropic",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 0.000025,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"tool_use_system_prompt_tokens": 346,
"provider_specific_entry": {
"us": 1.1,
"fast": 6
@ -4148,7 +4388,8 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346
"tool_use_system_prompt_tokens": 346,
"supports_max_reasoning_effort": true
},
"vertex_ai/claude-opus-4-6@default": {
"cache_creation_input_token_cost": 0.00000625,
@ -4174,6 +4415,61 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
"supports_max_reasoning_effort": true
},
"vertex_ai/claude-opus-4-7": {
"cache_creation_input_token_cost": 0.00000625,
"cache_read_input_token_cost": 5e-7,
"input_cost_per_token": 0.000005,
"litellm_provider": "vertex_ai-anthropic_models",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 0.000025,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"tool_use_system_prompt_tokens": 346
},
"vertex_ai/claude-opus-4-7@default": {
"cache_creation_input_token_cost": 0.00000625,
"cache_read_input_token_cost": 5e-7,
"input_cost_per_token": 0.000005,
"litellm_provider": "vertex_ai-anthropic_models",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 0.000025,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"tool_use_system_prompt_tokens": 346
},
"vertex_ai/claude-sonnet-4-5": {

View file

@ -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]);
}

View file

@ -3,7 +3,7 @@
* Follows the exact ProjectEditorOverlay pattern (lazy-loaded, fixed z-50).
*/
import { useCallback, useLayoutEffect, useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { GraphView } from '@claude-teams/agent-graph';
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
@ -11,6 +11,7 @@ import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHo
import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog';
import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility';
import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter';
import { useTeamGraphSlotReset } from '../hooks/useTeamGraphSlotReset';
import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions';
import { GraphActivityHud } from './GraphActivityHud';
@ -52,17 +53,15 @@ export const TeamGraphOverlay = ({
onOpenMemberProfile,
}: TeamGraphOverlayProps): React.JSX.Element => {
const graphData = useTeamGraphAdapter(teamName);
const {
openTeamPage: openTeamTab,
resetOwnerSlotAssignmentsToDefaults,
commitOwnerSlotDrop,
} = useTeamGraphSurfaceActions(teamName);
const { openTeamPage: openTeamTab, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName);
const { sidebarVisible: persistedSidebarVisible, toggleSidebarVisible } =
useGraphSidebarVisibility();
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
const effectiveSidebarVisible = sidebarVisible ?? persistedSidebarVisible;
const handleToggleSidebar = onToggleSidebar ?? toggleSidebarVisible;
useTeamGraphSlotReset(teamName);
// Task action dispatchers (same pattern as TeamGraphTab)
const dispatchTaskAction = useCallback(
(action: string) => (taskId: string) =>
@ -89,11 +88,6 @@ export const TeamGraphOverlay = ({
const openCreateTask = useCallback(() => {
openCreateTaskDialog('');
}, [openCreateTaskDialog]);
useLayoutEffect(() => {
resetOwnerSlotAssignmentsToDefaults();
}, [resetOwnerSlotAssignmentsToDefaults]);
const events: GraphEventPort = {
onNodeDoubleClick: useCallback(
(ref: GraphDomainRef) => {

View file

@ -3,7 +3,7 @@
* Provides Fullscreen button that opens the overlay.
*/
import { lazy, Suspense, useCallback, useLayoutEffect, useMemo, useState } from 'react';
import { lazy, Suspense, useCallback, useMemo, useState } from 'react';
import { GraphView } from '@claude-teams/agent-graph';
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
@ -11,6 +11,7 @@ import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHo
import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog';
import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility';
import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter';
import { useTeamGraphSlotReset } from '../hooks/useTeamGraphSlotReset';
import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions';
import { GraphActivityHud } from './GraphActivityHud';
@ -45,12 +46,13 @@ export const TeamGraphTab = ({
isPaneFocused = false,
}: TeamGraphTabProps): React.JSX.Element => {
const graphData = useTeamGraphAdapter(teamName);
const { openTeamPage, resetOwnerSlotAssignmentsToDefaults, commitOwnerSlotDrop } =
useTeamGraphSurfaceActions(teamName);
const { openTeamPage, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName);
const [fullscreen, setFullscreen] = useState(false);
const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility();
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
useTeamGraphSlotReset(teamName, isActive);
// Typed event dispatchers (DRY — used in both events + renderOverlay)
const dispatchOpenTask = useCallback(
(taskId: string) =>
@ -76,14 +78,6 @@ export const TeamGraphTab = ({
const openCreateTask = useCallback(() => {
openCreateTaskDialog('');
}, [openCreateTaskDialog]);
useLayoutEffect(() => {
if (!isActive) {
return;
}
resetOwnerSlotAssignmentsToDefaults();
}, [isActive, resetOwnerSlotAssignmentsToDefaults]);
// Task action dispatchers
const dispatchTaskAction = useCallback(
(action: string) => (taskId: string) =>

View file

@ -3,6 +3,7 @@ import type { DashboardRecentProject, DashboardRecentProjectsPayload } from './d
export type DashboardRecentProjectsPayloadLike =
| DashboardRecentProjectsPayload
| DashboardRecentProject[]
| { degraded?: unknown; projects?: unknown }
| null
| undefined;

View file

@ -199,7 +199,7 @@ export class CodexAppServerClient {
{
clientInfo: {
name: 'claude-agent-teams-ui',
title: 'Claude Agent Teams UI',
title: 'Agent Teams UI',
version: '0.1.0',
},
capabilities: {

View file

@ -1,5 +1,5 @@
/**
* Main process entry point for Claude Agent Teams UI.
* Main process entry point for Agent Teams UI.
*
* Responsibilities:
* - Initialize Electron app and main window
@ -70,6 +70,7 @@ import { setReviewMainWindow } from './ipc/review';
import { setTmuxMainWindow } from './ipc/tmux';
import {
ApiKeyService,
createExtensionsRuntimeAdapter,
ExtensionFacadeService,
GlamaMcpEnrichmentService,
McpCatalogAggregator,
@ -99,6 +100,7 @@ import {
type TeamReconcileTrigger,
} from './services/team/TeamReconcileDrainScheduler';
import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore';
import { clearAutoResumeService } from './services/team/AutoResumeService';
import { getAppIconPath } from './utils/appIcon';
import { getProjectsBasePath, getTeamsBasePath, getTodosBasePath } from './utils/pathDecoder';
import {
@ -878,8 +880,9 @@ async function initializeServices(): Promise<void> {
const officialMcpRegistry = new OfficialMcpRegistryService();
const glamaMcpService = new GlamaMcpEnrichmentService();
const mcpAggregator = new McpCatalogAggregator(officialMcpRegistry, glamaMcpService);
const mcpStateService = new McpInstallationStateService();
const mcpHealthDiagnosticsService = new McpHealthDiagnosticsService();
const extensionsRuntimeAdapter = createExtensionsRuntimeAdapter();
const mcpStateService = new McpInstallationStateService(extensionsRuntimeAdapter);
const mcpHealthDiagnosticsService = new McpHealthDiagnosticsService(extensionsRuntimeAdapter);
const skillsCatalogService = new SkillsCatalogService();
const skillsMutationService = new SkillsMutationService();
skillsWatcherService = new SkillsWatcherService();
@ -891,8 +894,11 @@ async function initializeServices(): Promise<void> {
);
// Install services — resolve binary dynamically via ClaudeBinaryResolver
const pluginInstallService = new PluginInstallService(pluginCatalogService);
const mcpInstallService = new McpInstallService(mcpAggregator);
const pluginInstallService = new PluginInstallService(
pluginCatalogService,
extensionsRuntimeAdapter
);
const mcpInstallService = new McpInstallService(mcpAggregator, extensionsRuntimeAdapter);
const apiKeyService = new ApiKeyService();
await apiKeyService.syncProcessEnv(RUNTIME_MANAGED_API_KEY_ENV_VARS);
// warmup() and ensureInstalled() are deferred to after window creation
@ -1078,6 +1084,11 @@ async function startHttpServer(
function shutdownServices(): void {
logger.info('Shutting down services...');
// Clear pending auto-resume timers before anything else — otherwise the
// dangling setTimeout handles keep the event loop alive past shutdown and
// may fire against a torn-down provisioning service.
clearAutoResumeService();
// Kill all team CLI processes via SIGKILL BEFORE anything else.
// This must happen before the OS closes stdin pipes (on app exit),
// because stdin EOF triggers CLI's graceful shutdown which deletes team files.
@ -1220,7 +1231,7 @@ function createWindow(): void {
backgroundColor: '#1a1a1a',
...(useNativeTitleBar ? {} : { titleBarStyle: 'hidden' as const }),
...(isMac && { trafficLightPosition: getTrafficLightPositionForZoom(1) }),
title: 'Claude Agent Teams UI',
title: 'Agent Teams UI',
});
markRendererUnavailable(mainWindow);

View file

@ -125,6 +125,7 @@ function validateNotificationsSection(
'notifyOnCrossTeamMessage',
'notifyOnTeamLaunched',
'notifyOnToolApproval',
'autoResumeOnRateLimit',
'statusChangeOnlySolo',
'statusChangeStatuses',
'triggers',
@ -219,6 +220,12 @@ function validateNotificationsSection(
}
result.notifyOnToolApproval = value;
break;
case 'autoResumeOnRateLimit':
if (typeof value !== 'boolean') {
return { valid: false, error: `notifications.${key} must be a boolean` };
}
result.autoResumeOnRateLimit = value;
break;
case 'statusChangeOnlySolo':
if (typeof value !== 'boolean') {
return { valid: false, error: `notifications.${key} must be a boolean` };

View file

@ -239,8 +239,13 @@ function getMcpHealthDiagnostics(): McpHealthDiagnosticsService {
return mcpHealthDiagnostics;
}
async function handleMcpDiagnose(): Promise<IpcResult<McpServerDiagnostic[]>> {
return wrapHandler('mcpDiagnose', () => getMcpHealthDiagnostics().diagnose());
async function handleMcpDiagnose(
_event: IpcMainInvokeEvent,
projectPath?: string
): Promise<IpcResult<McpServerDiagnostic[]>> {
return wrapHandler('mcpDiagnose', () =>
getMcpHealthDiagnostics().diagnose(typeof projectPath === 'string' ? projectPath : undefined)
);
}
// ── Install/Uninstall Handlers ────────────────────────────────────────────
@ -416,11 +421,15 @@ async function handleApiKeysDelete(
async function handleApiKeysLookup(
_event: IpcMainInvokeEvent,
envVarNames?: string[]
envVarNames?: string[],
projectPath?: string
): Promise<IpcResult<ApiKeyLookupResult[]>> {
return wrapHandler('apiKeysLookup', () => {
if (!Array.isArray(envVarNames)) throw new Error('envVarNames array is required');
return getApiKeyService().lookup(envVarNames);
return getApiKeyService().lookup(
envVarNames,
typeof projectPath === 'string' ? projectPath : undefined
);
});
}

View file

@ -104,10 +104,15 @@ import {
buildActionModeAgentBlock,
isAgentActionMode,
} from '../services/team/actionModeInstructions';
import {
getAutoResumeService,
initializeAutoResumeService,
} from '../services/team/AutoResumeService';
import {
buildReplaceMembersDiff,
buildReplaceMembersSummaryMessage,
} from '../services/team/memberUpdateNotifications';
import { mergeLiveLeadProcessMessages } from '../services/team/mergeLiveLeadProcessMessages';
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore';
import { TeamMetaStore } from '../services/team/TeamMetaStore';
@ -153,11 +158,9 @@ import type {
IpcResult,
KanbanColumnId,
LeadActivitySnapshot,
LeadContextUsage,
LeadContextUsageSnapshot,
MemberFullStats,
MemberLogSummary,
MemberSpawnStatusEntry,
MemberSpawnStatusesSnapshot,
MessagesPage,
SendMessageRequest,
@ -313,11 +316,25 @@ const SEEN_API_ERROR_KEYS_MAX = 500;
* and NotificationManager dedupeKey (to prevent storage duplicates).
*/
function checkRateLimitMessages(
messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[],
messages: readonly {
messageId?: string;
from: string;
text: string;
timestamp: string;
to?: string;
source?: string;
leadSessionId?: string;
}[],
teamName: string,
teamDisplayName: string,
projectPath?: string
projectPath?: string,
teamIsAlive = true,
currentLeadSessionId: string | null = null
): void {
const observedAt = new Date();
const autoResumeEnabled =
ConfigManager.getInstance().getConfig().notifications.autoResumeOnRateLimit;
for (const msg of messages) {
if (msg.from === 'user') continue;
if (!isRateLimitMessage(msg.text)) continue;
@ -325,28 +342,55 @@ function checkRateLimitMessages(
const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`;
const dedupeKey = `rate-limit:${teamName}:${rawKey}`;
// In-memory guard: prevents resurrection after user deletes the notification
if (seenRateLimitKeys.has(dedupeKey)) continue;
seenRateLimitKeys.add(dedupeKey);
// In-memory guard: prevents resurrection after user deletes the notification.
if (!seenRateLimitKeys.has(dedupeKey)) {
seenRateLimitKeys.add(dedupeKey);
// Evict oldest entries to prevent unbounded growth
if (seenRateLimitKeys.size > SEEN_RATE_LIMIT_KEYS_MAX) {
const first = seenRateLimitKeys.values().next().value;
if (first) seenRateLimitKeys.delete(first);
// Evict oldest entries to prevent unbounded growth
if (seenRateLimitKeys.size > SEEN_RATE_LIMIT_KEYS_MAX) {
const first = seenRateLimitKeys.values().next().value;
if (first) seenRateLimitKeys.delete(first);
}
void NotificationManager.getInstance()
.addTeamNotification({
teamEventType: 'rate_limit',
teamName,
teamDisplayName,
from: msg.from,
summary: `Rate limit: ${msg.from}`,
body: msg.text.slice(0, 200),
dedupeKey,
projectPath,
})
.catch(() => undefined);
}
void NotificationManager.getInstance()
.addTeamNotification({
teamEventType: 'rate_limit',
// Only schedule auto-resume while a live team run currently exists.
// Persisted history for an offline/stopped team may still contain the old
// rate-limit message, but arming a new timer from that stale history would
// resurrect the nudge into a later manual restart.
const isLeadAutoResumeCandidate =
!msg.to && (msg.source === 'lead_process' || msg.source === 'lead_session');
if (autoResumeEnabled && teamIsAlive && isLeadAutoResumeCandidate) {
// Only let persisted lead_session history rebuild auto-resume when it
// clearly belongs to the currently running lead session. Otherwise an old
// rate-limit from a previous manual run can resurrect into a newer restart.
if (msg.source === 'lead_session') {
if (!currentLeadSessionId) continue;
if (msg.leadSessionId !== currentLeadSessionId) continue;
}
// Pass the original message timestamp so relative reset windows survive restarts
// and old history does not rebuild a fresh auto-resume timer from "now".
getAutoResumeService().handleRateLimitMessage(
teamName,
teamDisplayName,
from: msg.from,
summary: `Rate limit: ${msg.from}`,
body: msg.text.slice(0, 200),
dedupeKey,
projectPath,
})
.catch(() => undefined);
msg.text,
observedAt,
new Date(msg.timestamp)
);
}
}
}
@ -461,6 +505,7 @@ export function initializeTeamHandlers(
): void {
teamDataService = service;
teamProvisioningService = provisioningService;
initializeAutoResumeService(provisioningService);
teamMemberLogsFinder = logsFinder ?? null;
memberStatsComputer = statsComputer ?? null;
teamBackupService = backupService ?? null;
@ -788,11 +833,51 @@ async function handleGetData(
}
const provisioning = getTeamProvisioningService();
const isAlive = provisioning.isTeamAlive(tn);
const currentLeadSessionId = provisioning.getCurrentLeadSessionId(tn);
const displayName = data.config.name || tn;
const projectPath = data.config.projectPath;
const live = provisioning.getLiveLeadProcessMessages(tn);
scanTeamMessageNotifications(live, tn, displayName, projectPath);
const durableMessages = Array.isArray((data as { messages?: unknown }).messages)
? (((data as { messages?: typeof live }).messages ?? []) as typeof live)
: [];
if (live.length === 0) {
if (durableMessages.length > 0) {
checkRateLimitMessages(
durableMessages,
tn,
displayName,
projectPath,
isAlive,
currentLeadSessionId
);
checkApiErrorMessages(durableMessages, tn, displayName, projectPath);
} else {
scanTeamMessageNotifications(live, tn, displayName, projectPath);
}
return { success: true, data: { ...data, isAlive } };
}
let merged = mergeLiveLeadProcessMessages(durableMessages, live);
if (durableMessages.length >= 50) {
try {
const newestPage = await teamDataService.getMessagesPage(tn, {
limit: 50,
liveMessages: live,
});
merged = newestPage.messages;
} catch (error) {
logger.warn(
`[teams:getData] failed to rebuild newest merged messages for ${tn}: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
checkRateLimitMessages(merged, tn, displayName, projectPath, isAlive, currentLeadSessionId);
checkApiErrorMessages(merged, tn, displayName, projectPath);
return { success: true, data: { ...data, isAlive } };
}
@ -872,6 +957,7 @@ async function handleDeleteTeam(
return { success: false, error: validated.error ?? 'Invalid teamName' };
}
return wrapTeamHandler('deleteTeam', async () => {
getAutoResumeService().cancelPendingAutoResume(validated.value!);
getTeamProvisioningService().stopTeam(validated.value!);
await getTeamDataService().deleteTeam(validated.value!);
});
@ -897,6 +983,7 @@ async function handlePermanentlyDeleteTeam(
return { success: false, error: validated.error ?? 'Invalid teamName' };
}
return wrapTeamHandler('permanentlyDeleteTeam', async () => {
getAutoResumeService().cancelPendingAutoResume(validated.value!);
await getTeamDataService().permanentlyDeleteTeam(validated.value!);
// Clean up app-owned data (attachments, task-attachments) that lives outside ~/.claude/
const appData = getAppDataPath();
@ -1680,6 +1767,24 @@ async function handleGetMessagesPage(
return wrapTeamHandler('getMessagesPage', async () => {
let page: MessagesPage;
const notificationContext = await getTeamDataService().getTeamNotificationContext(vTeam.value!);
const liveMessages =
cursor == null ? getTeamProvisioningService().getLiveLeadProcessMessages(vTeam.value!) : [];
if (liveMessages.length > 0) {
page = await getTeamDataService().getMessagesPage(vTeam.value!, {
cursor,
limit,
liveMessages,
});
scanTeamMessageNotifications(
page.messages,
vTeam.value!,
notificationContext.displayName,
notificationContext.projectPath
);
return page;
}
const worker = getTeamDataWorkerClient();
if (worker.isAvailable()) {
try {
@ -2734,6 +2839,7 @@ async function handleStopTeam(
}
return wrapTeamHandler('stop', async () => {
addMainBreadcrumb('team', 'stop', { teamName: validated.value! });
getAutoResumeService().cancelPendingAutoResume(validated.value!);
getTeamProvisioningService().stopTeam(validated.value!);
});
}

View file

@ -39,6 +39,7 @@ interface StoredApiKey {
encrypted?: boolean;
encryptionMethod?: EncryptionMethod;
scope: 'user' | 'project';
projectPath?: string;
createdAt: string;
updatedAt?: string;
}
@ -73,6 +74,7 @@ export class ApiKeyService {
envVarName: k.envVarName,
maskedValue: this.mask(this.decrypt(k)),
scope: k.scope,
projectPath: k.projectPath,
createdAt: k.createdAt,
}));
}
@ -86,6 +88,9 @@ export class ApiKeyService {
);
}
if (!request.value) throw new Error('Key value is required');
if (request.scope === 'project' && (!request.projectPath || !request.projectPath.trim())) {
throw new Error('Project-scoped API keys require a project path');
}
const keys = await this.readStore();
const now = new Date().toISOString();
@ -101,6 +106,7 @@ export class ApiKeyService {
encryptedValue: value,
encryptionMethod: method,
scope: request.scope,
projectPath: request.scope === 'project' ? request.projectPath?.trim() : undefined,
updatedAt: now,
};
delete keys[idx].encrypted;
@ -112,6 +118,7 @@ export class ApiKeyService {
encryptedValue: value,
encryptionMethod: method,
scope: request.scope,
projectPath: request.scope === 'project' ? request.projectPath?.trim() : undefined,
createdAt: now,
});
}
@ -124,6 +131,7 @@ export class ApiKeyService {
envVarName: saved.envVarName,
maskedValue: this.mask(request.value),
scope: saved.scope,
projectPath: saved.projectPath,
createdAt: saved.createdAt,
};
}
@ -135,25 +143,36 @@ export class ApiKeyService {
await this.writeStore(filtered);
}
async lookup(envVarNames: string[]): Promise<ApiKeyLookupResult[]> {
async lookup(envVarNames: string[], projectPath?: string): Promise<ApiKeyLookupResult[]> {
if (!envVarNames.length) return [];
const keys = await this.readStore();
const nameSet = new Set(envVarNames);
return keys
.filter((k) => nameSet.has(k.envVarName))
.map((k) => ({
envVarName: k.envVarName,
value: this.decrypt(k),
}));
return Array.from(new Set(envVarNames)).flatMap((envVarName) => {
const preferred = this.pickPreferredKey(
keys.filter((key) => key.envVarName === envVarName),
projectPath
);
if (!preferred) {
return [];
}
return [
{
envVarName: preferred.envVarName,
value: this.decrypt(preferred),
},
];
});
}
async lookupPreferred(envVarName: string): Promise<ApiKeyLookupResult | null> {
async lookupPreferred(
envVarName: string,
projectPath?: string
): Promise<ApiKeyLookupResult | null> {
const keys = await this.readStore();
const matching = keys.filter((key) => key.envVarName === envVarName);
const preferred =
matching.find((key) => key.scope === 'user') ??
matching.find((key) => key.scope === 'project') ??
null;
const preferred = this.pickPreferredKey(
keys.filter((key) => key.envVarName === envVarName),
projectPath
);
if (!preferred) {
return null;
@ -280,6 +299,20 @@ export class ApiKeyService {
return stored.encrypted ? 'safeStorage' : 'base64';
}
private pickPreferredKey(matching: StoredApiKey[], projectPath?: string): StoredApiKey | null {
const normalizedProjectPath = projectPath?.trim();
if (normalizedProjectPath) {
const projectMatch = matching.find(
(key) => key.scope === 'project' && key.projectPath === normalizedProjectPath
);
if (projectMatch) {
return projectMatch;
}
}
return matching.find((key) => key.scope === 'user') ?? null;
}
// ── AES-256-GCM local encryption ───────────────────────────────────────
/**

View file

@ -11,6 +11,11 @@ export { PluginCatalogService } from './catalog/PluginCatalogService';
export { ExtensionFacadeService } from './ExtensionFacadeService';
export { McpInstallService } from './install/McpInstallService';
export { PluginInstallService } from './install/PluginInstallService';
export {
ClaudeExtensionsAdapter,
createExtensionsRuntimeAdapter,
MultimodelExtensionsAdapter,
} from './runtime/ExtensionsRuntimeAdapter';
export { SkillImportService } from './skills/SkillImportService';
export { SkillMetadataParser } from './skills/SkillMetadataParser';
export { SkillPlanService } from './skills/SkillPlanService';

View file

@ -9,12 +9,15 @@
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
import { execCli } from '@main/utils/childProcess';
import { buildEnrichedEnv } from '@main/utils/cliEnv';
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
import { createLogger } from '@shared/utils/logger';
import { isProjectScopedMcpScope } from '@shared/utils/mcpScopes';
import path from 'path';
import { createExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
import type { McpCatalogAggregator } from '../catalog/McpCatalogAggregator';
import type { ExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
import type {
McpCustomInstallRequest,
McpInstallRequest,
@ -27,7 +30,7 @@ const logger = createLogger('Extensions:McpInstall');
const SERVER_NAME_RE = /^[\w.-]{1,100}$/;
/** Allowed scope values (prevent command injection) */
const VALID_SCOPES = new Set(['local', 'user', 'project']);
const VALID_SCOPES = new Set(['local', 'user', 'project', 'global']);
/** Env var key must be safe shell identifier */
const ENV_KEY_RE = /^[A-Z_][A-Z0-9_]{0,100}$/i;
@ -38,11 +41,14 @@ const HEADER_KEY_RE = /^[A-Za-z][\w-]{0,100}$/;
const TIMEOUT_MS = 30_000;
function scopeRequiresProjectPath(scope?: string): boolean {
return scope === 'local' || scope === 'project';
return isProjectScopedMcpScope(scope);
}
export class McpInstallService {
constructor(private readonly aggregator: McpCatalogAggregator) {}
constructor(
private readonly aggregator: McpCatalogAggregator,
private readonly runtimeAdapter: ExtensionsRuntimeAdapter = createExtensionsRuntimeAdapter()
) {}
async install(request: McpInstallRequest): Promise<OperationResult> {
const { registryId, serverName, scope, projectPath, envValues, headers } = request;
@ -59,7 +65,7 @@ export class McpInstallService {
if (scope && !VALID_SCOPES.has(scope)) {
return {
state: 'error',
error: `Invalid scope: "${scope}". Must be one of: local, user, project.`,
error: `Invalid scope: "${scope}". Must be one of: local, user, project, global.`,
};
}
@ -180,11 +186,12 @@ export class McpInstallService {
error: CLI_NOT_FOUND_MESSAGE,
};
}
const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary);
const { stderr } = await execCli(claudeBinary, args, {
timeout: TIMEOUT_MS,
cwd: projectPath,
env: buildEnrichedEnv(claudeBinary),
env,
});
if (stderr) {
@ -295,11 +302,12 @@ export class McpInstallService {
error: CLI_NOT_FOUND_MESSAGE,
};
}
const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary);
const { stderr } = await execCli(claudeBinary, args, {
timeout: TIMEOUT_MS,
cwd: projectPath,
env: buildEnrichedEnv(claudeBinary),
env,
});
if (stderr) {
@ -330,7 +338,7 @@ export class McpInstallService {
if (scope && !VALID_SCOPES.has(scope)) {
return {
state: 'error',
error: `Invalid scope: "${scope}". Must be one of: local, user, project.`,
error: `Invalid scope: "${scope}". Must be one of: local, user, project, global.`,
};
}
@ -364,11 +372,12 @@ export class McpInstallService {
error: CLI_NOT_FOUND_MESSAGE,
};
}
const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary);
await execCli(claudeBinary, args, {
timeout: TIMEOUT_MS,
cwd: projectPath,
env: buildEnrichedEnv(claudeBinary),
env,
});
return { state: 'success' };
} catch (err) {

View file

@ -7,12 +7,14 @@
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
import { execCli } from '@main/utils/childProcess';
import { buildEnrichedEnv } from '@main/utils/cliEnv';
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
import { createLogger } from '@shared/utils/logger';
import path from 'path';
import { createExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
import type { PluginCatalogService } from '../catalog/PluginCatalogService';
import type { ExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
import type { OperationResult, PluginInstallRequest } from '@shared/types/extensions';
const logger = createLogger('Extensions:PluginInstall');
@ -31,7 +33,10 @@ function scopeRequiresProjectPath(scope?: string): boolean {
}
export class PluginInstallService {
constructor(private readonly catalogService: PluginCatalogService) {}
constructor(
private readonly catalogService: PluginCatalogService,
private readonly runtimeAdapter: ExtensionsRuntimeAdapter = createExtensionsRuntimeAdapter()
) {}
async install(request: PluginInstallRequest): Promise<OperationResult> {
const { pluginId, scope, projectPath } = request;
@ -95,11 +100,12 @@ export class PluginInstallService {
error: CLI_NOT_FOUND_MESSAGE,
};
}
const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary);
const { stdout, stderr } = await execCli(claudeBinary, args, {
timeout: INSTALL_TIMEOUT_MS,
cwd: projectPath,
env: buildEnrichedEnv(claudeBinary),
env,
});
if (stderr && !stdout) {
@ -175,11 +181,12 @@ export class PluginInstallService {
error: CLI_NOT_FOUND_MESSAGE,
};
}
const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary);
await execCli(claudeBinary, args, {
timeout: UNINSTALL_TIMEOUT_MS,
cwd: projectPath,
env: buildEnrichedEnv(claudeBinary),
env,
});
return { state: 'success' };
} catch (err) {

View 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()
);
}

View 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 [];
}
}
}

View 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] : [];
});
}

View 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,
},
];
});
}

View file

@ -117,7 +117,7 @@ export class SkillMetadataParser {
code: 'has-scripts',
message:
'This skill includes a scripts directory. Review bundled scripts before trusting it.',
severity: 'warning',
severity: 'info',
});
}

View file

@ -1,6 +1,7 @@
import * as path from 'node:path';
import { getHomeDir } from '@main/utils/pathDecoder';
import { SKILL_ROOT_DEFINITIONS } from '@shared/utils/skillRoots';
import type { SkillRootKind, SkillScope } from '@shared/types/extensions';
@ -11,11 +12,12 @@ export interface ResolvedSkillRoot {
rootPath: string;
}
const USER_ROOTS: { rootKind: SkillRootKind; segments: string[] }[] = [
{ rootKind: 'claude', segments: ['.claude', 'skills'] },
{ rootKind: 'cursor', segments: ['.cursor', 'skills'] },
{ rootKind: 'agents', segments: ['.agents', 'skills'] },
];
const USER_ROOTS: { rootKind: SkillRootKind; segments: string[] }[] = SKILL_ROOT_DEFINITIONS.map(
(definition) => ({
rootKind: definition.rootKind,
segments: [...definition.segments],
})
);
export class SkillRootsResolver {
resolve(projectPath?: string): ResolvedSkillRoot[] {

View file

@ -1,9 +1,12 @@
import { formatSkillRootKind, getSkillAudience } from '@shared/utils/skillRoots';
import type { SkillCatalogItem } from '@shared/types/extensions';
const ROOT_PRECEDENCE: Record<SkillCatalogItem['rootKind'], number> = {
claude: 0,
cursor: 1,
agents: 2,
codex: 3,
};
export class SkillValidator {
@ -21,14 +24,14 @@ export class SkillValidator {
private annotateDuplicateNames(items: SkillCatalogItem[]): SkillCatalogItem[] {
const itemsByName = new Map<string, SkillCatalogItem[]>();
for (const item of items) {
const key = item.name.trim().toLowerCase();
const key = `${item.name.trim().toLowerCase()}::${getSkillAudience(item.rootKind)}`;
const bucket = itemsByName.get(key) ?? [];
bucket.push(item);
itemsByName.set(key, bucket);
}
return items.map((item) => {
const key = item.name.trim().toLowerCase();
const key = `${item.name.trim().toLowerCase()}::${getSkillAudience(item.rootKind)}`;
const duplicates = itemsByName.get(key) ?? [];
if (duplicates.length <= 1) {
return item;
@ -59,6 +62,7 @@ export class SkillValidator {
}
private formatRootLabel(item: SkillCatalogItem): string {
return item.scope === 'project' ? `project .${item.rootKind}` : `.${item.rootKind}`;
const rootLabel = formatSkillRootKind(item.rootKind);
return item.scope === 'project' ? `project ${rootLabel}` : rootLabel;
}
}

View file

@ -1,97 +1,27 @@
/**
* Runs `claude mcp list` and parses per-server health statuses.
* Resolves MCP diagnostics through the active runtime adapter.
*
* Direct Claude mode parses `claude mcp list` text output.
* Multimodel mode uses the structured `mcp diagnose --json` runtime contract.
*/
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
import { execCli } from '@main/utils/childProcess';
import { buildEnrichedEnv } from '@main/utils/cliEnv';
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
import { createLogger } from '@shared/utils/logger';
import { createExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
import {
parseMcpDiagnosticsJsonOutput,
parseMcpDiagnosticsOutput,
} from '../runtime/mcpDiagnosticsParser';
import type { McpServerDiagnostic, McpServerHealthStatus } from '@shared/types/extensions';
import type { ExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
import type { McpServerDiagnostic } from '@shared/types/extensions';
const logger = createLogger('Extensions:McpHealthDiagnostics');
const TIMEOUT_MS = 30_000;
export { parseMcpDiagnosticsJsonOutput, parseMcpDiagnosticsOutput };
export class McpHealthDiagnosticsService {
async diagnose(): Promise<McpServerDiagnostic[]> {
const claudeBinary = await ClaudeBinaryResolver.resolve();
if (!claudeBinary) {
throw new Error(CLI_NOT_FOUND_MESSAGE);
}
constructor(
private readonly runtimeAdapter: ExtensionsRuntimeAdapter = createExtensionsRuntimeAdapter()
) {}
const { stdout, stderr } = await execCli(claudeBinary, ['mcp', 'list'], {
timeout: TIMEOUT_MS,
env: buildEnrichedEnv(claudeBinary),
});
const output = [stdout, stderr].filter(Boolean).join('\n');
const diagnostics = parseMcpDiagnosticsOutput(output);
logger.info(`Parsed ${diagnostics.length} MCP diagnostic entries`);
return diagnostics;
}
}
export function parseMcpDiagnosticsOutput(output: string): McpServerDiagnostic[] {
const checkedAt = Date.now();
return output
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith('Checking MCP server health'))
.map((line) => parseDiagnosticLine(line, checkedAt))
.filter((entry): entry is McpServerDiagnostic => entry !== null);
}
function parseDiagnosticLine(line: string, checkedAt: number): McpServerDiagnostic | null {
const statusSeparatorIdx = line.lastIndexOf(' - ');
if (statusSeparatorIdx === -1) {
return null;
}
const descriptor = line.slice(0, statusSeparatorIdx).trim();
const statusChunk = line.slice(statusSeparatorIdx + 3).trim();
const nameSeparatorIdx = descriptor.indexOf(': ');
if (nameSeparatorIdx === -1) {
return null;
}
const name = descriptor.slice(0, nameSeparatorIdx).trim();
const target = descriptor.slice(nameSeparatorIdx + 2).trim();
if (!name || !target) {
return null;
}
const { status, statusLabel } = parseStatusChunk(statusChunk);
return {
name,
target,
status,
statusLabel,
rawLine: line,
checkedAt,
};
}
function parseStatusChunk(statusChunk: string): {
status: McpServerHealthStatus;
statusLabel: string;
} {
const symbol = statusChunk[0];
const label = statusChunk.slice(1).trim() || 'Unknown';
switch (symbol) {
case '✓':
return { status: 'connected', statusLabel: label };
case '!':
return { status: 'needs-authentication', statusLabel: label };
case '✗':
return { status: 'failed', statusLabel: label };
default:
return { status: 'unknown', statusLabel: statusChunk };
async diagnose(projectPath?: string): Promise<McpServerDiagnostic[]> {
return this.runtimeAdapter.diagnoseMcp(projectPath);
}
}

View file

@ -1,24 +1,15 @@
/**
* Reads installed MCP server state from the filesystem.
* Resolves installed MCP server state through the active runtime adapter.
*
* Sources:
* - User scope: ~/.claude.json mcpServers
* - Local scope: ~/.claude.json projects[projectPath].mcpServers
* - Project scope: .mcp.json in project root
*
* Both files are managed by the Claude CLI. This service is read-only.
* Direct Claude mode reads CLI-managed config files.
* Multimodel mode uses the structured `mcp list --json` runtime contract.
*/
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { getHomeDir } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import { createExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
import type { ExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
import type { InstalledMcpEntry } from '@shared/types/extensions';
const logger = createLogger('Extensions:McpState');
const CACHE_TTL_MS = 10_000; // 10 seconds
interface TimedCache<T> {
@ -29,113 +20,23 @@ interface TimedCache<T> {
export class McpInstallationStateService {
private cache = new Map<string, TimedCache<InstalledMcpEntry[]>>();
/**
* Get all installed MCP servers across user, local, and project scopes.
*/
constructor(
private readonly runtimeAdapter: ExtensionsRuntimeAdapter = createExtensionsRuntimeAdapter()
) {}
async getInstalled(projectPath?: string): Promise<InstalledMcpEntry[]> {
const cacheKey = projectPath ?? '__user__';
const cacheKey = `${this.runtimeAdapter.flavor}:${projectPath ?? '__user__'}`;
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
return cached.data;
}
const entries: InstalledMcpEntry[] = [];
const claudeConfig = await this.readClaudeConfig();
// User scope: ~/.claude.json
entries.push(...this.readUserMcpServers(claudeConfig));
if (projectPath) {
entries.push(...this.readLocalMcpServers(claudeConfig, projectPath));
entries.push(...(await this.readProjectMcpServers(projectPath)));
}
const entries = await this.runtimeAdapter.getInstalledMcp(projectPath);
this.cache.set(cacheKey, { data: entries, fetchedAt: Date.now() });
return entries;
}
/**
* Invalidate cache. Call after install/uninstall operations.
*/
invalidateCache(): void {
this.cache.clear();
}
// ── Private ────────────────────────────────────────────────────────────
private async readClaudeConfig(): Promise<Record<string, unknown> | null> {
const configPath = path.join(getHomeDir(), '.claude.json');
try {
const raw = await fs.readFile(configPath, 'utf-8');
return JSON.parse(raw) as Record<string, unknown>;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
logger.error(`Failed to read MCP servers from ${configPath}:`, err);
return null;
}
}
private readUserMcpServers(config: Record<string, unknown> | null): InstalledMcpEntry[] {
return this.readMcpServersFromConfig(config?.mcpServers, 'user');
}
private readLocalMcpServers(
config: Record<string, unknown> | null,
projectPath: string
): InstalledMcpEntry[] {
const projects =
config && typeof config.projects === 'object' && config.projects
? (config.projects as Record<string, unknown>)
: null;
const projectConfig =
projects && typeof projects[projectPath] === 'object' && projects[projectPath]
? (projects[projectPath] as Record<string, unknown>)
: null;
return this.readMcpServersFromConfig(projectConfig?.mcpServers, 'local');
}
private async readProjectMcpServers(projectPath: string): Promise<InstalledMcpEntry[]> {
const configPath = path.join(projectPath, '.mcp.json');
return this.readMcpServersFromFile(configPath, 'project');
}
private readMcpServersFromConfig(
value: unknown,
scope: 'user' | 'project' | 'local'
): InstalledMcpEntry[] {
const mcpServers =
value && typeof value === 'object'
? (value as Record<string, { command?: string; url?: string }>)
: null;
if (!mcpServers) {
return [];
}
return Object.entries(mcpServers).map(([name, config]): InstalledMcpEntry => {
let transport: string | undefined;
if (config.command) transport = 'stdio';
else if (config.url) transport = 'http';
return { name, scope, transport };
});
}
private async readMcpServersFromFile(
filePath: string,
scope: 'user' | 'project'
): Promise<InstalledMcpEntry[]> {
try {
const raw = await fs.readFile(filePath, 'utf-8');
const json = JSON.parse(raw) as Record<string, unknown>;
return this.readMcpServersFromConfig(json.mcpServers, scope);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
}
logger.error(`Failed to read MCP servers from ${filePath}:`, err);
return [];
}
}
}

View file

@ -30,6 +30,7 @@ import {
} from '@main/utils/shellEnv';
import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger';
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
import { createHash } from 'crypto';
import { createWriteStream, existsSync, promises as fsp } from 'fs';
import http from 'http';
@ -145,7 +146,13 @@ function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallat
...provider,
modelVerificationState: provider.modelVerificationState ?? 'idle',
modelAvailability: provider.modelAvailability?.map((item) => ({ ...item })) ?? [],
capabilities: { ...provider.capabilities },
capabilities: {
...provider.capabilities,
extensions: {
...createDefaultCliExtensionCapabilities(),
...provider.capabilities.extensions,
},
},
selectedBackendId: provider.selectedBackendId ?? null,
resolvedBackendId: provider.resolvedBackendId ?? null,
availableBackends: provider.availableBackends?.map((backend) => ({ ...backend })) ?? [],
@ -467,6 +474,7 @@ export class CliInstallerService {
capabilities: {
teamLaunch: false,
oneShot: false,
extensions: createDefaultCliExtensionCapabilities(),
},
backend: null,
}))
@ -528,7 +536,13 @@ export class CliInstallerService {
authMethod: provider.authMethod,
selectedBackendId: provider.selectedBackendId ?? null,
resolvedBackendId: provider.resolvedBackendId ?? null,
capabilities: { ...provider.capabilities },
capabilities: {
...provider.capabilities,
extensions: {
...createDefaultCliExtensionCapabilities(),
...provider.capabilities.extensions,
},
},
backend: provider.backend ? { ...provider.backend } : null,
},
};

View file

@ -62,6 +62,12 @@ export interface NotificationConfig {
notifyOnTeamLaunched: boolean;
/** Whether to show native OS notifications when a tool needs user approval */
notifyOnToolApproval: boolean;
/** Whether to automatically resume a rate-limited team when the limit resets.
* When enabled, the app parses the reset time from Claude's rate-limit
* message and schedules a nudge to the team lead once the limit expires.
* Default is `false` opt-in to avoid unexpected API usage after the reset.
*/
autoResumeOnRateLimit: boolean;
/** Only notify on status changes in solo teams (no teammates) */
statusChangeOnlySolo: boolean;
/** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */
@ -306,6 +312,7 @@ const DEFAULT_CONFIG: AppConfig = {
notifyOnCrossTeamMessage: true,
notifyOnTeamLaunched: true,
notifyOnToolApproval: true,
autoResumeOnRateLimit: false,
statusChangeOnlySolo: false,
statusChangeStatuses: ['in_progress', 'completed'],
triggers: DEFAULT_TRIGGERS,
@ -502,8 +509,55 @@ export class ConfigManager {
return {
notifications: {
...DEFAULT_CONFIG.notifications,
...loadedNotifications,
enabled: loadedNotifications.enabled ?? DEFAULT_CONFIG.notifications.enabled,
soundEnabled: loadedNotifications.soundEnabled ?? DEFAULT_CONFIG.notifications.soundEnabled,
ignoredRegex: loadedNotifications.ignoredRegex ?? DEFAULT_CONFIG.notifications.ignoredRegex,
ignoredRepositories:
loadedNotifications.ignoredRepositories ??
DEFAULT_CONFIG.notifications.ignoredRepositories,
snoozedUntil: loadedNotifications.snoozedUntil ?? DEFAULT_CONFIG.notifications.snoozedUntil,
snoozeMinutes:
loadedNotifications.snoozeMinutes ?? DEFAULT_CONFIG.notifications.snoozeMinutes,
includeSubagentErrors:
loadedNotifications.includeSubagentErrors ??
DEFAULT_CONFIG.notifications.includeSubagentErrors,
notifyOnLeadInbox:
loadedNotifications.notifyOnLeadInbox ?? DEFAULT_CONFIG.notifications.notifyOnLeadInbox,
notifyOnUserInbox:
loadedNotifications.notifyOnUserInbox ?? DEFAULT_CONFIG.notifications.notifyOnUserInbox,
notifyOnClarifications:
loadedNotifications.notifyOnClarifications ??
DEFAULT_CONFIG.notifications.notifyOnClarifications,
notifyOnStatusChange:
loadedNotifications.notifyOnStatusChange ??
DEFAULT_CONFIG.notifications.notifyOnStatusChange,
notifyOnTaskComments:
loadedNotifications.notifyOnTaskComments ??
DEFAULT_CONFIG.notifications.notifyOnTaskComments,
notifyOnTaskCreated:
loadedNotifications.notifyOnTaskCreated ??
DEFAULT_CONFIG.notifications.notifyOnTaskCreated,
notifyOnAllTasksCompleted:
loadedNotifications.notifyOnAllTasksCompleted ??
DEFAULT_CONFIG.notifications.notifyOnAllTasksCompleted,
notifyOnCrossTeamMessage:
loadedNotifications.notifyOnCrossTeamMessage ??
DEFAULT_CONFIG.notifications.notifyOnCrossTeamMessage,
notifyOnTeamLaunched:
loadedNotifications.notifyOnTeamLaunched ??
DEFAULT_CONFIG.notifications.notifyOnTeamLaunched,
notifyOnToolApproval:
loadedNotifications.notifyOnToolApproval ??
DEFAULT_CONFIG.notifications.notifyOnToolApproval,
autoResumeOnRateLimit:
loadedNotifications.autoResumeOnRateLimit ??
DEFAULT_CONFIG.notifications.autoResumeOnRateLimit,
statusChangeOnlySolo:
loadedNotifications.statusChangeOnlySolo ??
DEFAULT_CONFIG.notifications.statusChangeOnlySolo,
statusChangeStatuses:
loadedNotifications.statusChangeStatuses ??
DEFAULT_CONFIG.notifications.statusChangeStatuses,
triggers: mergedTriggers,
},
general: mergedGeneral,

View file

@ -543,10 +543,10 @@ export class NotificationManager extends EventEmitter {
logger.debug(`[test-notification] creating Notification (platform=${process.platform})`);
const notification = new NotificationClass({
title: 'Test Notification',
...(isMac ? { subtitle: 'Claude Agent Teams UI' } : {}),
...(isMac ? { subtitle: 'Agent Teams UI' } : {}),
body: isMac
? 'Notifications are working correctly!'
: 'Claude Agent Teams UI\nNotifications are working correctly!',
: 'Agent Teams UI\nNotifications are working correctly!',
...(iconPath ? { icon: iconPath } : {}),
});

View file

@ -1,6 +1,10 @@
import { execCli } from '@main/utils/childProcess';
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import { createLogger } from '@shared/utils/logger';
import {
createDefaultCliExtensionCapabilities,
createLegacyRuntimeFallbackCliExtensionCapabilities,
} from '@shared/utils/providerExtensionCapabilities';
import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth';
import { buildProviderAwareCliEnv } from './providerAwareCliEnv';
@ -13,6 +17,19 @@ const logger = createLogger('ClaudeMultimodelBridgeService');
const PROVIDER_STATUS_TIMEOUT_MS = 10_000;
const PROVIDER_MODELS_TIMEOUT_MS = 10_000;
interface RuntimeExtensionCapabilityResponse {
status?: 'supported' | 'read-only' | 'unsupported';
ownership?: 'shared' | 'provider-scoped';
reason?: string | null;
}
interface RuntimeExtensionCapabilitiesResponse {
plugins?: RuntimeExtensionCapabilityResponse;
mcp?: RuntimeExtensionCapabilityResponse;
skills?: RuntimeExtensionCapabilityResponse;
apiKeys?: RuntimeExtensionCapabilityResponse;
}
interface ProviderStatusCommandResponse {
schemaVersion?: number;
providers?: Record<
@ -27,6 +44,7 @@ interface ProviderStatusCommandResponse {
capabilities?: {
teamLaunch?: boolean;
oneShot?: boolean;
extensions?: RuntimeExtensionCapabilitiesResponse;
};
backend?: {
kind?: string;
@ -84,6 +102,7 @@ interface UnifiedRuntimeStatusResponse {
capabilities?: {
teamLaunch?: boolean;
oneShot?: boolean;
extensions?: RuntimeExtensionCapabilitiesResponse;
};
backend?: {
kind?: string;
@ -129,6 +148,7 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat
capabilities: {
teamLaunch: false,
oneShot: false,
extensions: createLegacyRuntimeFallbackCliExtensionCapabilities(),
},
selectedBackendId: null,
resolvedBackendId: null,
@ -139,6 +159,41 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat
};
}
function mapRuntimeExtensionCapabilities(
capabilities?: RuntimeExtensionCapabilitiesResponse
): CliProviderStatus['capabilities']['extensions'] {
const defaults = capabilities
? createDefaultCliExtensionCapabilities()
: createLegacyRuntimeFallbackCliExtensionCapabilities();
return {
plugins: {
...defaults.plugins,
status: capabilities?.plugins?.status ?? defaults.plugins.status,
ownership: capabilities?.plugins?.ownership ?? defaults.plugins.ownership,
reason: capabilities?.plugins?.reason ?? defaults.plugins.reason,
},
mcp: {
...defaults.mcp,
status: capabilities?.mcp?.status ?? defaults.mcp.status,
ownership: capabilities?.mcp?.ownership ?? defaults.mcp.ownership,
reason: capabilities?.mcp?.reason ?? defaults.mcp.reason,
},
skills: {
...defaults.skills,
status: capabilities?.skills?.status ?? defaults.skills.status,
ownership: capabilities?.skills?.ownership ?? defaults.skills.ownership,
reason: capabilities?.skills?.reason ?? defaults.skills.reason,
},
apiKeys: {
...defaults.apiKeys,
status: capabilities?.apiKeys?.status ?? defaults.apiKeys.status,
ownership: capabilities?.apiKeys?.ownership ?? defaults.apiKeys.ownership,
reason: capabilities?.apiKeys?.reason ?? defaults.apiKeys.reason,
},
};
}
function extractModelIds(
models: (string | { id?: string; label?: string; description?: string })[] | undefined
): string[] {
@ -203,6 +258,7 @@ export class ClaudeMultimodelBridgeService {
capabilities: {
teamLaunch: runtimeStatus.capabilities?.teamLaunch === true,
oneShot: runtimeStatus.capabilities?.oneShot === true,
extensions: mapRuntimeExtensionCapabilities(runtimeStatus.capabilities?.extensions),
},
selectedBackendId: runtimeStatus.selectedBackendId ?? null,
resolvedBackendId: runtimeStatus.resolvedBackendId ?? null,
@ -325,6 +381,7 @@ export class ClaudeMultimodelBridgeService {
provider.capabilities = {
teamLaunch: true,
oneShot: true,
extensions: createDefaultCliExtensionCapabilities(),
};
}
} catch (error) {
@ -428,6 +485,7 @@ export class ClaudeMultimodelBridgeService {
capabilities: {
teamLaunch: runtimeStatus.capabilities?.teamLaunch === true,
oneShot: runtimeStatus.capabilities?.oneShot === true,
extensions: mapRuntimeExtensionCapabilities(runtimeStatus.capabilities?.extensions),
},
backend: runtimeStatus.backend?.kind
? {

View 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;
}

View file

@ -1,12 +1,5 @@
import { yieldToEventLoop } from '@main/utils/asyncYield';
import {
encodePath,
extractBaseDir,
getClaudeBasePath,
getProjectsBasePath,
getTasksBasePath,
getTeamsBasePath,
} from '@main/utils/pathDecoder';
import { getClaudeBasePath, getTasksBasePath, getTeamsBasePath } from '@main/utils/pathDecoder';
import { killProcessByPid } from '@main/utils/processKill';
import {
AGENT_BLOCK_CLOSE,
@ -16,7 +9,7 @@ import {
} from '@shared/constants/agentBlocks';
import { getMemberColorByName } from '@shared/constants/memberColors';
import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics';
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
import { isLeadMember } from '@shared/utils/leadDetection';
import { createLogger } from '@shared/utils/logger';
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
@ -40,6 +33,10 @@ import {
import { atomicWriteAsync } from './atomicWrite';
import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor';
import { MemberActivityMetaService } from './MemberActivityMetaService';
import {
getLiveLeadProcessMessageKey,
mergeLiveLeadProcessMessages,
} from './mergeLiveLeadProcessMessages';
import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils';
import { TeamConfigReader } from './TeamConfigReader';
import { TeamInboxReader } from './TeamInboxReader';
@ -54,6 +51,7 @@ import { TeamSentMessagesStore } from './TeamSentMessagesStore';
import { TeamTaskCommentNotificationJournal } from './TeamTaskCommentNotificationJournal';
import { TeamTaskReader } from './TeamTaskReader';
import { TeamTaskWriter } from './TeamTaskWriter';
import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver';
import type { PersistedTaskChangePresenceIndex } from './cache/taskChangePresenceCacheTypes';
import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository';
@ -194,12 +192,15 @@ export class TeamDataService {
private readonly taskCommentNotificationJournal: TeamTaskCommentNotificationJournal = new TeamTaskCommentNotificationJournal(),
private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore(),
private memberRuntimeAdvisoryService: TeamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService(),
private readonly leadSessionParseCache: LeadSessionParseCache = new LeadSessionParseCache()
private readonly leadSessionParseCache: LeadSessionParseCache = new LeadSessionParseCache(),
private readonly projectResolver: TeamTranscriptProjectResolver = new TeamTranscriptProjectResolver(
configReader
)
) {
this.messageFeedService = new TeamMessageFeedService({
getConfig: (teamName) => this.configReader.getConfig(teamName),
getInboxMessages: (teamName) => this.inboxReader.getMessages(teamName),
getLeadSessionMessages: (config) => this.extractLeadSessionTexts(config),
getLeadSessionMessages: (teamName, config) => this.extractLeadSessionTexts(teamName, config),
getSentMessages: (teamName) => this.sentMessagesStore.readMessages(teamName),
});
this.memberActivityMetaService = new MemberActivityMetaService(this.messageFeedService);
@ -910,10 +911,14 @@ export class TeamDataService {
*/
async getMessagesPage(
teamName: string,
options: { cursor?: string | null; limit: number }
options: { cursor?: string | null; limit: number; liveMessages?: InboxMessage[] }
): Promise<MessagesPage> {
const feed = await this.messageFeedService.getFeed(teamName);
let messages = feed.messages;
const newestDurableMessages = feed.messages;
const durableMessageIndexByKey = new Map(
newestDurableMessages.map((message, index) => [getLiveLeadProcessMessageKey(message), index])
);
let messages = newestDurableMessages;
if (options.cursor) {
const [cursorTs, cursorId] = options.cursor.split('|');
@ -933,7 +938,61 @@ export class TeamDataService {
const nextCursor =
hasMore && lastMsg ? `${lastMsg.timestamp}|${requireCanonicalMessageId(lastMsg)}` : null;
return { messages: page, nextCursor, hasMore, feedRevision: feed.feedRevision };
if (options.cursor || !options.liveMessages?.length) {
return { messages: page, nextCursor, hasMore, feedRevision: feed.feedRevision };
}
// Merge live lead thoughts against the full durable newest-page history so we do not
// re-introduce persisted thoughts that have simply paged off the first durable page.
const displayMessages = mergeLiveLeadProcessMessages(
newestDurableMessages,
options.liveMessages
).slice(0, options.limit);
if (displayMessages.length === 0) {
return {
messages: displayMessages,
nextCursor: null,
hasMore: false,
feedRevision: feed.feedRevision,
};
}
let lastDurableDisplayed: InboxMessage | null = null;
for (let index = displayMessages.length - 1; index >= 0; index -= 1) {
const candidate = displayMessages[index];
if (durableMessageIndexByKey.has(getLiveLeadProcessMessageKey(candidate))) {
lastDurableDisplayed = candidate;
break;
}
}
if (!lastDurableDisplayed) {
const boundary = displayMessages[displayMessages.length - 1];
return {
messages: displayMessages,
nextCursor:
newestDurableMessages.length > 0
? `${boundary.timestamp}|${boundary.messageId ?? ''}`
: null,
hasMore: newestDurableMessages.length > 0,
feedRevision: feed.feedRevision,
};
}
const durableIndex =
durableMessageIndexByKey.get(getLiveLeadProcessMessageKey(lastDurableDisplayed)) ??
Number.POSITIVE_INFINITY;
const durableHasMore = durableIndex < newestDurableMessages.length - 1;
return {
messages: displayMessages,
nextCursor: durableHasMore
? `${lastDurableDisplayed.timestamp}|${lastDurableDisplayed.messageId ?? ''}`
: null,
hasMore: durableHasMore,
feedRevision: feed.feedRevision,
};
}
async getMessageFeed(
@ -2369,37 +2428,20 @@ export class TeamDataService {
}
}
private getLeadProjectDirCandidates(projectPath: string): string[] {
const projectId = encodePath(projectPath);
const baseDir = extractBaseDir(projectId);
const candidateDirs = [
path.join(getProjectsBasePath(), baseDir),
// Claude Code encodes underscores as hyphens in project directory names;
// our encodePath only handles slashes. Try the underscore-to-hyphen variant.
...(baseDir.includes('_')
? [path.join(getProjectsBasePath(), baseDir.replace(/_/g, '-'))]
: []),
];
return [...new Set(candidateDirs)];
}
private async getLeadSessionJsonlPaths(projectPath: string): Promise<Map<string, string>> {
private async getLeadSessionJsonlPaths(projectDir: string): Promise<Map<string, string>> {
const jsonlPaths = new Map<string, string>();
for (const dirPath of this.getLeadProjectDirCandidates(projectPath)) {
let entries: fs.Dirent[];
try {
entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
} catch {
continue;
}
let entries: fs.Dirent[];
try {
entries = await fs.promises.readdir(projectDir, { withFileTypes: true });
} catch {
return jsonlPaths;
}
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
const sessionId = entry.name.slice(0, -'.jsonl'.length).trim();
if (!sessionId || jsonlPaths.has(sessionId)) continue;
jsonlPaths.set(sessionId, path.join(dirPath, entry.name));
}
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
const sessionId = entry.name.slice(0, -'.jsonl'.length).trim();
if (!sessionId || jsonlPaths.has(sessionId)) continue;
jsonlPaths.set(sessionId, path.join(projectDir, entry.name));
}
return jsonlPaths;
@ -2645,17 +2687,23 @@ export class TeamDataService {
}
}
private async extractLeadSessionTexts(config: TeamConfig): Promise<InboxMessage[]> {
if (!config.projectPath) {
private async extractLeadSessionTexts(
teamName: string,
config: TeamConfig
): Promise<InboxMessage[]> {
const transcriptContext = await this.projectResolver.getContext(teamName);
if (!transcriptContext) {
return [];
}
const leadName = config.members?.find((m) => isLeadMember(m))?.name ?? 'team-lead';
const sessionIds = this.getRecentLeadSessionIds(config);
const leadName =
transcriptContext.config.members?.find((m) => isLeadMember(m))?.name ?? 'team-lead';
const sessionIds = Array.from(
new Set([...this.getRecentLeadSessionIds(config), ...transcriptContext.sessionIds])
);
if (sessionIds.length === 0) {
return [];
}
const availableJsonlPaths = await this.getLeadSessionJsonlPaths(config.projectPath);
const availableJsonlPaths = await this.getLeadSessionJsonlPaths(transcriptContext.projectDir);
if (availableJsonlPaths.size === 0) {
return [];
}

View file

@ -11,7 +11,7 @@ const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000;
interface TeamMessageFeedDeps {
getConfig: (teamName: string) => Promise<TeamConfig | null>;
getInboxMessages: (teamName: string) => Promise<InboxMessage[]>;
getLeadSessionMessages: (config: TeamConfig) => Promise<InboxMessage[]>;
getLeadSessionMessages: (teamName: string, config: TeamConfig) => Promise<InboxMessage[]>;
getSentMessages: (teamName: string) => Promise<InboxMessage[]>;
}
@ -370,7 +370,7 @@ export class TeamMessageFeedService {
const [inboxMessages, leadTexts, sentMessages] = await Promise.all([
this.deps.getInboxMessages(teamName).catch(() => [] as InboxMessage[]),
this.deps.getLeadSessionMessages(config).catch(() => [] as InboxMessage[]),
this.deps.getLeadSessionMessages(teamName, config).catch(() => [] as InboxMessage[]),
this.deps.getSentMessages(teamName).catch(() => [] as InboxMessage[]),
]);

View file

@ -112,6 +112,7 @@ import { TeamMembersMetaStore } from './TeamMembersMetaStore';
import { TeamMetaStore } from './TeamMetaStore';
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
import { TeamTaskReader } from './TeamTaskReader';
import { peekAutoResumeService } from './AutoResumeService';
/**
* Kill a team CLI process using SIGKILL (uncatchable).
@ -2413,6 +2414,40 @@ export class TeamProvisioningService {
return this.getProvisioningRunId(teamName) ?? this.getAliveRunId(teamName);
}
private clearSameTeamRetryTimers(teamName: string): void {
for (const suffix of ['deferred', 'persist']) {
const key = `same-team-${suffix}:${teamName}`;
const timer = this.pendingTimeouts.get(key);
if (timer) {
clearTimeout(timer);
this.pendingTimeouts.delete(key);
}
}
}
private resetTeamScopedTransientStateForNewRun(teamName: string): void {
peekAutoResumeService()?.cancelPendingAutoResume(teamName);
this.leadInboxRelayInFlight.delete(teamName);
this.relayedLeadInboxMessageIds.delete(teamName);
this.pendingCrossTeamFirstReplies.delete(teamName);
this.recentCrossTeamLeadDeliveryMessageIds.delete(teamName);
this.recentSameTeamNativeFingerprints.delete(teamName);
this.clearSameTeamRetryTimers(teamName);
for (const key of Array.from(this.memberInboxRelayInFlight.keys())) {
if (key.startsWith(`${teamName}:`)) {
this.memberInboxRelayInFlight.delete(key);
}
}
for (const key of Array.from(this.relayedMemberInboxMessageIds.keys())) {
if (key.startsWith(`${teamName}:`)) {
this.relayedMemberInboxMessageIds.delete(key);
}
}
this.liveLeadProcessMessages.delete(teamName);
}
private appendCliLogs(run: ProvisioningRun, stream: 'stdout' | 'stderr', text: string): void {
const nowMs = Date.now();
run.claudeLogsUpdatedAt = new Date(nowMs).toISOString();
@ -3191,7 +3226,58 @@ export class TeamProvisioningService {
}
getLiveLeadProcessMessages(teamName: string): InboxMessage[] {
return [...(this.liveLeadProcessMessages.get(teamName) ?? [])];
const runId = this.getTrackedRunId(teamName);
const detectedSessionId = runId ? (this.runs.get(runId)?.detectedSessionId ?? null) : null;
return (this.liveLeadProcessMessages.get(teamName) ?? []).map((message) =>
!message.leadSessionId && detectedSessionId
? { ...message, leadSessionId: detectedSessionId }
: { ...message }
);
}
private pruneLiveLeadMessagesForCleanedRun(run: ProvisioningRun): void {
const list = this.liveLeadProcessMessages.get(run.teamName);
if (!list || list.length === 0) {
return;
}
const runMessageIdPrefixes = [
`lead-turn-${run.runId}-`,
`lead-sendmsg-${run.runId}-`,
`lead-process-${run.runId}-`,
`compact-${run.runId}-`,
];
const filtered = list.filter((message) => {
const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : '';
if (messageId && runMessageIdPrefixes.some((prefix) => messageId.startsWith(prefix))) {
return false;
}
if (run.detectedSessionId && message.leadSessionId === run.detectedSessionId) {
return false;
}
return true;
});
if (filtered.length === 0) {
this.liveLeadProcessMessages.delete(run.teamName);
return;
}
this.liveLeadProcessMessages.set(run.teamName, filtered);
}
getCurrentLeadSessionId(teamName: string): string | null {
const runId = this.getTrackedRunId(teamName);
if (!runId) return null;
return this.runs.get(runId)?.detectedSessionId ?? null;
}
getCurrentRunId(teamName: string): string | null {
return this.getAliveRunId(teamName);
}
getLeadActivityState(teamName: string): {
@ -5110,6 +5196,7 @@ export class TeamProvisioningService {
},
};
this.resetTeamScopedTransientStateForNewRun(request.teamName);
this.runs.set(runId, run);
this.provisioningRunByTeam.set(request.teamName, runId);
run.onProgress(run.progress);
@ -5655,7 +5742,7 @@ export class TeamProvisioningService {
pendingInboxRelayCandidates: [],
provisioningOutputParts: [],
provisioningOutputIndexByMessageId: new Map(),
detectedSessionId: null,
detectedSessionId: previousSessionId ?? null,
leadActivityState: 'active',
leadContextUsage: null,
authFailureRetried: false,
@ -5696,6 +5783,7 @@ export class TeamProvisioningService {
},
};
this.resetTeamScopedTransientStateForNewRun(request.teamName);
this.runs.set(runId, run);
this.provisioningRunByTeam.set(request.teamName, runId);
run.onProgress(run.progress);
@ -5954,6 +6042,21 @@ export class TeamProvisioningService {
throw new Error(`Team "${teamName}" process stdin is not writable`);
}
await this.sendMessageToRun(run, message, attachments);
}
private async sendMessageToRun(
run: ProvisioningRun,
message: string,
attachments?: { data: string; mimeType: string; filename?: string }[]
): Promise<void> {
if (!this.isCurrentTrackedRun(run)) {
throw new Error(`Team "${run.teamName}" run "${run.runId}" is no longer current`);
}
if (run.processKilled || run.cancelRequested || !run.child?.stdin?.writable) {
throw new Error(`Team "${run.teamName}" process stdin is not writable`);
}
const contentBlocks: Record<string, unknown>[] = [{ type: 'text', text: message }];
if (attachments?.length) {
for (const att of attachments) {
@ -6073,7 +6176,7 @@ export class TeamProvisioningService {
userText,
].join('\n');
await this.sendMessageToTeam(teamName, message);
await this.sendMessageToRun(run, message);
}
async relayMemberInboxMessages(teamName: string, memberName: string): Promise<number> {
@ -6095,6 +6198,8 @@ export class TeamProvisioningService {
const run = this.runs.get(runId);
if (!run?.child || run.processKilled || run.cancelRequested) return 0;
if (!run.provisioningComplete) return 0;
const isStaleRelayRun = (): boolean =>
!this.isCurrentTrackedRun(run) || !run.child || run.processKilled || run.cancelRequested;
const relayedIds = this.relayedMemberInboxMessageIds.get(relayKey) ?? new Set<string>();
@ -6104,6 +6209,7 @@ export class TeamProvisioningService {
} catch {
return 0;
}
if (isStaleRelayRun()) return 0;
const unread = memberInboxMessages
.filter((m): m is InboxMessage & { messageId: string } => {
@ -6134,6 +6240,7 @@ export class TeamProvisioningService {
.map(({ message }) => message);
const readOnlyIgnoredUnread = [...silentNoiseUnread, ...passiveIdleUnread];
if (isStaleRelayRun()) return 0;
if (readOnlyIgnoredUnread.length > 0) {
try {
@ -6207,7 +6314,7 @@ export class TeamProvisioningService {
].join('\n');
try {
await this.sendMessageToTeam(teamName, message);
await this.sendMessageToRun(run, message);
} catch {
this.forgetPendingInboxRelayCandidates(run, memberName, rememberedRelayIds);
return 0;
@ -6263,6 +6370,8 @@ export class TeamProvisioningService {
if (!runId) return 0;
const run = this.runs.get(runId);
if (!run?.child || run.processKilled || run.cancelRequested) return 0;
const isStaleRelayRun = (): boolean =>
!this.isCurrentTrackedRun(run) || !run.child || run.processKilled || run.cancelRequested;
// Permission request scan runs even during provisioning — teammates may need
// tool approval before the lead's first turn completes. CLI marks inbox messages
@ -6273,10 +6382,12 @@ export class TeamProvisioningService {
} catch {
// config not ready yet during early provisioning — skip scan
}
if (isStaleRelayRun()) return 0;
if (config) {
const leadName = config.members?.find((m) => isLeadMember(m))?.name?.trim() || 'team-lead';
try {
const leadInboxMessages = await this.inboxReader.getMessagesFor(teamName, leadName);
if (isStaleRelayRun()) return 0;
const permMsgsToMarkRead: { messageId: string }[] = [];
const runStartedAtMs = Date.parse(run.startedAt);
for (const msg of leadInboxMessages) {
@ -6321,6 +6432,7 @@ export class TeamProvisioningService {
return 0;
}
}
if (isStaleRelayRun()) return 0;
if (!config) return 0;
const leadName = config.members?.find((m) => isLeadMember(m))?.name?.trim() || 'team-lead';
@ -6330,8 +6442,10 @@ export class TeamProvisioningService {
} catch {
return 0;
}
if (isStaleRelayRun()) return 0;
await this.refreshMemberSpawnStatusesFromLeadInbox(run);
if (isStaleRelayRun()) return 0;
const unread = leadInboxMessages
.filter((m): m is InboxMessage & { messageId: string } => {
@ -6469,6 +6583,7 @@ export class TeamProvisioningService {
...passiveIdleUnread.map((m) => m.messageId),
]);
const remainingUnread = unread.filter((m) => !readOnlyIgnoredIds.has(m.messageId));
if (isStaleRelayRun()) return 0;
// Category 2: same-team native delivery confirmation (one-to-one pairing).
const { nativeMatchedMessageIds, persisted: sameTeamPersisted } =
@ -6631,7 +6746,7 @@ export class TeamProvisioningService {
});
try {
await this.sendMessageToTeam(teamName, message);
await this.sendMessageToRun(run, message);
} catch {
if (run.leadRelayCapture) {
clearTimeout(run.leadRelayCapture.timeoutHandle);
@ -7677,6 +7792,12 @@ export class TeamProvisioningService {
if (result.deduplicated) {
return;
}
if (this.getTrackedRunId(run.teamName) !== run.runId) {
logger.debug(
`[${run.teamName}] Skipping stale cross-team send result for old run ${run.runId}`
);
return;
}
const msg: InboxMessage = {
from: leadName,
to: recipient.startsWith('cross-team:')
@ -7904,11 +8025,18 @@ export class TeamProvisioningService {
private pushLiveLeadTextMessage(
run: ProvisioningRun,
cleanText: string,
stableMessageId?: string
stableMessageId?: string,
messageTimestamp?: string
): void {
run.leadMsgSeq += 1;
const leadName = this.getRunLeadName(run);
const messageId = stableMessageId || `lead-turn-${run.runId}-${run.leadMsgSeq}`;
const timestamp =
typeof messageTimestamp === 'string' &&
messageTimestamp.trim().length > 0 &&
Number.isFinite(Date.parse(messageTimestamp))
? messageTimestamp
: nowIso();
// Attach accumulated tool call details from preceding tool_use messages, then reset.
const toolCalls = run.pendingToolCalls.length > 0 ? [...run.pendingToolCalls] : undefined;
const toolSummary = toolCalls ? formatToolSummaryFromCalls(toolCalls) : undefined;
@ -7916,7 +8044,7 @@ export class TeamProvisioningService {
const leadMsg: InboxMessage = {
from: leadName,
text: cleanText,
timestamp: nowIso(),
timestamp,
read: true,
summary: cleanText.length > 60 ? cleanText.slice(0, 57) + '...' : cleanText,
messageId,
@ -8264,6 +8392,18 @@ export class TeamProvisioningService {
// stream-json output has various message types:
// {"type":"assistant","content":[{"type":"text","text":"..."},...]}
// {"type":"result","subtype":"success",...}
// Capture session_id as early as possible so live messages emitted during this
// handler already carry the session identity used by merge/dedup paths.
if (!run.detectedSessionId) {
const sid = typeof msg.session_id === 'string' ? msg.session_id : undefined;
if (sid && sid.trim().length > 0) {
run.detectedSessionId = sid.trim();
logger.info(
`[${run.teamName}] Detected session ID from stream-json: ${run.detectedSessionId}`
);
}
}
if (msg.type === 'user') {
// Check for permission_request in raw user message text BEFORE teammate-message parsing.
// The permission_request may arrive as plain JSON without <teammate-message> wrapper,
@ -8306,6 +8446,12 @@ export class TeamProvisioningService {
.map((part) => part.text as string);
if (textParts.length > 0) {
const text = textParts.join('\n');
const messageTimestamp =
typeof msg.timestamp === 'string' &&
msg.timestamp.trim().length > 0 &&
Number.isFinite(Date.parse(msg.timestamp))
? msg.timestamp
: undefined;
// Auth failures sometimes show up as assistant text (e.g. "401", "Please run /login")
// rather than stderr or a result.subtype=error. Detect early to avoid false "ready".
this.handleAuthFailureInOutput(run, text, 'assistant');
@ -8348,7 +8494,8 @@ export class TeamProvisioningService {
this.pushLiveLeadTextMessage(
run,
cleanText,
this.getStableLeadThoughtMessageId(msg) ?? undefined
this.getStableLeadThoughtMessageId(msg) ?? undefined,
messageTimestamp
);
}
}
@ -8361,7 +8508,8 @@ export class TeamProvisioningService {
this.pushLiveLeadTextMessage(
run,
cleanText,
this.getStableLeadThoughtMessageId(msg) ?? undefined
this.getStableLeadThoughtMessageId(msg) ?? undefined,
messageTimestamp
);
}
}
@ -8445,17 +8593,6 @@ export class TeamProvisioningService {
}
}
// Capture session_id from any message type (first occurrence wins)
if (!run.detectedSessionId) {
const sid = typeof msg.session_id === 'string' ? msg.session_id : undefined;
if (sid && sid.trim().length > 0) {
run.detectedSessionId = sid.trim();
logger.info(
`[${run.teamName}] Detected session ID from stream-json: ${run.detectedSessionId}`
);
}
}
if (this.handleDeterministicBootstrapEvent(run, msg)) {
return;
}
@ -10041,7 +10178,7 @@ export class TeamProvisioningService {
`Не стартовали тиммейты: ${failedSpawnMembers.map((member) => `@${member.name}`).join(', ')}.`,
`Не считай их доступными, пока их запуск не будет повторён успешно.`,
].join(' ');
await this.sendMessageToTeam(run.teamName, failureNotice).catch((error: unknown) =>
await this.sendMessageToRun(run, failureNotice).catch((error: unknown) =>
logger.warn(
`[${run.teamName}] failed to send teammate-start failure notice to lead: ${
error instanceof Error ? error.message : String(error)
@ -10089,7 +10226,7 @@ export class TeamProvisioningService {
.filter(Boolean)
.join('\n\n');
await this.sendMessageToTeam(run.teamName, message);
await this.sendMessageToRun(run, message);
} catch (error) {
logger.warn(
`[${run.teamName}] Failed to kick off solo task resumption: ${
@ -10209,7 +10346,7 @@ export class TeamProvisioningService {
`Не стартовали тиммейты: ${failedSpawnMembers.map((member) => `@${member.name}`).join(', ')}.`,
`Не считай их доступными, пока их запуск не будет повторён успешно.`,
].join(' ');
await this.sendMessageToTeam(run.teamName, failureNotice).catch((error: unknown) =>
await this.sendMessageToRun(run, failureNotice).catch((error: unknown) =>
logger.warn(
`[${run.teamName}] failed to send teammate-start failure notice to lead: ${
error instanceof Error ? error.message : String(error)
@ -10529,7 +10666,14 @@ export class TeamProvisioningService {
* Remove a run from tracking maps.
*/
private cleanupRun(run: ProvisioningRun): void {
if (run.isLaunch && !run.provisioningComplete) {
const currentTrackedRunId = this.getTrackedRunId(run.teamName);
const hasNewerTrackedRun = currentTrackedRunId !== null && currentTrackedRunId !== run.runId;
if (!hasNewerTrackedRun) {
peekAutoResumeService()?.cancelPendingAutoResume(run.teamName);
}
if (!hasNewerTrackedRun && run.isLaunch && !run.provisioningComplete) {
void this.persistLaunchStateSnapshot(run, 'finished');
}
this.resetRuntimeToolActivity(run);
@ -10558,19 +10702,13 @@ export class TeamProvisioningService {
if (this.aliveRunByTeam.get(run.teamName) === run.runId) {
this.aliveRunByTeam.delete(run.teamName);
}
this.leadInboxRelayInFlight.delete(run.teamName);
this.relayedLeadInboxMessageIds.delete(run.teamName);
this.pendingCrossTeamFirstReplies.delete(run.teamName);
this.recentCrossTeamLeadDeliveryMessageIds.delete(run.teamName);
this.recentSameTeamNativeFingerprints.delete(run.teamName);
// Clear same-team retry timers
for (const suffix of ['deferred', 'persist']) {
const key = `same-team-${suffix}:${run.teamName}`;
const timer = this.pendingTimeouts.get(key);
if (timer) {
clearTimeout(timer);
this.pendingTimeouts.delete(key);
}
if (!hasNewerTrackedRun) {
this.leadInboxRelayInFlight.delete(run.teamName);
this.relayedLeadInboxMessageIds.delete(run.teamName);
this.pendingCrossTeamFirstReplies.delete(run.teamName);
this.recentCrossTeamLeadDeliveryMessageIds.delete(run.teamName);
this.recentSameTeamNativeFingerprints.delete(run.teamName);
this.clearSameTeamRetryTimers(run.teamName);
}
for (const memberName of run.memberSpawnStatuses.keys()) {
const key = this.getMemberLaunchGraceKey(run, memberName);
@ -10582,17 +10720,21 @@ export class TeamProvisioningService {
}
run.activeCrossTeamReplyHints = [];
run.pendingInboxRelayCandidates = [];
for (const key of Array.from(this.memberInboxRelayInFlight.keys())) {
if (key.startsWith(`${run.teamName}:`)) {
this.memberInboxRelayInFlight.delete(key);
if (!hasNewerTrackedRun) {
for (const key of Array.from(this.memberInboxRelayInFlight.keys())) {
if (key.startsWith(`${run.teamName}:`)) {
this.memberInboxRelayInFlight.delete(key);
}
}
}
for (const key of Array.from(this.relayedMemberInboxMessageIds.keys())) {
if (key.startsWith(`${run.teamName}:`)) {
this.relayedMemberInboxMessageIds.delete(key);
for (const key of Array.from(this.relayedMemberInboxMessageIds.keys())) {
if (key.startsWith(`${run.teamName}:`)) {
this.relayedMemberInboxMessageIds.delete(key);
}
}
this.liveLeadProcessMessages.delete(run.teamName);
} else {
this.pruneLiveLeadMessagesForCleanedRun(run);
}
this.liveLeadProcessMessages.delete(run.teamName);
// Dismiss any pending tool approvals for this run
if (run.pendingApprovals.size > 0) {
for (const requestId of run.pendingApprovals.keys()) {

View file

@ -1,4 +1,12 @@
import { encodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder';
import { extractCwd } from '@main/utils/jsonl';
import {
encodePath,
extractBaseDir,
getProjectsBasePath,
getTeamsBasePath,
} from '@main/utils/pathDecoder';
import { atomicWriteAsync } from '@main/utils/atomicWrite';
import { isLeadMember } from '@shared/utils/leadDetection';
import { createLogger } from '@shared/utils/logger';
import { createReadStream, type Dirent } from 'fs';
import * as fs from 'fs/promises';
@ -15,6 +23,33 @@ const SESSION_DISCOVERY_CACHE_TTL = 30_000;
const TEAM_AFFINITY_SCAN_LINES = 40;
const ROOT_DISCOVERY_CONCURRENCY = 12;
type ProjectEvidenceSource =
| 'projectPath'
| 'projectPathHistory'
| 'leadCwd'
| 'memberCwd'
| 'projectsScan';
interface ProjectPathCandidate {
projectPath: string;
source: Exclude<ProjectEvidenceSource, 'projectsScan'>;
}
interface ProjectDirCandidate {
projectPath: string;
projectDir: string;
projectId: string;
source: ProjectEvidenceSource;
}
interface SessionProjectMatch extends ProjectDirCandidate {
matchedSessionId: string;
}
type ScannedSessionProjectMatch = Omit<SessionProjectMatch, 'projectPath'> & {
projectPath?: string;
};
function trimTrailingSlashes(value: string): string {
let end = value.length;
while (end > 0) {
@ -32,6 +67,17 @@ function isSessionDirectoryName(name: string): boolean {
return name !== 'memory' && !name.startsWith('.');
}
function normalizeProjectPathCandidate(value: unknown): string | null {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
return trimTrailingSlashes(trimmed);
}
function extractTextContent(entry: Record<string, unknown>): string | null {
if (typeof entry.content === 'string') {
return entry.content;
@ -71,6 +117,9 @@ function lineMentionsTeam(text: string, teamName: string): boolean {
return false;
}
return (
normalizedText.includes(`team name: ${normalizedTeam}`) ||
normalizedText.includes(`team name "${normalizedTeam}"`) ||
normalizedText.includes(`team name '${normalizedTeam}'`) ||
normalizedText.includes(`on team "${normalizedTeam}"`) ||
normalizedText.includes(`on team '${normalizedTeam}'`) ||
normalizedText.includes(`team "${normalizedTeam}"`) ||
@ -79,6 +128,28 @@ function lineMentionsTeam(text: string, teamName: string): boolean {
);
}
function entryContainsNestedTeamName(value: unknown, teamName: string, depth: number = 0): boolean {
if (!value || depth > 8 || typeof value !== 'object') {
return false;
}
if (Array.isArray(value)) {
return value.some((item) => entryContainsNestedTeamName(item, teamName, depth + 1));
}
const entry = value as Record<string, unknown>;
if (typeof entry.teamName === 'string' && entry.teamName.trim().toLowerCase() === teamName) {
return true;
}
return Object.entries(entry).some(([key, nested]) => {
if (key === 'teamName') {
return false;
}
return entryContainsNestedTeamName(nested, teamName, depth + 1);
});
}
function collectKnownSessionIds(config: TeamConfig): string[] {
const knownSessionIds = new Set<string>();
const push = (value: unknown): void => {
@ -93,7 +164,8 @@ function collectKnownSessionIds(config: TeamConfig): string[] {
push(config.leadSessionId);
if (Array.isArray(config.sessionHistory)) {
for (const sessionId of config.sessionHistory) {
for (let index = config.sessionHistory.length - 1; index >= 0; index -= 1) {
const sessionId = config.sessionHistory[index];
push(sessionId);
}
}
@ -130,13 +202,39 @@ export class TeamTranscriptProjectResolver {
}
const config = await this.configReader.getConfig(teamName);
if (!config?.projectPath) {
if (!config) {
return null;
}
const { projectDir, projectId } = await this.resolveProjectDirectory(config);
const sessionIds = await this.discoverSessionIds(teamName, projectDir, config);
const value = { projectDir, projectId, config, sessionIds };
const resolution = await this.resolveProjectDirectory(teamName, config);
if (!resolution) {
return null;
}
const resolvedConfig =
resolution.effectiveProjectPath &&
trimTrailingSlashes(resolution.effectiveProjectPath) !==
trimTrailingSlashes(config.projectPath ?? '')
? {
...config,
projectPath: resolution.effectiveProjectPath,
projectPathHistory: this.buildRepairedProjectPathHistory(
config,
resolution.effectiveProjectPath
),
}
: config;
const sessionIds = await this.discoverSessionIds(
teamName,
resolution.projectDir,
resolvedConfig
);
const value = {
projectDir: resolution.projectDir,
projectId: resolution.projectId,
config: resolvedConfig,
sessionIds,
};
this.contextCache.set(teamName, {
value,
expiresAt: Date.now() + SESSION_DISCOVERY_CACHE_TTL,
@ -145,47 +243,391 @@ export class TeamTranscriptProjectResolver {
}
private async resolveProjectDirectory(
teamName: string,
config: TeamConfig
): Promise<{ projectDir: string; projectId: string }> {
const normalizedProjectPath = trimTrailingSlashes(config.projectPath ?? '');
let projectId = encodePath(normalizedProjectPath);
let projectDir = path.join(getProjectsBasePath(), extractBaseDir(projectId));
): Promise<{ projectDir: string; projectId: string; effectiveProjectPath?: string } | null> {
const sessionIds = collectKnownSessionIds(config);
const pathCandidates = this.collectProjectPathCandidates(config);
const currentCandidate = pathCandidates[0] ?? null;
if (sessionIds.length === 0) {
return this.buildFallbackResolution(teamName, pathCandidates);
}
try {
const stat = await fs.stat(projectDir);
if (!stat.isDirectory()) {
throw new Error('not a directory');
}
return { projectDir, projectId };
} catch {
const leadSessionId =
typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0
? config.leadSessionId.trim()
: null;
if (!leadSessionId) {
return { projectDir, projectId };
}
const rankBySessionId = new Map(sessionIds.map((sessionId, index) => [sessionId, index]));
const getMatchRank = (match: { matchedSessionId: string } | null): number =>
match
? (rankBySessionId.get(match.matchedSessionId) ?? Number.POSITIVE_INFINITY)
: Number.POSITIVE_INFINITY;
try {
const projectEntries = await fs.readdir(getProjectsBasePath(), { withFileTypes: true });
for (const entry of projectEntries) {
if (!entry.isDirectory()) continue;
const candidateDir = path.join(getProjectsBasePath(), entry.name);
try {
await fs.access(path.join(candidateDir, `${leadSessionId}.jsonl`));
projectDir = candidateDir;
projectId = entry.name;
break;
} catch {
// not this project
}
}
} catch {
// best-effort fallback
const toResolution = (
match: Pick<ProjectDirCandidate, 'projectDir' | 'projectId'> & { projectPath?: string }
): { projectDir: string; projectId: string; effectiveProjectPath?: string } => ({
projectDir: match.projectDir,
projectId: match.projectId,
...(match.projectPath ? { effectiveProjectPath: match.projectPath } : {}),
});
let currentMatch: SessionProjectMatch | null = null;
if (currentCandidate) {
const resolvedCurrentMatch = await this.findMatchInProjectPathCandidate(
currentCandidate,
sessionIds
);
if (resolvedCurrentMatch && getMatchRank(resolvedCurrentMatch) === 0) {
return toResolution(resolvedCurrentMatch);
}
if (resolvedCurrentMatch) {
currentMatch = resolvedCurrentMatch;
}
}
return { projectDir, projectId };
const configuredMatches =
pathCandidates.length > 1
? await this.findMatchesInProjectPathCandidates(pathCandidates.slice(1), sessionIds)
: [];
const scannedMatches = await this.findMatchesByScanningProjects(sessionIds);
const candidateMatchesByProjectDir = new Map<
string,
SessionProjectMatch | ScannedSessionProjectMatch
>();
for (const match of configuredMatches) {
if (match.projectDir === currentMatch?.projectDir) {
continue;
}
candidateMatchesByProjectDir.set(match.projectDir, match);
}
for (const match of scannedMatches) {
if (match.projectDir === currentMatch?.projectDir) {
continue;
}
if (!candidateMatchesByProjectDir.has(match.projectDir)) {
candidateMatchesByProjectDir.set(match.projectDir, match);
}
}
const alternateMatches = [...candidateMatchesByProjectDir.values()];
const bestAlternateRank = alternateMatches.reduce(
(best, match) => Math.min(best, getMatchRank(match)),
Number.POSITIVE_INFINITY
);
const currentRank = getMatchRank(currentMatch);
if (currentMatch && currentRank <= bestAlternateRank) {
return toResolution(currentMatch);
}
if (bestAlternateRank !== Number.POSITIVE_INFINITY) {
const bestAlternates = alternateMatches.filter(
(match) => getMatchRank(match) === bestAlternateRank
);
if (bestAlternates.length === 1) {
const winner = bestAlternates[0];
if (winner.projectPath) {
await this.persistResolvedProjectPath(teamName, config, winner.projectPath);
}
return toResolution(winner);
}
logger.warn(
`[${teamName}] Transcript project resolution ambiguous across exact-session candidates; keeping current path`
);
return currentMatch
? toResolution(currentMatch)
: this.buildFallbackResolution(teamName, pathCandidates);
}
if (currentMatch) {
return toResolution(currentMatch);
}
return this.buildFallbackResolution(teamName, pathCandidates);
}
private async buildFallbackResolution(
teamName: string,
candidates: readonly ProjectPathCandidate[]
): Promise<{ projectDir: string; projectId: string; effectiveProjectPath?: string } | null> {
let firstResolution: {
projectDir: string;
projectId: string;
effectiveProjectPath?: string;
} | null = null;
let firstExistingResolution: {
projectDir: string;
projectId: string;
effectiveProjectPath?: string;
} | null = null;
for (const candidate of candidates) {
for (const dirCandidate of this.buildProjectDirCandidates(candidate.projectPath)) {
const resolution = {
projectDir: dirCandidate.projectDir,
projectId: dirCandidate.projectId,
effectiveProjectPath: candidate.projectPath,
};
if (!firstResolution) {
firstResolution = resolution;
}
if (!(await this.projectDirExists(dirCandidate.projectDir))) {
continue;
}
if (!firstExistingResolution) {
firstExistingResolution = resolution;
}
const teamRootSessionIds = await this.listTeamRootSessionIds(
dirCandidate.projectDir,
teamName
);
if (teamRootSessionIds.length > 0) {
return resolution;
}
}
}
return firstExistingResolution ?? firstResolution;
}
private collectProjectPathCandidates(config: TeamConfig): ProjectPathCandidate[] {
const candidates: ProjectPathCandidate[] = [];
const seen = new Set<string>();
const push = (value: unknown, source: Exclude<ProjectEvidenceSource, 'projectsScan'>): void => {
const normalized = normalizeProjectPathCandidate(value);
if (!normalized || seen.has(normalized)) {
return;
}
seen.add(normalized);
candidates.push({ projectPath: normalized, source });
};
push(config.projectPath, 'projectPath');
if (Array.isArray(config.projectPathHistory)) {
for (let index = config.projectPathHistory.length - 1; index >= 0; index -= 1) {
push(config.projectPathHistory[index], 'projectPathHistory');
}
}
const leadCwd = (config.members ?? []).find((member) => isLeadMember(member))?.cwd;
push(leadCwd, 'leadCwd');
const distinctMemberCwds = Array.from(
new Set(
(config.members ?? [])
.map((member) => normalizeProjectPathCandidate(member.cwd))
.filter((cwd): cwd is string => Boolean(cwd))
)
);
if (distinctMemberCwds.length === 1) {
push(distinctMemberCwds[0], 'memberCwd');
}
return candidates;
}
private buildProjectDirCandidates(projectPath: string): ProjectDirCandidate[] {
const normalizedProjectPath = trimTrailingSlashes(projectPath);
const projectId = extractBaseDir(encodePath(normalizedProjectPath));
const baseCandidates = [
{ projectDir: path.join(getProjectsBasePath(), projectId), projectId },
...(projectId.includes('_')
? [
{
projectDir: path.join(getProjectsBasePath(), projectId.replace(/_/g, '-')),
projectId: projectId.replace(/_/g, '-'),
},
]
: []),
];
const seen = new Set<string>();
return baseCandidates
.filter((candidate) => {
if (seen.has(candidate.projectDir)) {
return false;
}
seen.add(candidate.projectDir);
return true;
})
.map((candidate) => ({
projectPath: normalizedProjectPath,
projectDir: candidate.projectDir,
projectId: candidate.projectId,
source: 'projectPath' as const,
}));
}
private async findMatchInProjectPathCandidate(
candidate: ProjectPathCandidate,
sessionIds: string[]
): Promise<SessionProjectMatch | null> {
const rankBySessionId = new Map(sessionIds.map((sessionId, index) => [sessionId, index]));
let bestMatch: SessionProjectMatch | null = null;
for (const projectCandidate of this.buildProjectDirCandidates(candidate.projectPath)) {
const matchedSessionId = await this.findMatchingSessionId(
projectCandidate.projectDir,
sessionIds
);
if (!matchedSessionId) {
continue;
}
const match = {
...projectCandidate,
source: candidate.source,
matchedSessionId,
};
const matchRank = rankBySessionId.get(match.matchedSessionId) ?? Number.POSITIVE_INFINITY;
const bestRank = bestMatch
? (rankBySessionId.get(bestMatch.matchedSessionId) ?? Number.POSITIVE_INFINITY)
: Number.POSITIVE_INFINITY;
if (!bestMatch || matchRank < bestRank) {
bestMatch = match;
}
if (matchRank === 0) {
break;
}
}
return bestMatch;
}
private async findMatchesInProjectPathCandidates(
candidates: ProjectPathCandidate[],
sessionIds: string[]
): Promise<SessionProjectMatch[]> {
const matches: SessionProjectMatch[] = [];
const seenProjectDirs = new Set<string>();
for (const candidate of candidates) {
const match = await this.findMatchInProjectPathCandidate(candidate, sessionIds);
if (!match || seenProjectDirs.has(match.projectDir)) {
continue;
}
seenProjectDirs.add(match.projectDir);
matches.push(match);
}
return matches;
}
private async findMatchingSessionId(
projectDir: string,
sessionIds: string[]
): Promise<string | null> {
for (const sessionId of sessionIds) {
try {
const stat = await fs.stat(path.join(projectDir, `${sessionId}.jsonl`));
if (stat.isFile()) {
return sessionId;
}
} catch {
// continue
}
}
return null;
}
private async findMatchesByScanningProjects(
sessionIds: string[]
): Promise<ScannedSessionProjectMatch[]> {
let projectEntries: Dirent[];
try {
projectEntries = await fs.readdir(getProjectsBasePath(), { withFileTypes: true });
} catch {
return [];
}
const directories = projectEntries.filter((entry) => entry.isDirectory());
const matches: ScannedSessionProjectMatch[] = [];
let nextIndex = 0;
const worker = async (): Promise<void> => {
while (nextIndex < directories.length) {
const index = nextIndex++;
const entry = directories[index];
const projectDir = path.join(getProjectsBasePath(), entry.name);
const matchedSessionId = await this.findMatchingSessionId(projectDir, sessionIds);
if (!matchedSessionId) {
continue;
}
const jsonlPath = path.join(projectDir, `${matchedSessionId}.jsonl`);
const cwd = await extractCwd(jsonlPath);
matches.push({
projectPath: cwd ?? undefined,
projectDir,
projectId: entry.name,
source: 'projectsScan',
matchedSessionId,
});
}
};
await Promise.all(
Array.from({ length: Math.min(ROOT_DISCOVERY_CONCURRENCY, directories.length) }, () =>
worker()
)
);
const deduped = new Map<string, ScannedSessionProjectMatch>();
for (const match of matches) {
if (!deduped.has(match.projectDir)) {
deduped.set(match.projectDir, match);
}
}
return [...deduped.values()];
}
private async persistResolvedProjectPath(
teamName: string,
config: TeamConfig,
nextProjectPath: string
): Promise<void> {
const normalizedNextPath = normalizeProjectPathCandidate(nextProjectPath);
if (!normalizedNextPath) {
return;
}
const currentProjectPath = normalizeProjectPathCandidate(config.projectPath);
if (currentProjectPath === normalizedNextPath) {
return;
}
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
try {
const raw = await fs.readFile(configPath, 'utf8');
const parsed = JSON.parse(raw) as Record<string, unknown>;
const rawProjectPath =
normalizeProjectPathCandidate(parsed.projectPath) ?? currentProjectPath ?? null;
parsed.projectPath = normalizedNextPath;
const history: string[] = [];
const seen = new Set<string>();
const pushHistory = (value: unknown): void => {
const normalized = normalizeProjectPathCandidate(value);
if (!normalized || normalized === normalizedNextPath || seen.has(normalized)) {
return;
}
seen.add(normalized);
history.push(normalized);
};
if (Array.isArray(parsed.projectPathHistory)) {
for (const value of parsed.projectPathHistory) {
pushHistory(value);
}
}
pushHistory(rawProjectPath);
parsed.projectPathHistory = history.slice(-500);
await atomicWriteAsync(configPath, JSON.stringify(parsed, null, 2));
logger.info(
`[${teamName}] Repaired transcript projectPath via exact session match: ${normalizedNextPath}`
);
} catch (error) {
logger.warn(
`[${teamName}] Failed to persist repaired transcript projectPath: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
private async discoverSessionIds(
@ -199,9 +641,58 @@ export class TeamTranscriptProjectResolver {
this.listSessionDirIds(projectDir),
]);
return Array.from(new Set([...knownSessionIds, ...teamRootSessionIds, ...sessionDirIds])).sort(
(left, right) => left.localeCompare(right)
);
const orderedSessionIds: string[] = [];
const seen = new Set<string>();
const push = (sessionId: string): void => {
if (seen.has(sessionId)) {
return;
}
seen.add(sessionId);
orderedSessionIds.push(sessionId);
};
for (const sessionId of knownSessionIds) {
push(sessionId);
}
for (const sessionId of [...teamRootSessionIds, ...sessionDirIds].sort((left, right) =>
left.localeCompare(right)
)) {
push(sessionId);
}
return orderedSessionIds;
}
private buildRepairedProjectPathHistory(config: TeamConfig, nextProjectPath: string): string[] {
const normalizedNextPath = normalizeProjectPathCandidate(nextProjectPath);
const history: string[] = [];
const seen = new Set<string>();
const pushHistory = (value: unknown): void => {
const normalized = normalizeProjectPathCandidate(value);
if (!normalized || normalized === normalizedNextPath || seen.has(normalized)) {
return;
}
seen.add(normalized);
history.push(normalized);
};
if (Array.isArray(config.projectPathHistory)) {
for (const value of config.projectPathHistory) {
pushHistory(value);
}
}
pushHistory(config.projectPath);
return history.slice(-500);
}
private async projectDirExists(projectDir: string): Promise<boolean> {
try {
const stat = await fs.stat(projectDir);
return stat.isDirectory();
} catch {
return false;
}
}
private async listSessionDirIds(projectDir: string): Promise<string[]> {
@ -272,6 +763,9 @@ export class TeamTranscriptProjectResolver {
if (directTeamName === normalizedTeam) {
return true;
}
if (entryContainsNestedTeamName(entry, normalizedTeam)) {
return true;
}
const textContent = extractTextContent(entry);
if (textContent && lineMentionsTeam(textContent, normalizedTeam)) {

View file

@ -145,7 +145,7 @@ export class TeammateToolTracker {
const state = this.stateByTeam.get(teamName);
if (!state?.enabled || state.epoch !== expectedEpoch) return;
const attributedFiles = await this.logsFinder.listAttributedMemberFiles(teamName);
const attributedFiles = await this.logsFinder.listAttributedSubagentFiles(teamName);
const currentState = this.stateByTeam.get(teamName);
if (!currentState?.enabled || currentState.epoch !== expectedEpoch) return;

View file

@ -16,6 +16,12 @@ export { BoardTaskActivityService } from './taskLogs/activity/BoardTaskActivityS
export { BoardTaskExactLogDetailService } from './taskLogs/exact/BoardTaskExactLogDetailService';
export { BoardTaskExactLogsService } from './taskLogs/exact/BoardTaskExactLogsService';
export { BoardTaskLogStreamService } from './taskLogs/stream/BoardTaskLogStreamService';
export {
AutoResumeService,
clearAutoResumeService,
getAutoResumeService,
initializeAutoResumeService,
} from './AutoResumeService';
export { TeamAttachmentStore } from './TeamAttachmentStore';
export { TeamBackupService } from './TeamBackupService';
export { TeamConfigReader } from './TeamConfigReader';

View 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;
}

View file

@ -1,5 +1,5 @@
/**
* Standalone (non-Electron) entry point for Claude Agent Teams UI.
* Standalone (non-Electron) entry point for Agent Teams UI.
*
* Runs the HTTP server + API without Electron, suitable for Docker
* or any headless/remote environment. The renderer is served as

View file

@ -1,5 +1,5 @@
/**
* Chunk and visualization types for Claude Agent Teams UI.
* Chunk and visualization types for Agent Teams UI.
*
* This module contains:
* - Chunk types (UserChunk, AIChunk, SystemChunk, CompactChunk)

View file

@ -1,5 +1,5 @@
/**
* Domain/business entity types for Claude Agent Teams UI.
* Domain/business entity types for Agent Teams UI.
*
* These types represent the application's domain model:
* - Projects and sessions

View file

@ -1,5 +1,5 @@
/**
* Parsed message types and type guards for Claude Agent Teams UI.
* Parsed message types and type guards for Agent Teams UI.
*
* ParsedMessage is the application's internal representation after parsing
* raw JSONL entries. This module also contains type guards for classifying

View file

@ -1583,7 +1583,8 @@ const electronAPI: ElectronAPI = {
invokeIpcWithResult<McpCatalogItem | null>(MCP_REGISTRY_GET_BY_ID, registryId),
getInstalled: (projectPath?: string) =>
invokeIpcWithResult<InstalledMcpEntry[]>(MCP_REGISTRY_GET_INSTALLED, projectPath),
diagnose: () => invokeIpcWithResult<McpServerDiagnostic[]>(MCP_REGISTRY_DIAGNOSE),
diagnose: (projectPath?: string) =>
invokeIpcWithResult<McpServerDiagnostic[]>(MCP_REGISTRY_DIAGNOSE, projectPath),
install: (request: McpInstallRequest) =>
invokeIpcWithResult<OperationResult>(MCP_REGISTRY_INSTALL, request),
installCustom: (request: McpCustomInstallRequest) =>
@ -1627,8 +1628,8 @@ const electronAPI: ElectronAPI = {
list: () => invokeIpcWithResult<ApiKeyEntry[]>(API_KEYS_LIST),
save: (request: ApiKeySaveRequest) => invokeIpcWithResult<ApiKeyEntry>(API_KEYS_SAVE, request),
delete: (id: string) => invokeIpcWithResult<void>(API_KEYS_DELETE, id),
lookup: (envVarNames: string[]) =>
invokeIpcWithResult<ApiKeyLookupResult[]>(API_KEYS_LOOKUP, envVarNames),
lookup: (envVarNames: string[], projectPath?: string) =>
invokeIpcWithResult<ApiKeyLookupResult[]>(API_KEYS_LOOKUP, envVarNames, projectPath),
getStorageStatus: () => invokeIpcWithResult<ApiKeyStorageStatus>(API_KEYS_STORAGE_STATUS),
},

View file

@ -33,6 +33,7 @@ import { useStore } from '@renderer/store';
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
import { formatBytes } from '@renderer/utils/formatters';
import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze';
import { isMultimodelRuntimeStatus } from '@renderer/utils/multimodelProviderVisibility';
import {
AlertTriangle,
CheckCircle,
@ -321,7 +322,11 @@ function formatRuntimeAuthSummary(
cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']>,
visibleProviders: readonly CliProviderStatus[]
): string | null {
if (cliStatus.flavor === 'agent_teams_orchestrator' && visibleProviders.length > 0) {
if (isMultimodelRuntimeStatus(cliStatus)) {
if (visibleProviders.length === 0) {
return null;
}
if (
visibleProviders.every(
(provider) => provider.statusMessage === 'Checking...' && !provider.authenticated
@ -351,7 +356,7 @@ function isCheckingMultimodelStatus(
visibleProviders: readonly CliProviderStatus[]
): boolean {
return (
cliStatus.flavor === 'agent_teams_orchestrator' &&
isMultimodelRuntimeStatus(cliStatus) &&
visibleProviders.length > 0 &&
visibleProviders.every(
(provider) => provider.statusMessage === 'Checking...' && !provider.authenticated
@ -359,6 +364,12 @@ function isCheckingMultimodelStatus(
);
}
function hasVisibleAuthenticatedMultimodelProvider(
visibleProviders: readonly CliProviderStatus[]
): boolean {
return visibleProviders.some((provider) => provider.authenticated);
}
const InstalledBanner = ({
cliStatus,
cliStatusLoading,
@ -382,6 +393,7 @@ const InstalledBanner = ({
() => filterMainScreenCliProviders(cliStatus.providers),
[cliStatus.providers]
);
const canOpenExtensions = cliStatus.installed;
const runtimeLabel = formatRuntimeLabel(cliStatus);
const runtimeAuthSummary = formatRuntimeAuthSummary(cliStatus, visibleProviders);
@ -466,8 +478,8 @@ const InstalledBanner = ({
disabled={isBusy || cliStatusLoading || multimodelBusy}
/>
</div>
{/* Extensions button — only when installed + authenticated */}
{cliStatus.authLoggedIn && (
{/* Extensions button — available whenever the runtime is installed */}
{canOpenExtensions && (
<button
onClick={openExtensionsTab}
className="flex shrink-0 items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/5"
@ -844,6 +856,16 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
if (isCheckingMultimodelStatus(cliStatus, visibleCliProviders)) return 'info';
if (cliStatus.authStatusChecking) return 'info';
if (!cliStatus.installed) return 'error';
if (isMultimodelRuntimeStatus(cliStatus) && visibleCliProviders.length === 0) {
return 'warning';
}
if (
isMultimodelRuntimeStatus(cliStatus) &&
visibleCliProviders.length > 0 &&
!hasVisibleAuthenticatedMultimodelProvider(visibleCliProviders)
) {
return 'warning';
}
if (cliStatus.installed && !cliStatus.authLoggedIn) return 'warning';
if (cliStatus.updateAvailable) return 'info';
return 'success';

View file

@ -7,6 +7,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '@renderer/api';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { Tabs, TabsContent, TabsList } from '@renderer/components/ui/tabs';
import {
@ -18,8 +20,25 @@ import {
import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState';
import { useStore } from '@renderer/store';
import {
formatCliExtensionCapabilityStatus,
getVisibleMultimodelProviders,
isMultimodelRuntimeStatus,
} from '@renderer/utils/multimodelProviderVisibility';
import { resolveProjectPathById } from '@renderer/utils/projectLookup';
import { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react';
import { getExtensionActionDisableReason } from '@shared/utils/extensionNormalizers';
import { getCliProviderExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
import {
AlertTriangle,
BookOpen,
Info,
Key,
Loader2,
Plus,
Puzzle,
RefreshCw,
Server,
} from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { ApiKeysPanel } from './apikeys/ApiKeysPanel';
@ -29,6 +48,55 @@ import { PluginsPanel } from './plugins/PluginsPanel';
import { SkillsPanel } from './skills/SkillsPanel';
import { ExtensionsSubTabTrigger } from './ExtensionsSubTabTrigger';
import type { CliProviderStatus } from '@shared/types';
const ProviderCapabilityCardSkeleton = ({
providerId,
displayName,
}: {
providerId: 'anthropic' | 'codex' | 'gemini';
displayName: string;
}): React.JSX.Element => (
<div className="rounded-md border border-border bg-surface-raised px-3 py-2">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<p className="inline-flex items-center gap-2 text-sm font-medium text-text">
<ProviderBrandLogo providerId={providerId} className="size-4 shrink-0" />
<span>{displayName}</span>
</p>
<div className="mt-1 flex items-center gap-2 text-[11px] text-text-muted">
<Loader2 className="size-3 animate-spin" />
<span>Checking provider status...</span>
</div>
</div>
<Badge variant="outline" className="shrink-0 text-text-muted">
Loading...
</Badge>
</div>
<div className="mt-2 flex flex-wrap gap-1.5">
{Array.from({ length: 3 }, (_, index) => (
<span
key={index}
className="h-7 w-28 animate-pulse rounded-md border border-border bg-surface"
/>
))}
</div>
</div>
);
function isProviderCapabilityCardLoading(
provider: CliProviderStatus,
providerLoading: boolean
): boolean {
return (
providerLoading ||
(!provider.authenticated &&
provider.statusMessage === 'Checking...' &&
provider.models.length === 0 &&
provider.backend == null)
);
}
export const ExtensionStoreView = (): React.JSX.Element => {
const tabId = useTabIdOptional();
const {
@ -44,6 +112,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
skillsLoading,
cliStatus,
cliStatusLoading,
cliProviderStatusLoading,
openDashboard,
sessions,
projects,
@ -62,6 +131,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
skillsLoading: s.skillsLoading,
cliStatus: s.cliStatus,
cliStatusLoading: s.cliStatusLoading,
cliProviderStatusLoading: s.cliProviderStatusLoading,
openDashboard: s.openDashboard,
sessions: s.sessions,
projects: s.projects,
@ -92,21 +162,21 @@ export const ExtensionStoreView = (): React.JSX.Element => {
label: 'Plugins',
icon: Puzzle,
description:
'Small add-ons for Claude. They give the app extra features and integrations you can install when you need them.',
'Small add-ons for the runtime. In multimodel mode they currently apply to Anthropic sessions when supported. Broader provider support is in development.',
},
{
value: 'mcp-servers' as const,
label: 'MCP Servers',
icon: Server,
description:
'Connections to outside tools and apps. They let Claude read data or do actions beyond this app.',
'Connections to outside tools and apps. They let the runtime read data or do actions beyond this app.',
},
{
value: 'skills' as const,
label: 'Skills',
icon: BookOpen,
description:
'Ready-made instructions for common jobs. They help Claude do specific tasks better and more consistently.',
'Ready-made instructions for common jobs. They help the runtime handle repeatable tasks more consistently.',
},
{
value: 'api-keys' as const,
@ -163,15 +233,34 @@ export const ExtensionStoreView = (): React.JSX.Element => {
const isRefreshing =
cliStatusLoading || apiKeysLoading || pluginCatalogLoading || mcpBrowseLoading || skillsLoading;
const mcpMutationDisableReason = useMemo(
() =>
getExtensionActionDisableReason({
isInstalled: false,
cliStatus,
cliStatusLoading,
section: 'mcp',
}),
[cliStatus, cliStatusLoading]
);
const cliStatusBanner = useMemo(() => {
if (cliStatusLoading || cliStatus === null) {
const providers = cliStatus?.providers ?? [];
const visibleProviders = getVisibleMultimodelProviders(providers);
const isMultimodel = isMultimodelRuntimeStatus(cliStatus);
const shouldShowMultimodelProviderCards =
isMultimodel && visibleProviders.length > 0 && cliStatus !== null;
if ((cliStatusLoading || cliStatus === null) && !shouldShowMultimodelProviderCards) {
return (
<div className="bg-surface/70 mx-4 mt-3 flex items-start gap-3 rounded-md border border-border px-4 py-3">
<Info className="mt-0.5 size-4 shrink-0 text-text-secondary" />
<div>
<p className="text-sm font-medium text-text">Checking Claude CLI availability</p>
<p className="text-sm font-medium text-text">
Checking extensions runtime availability
</p>
<p className="mt-0.5 text-xs text-text-muted">
Extensions need Claude CLI to install plugins, run MCP servers, and validate auth.
Extensions need the configured runtime to manage plugins, MCP servers, skills, and
provider connections.
</p>
</div>
</div>
@ -186,13 +275,13 @@ export const ExtensionStoreView = (): React.JSX.Element => {
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-amber-300">
{cliLaunchIssue
? 'Claude CLI was found but failed to start'
: 'Claude CLI is not available'}
? 'The configured runtime was found but failed to start'
: 'The configured runtime is not available'}
</p>
<p className="mt-0.5 text-xs text-text-muted">
{cliLaunchIssue
? 'Plugin installs are disabled until Claude CLI passes its startup health check. Open the Dashboard to repair or reinstall it.'
: 'Plugin installs are disabled until Claude CLI is installed. Open the Dashboard to install it and retry.'}
? 'Extensions are disabled until the runtime passes its startup health check. Open the Dashboard to repair or reinstall it.'
: 'Extensions are disabled until the runtime is installed. Open the Dashboard to install it and retry.'}
</p>
{cliLaunchIssue && cliStatus.launchError && (
<p className="mt-2 break-all font-mono text-[11px] text-text-muted">
@ -207,7 +296,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
);
}
if (!cliStatus.authLoggedIn) {
if (!isMultimodel && !cliStatus.authLoggedIn) {
return (
<div className="mx-4 mt-3 flex items-start gap-3 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
@ -226,6 +315,97 @@ export const ExtensionStoreView = (): React.JSX.Element => {
);
}
if (isMultimodel) {
return (
<div className="bg-surface/70 mx-4 mt-3 rounded-md border border-border px-4 py-3">
<div className="flex items-start gap-3">
<Info className="mt-0.5 size-4 shrink-0 text-text-secondary" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-text">Multimodel runtime capabilities</p>
<p className="mt-0.5 text-xs text-text-muted">
Provider support can differ by section. Plugins are shown only where the runtime
explicitly declares support.
</p>
</div>
</div>
{visibleProviders.length > 0 && (
<div className="mt-3 grid gap-2 md:grid-cols-2">
{visibleProviders.map((provider) => {
const providerLoading = cliProviderStatusLoading[provider.providerId] === true;
if (isProviderCapabilityCardLoading(provider, providerLoading)) {
return (
<ProviderCapabilityCardSkeleton
key={provider.providerId}
providerId={provider.providerId}
displayName={provider.displayName}
/>
);
}
const statusTone = provider.authenticated
? 'border-emerald-500/30 bg-emerald-500/5 text-emerald-300'
: provider.supported
? 'border-amber-500/30 bg-amber-500/5 text-amber-300'
: 'border-border bg-surface-raised text-text-muted';
const statusLabel = provider.authenticated
? 'Connected'
: provider.supported
? 'Needs setup'
: 'Unsupported';
const extensionCapabilities = getCliProviderExtensionCapabilities(provider);
const pluginStatus = extensionCapabilities.plugins.status;
return (
<div
key={provider.providerId}
className={`rounded-md border px-3 py-2 ${statusTone}`}
>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<p className="inline-flex items-center gap-2 text-sm font-medium">
<ProviderBrandLogo
providerId={provider.providerId}
className="size-4 shrink-0"
/>
<span>{provider.displayName}</span>
</p>
<p className="truncate text-[11px] text-text-muted">
{provider.statusMessage ??
provider.backend?.label ??
'Ready to configure'}
</p>
</div>
<Badge variant="outline" className="shrink-0">
{statusLabel}
</Badge>
</div>
<div className="mt-2 flex flex-wrap gap-1.5 text-[11px]">
<Badge
variant={pluginStatus === 'unsupported' ? 'outline' : 'secondary'}
className={
pluginStatus === 'unsupported'
? 'border-amber-500/30 bg-amber-500/10 text-amber-300'
: undefined
}
>
Plugins: {formatCliExtensionCapabilityStatus(pluginStatus)}
</Badge>
<Badge variant="secondary">
MCP: {formatCliExtensionCapabilityStatus(extensionCapabilities.mcp.status)}
</Badge>
<Badge variant="secondary">
Skills: {extensionCapabilities.skills.ownership}
</Badge>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
return (
<div className="mx-4 mt-3 flex items-start gap-3 rounded-md border border-emerald-500/30 bg-emerald-500/5 px-4 py-3">
<Info className="mt-0.5 size-4 shrink-0 text-emerald-300" />
@ -238,7 +418,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
</div>
</div>
);
}, [cliStatus, cliStatusLoading, openDashboard]);
}, [cliProviderStatusLoading, cliStatus, cliStatusLoading, openDashboard]);
// Browser mode guard
if (!api.plugins && !api.mcpRegistry && !api.skills) {
@ -280,7 +460,8 @@ export const ExtensionStoreView = (): React.JSX.Element => {
{!cliInstalled && (
<div className="mb-4 flex items-center gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-400">
<AlertTriangle className="size-4 shrink-0" />
Claude CLI is required to install or uninstall extensions. Install it from Settings.
The configured runtime is required to install or uninstall extensions. Install or
repair it from the Dashboard.
</div>
)}
{/* Active sessions warning */}
@ -309,15 +490,25 @@ export const ExtensionStoreView = (): React.JSX.Element => {
))}
</TabsList>
{tabState.activeSubTab === 'mcp-servers' && (
<Button
variant="outline"
size="sm"
onClick={() => setCustomMcpDialogOpen(true)}
className="mb-1 whitespace-nowrap"
>
<Plus className="mr-1 size-3.5" />
Add Custom
</Button>
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={mcpMutationDisableReason ? 0 : -1}>
<Button
variant="outline"
size="sm"
onClick={() => setCustomMcpDialogOpen(true)}
className="mb-1 whitespace-nowrap"
disabled={Boolean(mcpMutationDisableReason)}
>
<Plus className="mr-1 size-3.5" />
Add Custom
</Button>
</span>
</TooltipTrigger>
{mcpMutationDisableReason && (
<TooltipContent>{mcpMutationDisableReason}</TooltipContent>
)}
</Tooltip>
)}
</div>
@ -352,7 +543,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
</TabsContent>
<TabsContent value="api-keys" className="mt-0 pt-4">
<ApiKeysPanel />
<ApiKeysPanel projectPath={projectPath} projectLabel={projectLabel} />
</TabsContent>
<TabsContent value="skills" className="mt-0 pt-4">

View file

@ -66,6 +66,12 @@ export const ApiKeyCard = ({ apiKey, onEdit }: ApiKeyCardProps): React.JSX.Eleme
</Badge>
</div>
{apiKey.scope === 'project' && apiKey.projectPath && (
<p className="truncate text-xs text-text-muted" title={apiKey.projectPath}>
{apiKey.projectPath}
</p>
)}
{/* Env var name */}
<div className="flex items-center gap-1.5">
<code className="rounded bg-surface-raised px-1.5 py-0.5 text-xs text-blue-400">

View file

@ -32,6 +32,8 @@ const ENV_KEY_RE = /^[A-Z_][A-Z0-9_]{0,100}$/i;
interface ApiKeyFormDialogProps {
open: boolean;
editingKey: ApiKeyEntry | null;
currentProjectPath: string | null;
currentProjectLabel: string | null;
onClose: () => void;
}
@ -45,6 +47,8 @@ const SCOPE_OPTIONS: { value: Scope; label: string }[] = [
export const ApiKeyFormDialog = ({
open,
editingKey,
currentProjectPath,
currentProjectLabel,
onClose,
}: ApiKeyFormDialogProps): React.JSX.Element => {
const saveApiKey = useStore((s) => s.saveApiKey);
@ -57,6 +61,14 @@ export const ApiKeyFormDialog = ({
const [scope, setScope] = useState<Scope>('user');
const [error, setError] = useState<string | null>(null);
const [envVarError, setEnvVarError] = useState<string | null>(null);
const editingProjectPath =
editingKey?.scope === 'project' ? (editingKey.projectPath ?? null) : null;
const effectiveProjectPath = editingProjectPath ?? currentProjectPath;
const effectiveProjectLabel =
effectiveProjectPath && effectiveProjectPath === currentProjectPath
? currentProjectLabel
: effectiveProjectPath;
const canUseProjectScope = Boolean(effectiveProjectPath);
// Reset form when dialog opens/closes or editing key changes
useEffect(() => {
@ -77,6 +89,12 @@ export const ApiKeyFormDialog = ({
}
}, [open, editingKey]);
useEffect(() => {
if (open && scope === 'project' && !canUseProjectScope) {
setScope('user');
}
}, [canUseProjectScope, open, scope]);
const validateEnvVar = (v: string) => {
if (!v.trim()) {
setEnvVarError(null);
@ -109,6 +127,10 @@ export const ApiKeyFormDialog = ({
setError('Key value is required');
return;
}
if (scope === 'project' && !effectiveProjectPath) {
setError('Project-scoped API keys require an active project');
return;
}
try {
await saveApiKey({
@ -117,6 +139,7 @@ export const ApiKeyFormDialog = ({
envVarName: envVarName.trim(),
value,
scope,
projectPath: scope === 'project' ? (effectiveProjectPath ?? undefined) : undefined,
});
onClose();
} catch (err) {
@ -125,7 +148,13 @@ export const ApiKeyFormDialog = ({
};
const isEdit = editingKey !== null;
const canSubmit = name.trim() && envVarName.trim() && value && !envVarError && !apiKeySaving;
const canSubmit =
name.trim() &&
envVarName.trim() &&
value &&
!envVarError &&
!apiKeySaving &&
(scope !== 'project' || canUseProjectScope);
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
@ -212,12 +241,23 @@ export const ApiKeyFormDialog = ({
</SelectTrigger>
<SelectContent>
{SCOPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
<SelectItem
key={opt.value}
value={opt.value}
disabled={opt.value === 'project' && !canUseProjectScope}
>
{opt.value === 'project'
? effectiveProjectPath
? `Project: ${effectiveProjectLabel}`
: 'Project unavailable'
: opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{scope === 'project' && effectiveProjectPath && (
<p className="text-xs text-text-muted">Bound to {effectiveProjectPath}</p>
)}
</div>
{/* Error display */}

View file

@ -2,7 +2,7 @@
* ApiKeysPanel grid of saved API keys with add button and empty state.
*/
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
@ -15,16 +15,26 @@ import { ApiKeyFormDialog } from './ApiKeyFormDialog';
import type { ApiKeyEntry } from '@shared/types/extensions';
export const ApiKeysPanel = (): React.JSX.Element => {
const { apiKeys, apiKeysLoading, apiKeysError, storageStatus, fetchStorageStatus } = useStore(
useShallow((s) => ({
apiKeys: s.apiKeys,
apiKeysLoading: s.apiKeysLoading,
apiKeysError: s.apiKeysError,
storageStatus: s.apiKeyStorageStatus,
fetchStorageStatus: s.fetchApiKeyStorageStatus,
}))
);
interface ApiKeysPanelProps {
projectPath: string | null;
projectLabel: string | null;
}
export const ApiKeysPanel = ({
projectPath,
projectLabel,
}: ApiKeysPanelProps): React.JSX.Element => {
const { apiKeys, apiKeysLoading, apiKeysError, storageStatus, fetchStorageStatus, cliStatus } =
useStore(
useShallow((s) => ({
apiKeys: s.apiKeys,
apiKeysLoading: s.apiKeysLoading,
apiKeysError: s.apiKeysError,
storageStatus: s.apiKeyStorageStatus,
fetchStorageStatus: s.fetchApiKeyStorageStatus,
cliStatus: s.cliStatus,
}))
);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingKey, setEditingKey] = useState<ApiKeyEntry | null>(null);
@ -49,9 +59,82 @@ export const ApiKeysPanel = (): React.JSX.Element => {
};
const isOsKeychain = storageStatus?.encryptionMethod === 'os-keychain';
const providerKeyCards = useMemo(() => {
if (!cliStatus?.providers?.length) {
return [];
}
return (
[
{
providerId: 'anthropic',
label: 'Anthropic runtime',
envVar: 'ANTHROPIC_API_KEY',
},
{
providerId: 'codex',
label: 'Codex runtime',
envVar: 'OPENAI_API_KEY',
},
] as const
).flatMap((item) => {
const provider = cliStatus.providers.find((entry) => entry.providerId === item.providerId);
if (!provider) {
return [];
}
return [
{
...item,
authenticated: provider.authenticated,
apiKeyConfigured: provider.connection?.apiKeyConfigured ?? false,
sourceLabel: provider.connection?.apiKeySourceLabel ?? null,
statusMessage: provider.statusMessage ?? null,
},
];
});
}, [cliStatus]);
return (
<div className="flex flex-col gap-4">
{providerKeyCards.length > 0 && (
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{providerKeyCards.map((provider) => (
<div
key={provider.providerId}
className="bg-surface-raised/30 rounded-lg border border-border p-4"
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-medium text-text">{provider.label}</p>
<p className="mt-0.5 font-mono text-[11px] text-text-muted">{provider.envVar}</p>
</div>
<span
className={`rounded-full px-2 py-0.5 text-[11px] ${
provider.authenticated
? 'bg-emerald-500/10 text-emerald-300'
: provider.apiKeyConfigured
? 'bg-blue-500/10 text-blue-300'
: 'bg-amber-500/10 text-amber-300'
}`}
>
{provider.authenticated
? 'Connected'
: provider.apiKeyConfigured
? 'Key configured'
: 'Key missing'}
</span>
</div>
<p className="mt-2 text-xs text-text-muted">
{provider.sourceLabel
? `Current source: ${provider.sourceLabel}.`
: 'No stored or environment key detected for this provider.'}
{provider.statusMessage ? ` ${provider.statusMessage}` : ''}
</p>
</div>
))}
</div>
)}
{/* Header row */}
<div className="flex items-center justify-between">
<p className="flex items-center gap-1.5 text-sm text-text-secondary">
@ -138,7 +221,13 @@ export const ApiKeysPanel = (): React.JSX.Element => {
)}
{/* Form dialog */}
<ApiKeyFormDialog open={dialogOpen} editingKey={editingKey} onClose={handleDialogClose} />
<ApiKeyFormDialog
open={dialogOpen}
editingKey={editingKey}
currentProjectPath={projectPath}
currentProjectLabel={projectLabel}
onClose={handleDialogClose}
/>
</div>
);
};

View file

@ -24,6 +24,7 @@ interface InstallButtonProps {
isInstalled: boolean;
onInstall: () => void;
onUninstall: () => void;
section?: 'plugins' | 'mcp';
disabled?: boolean;
size?: 'sm' | 'default';
errorMessage?: string;
@ -34,6 +35,7 @@ export const InstallButton = ({
isInstalled,
onInstall,
onUninstall,
section = 'plugins',
disabled,
size = 'sm',
errorMessage,
@ -48,6 +50,7 @@ export const InstallButton = ({
isInstalled,
cliStatus,
cliStatusLoading,
section,
});
const isDisabled = disabled || Boolean(disableReason);
const [lastAction, setLastAction] = useState<'install' | 'uninstall' | null>(null);

View file

@ -3,7 +3,7 @@
* Supports stdio (npm package) and HTTP/SSE transports.
*/
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
@ -24,6 +24,13 @@ import {
SelectValue,
} from '@renderer/components/ui/select';
import { useStore } from '@renderer/store';
import { getExtensionActionDisableReason } from '@shared/utils/extensionNormalizers';
import {
getDefaultMcpSharedScope,
getMcpScopeLabel,
isProjectScopedMcpScope,
isSharedMcpScope,
} from '@shared/utils/mcpScopes';
import { Plus, Server, Trash2 } from 'lucide-react';
import type {
@ -42,13 +49,7 @@ interface CustomMcpServerDialogProps {
type TransportMode = 'stdio' | 'http';
type HttpTransport = 'streamable-http' | 'sse' | 'http';
type Scope = 'local' | 'user' | 'project';
const SCOPE_OPTIONS: { value: Scope; label: string }[] = [
{ value: 'user', label: 'User (global)' },
{ value: 'project', label: 'Project' },
{ value: 'local', label: 'Local' },
];
type Scope = 'local' | 'user' | 'project' | 'global';
const HTTP_TRANSPORT_OPTIONS: { value: HttpTransport; label: string }[] = [
{ value: 'streamable-http', label: 'Streamable HTTP' },
@ -67,11 +68,19 @@ export const CustomMcpServerDialog = ({
projectPath,
}: CustomMcpServerDialogProps): React.JSX.Element => {
const installCustomMcpServer = useStore((s) => s.installCustomMcpServer);
const cliStatus = useStore((s) => s.cliStatus);
const cliStatusLoading = useStore((s) => s.cliStatusLoading);
const defaultSharedScope = getDefaultMcpSharedScope(cliStatus?.flavor);
const scopeOptions: { value: Scope; label: string }[] = [
{ value: defaultSharedScope, label: getMcpScopeLabel(defaultSharedScope, cliStatus?.flavor) },
{ value: 'project', label: 'Project' },
{ value: 'local', label: 'Local' },
];
// Form state
const [serverName, setServerName] = useState('');
const [transportMode, setTransportMode] = useState<TransportMode>('stdio');
const [scope, setScope] = useState<Scope>('user');
const [scope, setScope] = useState<Scope>(defaultSharedScope);
// Stdio fields
const [npmPackage, setNpmPackage] = useState('');
@ -86,13 +95,31 @@ export const CustomMcpServerDialog = ({
const [envVars, setEnvVars] = useState<EnvEntry[]>([]);
const [error, setError] = useState<string | null>(null);
const [installing, setInstalling] = useState(false);
const autoFilledValuesRef = useRef<Record<string, string>>({});
const wasOpenRef = useRef(false);
const previousDefaultSharedScopeRef = useRef<Scope>(defaultSharedScope);
const envVarLookupNames = envVars
.map((entry) => entry.key.trim())
.filter(Boolean)
.sort()
.join('\0');
const apiKeyLookupProjectPath = isProjectScopedMcpScope(scope)
? (projectPath ?? undefined)
: undefined;
const mutationDisableReason = getExtensionActionDisableReason({
isInstalled: false,
cliStatus,
cliStatusLoading,
section: 'mcp',
});
// Reset on open
useEffect(() => {
if (open) {
const justOpened = open && !wasOpenRef.current;
if (justOpened) {
setServerName('');
setTransportMode('stdio');
setScope('user');
setScope(defaultSharedScope);
setNpmPackage('');
setNpmVersion('');
setHttpUrl('');
@ -101,39 +128,98 @@ export const CustomMcpServerDialog = ({
setEnvVars([]);
setError(null);
setInstalling(false);
autoFilledValuesRef.current = {};
}
}, [open]);
wasOpenRef.current = open;
if (!open) {
previousDefaultSharedScopeRef.current = defaultSharedScope;
}
}, [defaultSharedScope, open]);
useEffect(() => {
if (open && scope !== 'user' && !projectPath) {
setScope('user');
if (!open) {
previousDefaultSharedScopeRef.current = defaultSharedScope;
return;
}
}, [open, projectPath, scope]);
const previousDefaultSharedScope = previousDefaultSharedScopeRef.current;
if (
previousDefaultSharedScope !== defaultSharedScope &&
scope === previousDefaultSharedScope &&
isSharedMcpScope(scope)
) {
setScope(defaultSharedScope);
}
previousDefaultSharedScopeRef.current = defaultSharedScope;
}, [defaultSharedScope, open, scope]);
useEffect(() => {
if (open && isProjectScopedMcpScope(scope) && !projectPath) {
setScope(defaultSharedScope);
}
}, [defaultSharedScope, open, projectPath, scope]);
// Auto-fill env vars from saved API keys
useEffect(() => {
if (!open || envVars.length === 0 || !api.apiKeys) return;
const envVarNames = envVars.map((e) => e.key).filter(Boolean);
const envVarNames = envVars.map((e) => e.key.trim()).filter(Boolean);
if (envVarNames.length === 0) return;
void api.apiKeys.lookup(envVarNames).then(
void api.apiKeys.lookup(envVarNames, apiKeyLookupProjectPath).then(
(results) => {
if (results.length === 0) return;
const lookup = new Map(results.map((r) => [r.envVarName, r.value]));
setEnvVars((prev) =>
prev.map((e) => (lookup.has(e.key) && !e.value ? { ...e, value: lookup.get(e.key)! } : e))
const previousAutoFilledValues = autoFilledValuesRef.current;
const nextAutoFilledValues = Object.fromEntries(
results.map((result) => [result.envVarName, result.value])
);
setEnvVars((prev) => {
let changed = false;
const next = prev.map((entry) => {
const envVarName = entry.key.trim();
if (!envVarName) {
return entry;
}
const previousValue = previousAutoFilledValues[envVarName];
const nextValue = nextAutoFilledValues[envVarName];
if (!nextValue) {
if (previousValue && entry.value === previousValue) {
changed = true;
return { ...entry, value: '' };
}
return entry;
}
if (!entry.value || entry.value === previousValue) {
if (entry.value !== nextValue) {
changed = true;
return { ...entry, value: nextValue };
}
}
return entry;
});
return changed ? next : prev;
});
autoFilledValuesRef.current = nextAutoFilledValues;
},
() => {
// Silently fail
}
);
}, [open, envVars.length]); // eslint-disable-line react-hooks/exhaustive-deps
}, [apiKeyLookupProjectPath, envVarLookupNames, open]); // eslint-disable-line react-hooks/exhaustive-deps
const handleInstall = async () => {
setError(null);
if (mutationDisableReason) {
setError(mutationDisableReason);
return;
}
if (!serverName.trim()) {
setError('Server name is required');
return;
@ -177,7 +263,7 @@ export const CustomMcpServerDialog = ({
const request: McpCustomInstallRequest = {
serverName,
scope,
projectPath: scope !== 'user' ? (projectPath ?? undefined) : undefined,
projectPath: isProjectScopedMcpScope(scope) ? (projectPath ?? undefined) : undefined,
installSpec,
envValues,
headers: headers.filter((h) => h.key.trim() && h.value.trim()),
@ -207,7 +293,8 @@ export const CustomMcpServerDialog = ({
const canSubmit =
serverName.trim() &&
(transportMode === 'stdio' ? npmPackage.trim() : httpUrl.trim()) &&
!(scope !== 'user' && !projectPath) &&
!(isProjectScopedMcpScope(scope) && !projectPath) &&
!mutationDisableReason &&
!installing;
return (
@ -382,11 +469,11 @@ export const CustomMcpServerDialog = ({
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCOPE_OPTIONS.map((opt) => (
{scopeOptions.map((opt) => (
<SelectItem
key={opt.value}
value={opt.value}
disabled={opt.value !== 'user' && !projectPath}
disabled={isProjectScopedMcpScope(opt.value) && !projectPath}
>
{opt.label}
</SelectItem>
@ -436,6 +523,11 @@ export const CustomMcpServerDialog = ({
</div>
{/* Error */}
{mutationDisableReason && (
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-300">
{mutationDisableReason}
</div>
)}
{error && (
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
{error}

View file

@ -16,6 +16,7 @@ import {
getMcpOperationKey,
sanitizeMcpServerName,
} from '@shared/utils/extensionNormalizers';
import { getDefaultMcpSharedScope } from '@shared/utils/mcpScopes';
import { Clock, Cloud, Globe, KeyRound, Lock, Monitor, Star, Tag, Wrench } from 'lucide-react';
import { Github as GithubIcon } from 'lucide-react';
@ -47,7 +48,9 @@ export const McpServerCard = ({
diagnosticsLoading,
onClick,
}: McpServerCardProps): React.JSX.Element => {
const operationKey = getMcpOperationKey(server.id, 'user');
const cliStatus = useStore((s) => s.cliStatus);
const sharedScope = getDefaultMcpSharedScope(cliStatus?.flavor);
const operationKey = getMcpOperationKey(server.id, sharedScope);
const installProgress = useStore((s) => s.mcpInstallProgress[operationKey] ?? 'idle');
const installMcpServer = useStore((s) => s.installMcpServer);
const uninstallMcpServer = useStore((s) => s.uninstallMcpServer);
@ -67,13 +70,13 @@ export const McpServerCard = ({
server.requiresAuth ||
(server.authHeaders?.length ?? 0) > 0;
const defaultServerName = sanitizeMcpServerName(server.name);
const userInstallEntry =
normalizedInstalledEntries.find((entry) => entry.scope === 'user') ?? null;
const sharedInstallEntry =
normalizedInstalledEntries.find((entry) => entry.scope === sharedScope) ?? null;
const installSummaryLabel = getMcpInstallationSummaryLabel(normalizedInstalledEntries);
const supportsDirectInstalledAction =
isInstalled &&
normalizedInstalledEntries.length === 1 &&
userInstallEntry?.name === defaultServerName &&
sharedInstallEntry?.name === defaultServerName &&
!requiresConfiguration;
const shouldShowDirectInstallButton =
canAutoInstall && (!isInstalled ? !requiresConfiguration : supportsDirectInstalledAction);
@ -258,17 +261,22 @@ export const McpServerCard = ({
<InstallButton
state={installProgress}
isInstalled={isInstalled}
section="mcp"
onInstall={() =>
installMcpServer({
registryId: server.id,
serverName: defaultServerName,
scope: 'user',
scope: sharedScope,
envValues: {},
headers: [],
})
}
onUninstall={() =>
uninstallMcpServer(server.id, userInstallEntry?.name ?? defaultServerName, 'user')
uninstallMcpServer(
server.id,
sharedInstallEntry?.name ?? defaultServerName,
sharedScope
)
}
size="sm"
errorMessage={installError}

View file

@ -3,7 +3,7 @@
* Uses Radix UI Kit for all form elements.
*/
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { api } from '@renderer/api';
import { Badge } from '@renderer/components/ui/badge';
@ -31,6 +31,12 @@ import {
getPreferredMcpInstallationEntry,
sanitizeMcpServerName,
} from '@shared/utils/extensionNormalizers';
import {
getDefaultMcpSharedScope,
getMcpScopeLabel,
isProjectScopedMcpScope,
isSharedMcpScope,
} from '@shared/utils/mcpScopes';
import { ExternalLink, Lock, Plus, Star, Trash2, Wrench } from 'lucide-react';
import { InstallButton } from '../common/InstallButton';
@ -55,13 +61,7 @@ interface McpServerDetailDialogProps {
onClose: () => void;
}
type Scope = 'local' | 'user' | 'project';
const SCOPE_OPTIONS: { value: Scope; label: string }[] = [
{ value: 'user', label: 'User (global)' },
{ value: 'project', label: 'Project' },
{ value: 'local', label: 'Local' },
];
type Scope = 'local' | 'user' | 'project' | 'global';
export const McpServerDetailDialog = ({
server,
@ -74,8 +74,10 @@ export const McpServerDetailDialog = ({
open,
onClose,
}: McpServerDetailDialogProps): React.JSX.Element => {
const [scope, setScope] = useState<Scope>('user');
const operationKey = server ? getMcpOperationKey(server.id, scope) : null;
const cliStatus = useStore((s) => s.cliStatus);
const defaultSharedScope = getDefaultMcpSharedScope(cliStatus?.flavor);
const [scope, setScope] = useState<Scope>(defaultSharedScope);
const operationKey = server ? getMcpOperationKey(server.id, scope, projectPath) : null;
const installProgress = useStore(
(s) => (operationKey ? s.mcpInstallProgress[operationKey] : undefined) ?? 'idle'
);
@ -91,15 +93,36 @@ export const McpServerDetailDialog = ({
const [headers, setHeaders] = useState<McpHeaderDef[]>([]);
const [imgError, setImgError] = useState(false);
const [autoFilledFields, setAutoFilledFields] = useState<Set<string>>(new Set());
const autoFilledValuesRef = useRef<Record<string, string>>({});
const previousDefaultSharedScopeRef = useRef<Scope>(defaultSharedScope);
const normalizedInstalledEntries = installedEntries.length
? installedEntries
: installedEntry
? [installedEntry]
: [];
const scopeOptions: { value: Scope; label: string }[] = [
{ value: defaultSharedScope, label: getMcpScopeLabel(defaultSharedScope, cliStatus?.flavor) },
...(defaultSharedScope !== 'user' &&
normalizedInstalledEntries.some((entry) => entry.scope === 'user')
? [{ value: 'user' as const, label: getMcpScopeLabel('user', cliStatus?.flavor) }]
: []),
{ value: 'project', label: 'Project' },
{ value: 'local', label: 'Local' },
];
const preferredInstalledEntry = getPreferredMcpInstallationEntry(normalizedInstalledEntries);
const selectedInstalledEntry =
normalizedInstalledEntries.find((entry) => entry.scope === scope) ?? null;
const installSummaryLabel = getMcpInstallationSummaryLabel(normalizedInstalledEntries);
const envVarLookupNames =
server?.envVars
.map((entry) => entry.name)
.sort()
.join('\0') ?? '';
const statusSectionLabel =
cliStatus?.flavor === 'agent_teams_orchestrator' ? 'Runtime Status' : 'Claude Status';
const apiKeyLookupProjectPath = isProjectScopedMcpScope(scope)
? (projectPath ?? undefined)
: undefined;
// Initialize form when dialog opens or server changes
useEffect(() => {
@ -120,47 +143,82 @@ export const McpServerDetailDialog = ({
}))
);
setServerName(preferredInstalledEntry?.name ?? sanitizeMcpServerName(server.name));
setScope(preferredInstalledEntry?.scope ?? 'user');
setScope((preferredInstalledEntry?.scope as Scope | undefined) ?? defaultSharedScope);
setImgError(false);
setAutoFilledFields(new Set());
autoFilledValuesRef.current = {};
}, [open, preferredInstalledEntry?.name, preferredInstalledEntry?.scope, server?.id]);
useEffect(() => {
if (!server || !open) {
if (!open) {
previousDefaultSharedScopeRef.current = defaultSharedScope;
return;
}
setServerName(selectedInstalledEntry?.name ?? sanitizeMcpServerName(server.name));
}, [open, scope, selectedInstalledEntry?.name, server]);
const previousDefaultSharedScope = previousDefaultSharedScopeRef.current;
if (
previousDefaultSharedScope !== defaultSharedScope &&
!preferredInstalledEntry &&
scope === previousDefaultSharedScope &&
isSharedMcpScope(scope)
) {
setScope(defaultSharedScope);
}
previousDefaultSharedScopeRef.current = defaultSharedScope;
}, [defaultSharedScope, open, preferredInstalledEntry, scope]);
useEffect(() => {
if (open && scope !== 'user' && !projectPath) {
setScope('user');
if (!server || !open || !selectedInstalledEntry) {
return;
}
}, [open, projectPath, scope]);
setServerName(selectedInstalledEntry.name);
}, [open, selectedInstalledEntry, server]);
useEffect(() => {
if (open && isProjectScopedMcpScope(scope) && !projectPath) {
setScope(defaultSharedScope);
}
}, [defaultSharedScope, open, projectPath, scope]);
// Auto-fill env values from saved API keys
useEffect(() => {
if (!server || !open || server.envVars.length === 0 || !api.apiKeys) return;
const envVarNames = server.envVars.map((e) => e.name);
void api.apiKeys.lookup(envVarNames).then(
void api.apiKeys.lookup(envVarNames, apiKeyLookupProjectPath).then(
(results) => {
if (results.length === 0) return;
const filled = new Set<string>();
const values: Record<string, string> = {};
const previousAutoFilledValues = autoFilledValuesRef.current;
const nextAutoFilledValues: Record<string, string> = {};
for (const r of results) {
values[r.envVarName] = r.value;
filled.add(r.envVarName);
nextAutoFilledValues[r.envVarName] = r.value;
}
setEnvValues((prev) => ({ ...prev, ...values }));
setAutoFilledFields(filled);
setEnvValues((prev) => {
const next = { ...prev };
for (const [envVarName, previousValue] of Object.entries(previousAutoFilledValues)) {
if (!(envVarName in nextAutoFilledValues) && next[envVarName] === previousValue) {
next[envVarName] = '';
}
}
for (const [envVarName, nextValue] of Object.entries(nextAutoFilledValues)) {
if (!next[envVarName] || next[envVarName] === previousAutoFilledValues[envVarName]) {
next[envVarName] = nextValue;
}
}
return next;
});
setAutoFilledFields(new Set(Object.keys(nextAutoFilledValues)));
autoFilledValuesRef.current = nextAutoFilledValues;
},
() => {
// Silently fail — auto-fill is supplementary
}
);
}, [server?.id, open]); // eslint-disable-line react-hooks/exhaustive-deps
}, [apiKeyLookupProjectPath, envVarLookupNames, open, server?.id]); // eslint-disable-line react-hooks/exhaustive-deps
if (!server) return <></>;
@ -181,7 +239,7 @@ export const McpServerDetailDialog = ({
const isInstalledForScope = selectedInstalledEntry !== null;
const uninstallServerName = selectedInstalledEntry?.name ?? serverName;
const uninstallScope = selectedInstalledEntry?.scope ?? scope;
const scopeRequiresProjectPath = scope !== 'user' && !projectPath;
const scopeRequiresProjectPath = isProjectScopedMcpScope(scope) && !projectPath;
const installDisabled =
!serverName.trim() ||
missingRequiredEnvVars ||
@ -201,7 +259,7 @@ export const McpServerDetailDialog = ({
registryId: server.id,
serverName,
scope,
projectPath: scope !== 'user' ? (projectPath ?? undefined) : undefined,
projectPath: isProjectScopedMcpScope(scope) ? (projectPath ?? undefined) : undefined,
envValues,
headers,
});
@ -212,7 +270,7 @@ export const McpServerDetailDialog = ({
server.id,
uninstallServerName,
uninstallScope,
uninstallScope !== 'user' ? (projectPath ?? undefined) : undefined
isProjectScopedMcpScope(uninstallScope) ? (projectPath ?? undefined) : undefined
);
};
@ -353,7 +411,7 @@ export const McpServerDetailDialog = ({
{isInstalledForScope && (
<div className="space-y-2 rounded-md border border-border bg-surface-raised px-4 py-3">
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium text-text">Claude Status</span>
<span className="text-sm font-medium text-text">{statusSectionLabel}</span>
{diagnosticsLoading && !diagnostic ? (
<Badge
className="border-border bg-surface-raised text-text-muted"
@ -415,11 +473,11 @@ export const McpServerDetailDialog = ({
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCOPE_OPTIONS.map((opt) => (
{scopeOptions.map((opt) => (
<SelectItem
key={opt.value}
value={opt.value}
disabled={opt.value !== 'user' && !projectPath}
disabled={isProjectScopedMcpScope(opt.value) && !projectPath}
>
{opt.label}
</SelectItem>
@ -528,6 +586,7 @@ export const McpServerDetailDialog = ({
<InstallButton
state={installProgress}
isInstalled={isInstalledForScope}
section="mcp"
onInstall={handleInstall}
onUninstall={handleUninstall}
disabled={installDisabled}

View file

@ -17,6 +17,8 @@ import { useStore } from '@renderer/store';
import { formatRelativeTime } from '@renderer/utils/formatters';
import { CLI_NOT_FOUND_MARKER } from '@shared/constants/cli';
import {
getMcpDiagnosticKey,
getMcpProjectStateKey,
getPreferredMcpInstallationEntry,
sanitizeMcpServerName,
} from '@shared/utils/extensionNormalizers';
@ -78,19 +80,27 @@ export const McpServersPanel = ({
selectedMcpServerId,
setSelectedMcpServerId,
}: McpServersPanelProps): React.JSX.Element => {
const projectStateKey = getMcpProjectStateKey(projectPath);
const {
browseCatalog,
browseNextCursor,
browseLoading,
browseError,
mcpBrowse,
installedServers,
installedServersByProjectPath,
installedServersFallback,
fetchMcpGitHubStars,
mcpDiagnostics,
mcpDiagnosticsLoading,
mcpDiagnosticsError,
mcpDiagnosticsLastCheckedAt,
mcpDiagnosticsByProjectPath,
mcpDiagnosticsFallback,
mcpDiagnosticsLoadingByProjectPath,
mcpDiagnosticsLoadingFallback,
mcpDiagnosticsErrorByProjectPath,
mcpDiagnosticsErrorFallback,
mcpDiagnosticsLastCheckedAtByProjectPath,
mcpDiagnosticsLastCheckedAtFallback,
runMcpDiagnostics,
cliStatus,
cliStatusLoading,
} = useStore(
useShallow((s) => ({
browseCatalog: s.mcpBrowseCatalog,
@ -98,15 +108,34 @@ export const McpServersPanel = ({
browseLoading: s.mcpBrowseLoading,
browseError: s.mcpBrowseError,
mcpBrowse: s.mcpBrowse,
installedServers: s.mcpInstalledServers,
installedServersByProjectPath: s.mcpInstalledServersByProjectPath,
installedServersFallback: s.mcpInstalledServers,
fetchMcpGitHubStars: s.fetchMcpGitHubStars,
mcpDiagnostics: s.mcpDiagnostics,
mcpDiagnosticsLoading: s.mcpDiagnosticsLoading,
mcpDiagnosticsError: s.mcpDiagnosticsError,
mcpDiagnosticsLastCheckedAt: s.mcpDiagnosticsLastCheckedAt,
mcpDiagnosticsByProjectPath: s.mcpDiagnosticsByProjectPath,
mcpDiagnosticsFallback: s.mcpDiagnostics,
mcpDiagnosticsLoadingByProjectPath: s.mcpDiagnosticsLoadingByProjectPath,
mcpDiagnosticsLoadingFallback: s.mcpDiagnosticsLoading,
mcpDiagnosticsErrorByProjectPath: s.mcpDiagnosticsErrorByProjectPath,
mcpDiagnosticsErrorFallback: s.mcpDiagnosticsError,
mcpDiagnosticsLastCheckedAtByProjectPath: s.mcpDiagnosticsLastCheckedAtByProjectPath,
mcpDiagnosticsLastCheckedAtFallback: s.mcpDiagnosticsLastCheckedAt,
runMcpDiagnostics: s.runMcpDiagnostics,
cliStatus: s.cliStatus,
cliStatusLoading: s.cliStatusLoading,
}))
);
const installedServers =
installedServersByProjectPath?.[projectStateKey] ?? installedServersFallback ?? [];
const mcpDiagnostics =
mcpDiagnosticsByProjectPath?.[projectStateKey] ?? mcpDiagnosticsFallback ?? {};
const mcpDiagnosticsLoading =
mcpDiagnosticsLoadingByProjectPath?.[projectStateKey] ?? mcpDiagnosticsLoadingFallback ?? false;
const mcpDiagnosticsError =
mcpDiagnosticsErrorByProjectPath?.[projectStateKey] ?? mcpDiagnosticsErrorFallback ?? null;
const mcpDiagnosticsLastCheckedAt =
mcpDiagnosticsLastCheckedAtByProjectPath?.[projectStateKey] ??
mcpDiagnosticsLastCheckedAtFallback ??
null;
const [mcpSort, setMcpSort] = useState<McpSortValue>('name-asc');
@ -117,9 +146,31 @@ export const McpServersPanel = ({
}
}, [browseCatalog.length, browseError, browseLoading, mcpBrowse]);
const diagnosticsDisableReason = useMemo(() => {
if (cliStatusLoading) {
return 'Checking runtime status...';
}
if (cliStatus === null || typeof cliStatus === 'undefined') {
return 'Checking runtime availability...';
}
if (cliStatus?.installed === false) {
if (cliStatus.binaryPath && cliStatus.launchError) {
return 'The configured runtime was found but failed to start. Open the Dashboard to repair or reinstall it.';
}
return 'The configured runtime is required. Install or repair it from the Dashboard.';
}
return null;
}, [cliStatus, cliStatusLoading]);
useEffect(() => {
void runMcpDiagnostics();
}, [runMcpDiagnostics]);
if (diagnosticsDisableReason) {
return;
}
void runMcpDiagnostics(projectPath ?? undefined);
}, [diagnosticsDisableReason, projectPath, runMcpDiagnostics]);
// Fetch GitHub stars after catalog loads (fire-and-forget)
useEffect(() => {
@ -162,7 +213,12 @@ export const McpServersPanel = ({
const getDiagnostic = (server: McpCatalogItem): McpServerDiagnostic | null => {
const installedEntry = getInstalledEntry(server);
return installedEntry ? (mcpDiagnostics[installedEntry.name] ?? null) : null;
return installedEntry
? (mcpDiagnostics[getMcpDiagnosticKey(installedEntry.name, installedEntry.scope)] ??
mcpDiagnostics[getMcpDiagnosticKey(installedEntry.name)] ??
mcpDiagnostics[installedEntry.name] ??
null)
: null;
};
const allDiagnostics = useMemo(
@ -185,6 +241,8 @@ export const McpServersPanel = ({
// Sort displayed servers
const displayServers = useMemo(() => sortMcpServers(rawServers, mcpSort), [rawServers, mcpSort]);
const runtimeLabel =
cliStatus?.flavor === 'agent_teams_orchestrator' ? 'multimodel runtime' : 'Claude CLI';
// Find selected server (search in both lists to avoid losing selection during search toggle)
const selectedServer = useMemo(() => {
@ -205,24 +263,21 @@ export const McpServersPanel = ({
<p className="text-sm font-medium text-text">MCP Health Status</p>
<p className="text-xs text-text-muted">
{mcpDiagnosticsLoading ? (
<>
Checking installed MCP servers via Claude CLI (<code>claude mcp list</code>) ...
</>
<>Checking installed MCP servers via {runtimeLabel} ...</>
) : diagnosticsDisableReason ? (
diagnosticsDisableReason
) : mcpDiagnosticsLastCheckedAt ? (
`Last checked ${formatRelativeTime(new Date(mcpDiagnosticsLastCheckedAt).toISOString())}`
) : (
<>
Run diagnostics (<code>claude mcp list</code>) to verify installed MCP
connectivity.
</>
<>Run diagnostics from this page to verify installed MCP connectivity.</>
)}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => void runMcpDiagnostics()}
disabled={mcpDiagnosticsLoading}
onClick={() => void runMcpDiagnostics(projectPath ?? undefined)}
disabled={mcpDiagnosticsLoading || Boolean(diagnosticsDisableReason)}
className="whitespace-nowrap"
>
<RefreshCw
@ -235,7 +290,7 @@ export const McpServersPanel = ({
{(mcpDiagnosticsLoading || allDiagnostics.length > 0) && (
<div className="mt-4 border-t border-black/10 pt-4 dark:border-white/10">
<div className="mb-3 flex items-center justify-between gap-3">
<p className="text-sm font-medium text-text">Claude MCP List Results</p>
<p className="text-sm font-medium text-text">Runtime MCP Diagnostics</p>
{allDiagnostics.length > 0 && (
<span className="text-xs text-text-muted">{allDiagnostics.length} servers</span>
)}
@ -244,11 +299,18 @@ export const McpServersPanel = ({
<div className="mcp-diagnostics-list max-h-[18.5rem] space-y-2 overflow-y-auto pr-1">
{allDiagnostics.map((diagnostic) => (
<div
key={diagnostic.name}
key={getMcpDiagnosticKey(diagnostic.name, diagnostic.scope)}
className="flex items-start justify-between gap-3 rounded-md border border-black/10 px-3 py-2 dark:border-white/10"
>
<div className="min-w-0 flex-1">
<p className="text-sm text-text">{diagnostic.name}</p>
<div className="flex items-center gap-2">
<p className="text-sm text-text">{diagnostic.name}</p>
{diagnostic.scope && (
<span className="rounded-full border border-border bg-surface-raised px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-text-muted">
{diagnostic.scope}
</span>
)}
</div>
<p
className="truncate font-mono text-[11px] text-text-muted"
title={diagnostic.target}
@ -263,7 +325,7 @@ export const McpServersPanel = ({
))}
</div>
) : (
<p className="text-xs text-text-muted">Waiting for `claude mcp list` results...</p>
<p className="text-xs text-text-muted">Waiting for diagnostics results...</p>
)}
</div>
)}
@ -347,10 +409,15 @@ export const McpServersPanel = ({
<div className="flex items-start gap-3 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
<div>
<p className="text-sm font-medium text-amber-300">Claude CLI not installed</p>
<p className="text-sm font-medium text-amber-300">
{cliStatus?.flavor === 'agent_teams_orchestrator'
? 'Configured runtime not available'
: 'Claude CLI not installed'}
</p>
<p className="mt-0.5 text-xs text-text-muted">
MCP health checks require Claude CLI. Go to the Dashboard to install it
automatically.
{cliStatus?.flavor === 'agent_teams_orchestrator'
? 'MCP health checks require the configured runtime. Go to the Dashboard to install or repair it.'
: 'MCP health checks require Claude CLI. Go to the Dashboard to install or repair it.'}
</p>
</div>
</div>

View file

@ -119,6 +119,7 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J
<InstallButton
state={installProgress}
isInstalled={isUserInstalled}
section="plugins"
onInstall={() => installPlugin({ pluginId: plugin.pluginId, scope: 'user' })}
onUninstall={() => uninstallPlugin(plugin.pluginId, 'user')}
size="sm"

View file

@ -91,7 +91,9 @@ export const PluginDetailDialog = ({
}
}, [projectScopeAvailable, scope]);
const operationKey = plugin ? getPluginOperationKey(plugin.pluginId, scope) : null;
const operationKey = plugin
? getPluginOperationKey(plugin.pluginId, scope, scope !== 'user' ? projectPath : undefined)
: null;
const installProgress = useStore(
(s) => (operationKey ? s.pluginInstallProgress[operationKey] : undefined) ?? 'idle'
);
@ -195,6 +197,7 @@ export const PluginDetailDialog = ({
<InstallButton
state={installProgress}
isInstalled={isInstalledForScope}
section="plugins"
onInstall={() =>
installPlugin({
pluginId: plugin.pluginId,

View file

@ -17,6 +17,7 @@ import {
} from '@renderer/components/ui/select';
import { useStore } from '@renderer/store';
import { inferCapabilities, normalizeCategory } from '@shared/utils/extensionNormalizers';
import { getCliProviderExtensionCapability } from '@shared/utils/providerExtensionCapabilities';
import { ArrowUpDown, Filter, Puzzle, Search } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
@ -125,11 +126,12 @@ export const PluginsPanel = ({
hasActiveFilters,
setPluginSort,
}: PluginsPanelProps): React.JSX.Element => {
const { catalog, loading, error } = useStore(
const { catalog, loading, error, cliStatus } = useStore(
useShallow((s) => ({
catalog: s.pluginCatalog,
loading: s.pluginCatalogLoading,
error: s.pluginCatalogError,
cliStatus: s.cliStatus,
}))
);
@ -175,9 +177,27 @@ export const PluginsPanel = ({
}
return counts.size;
}, [catalog]);
return (
<div className="flex flex-col gap-4">
{cliStatus?.flavor === 'agent_teams_orchestrator' &&
(() => {
const codexProvider = cliStatus.providers.find(
(provider) => provider.providerId === 'codex'
);
if (!codexProvider) return null;
const capability = getCliProviderExtensionCapability(codexProvider, 'plugins');
if (capability.status === 'supported') {
return null;
}
return (
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-300">
In the multimodel runtime, plugins currently apply only to Anthropic sessions. Broader
plugin support across providers is in development.
{capability.reason ? ` ${capability.reason}` : ''}
</div>
);
})()}
{/* Search + Sort + Installed only row */}
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="flex-1">

View file

@ -23,11 +23,14 @@ import {
DialogTitle,
} from '@renderer/components/ui/dialog';
import { useStore } from '@renderer/store';
import { AlertTriangle, ExternalLink, FolderOpen, Pencil, Trash2 } from 'lucide-react';
import { formatSkillRootKind, getSkillAudienceLabel } from '@shared/utils/skillRoots';
import { AlertTriangle, ExternalLink, FolderOpen, Info, Pencil, Trash2 } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { resolveSkillProjectPath } from './skillProjectUtils';
import type { SkillValidationIssue } from '@shared/types';
interface SkillDetailDialogProps {
skillId: string | null;
open: boolean;
@ -80,10 +83,7 @@ export const SkillDetailDialog = ({
const effectiveProjectPath = item
? resolveSkillProjectPath(item.scope, projectPath, item.projectRoot)
: (projectPath ?? undefined);
function formatRootKind(rootKind: 'claude' | 'cursor' | 'agents'): string {
return `.${rootKind}`;
}
const issuesTone = item?.issues.length ? getIssuesTone(item.issues) : null;
function formatScopeLabel(scope: 'user' | 'project'): string {
return scope === 'project' ? 'This project only' : 'Your personal skills';
@ -91,8 +91,29 @@ export const SkillDetailDialog = ({
function formatInvocationLabel(invocationMode: 'auto' | 'manual-only'): string {
return invocationMode === 'manual-only'
? 'Claude will only use this when you explicitly ask for it.'
: 'Claude can pick this automatically when it matches the task.';
? 'Only runs when you explicitly ask for it.'
: 'Runs automatically when it matches the task.';
}
function getIssuesTone(issues: SkillValidationIssue[]): {
className: string;
title: string;
Icon: typeof AlertTriangle;
} {
const informationalOnly = issues.every((issue) => issue.severity === 'info');
if (informationalOnly) {
return {
className: 'border-blue-500/30 bg-blue-500/5',
title: 'This skill includes bundled scripts',
Icon: Info,
};
}
return {
className: 'border-amber-500/30 bg-amber-500/5',
title: 'Review this skill carefully before using it',
Icon: AlertTriangle,
};
}
async function handleDelete(): Promise<void> {
@ -159,7 +180,8 @@ export const SkillDetailDialog = ({
)}
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{formatScopeLabel(item.scope)}</Badge>
<Badge variant="outline">Stored in {formatRootKind(item.rootKind)}</Badge>
<Badge variant="outline">Stored in {formatSkillRootKind(item.rootKind)}</Badge>
<Badge variant="outline">{getSkillAudienceLabel(item.rootKind)}</Badge>
<Badge variant="secondary">
{item.invocationMode === 'manual-only' ? 'Manual use' : 'Auto use'}
</Badge>
@ -169,16 +191,30 @@ export const SkillDetailDialog = ({
</div>
{item.issues.length > 0 && (
<div className="space-y-2 rounded-md border border-amber-500/30 bg-amber-500/5 p-4">
<p className="text-sm font-medium text-amber-700 dark:text-amber-300">
Review this skill carefully before using it
<div className={`space-y-2 rounded-md border p-4 ${issuesTone?.className ?? ''}`}>
<p
className={`text-sm font-medium ${
issuesTone?.Icon === Info
? 'text-blue-700 dark:text-blue-300'
: 'text-amber-700 dark:text-amber-300'
}`}
>
{issuesTone?.title}
</p>
{item.issues.map((issue, index) => (
<div
key={`${issue.code}-${index}`}
className="flex gap-2 text-sm text-amber-700 dark:text-amber-300"
className={`flex gap-2 text-sm ${
issue.severity === 'info'
? 'text-blue-700 dark:text-blue-300'
: 'text-amber-700 dark:text-amber-300'
}`}
>
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
{issue.severity === 'info' ? (
<Info className="mt-0.5 size-4 shrink-0" />
) : (
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
)}
<span>{issue.message}</span>
</div>
))}
@ -194,7 +230,7 @@ export const SkillDetailDialog = ({
</div>
<div className="space-y-1">
<p className="text-xs font-medium uppercase tracking-wide text-text-muted">
How Claude uses it
How it is used
</p>
<p className="text-sm text-text">{formatInvocationLabel(item.invocationMode)}</p>
</div>

View file

@ -23,6 +23,7 @@ import {
import { Textarea } from '@renderer/components/ui/textarea';
import { useMarkdownScrollSync } from '@renderer/hooks/useMarkdownScrollSync';
import { useStore } from '@renderer/store';
import { SKILL_ROOT_DEFINITIONS } from '@shared/utils/skillRoots';
import { FileSearch, RotateCcw, X } from 'lucide-react';
import { SkillCodeEditor } from './SkillCodeEditor';
@ -41,6 +42,7 @@ import type {
SkillDetail,
SkillInvocationMode,
SkillReviewPreview,
SkillRootKind,
} from '@shared/types/extensions';
type EditorMode = 'create' | 'edit';
@ -50,6 +52,7 @@ interface SkillEditorDialogProps {
mode: EditorMode;
projectPath: string | null;
projectLabel: string | null;
allowCodexRootKind: boolean;
detail: SkillDetail | null;
onClose: () => void;
onSaved: (skillId: string | null) => void;
@ -68,6 +71,7 @@ export const SkillEditorDialog = ({
mode,
projectPath,
projectLabel,
allowCodexRootKind,
detail,
onClose,
onSaved,
@ -79,7 +83,7 @@ export const SkillEditorDialog = ({
const applySkillUpsert = useStore((s) => s.applySkillUpsert);
const [scope, setScope] = useState<'user' | 'project'>('user');
const [rootKind, setRootKind] = useState<'claude' | 'cursor' | 'agents'>('claude');
const [rootKind, setRootKind] = useState<SkillRootKind>('claude');
const [folderName, setFolderName] = useState('');
const [name, setName] = useState('');
const [description, setDescription] = useState('');
@ -218,7 +222,7 @@ export const SkillEditorDialog = ({
setReviewLoading(false);
setSaveLoading(false);
setMutationError(null);
}, [detail, mode, open, projectPath]);
}, [allowCodexRootKind, detail, mode, open, projectPath]);
useEffect(() => {
if (open) {
@ -238,6 +242,12 @@ export const SkillEditorDialog = ({
}
}, [mode, open, projectPath, scope]);
useEffect(() => {
if (open && mode === 'create' && rootKind === 'codex' && !allowCodexRootKind) {
setRootKind('claude');
}
}, [allowCodexRootKind, mode, open, rootKind]);
useEffect(() => {
rawContentRef.current = rawContent;
}, [rawContent]);
@ -289,6 +299,14 @@ export const SkillEditorDialog = ({
);
const canUseProjectScope = Boolean(projectPath);
const visibleRootDefinitions = useMemo(
() =>
SKILL_ROOT_DEFINITIONS.filter(
(definition) =>
definition.rootKind !== 'codex' || allowCodexRootKind || detail?.item.rootKind === 'codex'
),
[allowCodexRootKind, detail?.item.rootKind]
);
const instructionsLocked = manualRawEdit || customMarkdownDetected;
const title = mode === 'create' ? 'Create skill' : 'Edit skill';
const descriptionText =
@ -427,18 +445,19 @@ export const SkillEditorDialog = ({
<Label htmlFor="skill-root">Where to store it</Label>
<Select
value={rootKind}
onValueChange={(value) =>
setRootKind(value as 'claude' | 'cursor' | 'agents')
}
onValueChange={(value) => setRootKind(value as SkillRootKind)}
disabled={mode === 'edit'}
>
<SelectTrigger id="skill-root">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="claude">.claude</SelectItem>
<SelectItem value="cursor">.cursor</SelectItem>
<SelectItem value="agents">.agents</SelectItem>
{visibleRootDefinitions.map((definition) => (
<SelectItem key={definition.rootKind} value={definition.rootKind}>
{definition.directoryName}
{definition.audience === 'codex' ? ' - Codex only' : ' - Shared'}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
@ -463,7 +482,7 @@ export const SkillEditorDialog = ({
</div>
<div className="space-y-2">
<Label htmlFor="skill-invocation">How Claude should use it</Label>
<Label htmlFor="skill-invocation">How it should be used</Label>
<Select
value={invocationMode}
onValueChange={(value) => {
@ -476,7 +495,7 @@ export const SkillEditorDialog = ({
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Claude can use it automatically</SelectItem>
<SelectItem value="auto">Can be used automatically</SelectItem>
<SelectItem value="manual-only">Only when you ask for it</SelectItem>
</SelectContent>
</Select>
@ -556,7 +575,7 @@ export const SkillEditorDialog = ({
<div className="grid gap-3">
<div className="space-y-2">
<Label htmlFor="skill-when-to-use">When Claude should reach for this</Label>
<Label htmlFor="skill-when-to-use">When to reach for this</Label>
<Textarea
id="skill-when-to-use"
value={whenToUse}
@ -572,7 +591,7 @@ export const SkillEditorDialog = ({
</div>
<div className="space-y-2">
<Label htmlFor="skill-steps">Main steps Claude should follow</Label>
<Label htmlFor="skill-steps">Main steps to follow</Label>
<Textarea
id="skill-steps"
value={steps}
@ -647,7 +666,7 @@ export const SkillEditorDialog = ({
<div>
<p className="font-medium text-text">References</p>
<p className="mt-1 text-xs text-text-muted">
Add supporting docs, links, or examples that Claude can look at.
Add supporting docs, links, or examples the runtime can look at.
</p>
</div>
</label>

View file

@ -20,14 +20,15 @@ import {
SelectValue,
} from '@renderer/components/ui/select';
import { useStore } from '@renderer/store';
import { SKILL_ROOT_DEFINITIONS } from '@shared/utils/skillRoots';
import { FileSearch, FolderOpen, X } from 'lucide-react';
import { getSuggestedSkillFolderNameFromPath } from './skillFolderNameUtils';
import { SkillReviewDialog } from './SkillReviewDialog';
import { resolveSkillProjectPath } from './skillProjectUtils';
import { SkillReviewDialog } from './SkillReviewDialog';
import { validateSkillFolderName, validateSkillImportSourceDir } from './skillValidationUtils';
import type { SkillReviewPreview } from '@shared/types/extensions';
import type { SkillReviewPreview, SkillRootKind } from '@shared/types/extensions';
function getFriendlyImportError(message: string): string {
if (message.includes('valid skill file')) {
@ -55,6 +56,7 @@ interface SkillImportDialogProps {
open: boolean;
projectPath: string | null;
projectLabel: string | null;
allowCodexRootKind: boolean;
onClose: () => void;
onImported: (skillId: string | null) => void;
}
@ -63,6 +65,7 @@ export const SkillImportDialog = ({
open,
projectPath,
projectLabel,
allowCodexRootKind,
onClose,
onImported,
}: SkillImportDialogProps): React.JSX.Element => {
@ -73,7 +76,7 @@ export const SkillImportDialog = ({
const [folderName, setFolderName] = useState('');
const [folderNameEdited, setFolderNameEdited] = useState(false);
const [scope, setScope] = useState<'user' | 'project'>('user');
const [rootKind, setRootKind] = useState<'claude' | 'cursor' | 'agents'>('claude');
const [rootKind, setRootKind] = useState<SkillRootKind>('claude');
const [preview, setPreview] = useState<SkillReviewPreview | null>(null);
const [reviewOpen, setReviewOpen] = useState(false);
const [reviewLoading, setReviewLoading] = useState(false);
@ -119,6 +122,16 @@ export const SkillImportDialog = ({
}
}, [open, projectPath, scope]);
useEffect(() => {
if (open && rootKind === 'codex' && !allowCodexRootKind) {
setRootKind('claude');
}
}, [allowCodexRootKind, open, rootKind]);
const visibleRootDefinitions = SKILL_ROOT_DEFINITIONS.filter(
(definition) => definition.rootKind !== 'codex' || allowCodexRootKind
);
async function handleChooseFolder(): Promise<void> {
const selected = await api.config.selectFolders();
const first = selected[0];
@ -273,17 +286,18 @@ export const SkillImportDialog = ({
<Label htmlFor="skill-import-root">Where to store it</Label>
<Select
value={rootKind}
onValueChange={(value) =>
setRootKind(value as 'claude' | 'cursor' | 'agents')
}
onValueChange={(value) => setRootKind(value as SkillRootKind)}
>
<SelectTrigger id="skill-import-root">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="claude">.claude</SelectItem>
<SelectItem value="cursor">.cursor</SelectItem>
<SelectItem value="agents">.agents</SelectItem>
{visibleRootDefinitions.map((definition) => (
<SelectItem key={definition.rootKind} value={definition.rootKind}>
{definition.directoryName}
{definition.audience === 'codex' ? ' - Codex only' : ' - Shared'}
</SelectItem>
))}
</SelectContent>
</Select>
</div>

View file

@ -6,6 +6,17 @@ import { Button } from '@renderer/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
import { getVisibleMultimodelProviders } from '@renderer/utils/multimodelProviderVisibility';
import {
getCliProviderExtensionCapability,
isCliExtensionCapabilityAvailable,
} from '@shared/utils/providerExtensionCapabilities';
import {
formatSkillRootKind,
getSkillAudience,
getSkillAudienceLabel,
isCodexSkillOverlayAvailable,
} from '@shared/utils/skillRoots';
import {
AlertTriangle,
ArrowUpAZ,
@ -15,6 +26,7 @@ import {
CheckCircle2,
Clock3,
Download,
Info,
Plus,
Search,
} from 'lucide-react';
@ -28,12 +40,19 @@ import { SkillImportDialog } from './SkillImportDialog';
import { resolveSkillProjectPath } from './skillProjectUtils';
import type { SkillsSortState } from '@renderer/hooks/useExtensionsTabState';
import type { SkillCatalogItem, SkillDetail } from '@shared/types/extensions';
import type { SkillCatalogItem, SkillDetail, SkillValidationIssue } from '@shared/types/extensions';
const SUCCESS_BANNER_MS = 2500;
const NEW_SKILL_HIGHLIGHT_MS = 4000;
const USER_SKILLS_CATALOG_KEY = '__user__';
type SkillsQuickFilter = 'all' | 'project' | 'personal' | 'needs-attention' | 'has-scripts';
type SkillsQuickFilter =
| 'all'
| 'project'
| 'personal'
| 'shared'
| 'codex-only'
| 'needs-attention'
| 'has-scripts';
interface SkillsPanelProps {
projectPath: string | null;
@ -57,10 +76,6 @@ function sortSkills(skills: SkillCatalogItem[], sort: SkillsSortState): SkillCat
return next;
}
function formatRootKind(rootKind: SkillCatalogItem['rootKind']): string {
return `.${rootKind}`;
}
function getScopeLabel(skill: SkillCatalogItem): string {
return skill.scope === 'project' ? 'This project' : 'Personal';
}
@ -68,7 +83,7 @@ function getScopeLabel(skill: SkillCatalogItem): string {
function getInvocationLabel(skill: SkillCatalogItem): string {
return skill.invocationMode === 'manual-only'
? 'Only runs when you explicitly ask for it'
: 'Claude can use this automatically when it fits';
: 'Runs automatically when it fits';
}
function getSkillStatus(skill: SkillCatalogItem): string {
@ -81,6 +96,45 @@ function getSkillStatus(skill: SkillCatalogItem): string {
return 'Ready to use';
}
function getPrimarySkillIssue(skill: SkillCatalogItem): SkillValidationIssue | null {
return (
skill.issues.find((issue) => issue.severity === 'error') ??
skill.issues.find((issue) => issue.severity === 'warning') ??
skill.issues[0] ??
null
);
}
function getSkillIssueTone(issue: SkillValidationIssue | null): {
className: string;
Icon: typeof AlertTriangle;
} {
if (issue?.severity === 'info') {
return {
className: 'border-blue-500/20 bg-blue-500/10 text-blue-700 dark:text-blue-300',
Icon: Info,
};
}
return {
className: 'border-amber-500/20 bg-amber-500/5 text-amber-700 dark:text-amber-300',
Icon: AlertTriangle,
};
}
function formatRuntimeAudienceLabel(providerNames: readonly string[]): string {
if (providerNames.length === 0) {
return 'the configured runtime';
}
if (providerNames.length === 1) {
return providerNames[0];
}
if (providerNames.length === 2) {
return `${providerNames[0]} and ${providerNames[1]}`;
}
return `${providerNames.slice(0, -1).join(', ')}, and ${providerNames.at(-1)}`;
}
export const SkillsPanel = ({
projectPath,
projectLabel,
@ -94,6 +148,7 @@ export const SkillsPanel = ({
const catalogKey = projectPath ?? USER_SKILLS_CATALOG_KEY;
const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog);
const fetchSkillDetail = useStore((s) => s.fetchSkillDetail);
const cliStatus = useStore((s) => s.cliStatus);
const skillsLoading = useStore((s) => s.skillsCatalogLoadingByProjectPath[catalogKey] ?? false);
const skillsError = useStore((s) => s.skillsCatalogErrorByProjectPath[catalogKey] ?? null);
const detailById = useStore(useShallow((s) => s.skillsDetailsById));
@ -110,18 +165,47 @@ export const SkillsPanel = ({
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [highlightedSkillId, setHighlightedSkillId] = useState<string | null>(null);
const selectedSkillIdRef = useRef<string | null>(selectedSkillId);
const selectedSkillItemRef = useRef<SkillCatalogItem | SkillDetail['item'] | null>(null);
const selectedSkillItemRef = useRef<SkillCatalogItem | null>(null);
selectedSkillIdRef.current = selectedSkillId;
const mergedSkills = useMemo(
() => [...projectSkills, ...userSkills],
[projectSkills, userSkills]
);
const codexSkillOverlayAvailable = useMemo(
() => isCodexSkillOverlayAvailable(cliStatus),
[cliStatus]
);
const skillsAudienceLabel = useMemo(() => {
if (cliStatus?.flavor !== 'agent_teams_orchestrator') {
return null;
}
const providerNames = getVisibleMultimodelProviders(cliStatus.providers ?? [])
.filter((provider) =>
isCliExtensionCapabilityAvailable(getCliProviderExtensionCapability(provider, 'skills'))
)
.map((provider) => provider.displayName);
return formatRuntimeAudienceLabel(providerNames);
}, [cliStatus]);
const codexOnlySkillsCount = useMemo(
() => mergedSkills.filter((skill) => getSkillAudience(skill.rootKind) === 'codex').length,
[mergedSkills]
);
const sharedSkillsCount = mergedSkills.length - codexOnlySkillsCount;
const showCodexOnlyUi = codexSkillOverlayAvailable || codexOnlySkillsCount > 0;
const selectedDetail = selectedSkillId ? (detailById[selectedSkillId] ?? null) : null;
selectedSkillItemRef.current = selectedSkillId
? (selectedDetail?.item ?? mergedSkills.find((skill) => skill.id === selectedSkillId) ?? null)
: null;
useEffect(() => {
if (quickFilter === 'codex-only' && !showCodexOnlyUi) {
setQuickFilter('all');
}
}, [quickFilter, showCodexOnlyUi]);
useEffect(() => {
if (!selectedSkillId) return;
if (mergedSkills.some((skill) => skill.id === selectedSkillId)) return;
@ -204,6 +288,10 @@ export const SkillsPanel = ({
return skill.scope === 'project';
case 'personal':
return skill.scope === 'user';
case 'shared':
return getSkillAudience(skill.rootKind) === 'shared';
case 'codex-only':
return getSkillAudience(skill.rootKind) === 'codex';
case 'needs-attention':
return !skill.isValid;
case 'has-scripts':
@ -226,16 +314,23 @@ export const SkillsPanel = ({
return (
<div className="flex flex-col gap-4">
{cliStatus?.flavor === 'agent_teams_orchestrator' && (
<div className="rounded-md border border-blue-500/30 bg-blue-500/5 px-4 py-3 text-sm text-blue-300">
Shared skills in `.claude`, `.cursor`, and `.agents` are available to{' '}
{skillsAudienceLabel ?? 'the configured runtime'}. Skills stored in `.codex` stay
Codex-only when Codex support is available.
</div>
)}
<div className="bg-surface-raised/20 rounded-xl border border-border p-4">
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div className="min-w-0 flex-1 space-y-1 xl:max-w-2xl">
<div className="flex items-center gap-2">
<BookOpen className="size-4 text-text-muted" />
<h2 className="text-sm font-semibold text-text">Teach Claude repeatable work</h2>
<h2 className="text-sm font-semibold text-text">Teach repeatable work</h2>
</div>
<p className="max-w-2xl text-sm leading-5 text-text-muted">
Skills are reusable instructions that help Claude handle the same kind of task more
consistently.{' '}
Skills are reusable instructions that help the runtime handle the same kind of task
more consistently.{' '}
{projectPath
? `You are seeing skills for ${projectLabel ?? projectPath} plus your personal skills.`
: 'You are seeing only your personal skills right now.'}
@ -243,6 +338,9 @@ export const SkillsPanel = ({
<p className="max-w-2xl text-xs leading-5 text-text-muted">
Use personal skills for habits you want everywhere. Use project skills for workflows
that only make sense inside one codebase.
{codexSkillOverlayAvailable
? ' Use `.codex` when a skill should stay Codex-only.'
: ' Existing `.codex` skills stay editable here, but new Codex-only skills need the Codex runtime enabled.'}
</p>
</div>
@ -320,6 +418,14 @@ export const SkillsPanel = ({
<Badge variant="secondary" className="font-normal">
{userSkills.length} personal
</Badge>
<Badge variant="secondary" className="font-normal">
{sharedSkillsCount} shared
</Badge>
{showCodexOnlyUi && (
<Badge variant="secondary" className="font-normal">
{codexOnlySkillsCount} Codex only
</Badge>
)}
</div>
</div>
</div>
@ -331,6 +437,10 @@ export const SkillsPanel = ({
['all', 'All skills'],
['project', 'Project'],
['personal', 'Personal'],
['shared', 'Shared'],
...(showCodexOnlyUi
? ([['codex-only', 'Codex only']] as [SkillsQuickFilter, string][])
: []),
['needs-attention', 'Needs attention'],
['has-scripts', 'Has scripts'],
] as [SkillsQuickFilter, string][]
@ -383,7 +493,7 @@ export const SkillsPanel = ({
<p className="text-xs text-text-muted">
{skillsSearchQuery
? 'Try a different search term or switch filters.'
: 'Create your first skill to teach Claude a repeatable workflow, or import one you already use.'}
: 'Create your first skill to teach a repeatable workflow, or import one you already use.'}
</p>
</div>
)}
@ -404,71 +514,83 @@ export const SkillsPanel = ({
</Badge>
</div>
<div className="skills-grid grid grid-cols-1 gap-3 xl:grid-cols-2">
{visibleProjectSkills.map((skill) => (
<button
key={skill.id}
type="button"
onClick={() => setSelectedSkillId(skill.id)}
className={`rounded-xl border p-4 text-left transition-colors ${
highlightedSkillId === skill.id
? 'border-green-500/50 bg-green-500/10 shadow-[0_0_0_1px_rgba(34,197,94,0.18)]'
: 'bg-surface-raised/10 border-border hover:border-border-emphasis'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="truncate text-sm font-semibold text-text">{skill.name}</h3>
{!skill.isValid && (
<Badge
variant="outline"
className="border-amber-500/40 text-amber-700 dark:text-amber-300"
>
Needs attention
</Badge>
)}
{visibleProjectSkills.map((skill) => {
const primaryIssue = getPrimarySkillIssue(skill);
const issueTone = getSkillIssueTone(primaryIssue);
const IssueIcon = issueTone.Icon;
return (
<button
key={skill.id}
type="button"
onClick={() => setSelectedSkillId(skill.id)}
className={`rounded-xl border p-4 text-left transition-colors ${
highlightedSkillId === skill.id
? 'border-green-500/50 bg-green-500/10 shadow-[0_0_0_1px_rgba(34,197,94,0.18)]'
: 'bg-surface-raised/10 border-border hover:border-border-emphasis'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="truncate text-sm font-semibold text-text">
{skill.name}
</h3>
{!skill.isValid && (
<Badge
variant="outline"
className="border-amber-500/40 text-amber-700 dark:text-amber-300"
>
Needs attention
</Badge>
)}
</div>
<p className="line-clamp-2 text-sm text-text-secondary">
{skill.description}
</p>
</div>
<p className="line-clamp-2 text-sm text-text-secondary">
{skill.description}
</p>
<Badge variant="outline">{getScopeLabel(skill)}</Badge>
</div>
<Badge variant="outline">{getScopeLabel(skill)}</Badge>
</div>
<div className="mt-3 space-y-2 text-xs text-text-muted">
<p>{getInvocationLabel(skill)}</p>
<p>{getSkillStatus(skill)}</p>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Badge variant="secondary" className="font-normal">
Stored in {formatRootKind(skill.rootKind)}
</Badge>
{skill.flags.hasScripts && (
<Badge variant="destructive" className="font-normal">
Has scripts
</Badge>
)}
{skill.flags.hasReferences && (
<Badge variant="secondary" className="font-normal">
References
</Badge>
)}
{skill.flags.hasAssets && (
<Badge variant="secondary" className="font-normal">
Assets
</Badge>
)}
</div>
{skill.issues.length > 0 && (
<div className="mt-3 flex items-start gap-2 rounded-md border border-amber-500/20 bg-amber-500/5 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{skill.issues[0]?.message}</span>
<div className="mt-3 space-y-2 text-xs text-text-muted">
<p>{getInvocationLabel(skill)}</p>
<p>{getSkillStatus(skill)}</p>
</div>
)}
</button>
))}
<div className="mt-3 flex flex-wrap gap-2">
<Badge variant="secondary" className="font-normal">
Stored in {formatSkillRootKind(skill.rootKind)}
</Badge>
<Badge variant="outline" className="font-normal">
{getSkillAudienceLabel(skill.rootKind)}
</Badge>
{skill.flags.hasScripts && (
<Badge variant="destructive" className="font-normal">
Has scripts
</Badge>
)}
{skill.flags.hasReferences && (
<Badge variant="secondary" className="font-normal">
References
</Badge>
)}
{skill.flags.hasAssets && (
<Badge variant="secondary" className="font-normal">
Assets
</Badge>
)}
</div>
{primaryIssue && (
<div
className={`mt-3 flex items-start gap-2 rounded-md border px-3 py-2 text-xs ${issueTone.className}`}
>
<IssueIcon className="mt-0.5 size-3.5 shrink-0" />
<span>{primaryIssue.message}</span>
</div>
)}
</button>
);
})}
</div>
</section>
)}
@ -479,7 +601,7 @@ export const SkillsPanel = ({
<div>
<h3 className="text-sm font-semibold text-text">Personal skills</h3>
<p className="text-xs text-text-muted">
Habits and instructions you want Claude to remember everywhere.
Habits and instructions you want available everywhere.
</p>
</div>
<Badge variant="secondary" className="font-normal">
@ -487,71 +609,83 @@ export const SkillsPanel = ({
</Badge>
</div>
<div className="skills-grid grid grid-cols-1 gap-3 xl:grid-cols-2">
{visibleUserSkills.map((skill) => (
<button
key={skill.id}
type="button"
onClick={() => setSelectedSkillId(skill.id)}
className={`rounded-xl border p-4 text-left transition-colors ${
highlightedSkillId === skill.id
? 'border-green-500/50 bg-green-500/10 shadow-[0_0_0_1px_rgba(34,197,94,0.18)]'
: 'bg-surface-raised/10 border-border hover:border-border-emphasis'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="truncate text-sm font-semibold text-text">{skill.name}</h3>
{!skill.isValid && (
<Badge
variant="outline"
className="border-amber-500/40 text-amber-700 dark:text-amber-300"
>
Needs attention
</Badge>
)}
{visibleUserSkills.map((skill) => {
const primaryIssue = getPrimarySkillIssue(skill);
const issueTone = getSkillIssueTone(primaryIssue);
const IssueIcon = issueTone.Icon;
return (
<button
key={skill.id}
type="button"
onClick={() => setSelectedSkillId(skill.id)}
className={`rounded-xl border p-4 text-left transition-colors ${
highlightedSkillId === skill.id
? 'border-green-500/50 bg-green-500/10 shadow-[0_0_0_1px_rgba(34,197,94,0.18)]'
: 'bg-surface-raised/10 border-border hover:border-border-emphasis'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="truncate text-sm font-semibold text-text">
{skill.name}
</h3>
{!skill.isValid && (
<Badge
variant="outline"
className="border-amber-500/40 text-amber-700 dark:text-amber-300"
>
Needs attention
</Badge>
)}
</div>
<p className="line-clamp-2 text-sm text-text-secondary">
{skill.description}
</p>
</div>
<p className="line-clamp-2 text-sm text-text-secondary">
{skill.description}
</p>
<Badge variant="outline">{getScopeLabel(skill)}</Badge>
</div>
<Badge variant="outline">{getScopeLabel(skill)}</Badge>
</div>
<div className="mt-3 space-y-2 text-xs text-text-muted">
<p>{getInvocationLabel(skill)}</p>
<p>{getSkillStatus(skill)}</p>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Badge variant="secondary" className="font-normal">
Stored in {formatRootKind(skill.rootKind)}
</Badge>
{skill.flags.hasScripts && (
<Badge variant="destructive" className="font-normal">
Has scripts
</Badge>
)}
{skill.flags.hasReferences && (
<Badge variant="secondary" className="font-normal">
References
</Badge>
)}
{skill.flags.hasAssets && (
<Badge variant="secondary" className="font-normal">
Assets
</Badge>
)}
</div>
{skill.issues.length > 0 && (
<div className="mt-3 flex items-start gap-2 rounded-md border border-amber-500/20 bg-amber-500/5 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{skill.issues[0]?.message}</span>
<div className="mt-3 space-y-2 text-xs text-text-muted">
<p>{getInvocationLabel(skill)}</p>
<p>{getSkillStatus(skill)}</p>
</div>
)}
</button>
))}
<div className="mt-3 flex flex-wrap gap-2">
<Badge variant="secondary" className="font-normal">
Stored in {formatSkillRootKind(skill.rootKind)}
</Badge>
<Badge variant="outline" className="font-normal">
{getSkillAudienceLabel(skill.rootKind)}
</Badge>
{skill.flags.hasScripts && (
<Badge variant="destructive" className="font-normal">
Has scripts
</Badge>
)}
{skill.flags.hasReferences && (
<Badge variant="secondary" className="font-normal">
References
</Badge>
)}
{skill.flags.hasAssets && (
<Badge variant="secondary" className="font-normal">
Assets
</Badge>
)}
</div>
{primaryIssue && (
<div
className={`mt-3 flex items-start gap-2 rounded-md border px-3 py-2 text-xs ${issueTone.className}`}
>
<IssueIcon className="mt-0.5 size-3.5 shrink-0" />
<span>{primaryIssue.message}</span>
</div>
)}
</button>
);
})}
</div>
</section>
)}
@ -577,6 +711,7 @@ export const SkillsPanel = ({
mode="create"
projectPath={projectPath}
projectLabel={projectLabel}
allowCodexRootKind={codexSkillOverlayAvailable}
detail={null}
onClose={() => setCreateOpen(false)}
onSaved={(skillId) => {
@ -592,6 +727,7 @@ export const SkillsPanel = ({
mode="edit"
projectPath={projectPath}
projectLabel={projectLabel}
allowCodexRootKind={codexSkillOverlayAvailable}
detail={editingDetail}
onClose={() => {
setEditOpen(false);
@ -609,6 +745,7 @@ export const SkillsPanel = ({
open={importOpen}
projectPath={projectPath}
projectLabel={projectLabel}
allowCodexRootKind={codexSkillOverlayAvailable}
onClose={() => setImportOpen(false)}
onImported={(skillId) => {
setImportOpen(false);

View file

@ -204,7 +204,7 @@ export const Sidebar = (): React.JSX.Element => {
<button
type="button"
aria-label="Resize sidebar"
className={`absolute left-0 top-0 h-full w-1 cursor-col-resize border-0 bg-transparent p-0 transition-colors hover:bg-blue-500/50 ${
className={`absolute left-0 top-0 z-20 h-full w-1 cursor-col-resize border-0 bg-transparent p-0 transition-colors hover:bg-blue-500/50 ${
isResizing ? 'bg-blue-500/50' : ''
}`}
onMouseDown={handleResizeStart}

View file

@ -105,7 +105,7 @@ function isApiKeyProviderId(providerId: CliProviderId): providerId is ApiKeyProv
function findPreferredApiKeyEntry(apiKeys: ApiKeyEntry[], envVarName: string): ApiKeyEntry | null {
const matches = apiKeys.filter((entry) => entry.envVarName === envVarName);
return matches.find((entry) => entry.scope === 'user') ?? matches[0] ?? null;
return matches.find((entry) => entry.scope === 'user') ?? null;
}
function getConnectionDescription(provider: CliProviderStatus): string {

View file

@ -54,6 +54,7 @@ export interface SafeConfig {
notifyOnCrossTeamMessage: boolean;
notifyOnTeamLaunched: boolean;
notifyOnToolApproval: boolean;
autoResumeOnRateLimit: boolean;
statusChangeOnlySolo: boolean;
statusChangeStatuses: string[];
triggers: AppConfig['notifications']['triggers'];
@ -195,6 +196,7 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
notifyOnCrossTeamMessage: displayConfig?.notifications?.notifyOnCrossTeamMessage ?? true,
notifyOnTeamLaunched: displayConfig?.notifications?.notifyOnTeamLaunched ?? true,
notifyOnToolApproval: displayConfig?.notifications?.notifyOnToolApproval ?? true,
autoResumeOnRateLimit: displayConfig?.notifications?.autoResumeOnRateLimit ?? false,
statusChangeOnlySolo: displayConfig?.notifications?.statusChangeOnlySolo ?? true,
statusChangeStatuses: displayConfig?.notifications?.statusChangeStatuses ?? [
'in_progress',

View file

@ -311,6 +311,7 @@ export function useSettingsHandlers({
notifyOnCrossTeamMessage: true,
notifyOnTeamLaunched: true,
notifyOnToolApproval: true,
autoResumeOnRateLimit: false,
statusChangeOnlySolo: true,
statusChangeStatuses: ['in_progress', 'completed'],
triggers: defaultTriggers,

View file

@ -172,7 +172,7 @@ export const AdvancedSection = ({
<div>
<div className="flex items-center gap-3">
<p className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
Claude Agent Teams UI
Agent Teams UI
</p>
{isElectron && (
<button

View file

@ -163,6 +163,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
!cliStatus && cliStatusLoading && multimodelEnabled
? createLoadingMultimodelCliStatus()
: cliStatus;
const canOpenExtensions = effectiveCliStatus?.installed === true;
const showInstalledControls =
effectiveCliStatus !== null && (installerState === 'idle' || installerState === 'completed');
@ -396,7 +397,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
</button>
) : null}
{/* Extensions button — right-aligned */}
{effectiveCliStatus.authLoggedIn && (
{canOpenExtensions && (
<button
type="button"
onClick={openExtensionsTab}

View file

@ -76,6 +76,7 @@ interface NotificationsSectionProps {
| 'notifyOnCrossTeamMessage'
| 'notifyOnTeamLaunched'
| 'notifyOnToolApproval'
| 'autoResumeOnRateLimit'
| 'statusChangeOnlySolo',
value: boolean
) => void;
@ -360,6 +361,17 @@ export const NotificationsSection = ({
disabled={saving || !safeConfig.notifications.enabled}
/>
</SettingRow>
<SettingRow
label="Auto-resume after rate limit"
description="When Claude reports a reset time, schedule a follow-up nudge for the team lead after the limit resets"
icon={<Clock className="size-4" />}
>
<SettingsToggle
enabled={safeConfig.notifications.autoResumeOnRateLimit}
onChange={(v) => onNotificationToggle('autoResumeOnRateLimit', v)}
disabled={saving || !safeConfig.notifications.enabled}
/>
</SettingRow>
{/* Task Status Change Notifications — nested within team card */}
<div className="last:*:border-b-0">

View file

@ -21,6 +21,7 @@ import {
Check,
ChevronDown,
ChevronRight,
Folder,
ListTodo,
Pin,
Search,
@ -56,7 +57,7 @@ function loadGroupingMode(): TaskGroupingMode {
} catch {
/* ignore */
}
return 'none';
return 'project';
}
function saveGroupingMode(mode: TaskGroupingMode): void {
@ -625,6 +626,7 @@ export const GlobalTaskList = ({
projectGroups.map((group) => {
if (group.tasks.length === 0) return null;
const isGroupCollapsed = projectCollapsed.isCollapsed(group.projectKey);
const groupColor = projectColor(group.projectLabel);
let lastTeam: string | null = null;
return (
<div key={group.projectKey}>
@ -639,14 +641,12 @@ export const GlobalTaskList = ({
) : (
<ChevronDown className="size-3 shrink-0 text-text-muted" />
)}
<span
className="inline-block size-1.5 shrink-0 rounded-full"
style={{ backgroundColor: projectColor(group.projectLabel).border }}
<Folder
className="size-3 shrink-0"
style={{ color: groupColor.border }}
aria-hidden="true"
/>
<span
className="truncate"
style={{ color: projectColor(group.projectLabel).text }}
>
<span className="truncate" style={{ color: groupColor.text }}>
{group.projectLabel}
</span>
<span className="ml-auto shrink-0 text-[10px] font-normal text-text-muted">

View file

@ -111,8 +111,7 @@ export const MemberCard = ({
!isRemoved &&
presenceLabel === 'starting' &&
spawnLaunchState !== 'failed_to_start' &&
!activityTask &&
!runtimeSummary;
!activityTask;
const showStartingBadge = !isRemoved && presenceLabel === 'starting' && !activityTask;
const showRuntimeAdvisoryBadge =
!isRemoved &&

View file

@ -237,7 +237,8 @@ export const MessageComposer = ({
buildSlashCommandSuggestions(
getSuggestedSlashCommandsForProvider(leadProviderId),
projectSkills,
userSkills
userSkills,
leadProviderId
),
[leadProviderId, projectSkills, userSkills]
);

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/png" href="./favicon.png" />
<title>Claude Agent Teams UI</title>
<title>Agent Teams UI</title>
<style>
/* Splash: animated gradient background */
#splash {
@ -124,7 +124,7 @@
<circle class="splash-node splash-core-fill" style="animation-delay:1s" cx="37" cy="19" r="2" fill="#ede9fe"/>
<circle class="splash-node splash-core-fill" style="animation-delay:2s" cx="28" cy="37" r="2.2" fill="#f3e8ff"/>
</svg>
<div id="splash-text">Claude Agent Teams UI</div>
<div id="splash-text">Agent Teams UI</div>
</div>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>

View file

@ -4,6 +4,7 @@
import { api } from '@renderer/api';
import { createLogger } from '@shared/utils/logger';
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
import type { AppState } from '../types';
import type { CliInstallationStatus, CliProviderId, CliProviderStatus } from '@shared/types';
@ -36,6 +37,7 @@ export function createLoadingMultimodelCliStatus(): CliInstallationStatus {
capabilities: {
teamLaunch: false,
oneShot: false,
extensions: createDefaultCliExtensionCapabilities(),
},
backend: null,
}));

View file

@ -4,8 +4,14 @@
*/
import { api } from '@renderer/api';
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
import { getMcpOperationKey, getPluginOperationKey } from '@shared/utils/extensionNormalizers';
import {
getExtensionActionDisableReason,
getMcpDiagnosticKey,
getMcpOperationKey,
getMcpProjectStateKey,
getPluginOperationKey,
} from '@shared/utils/extensionNormalizers';
import { isProjectScopedMcpScope } from '@shared/utils/mcpScopes';
import { findPaneByTabId, updatePane } from '../utils/paneHelpers';
@ -51,11 +57,16 @@ export interface ExtensionsSlice {
mcpBrowseLoading: boolean;
mcpBrowseError: string | null;
mcpInstalledServers: InstalledMcpEntry[];
mcpInstalledServersByProjectPath: Record<string, InstalledMcpEntry[]>;
mcpInstalledProjectPath: string | null;
mcpDiagnostics: Record<string, McpServerDiagnostic>;
mcpDiagnosticsByProjectPath: Record<string, Record<string, McpServerDiagnostic>>;
mcpDiagnosticsLoading: boolean;
mcpDiagnosticsLoadingByProjectPath: Record<string, boolean>;
mcpDiagnosticsError: string | null;
mcpDiagnosticsErrorByProjectPath: Record<string, string | null>;
mcpDiagnosticsLastCheckedAt: number | null;
mcpDiagnosticsLastCheckedAtByProjectPath: Record<string, number | null>;
// ── Install progress ──
pluginInstallProgress: Record<string, ExtensionOperationState>;
@ -90,7 +101,7 @@ export interface ExtensionsSlice {
fetchPluginReadme: (pluginId: string) => void;
mcpBrowse: (cursor?: string) => Promise<void>;
mcpFetchInstalled: (projectPath?: string) => Promise<void>;
runMcpDiagnostics: () => Promise<void>;
runMcpDiagnostics: (projectPath?: string) => Promise<void>;
fetchSkillsCatalog: (projectPath?: string) => Promise<void>;
fetchSkillDetail: (skillId: string, projectPath?: string) => Promise<void>;
previewSkillUpsert: (request: SkillUpsertRequest) => Promise<SkillReviewPreview>;
@ -132,7 +143,7 @@ let pluginFetchInFlight: { key: string; promise: Promise<void>; token: symbol }
let pluginCatalogRequestSeq = 0;
const pluginSuccessResetTimers = new Map<string, ReturnType<typeof setTimeout>>();
const mcpSuccessResetTimers = new Map<string, ReturnType<typeof setTimeout>>();
let mcpDiagnosticsInFlight: Promise<void> | null = null;
const mcpDiagnosticsInFlightByKey = new Map<string, Promise<void>>();
let skillsCatalogRequestSeq = 0;
let skillsDetailRequestSeq = 0;
const latestSkillsCatalogRequestByKey = new Map<string, number>();
@ -152,8 +163,8 @@ function buildPluginIdSet(catalog: EnrichedPlugin[]): Set<string> {
return new Set(catalog.map((plugin) => plugin.pluginId));
}
function buildPluginOperationKeys(pluginId: string): string[] {
return PLUGIN_OPERATION_SCOPES.map((scope) => getPluginOperationKey(pluginId, scope));
function isPluginOperationKeyForPlugin(operationKey: string, pluginId: string): boolean {
return operationKey.startsWith(`plugin:${pluginId}:`);
}
function clearPluginOperationState(
@ -170,10 +181,16 @@ function clearPluginOperationState(
const nextPluginInstallProgress = { ...pluginInstallProgress };
const nextInstallErrors = { ...installErrors };
const pluginIdsList = Array.from(pluginIds);
for (const pluginId of pluginIds) {
for (const operationKey of buildPluginOperationKeys(pluginId)) {
for (const operationKey of Object.keys(nextPluginInstallProgress)) {
if (pluginIdsList.some((pluginId) => isPluginOperationKeyForPlugin(operationKey, pluginId))) {
delete nextPluginInstallProgress[operationKey];
}
}
for (const operationKey of Object.keys(nextInstallErrors)) {
if (pluginIdsList.some((pluginId) => isPluginOperationKeyForPlugin(operationKey, pluginId))) {
delete nextInstallErrors[operationKey];
}
}
@ -195,8 +212,9 @@ function clearPluginSuccessResetTimer(operationKey: string): void {
}
function clearPluginSuccessResetTimers(pluginIds: Set<string>): void {
for (const pluginId of pluginIds) {
for (const operationKey of buildPluginOperationKeys(pluginId)) {
const pluginIdsList = Array.from(pluginIds);
for (const operationKey of Array.from(pluginSuccessResetTimers.keys())) {
if (pluginIdsList.some((pluginId) => isPluginOperationKeyForPlugin(operationKey, pluginId))) {
clearPluginSuccessResetTimer(operationKey);
}
}
@ -222,10 +240,26 @@ function schedulePluginSuccessReset(
pluginSuccessResetTimers.set(operationKey, timer);
}
function getCustomMcpOperationKey(serverName: string, scope: InstallScope): string {
function getCustomMcpOperationKey(
serverName: string,
scope: InstallScope,
projectPath?: string | null
): string {
if (scope === 'project' || scope === 'local') {
return `mcp-custom:${serverName}:${scope}:${getMcpProjectStateKey(projectPath)}`;
}
return `mcp-custom:${serverName}:${scope}`;
}
function isProjectScopedMcpOperationKey(operationKey: string): boolean {
return (
operationKey.includes(':project:') ||
operationKey.endsWith(':project') ||
operationKey.includes(':local:') ||
operationKey.endsWith(':local')
);
}
function clearMcpSuccessResetTimer(operationKey: string): void {
const timer = mcpSuccessResetTimers.get(operationKey);
if (!timer) {
@ -269,7 +303,7 @@ function clearMcpProjectScopedOperationState(
for (const operationKey of Object.keys(nextMcpInstallProgress)) {
if (
(operationKey.startsWith('mcp:') || operationKey.startsWith('mcp-custom:')) &&
(operationKey.endsWith(':project') || operationKey.endsWith(':local'))
isProjectScopedMcpOperationKey(operationKey)
) {
delete nextMcpInstallProgress[operationKey];
}
@ -278,7 +312,7 @@ function clearMcpProjectScopedOperationState(
for (const operationKey of Object.keys(nextInstallErrors)) {
if (
(operationKey.startsWith('mcp:') || operationKey.startsWith('mcp-custom:')) &&
(operationKey.endsWith(':project') || operationKey.endsWith(':local'))
isProjectScopedMcpOperationKey(operationKey)
) {
delete nextInstallErrors[operationKey];
}
@ -292,7 +326,7 @@ function clearMcpProjectScopedOperationState(
function clearMcpProjectScopedSuccessResetTimers(): void {
for (const operationKey of Array.from(mcpSuccessResetTimers.keys())) {
if (operationKey.endsWith(':project') || operationKey.endsWith(':local')) {
if (isProjectScopedMcpOperationKey(operationKey)) {
clearMcpSuccessResetTimer(operationKey);
}
}
@ -302,18 +336,15 @@ function getSkillsCatalogKey(projectPath?: string): string {
return projectPath ?? USER_SKILLS_CATALOG_KEY;
}
function upsertApiKeyEntry(entries: ApiKeyEntry[], entry: ApiKeyEntry): ApiKeyEntry[] {
const nextEntries = entries.filter((candidate) => candidate.id !== entry.id);
return [entry, ...nextEntries];
}
/** Duration to show "success" state before returning to idle */
const SUCCESS_DISPLAY_MS = 2_000;
const CLI_AUTH_REQUIRED_MESSAGE =
'Claude CLI is installed but not signed in. Go to the Dashboard and sign in to enable plugin installs.';
const CLI_HEALTHCHECK_FAILED_MESSAGE =
'Claude CLI was found but failed its startup health check. Open the Dashboard to repair or reinstall it before retrying.';
const CLI_STATUS_UNKNOWN_MESSAGE =
'Unable to verify Claude CLI status. Open the Dashboard and check the CLI before retrying.';
const PROJECT_SCOPE_REQUIRED_MESSAGE =
'Project- and local-scoped plugins require an active project in the Extensions tab.';
const PLUGIN_OPERATION_SCOPES: InstallScope[] = ['user', 'project', 'local'];
export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSlice> = (
set,
get
@ -331,11 +362,16 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
mcpBrowseLoading: false,
mcpBrowseError: null,
mcpInstalledServers: [],
mcpInstalledServersByProjectPath: {},
mcpInstalledProjectPath: null,
mcpDiagnostics: {},
mcpDiagnosticsByProjectPath: {},
mcpDiagnosticsLoading: false,
mcpDiagnosticsLoadingByProjectPath: {},
mcpDiagnosticsError: null,
mcpDiagnosticsErrorByProjectPath: {},
mcpDiagnosticsLastCheckedAt: null,
mcpDiagnosticsLastCheckedAtByProjectPath: {},
pluginInstallProgress: {},
mcpInstallProgress: {},
@ -376,7 +412,8 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
const requestToken = Symbol('pluginCatalogRequest');
set({ pluginCatalogLoading: true, pluginCatalogError: null });
const promise = (async () => {
let currentPromise: Promise<void> | null = null;
currentPromise = (async () => {
try {
const result = await api.plugins!.getAll(projectPath, forceRefresh);
set((prev) => {
@ -438,8 +475,8 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
}
})();
pluginFetchInFlight = { key: requestKey, promise, token: requestToken };
await promise;
pluginFetchInFlight = { key: requestKey, promise: currentPromise, token: requestToken };
await currentPromise;
},
// ── Plugin README fetch ──
@ -514,6 +551,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
const installed = await api.mcpRegistry.getInstalled(projectPath);
set((prev) => {
const nextProjectPath = projectPath ?? null;
const stateKey = getMcpProjectStateKey(nextProjectPath);
const isSameProjectContext = prev.mcpInstalledProjectPath === nextProjectPath;
const nextOperationState = isSameProjectContext
? {
@ -528,6 +566,10 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
return {
mcpInstalledServers: installed,
mcpInstalledServersByProjectPath: {
...prev.mcpInstalledServersByProjectPath,
[stateKey]: installed,
},
mcpInstalledProjectPath: nextProjectPath,
mcpInstallProgress: nextOperationState.mcpInstallProgress,
installErrors: nextOperationState.installErrors,
@ -538,39 +580,75 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
}
},
runMcpDiagnostics: async () => {
runMcpDiagnostics: async (projectPath?: string) => {
const mcpRegistry = api.mcpRegistry;
if (!mcpRegistry) return;
const projectStateKey = getMcpProjectStateKey(projectPath);
if (mcpDiagnosticsInFlight) {
await mcpDiagnosticsInFlight;
const existing = mcpDiagnosticsInFlightByKey.get(projectStateKey);
if (existing) {
await existing;
return;
}
set({ mcpDiagnosticsLoading: true, mcpDiagnosticsError: null });
set((prev) => ({
mcpDiagnosticsLoading: true,
mcpDiagnosticsError: null,
mcpDiagnosticsLoadingByProjectPath: {
...prev.mcpDiagnosticsLoadingByProjectPath,
[projectStateKey]: true,
},
mcpDiagnosticsErrorByProjectPath: {
...prev.mcpDiagnosticsErrorByProjectPath,
[projectStateKey]: null,
},
}));
const promise = (async () => {
try {
const diagnostics = await mcpRegistry.diagnose();
const diagnostics = await mcpRegistry.diagnose(projectPath);
const diagnosticsRecord = Object.fromEntries(
diagnostics.map((entry) => [getMcpDiagnosticKey(entry.name, entry.scope), entry] as const)
);
const checkedAt = Date.now();
set({
mcpDiagnostics: Object.fromEntries(
diagnostics.map((entry) => [entry.name, entry] as const)
),
mcpDiagnostics: diagnosticsRecord,
mcpDiagnosticsLoading: false,
mcpDiagnosticsLastCheckedAt: Date.now(),
mcpDiagnosticsByProjectPath: {
...get().mcpDiagnosticsByProjectPath,
[projectStateKey]: diagnosticsRecord,
},
mcpDiagnosticsLoadingByProjectPath: {
...get().mcpDiagnosticsLoadingByProjectPath,
[projectStateKey]: false,
},
mcpDiagnosticsLastCheckedAt: checkedAt,
mcpDiagnosticsLastCheckedAtByProjectPath: {
...get().mcpDiagnosticsLastCheckedAtByProjectPath,
[projectStateKey]: checkedAt,
},
});
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Failed to check MCP server health';
set({
mcpDiagnosticsLoading: false,
mcpDiagnosticsError:
err instanceof Error ? err.message : 'Failed to check MCP server health',
mcpDiagnosticsError: errorMessage,
mcpDiagnosticsLoadingByProjectPath: {
...get().mcpDiagnosticsLoadingByProjectPath,
[projectStateKey]: false,
},
mcpDiagnosticsErrorByProjectPath: {
...get().mcpDiagnosticsErrorByProjectPath,
[projectStateKey]: errorMessage,
},
});
} finally {
mcpDiagnosticsInFlight = null;
mcpDiagnosticsInFlightByKey.delete(projectStateKey);
}
})();
mcpDiagnosticsInFlight = promise;
mcpDiagnosticsInFlightByKey.set(projectStateKey, promise);
await promise;
},
@ -792,11 +870,16 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
installPlugin: async (request: PluginInstallRequest) => {
if (!api.plugins) return;
const catalogProjectPathAtOperationStart = get().pluginCatalogProjectPath ?? undefined;
const effectiveProjectPath =
request.scope !== 'user'
? (request.projectPath ?? get().pluginCatalogProjectPath ?? undefined)
: request.projectPath;
const operationKey = getPluginOperationKey(request.pluginId, request.scope);
const operationKey = getPluginOperationKey(
request.pluginId,
request.scope,
effectiveProjectPath
);
const effectiveRequest =
effectiveProjectPath === request.projectPath
? request
@ -815,15 +898,12 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
const preflightError =
effectiveRequest.scope !== 'user' && !effectiveRequest.projectPath
? PROJECT_SCOPE_REQUIRED_MESSAGE
: cliStatus === null
? CLI_STATUS_UNKNOWN_MESSAGE
: !cliStatus.installed
? cliStatus.binaryPath && cliStatus.launchError
? CLI_HEALTHCHECK_FAILED_MESSAGE
: CLI_NOT_FOUND_MESSAGE
: !cliStatus.authLoggedIn
? CLI_AUTH_REQUIRED_MESSAGE
: null;
: getExtensionActionDisableReason({
isInstalled: false,
cliStatus,
cliStatusLoading: get().cliStatusLoading,
section: 'plugins',
});
if (preflightError) {
clearPluginSuccessResetTimer(operationKey);
@ -858,7 +938,12 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
}));
// Refresh catalog to pick up new installed state
void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true);
void get().fetchPluginCatalog(
effectiveRequest.scope !== 'user'
? effectiveRequest.projectPath
: catalogProjectPathAtOperationStart,
true
);
schedulePluginSuccessReset(operationKey, set);
} catch (err) {
@ -875,12 +960,13 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
uninstallPlugin: async (pluginId: string, scope?: InstallScope, projectPath?: string) => {
if (!api.plugins) return;
const catalogProjectPathAtOperationStart = get().pluginCatalogProjectPath ?? undefined;
const effectiveScope = scope ?? 'user';
const operationKey = getPluginOperationKey(pluginId, effectiveScope);
const effectiveProjectPath =
effectiveScope !== 'user'
? (projectPath ?? get().pluginCatalogProjectPath ?? undefined)
: projectPath;
const operationKey = getPluginOperationKey(pluginId, effectiveScope, effectiveProjectPath);
if (effectiveScope !== 'user' && !effectiveProjectPath) {
clearPluginSuccessResetTimer(operationKey);
set((prev) => ({
@ -890,6 +976,30 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
return;
}
const preflightState = get();
if (preflightState.cliStatus === null || preflightState.cliStatusLoading) {
try {
await preflightState.fetchCliStatus();
} catch {
// fetchCliStatus stores the error in cliStatusError; map to a user-facing uninstall error below.
}
}
const uninstallDisableReason = getExtensionActionDisableReason({
isInstalled: true,
cliStatus: get().cliStatus,
cliStatusLoading: get().cliStatusLoading,
section: 'plugins',
});
if (uninstallDisableReason) {
clearPluginSuccessResetTimer(operationKey);
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' },
installErrors: { ...prev.installErrors, [operationKey]: uninstallDisableReason },
}));
return;
}
clearPluginSuccessResetTimer(operationKey);
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'pending' },
@ -913,7 +1023,10 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
}));
// Refresh catalog
void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true);
void get().fetchPluginCatalog(
effectiveScope !== 'user' ? effectiveProjectPath : catalogProjectPathAtOperationStart,
true
);
schedulePluginSuccessReset(operationKey, set);
} catch (err) {
@ -928,7 +1041,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
// ── MCP install ──
installMcpServer: async (request: McpInstallRequest) => {
const operationKey = getMcpOperationKey(request.registryId, request.scope);
const operationKey = getMcpOperationKey(request.registryId, request.scope, request.projectPath);
if (!api.mcpRegistry) {
clearMcpSuccessResetTimer(operationKey);
set((prev) => ({
@ -941,6 +1054,30 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
return;
}
const preflightState = get();
if (preflightState.cliStatus === null || preflightState.cliStatusLoading) {
try {
await preflightState.fetchCliStatus();
} catch {
// fetchCliStatus stores the error in cliStatusError; map to a user-facing install error below.
}
}
const installDisableReason = getExtensionActionDisableReason({
isInstalled: false,
cliStatus: get().cliStatus,
cliStatusLoading: get().cliStatusLoading,
section: 'mcp',
});
if (installDisableReason) {
clearMcpSuccessResetTimer(operationKey);
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' },
installErrors: { ...prev.installErrors, [operationKey]: installDisableReason },
}));
return;
}
clearMcpSuccessResetTimer(operationKey);
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'pending' },
@ -960,8 +1097,8 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
}
await Promise.all([
get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined),
get().runMcpDiagnostics(),
get().mcpFetchInstalled(request.projectPath),
get().runMcpDiagnostics(request.projectPath),
]);
set((prev) => ({
@ -982,34 +1119,48 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
// ── MCP custom install ──
installCustomMcpServer: async (request: McpCustomInstallRequest) => {
const operationScope = request.scope;
const progressKey = getCustomMcpOperationKey(request.serverName, operationScope);
if (!api.mcpRegistry) {
const progressKey = getCustomMcpOperationKey(
request.serverName,
operationScope,
request.projectPath
);
try {
if (!api.mcpRegistry) {
throw new Error('MCP Registry not available');
}
const preflightState = get();
if (preflightState.cliStatus === null || preflightState.cliStatusLoading) {
try {
await preflightState.fetchCliStatus();
} catch {
// fetchCliStatus stores the error in cliStatusError; map to a user-facing install error below.
}
}
const installDisableReason = getExtensionActionDisableReason({
isInstalled: false,
cliStatus: get().cliStatus,
cliStatusLoading: get().cliStatusLoading,
section: 'mcp',
});
if (installDisableReason) {
throw new Error(installDisableReason);
}
clearMcpSuccessResetTimer(progressKey);
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' },
installErrors: { ...prev.installErrors, [progressKey]: 'MCP Registry not available' },
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'pending' },
}));
return;
}
clearMcpSuccessResetTimer(progressKey);
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'pending' },
}));
try {
const result = await api.mcpRegistry.installCustom(request);
if (result.state === 'error') {
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' },
installErrors: { ...prev.installErrors, [progressKey]: result.error ?? 'Install failed' },
}));
return;
throw new Error(result.error ?? 'Install failed');
}
await Promise.all([
get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined),
get().runMcpDiagnostics(),
get().mcpFetchInstalled(request.projectPath),
get().runMcpDiagnostics(request.projectPath),
]);
set((prev) => ({
@ -1024,6 +1175,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' },
installErrors: { ...prev.installErrors, [progressKey]: message },
}));
throw err instanceof Error ? err : new Error(message);
}
},
@ -1034,8 +1186,9 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
scope?: string,
projectPath?: string
) => {
const operationScope: InstallScope = scope === 'project' || scope === 'local' ? scope : 'user';
const operationKey = getMcpOperationKey(registryId, operationScope);
const operationScope: InstallScope =
scope === 'global' || scope === 'user' || isProjectScopedMcpScope(scope) ? scope : 'user';
const operationKey = getMcpOperationKey(registryId, operationScope, projectPath);
if (!api.mcpRegistry) {
clearMcpSuccessResetTimer(operationKey);
set((prev) => ({
@ -1045,6 +1198,30 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
return;
}
const preflightState = get();
if (preflightState.cliStatus === null || preflightState.cliStatusLoading) {
try {
await preflightState.fetchCliStatus();
} catch {
// fetchCliStatus stores the error in cliStatusError; map to a user-facing uninstall error below.
}
}
const uninstallDisableReason = getExtensionActionDisableReason({
isInstalled: true,
cliStatus: get().cliStatus,
cliStatusLoading: get().cliStatusLoading,
section: 'mcp',
});
if (uninstallDisableReason) {
clearMcpSuccessResetTimer(operationKey);
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' },
installErrors: { ...prev.installErrors, [operationKey]: uninstallDisableReason },
}));
return;
}
clearMcpSuccessResetTimer(operationKey);
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'pending' },
@ -1064,8 +1241,8 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
}
await Promise.all([
get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined),
get().runMcpDiagnostics(),
get().mcpFetchInstalled(projectPath),
get().runMcpDiagnostics(projectPath),
]);
set((prev) => ({
@ -1115,10 +1292,29 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
set({ apiKeySaving: true, apiKeysError: null });
try {
await api.apiKeys.save(request);
// Refresh the list to get updated masked values
const keys = await api.apiKeys.list();
set({ apiKeys: keys, apiKeySaving: false });
const savedKey = await api.apiKeys.save(request);
const warnings: string[] = [];
try {
const keys = await api.apiKeys.list();
set({ apiKeys: keys });
} catch (listError) {
warnings.push(
listError instanceof Error
? `API key saved, but failed to refresh key list. ${listError.message}`
: 'API key saved, but failed to refresh key list.'
);
set((prev) => ({
apiKeys: upsertApiKeyEntry(prev.apiKeys, savedKey),
}));
}
await get().fetchCliStatus();
const refreshError = get().cliStatusError;
if (refreshError) {
warnings.push(`API key saved, but failed to refresh provider status. ${refreshError}`);
}
set({ apiKeySaving: false, apiKeysError: warnings.length > 0 ? warnings.join(' ') : null });
} catch (err) {
set({
apiKeySaving: false,
@ -1137,6 +1333,13 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
set((prev) => ({
apiKeys: prev.apiKeys.filter((k) => k.id !== id),
}));
await get().fetchCliStatus();
const refreshError = get().cliStatusError;
set({
apiKeysError: refreshError
? `API key deleted, but failed to refresh provider status. ${refreshError}`
: null,
});
} catch (err) {
set({
apiKeysError: err instanceof Error ? err.message : 'Failed to delete API key',

View file

@ -1834,8 +1834,7 @@ function areTeamGraphSlotAssignmentsEqual(
for (const [stableOwnerId, leftAssignment] of leftEntries) {
const rightAssignment = right?.[stableOwnerId];
if (
!rightAssignment ||
rightAssignment.ringIndex !== leftAssignment.ringIndex ||
rightAssignment?.ringIndex !== leftAssignment.ringIndex ||
rightAssignment.sectorIndex !== leftAssignment.sectorIndex
) {
return false;
@ -2872,8 +2871,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
return (
(nextAssignment.ringIndex === assignment.ringIndex &&
nextAssignment.sectorIndex === assignment.sectorIndex) ||
(displacedAssignment != null &&
nextAssignment.ringIndex === displacedAssignment.ringIndex &&
(nextAssignment.ringIndex === displacedAssignment?.ringIndex &&
nextAssignment.sectorIndex === displacedAssignment.sectorIndex)
);
}

View file

@ -1,5 +1,5 @@
/**
* Notification and configuration types for Claude Agent Teams UI.
* Notification and configuration types for Agent Teams UI.
*
* Re-exports types from shared for backwards compatibility.
* The canonical definitions are in @shared/types/notifications.

View 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';
}
}

View file

@ -1,13 +1,41 @@
import { getSkillAudienceLabel, isSkillAvailableForProvider } from '@shared/utils/skillRoots';
import { isSupportedSlashCommandName } from '@shared/utils/slashCommands';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { TeamProviderId } from '@shared/types';
import type { SkillCatalogItem } from '@shared/types/extensions';
import type { KnownSlashCommandDefinition } from '@shared/utils/slashCommands';
function orderSkillsForProvider(
projectSkills: readonly SkillCatalogItem[],
userSkills: readonly SkillCatalogItem[],
providerId?: TeamProviderId
): SkillCatalogItem[] {
const visibleProjectSkills = projectSkills.filter((skill) =>
isSkillAvailableForProvider(skill.rootKind, providerId)
);
const visibleUserSkills = userSkills.filter((skill) =>
isSkillAvailableForProvider(skill.rootKind, providerId)
);
if (providerId !== 'codex') {
return [...visibleProjectSkills, ...visibleUserSkills];
}
const isCodexOnly = (skill: SkillCatalogItem) => skill.rootKind === 'codex';
return [
...visibleProjectSkills.filter(isCodexOnly),
...visibleProjectSkills.filter((skill) => !isCodexOnly(skill)),
...visibleUserSkills.filter(isCodexOnly),
...visibleUserSkills.filter((skill) => !isCodexOnly(skill)),
];
}
export function buildSlashCommandSuggestions(
builtIns: readonly KnownSlashCommandDefinition[],
projectSkills: readonly SkillCatalogItem[],
userSkills: readonly SkillCatalogItem[]
userSkills: readonly SkillCatalogItem[],
providerId?: TeamProviderId
): MentionSuggestion[] {
const builtInNames = new Set(builtIns.map((command) => command.name.trim().toLowerCase()));
const builtInSuggestions: MentionSuggestion[] = builtIns.map((command) => ({
@ -21,7 +49,7 @@ export function buildSlashCommandSuggestions(
const seenSkillNames = new Set<string>();
const skillSuggestions: MentionSuggestion[] = [];
for (const skill of [...projectSkills, ...userSkills]) {
for (const skill of orderSkillsForProvider(projectSkills, userSkills, providerId)) {
const normalizedFolderName = skill.folderName.trim().toLowerCase();
if (
!skill.isValid ||
@ -39,7 +67,7 @@ export function buildSlashCommandSuggestions(
name: skill.folderName,
command: `/${normalizedFolderName}`,
description: skill.description,
subtitle: skill.scope === 'project' ? 'Project skill' : 'Personal skill',
subtitle: `${skill.scope === 'project' ? 'Project skill' : 'Personal skill'} - ${getSkillAudienceLabel(skill.rootKind)}`,
searchText: `${skill.name} ${skill.folderName}`,
type: 'skill',
});

View file

@ -57,6 +57,22 @@ export interface CliExternalRuntimeDiagnostic {
detailMessage?: string | null;
}
export type CliExtensionCapabilityStatus = 'supported' | 'read-only' | 'unsupported';
export type CliExtensionOwnership = 'shared' | 'provider-scoped';
export interface CliExtensionCapability {
status: CliExtensionCapabilityStatus;
ownership: CliExtensionOwnership;
reason?: string | null;
}
export interface CliExtensionCapabilities {
plugins: CliExtensionCapability;
mcp: CliExtensionCapability;
skills: CliExtensionCapability;
apiKeys: CliExtensionCapability;
}
export type CliProviderModelAvailabilityStatus =
| 'checking'
| 'available'
@ -85,6 +101,7 @@ export interface CliProviderStatus {
capabilities: {
teamLaunch: boolean;
oneShot: boolean;
extensions: CliExtensionCapabilities;
};
selectedBackendId?: string | null;
resolvedBackendId?: string | null;

View file

@ -52,7 +52,7 @@ export interface McpCatalogAPI {
) => Promise<{ servers: McpCatalogItem[]; nextCursor?: string }>;
getById: (registryId: string) => Promise<McpCatalogItem | null>;
getInstalled: (projectPath?: string) => Promise<InstalledMcpEntry[]>;
diagnose: () => Promise<McpServerDiagnostic[]>;
diagnose: (projectPath?: string) => Promise<McpServerDiagnostic[]>;
install: (request: McpInstallRequest) => Promise<OperationResult>;
installCustom: (request: McpCustomInstallRequest) => Promise<OperationResult>;
uninstall: (name: string, scope?: string, projectPath?: string) => Promise<OperationResult>;
@ -80,6 +80,6 @@ export interface ApiKeysAPI {
list: () => Promise<ApiKeyEntry[]>;
save: (request: ApiKeySaveRequest) => Promise<ApiKeyEntry>;
delete: (id: string) => Promise<void>;
lookup: (envVarNames: string[]) => Promise<ApiKeyLookupResult[]>;
lookup: (envVarNames: string[], projectPath?: string) => Promise<ApiKeyLookupResult[]>;
getStorageStatus: () => Promise<ApiKeyStorageStatus>;
}

View file

@ -9,6 +9,7 @@ export interface ApiKeyEntry {
envVarName: string;
maskedValue: string;
scope: 'user' | 'project';
projectPath?: string;
createdAt: string;
}
@ -19,6 +20,7 @@ export interface ApiKeySaveRequest {
envVarName: string;
value: string;
scope: 'user' | 'project';
projectPath?: string;
}
/** Decrypted key lookup result (for auto-fill) */

View file

@ -6,7 +6,7 @@
export type ExtensionOperationState = 'idle' | 'pending' | 'success' | 'error';
/** Installation scope — where the extension is installed */
export type InstallScope = 'local' | 'user' | 'project';
export type InstallScope = 'local' | 'user' | 'project' | 'global';
/** Result of a mutation operation */
export interface OperationResult<T = void> {

View file

@ -83,7 +83,7 @@ export interface McpHeaderDef {
export interface InstalledMcpEntry {
name: string;
scope: 'local' | 'user' | 'project';
scope: 'local' | 'user' | 'project' | 'global';
transport?: string;
}
@ -92,6 +92,8 @@ export type McpServerHealthStatus = 'connected' | 'needs-authentication' | 'fail
export interface McpServerDiagnostic {
name: string;
target: string;
scope?: 'local' | 'user' | 'project' | 'global' | 'dynamic' | 'managed';
transport?: string;
status: McpServerHealthStatus;
statusLabel: string;
rawLine: string;
@ -100,7 +102,7 @@ export interface McpServerDiagnostic {
// ── Install request (renderer → main, minimal trusted data) ────────────────
export type McpInstallScope = 'local' | 'user' | 'project';
export type McpInstallScope = 'local' | 'user' | 'project' | 'global';
export interface McpInstallRequest {
registryId: string; // server ID from registry (NOT full catalog item)

View file

@ -4,13 +4,13 @@
export type SkillScope = 'user' | 'project';
export type SkillRootKind = 'claude' | 'cursor' | 'agents';
export type SkillRootKind = 'claude' | 'cursor' | 'agents' | 'codex';
export type SkillSourceType = 'filesystem';
export type SkillInvocationMode = 'auto' | 'manual-only';
export type SkillIssueSeverity = 'warning' | 'error';
export type SkillIssueSeverity = 'info' | 'warning' | 'error';
export interface SkillDirectoryFlags {
hasScripts: boolean;

View file

@ -1,5 +1,5 @@
/**
* Notification and configuration types for Claude Agent Teams UI.
* Notification and configuration types for Agent Teams UI.
*
* These types define:
* - Detected errors from session files
@ -291,6 +291,8 @@ export interface AppConfig {
notifyOnTeamLaunched: boolean;
/** Whether to show native OS notifications when a tool needs user approval (Allow/Deny) */
notifyOnToolApproval: boolean;
/** Whether to automatically nudge a rate-limited team after the limit resets */
autoResumeOnRateLimit: boolean;
/** Only notify on status changes in solo teams (no teammates) */
statusChangeOnlySolo: boolean;
/** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */

View file

@ -1,5 +1,5 @@
/**
* Visualization-specific types for Claude Agent Teams UI.
* Visualization-specific types for Agent Teams UI.
*
* These types are used for waterfall chart visualization
* and are shared between main and renderer processes.

View file

@ -2,6 +2,11 @@
* Pure-function normalizers for Extension Store data.
*/
import {
getCliProviderExtensionCapability,
isCliExtensionCapabilityMutable,
} from './providerExtensionCapabilities';
import type {
CliInstallationStatus,
InstalledMcpEntry,
@ -104,17 +109,46 @@ export function buildPluginId(pluginName: string, marketplaceName: string): stri
/**
* Namespaced operation-state key for plugin install/uninstall UI state.
*/
export function getPluginOperationKey(pluginId: string, scope: InstallScope): string {
export function getPluginOperationKey(
pluginId: string,
scope: InstallScope,
projectPath?: string | null
): string {
if (scope === 'project' || scope === 'local') {
return `plugin:${pluginId}:${scope}:${getMcpProjectStateKey(projectPath)}`;
}
return `plugin:${pluginId}:${scope}`;
}
/**
* Namespaced operation-state key for MCP install/uninstall UI state.
*/
export function getMcpOperationKey(registryId: string, scope: InstallScope): string {
export function getMcpOperationKey(
registryId: string,
scope: InstallScope,
projectPath?: string | null
): string {
if (scope === 'project' || scope === 'local') {
return `mcp:${registryId}:${scope}:${getMcpProjectStateKey(projectPath)}`;
}
return `mcp:${registryId}:${scope}`;
}
/**
* Namespaced lookup key for MCP diagnostics. Scope is included when available
* so the same server name can coexist across global/project/local installs.
*/
export function getMcpDiagnosticKey(name: string, scope?: string | null): string {
return scope ? `mcp-diagnostic:${scope}:${name}` : `mcp-diagnostic:${name}`;
}
/**
* Stable project-aware cache key for MCP installed/diagnostics state.
*/
export function getMcpProjectStateKey(projectPath?: string | null): string {
return projectPath ?? '__global__';
}
/**
* Check whether a plugin has an installation for the selected scope.
*/
@ -135,6 +169,7 @@ function summarizeInstallationScopes(scopes: InstallScope[]): string | null {
}
switch (scopes[0]) {
case 'global':
case 'user':
return 'Installed globally';
case 'project':
@ -159,6 +194,7 @@ export function getInstallationSummaryLabel(
const MCP_SCOPE_PRIORITY: Record<InstalledMcpEntry['scope'], number> = {
local: 0,
project: 1,
global: 2,
user: 2,
};
@ -175,7 +211,7 @@ export function getPreferredMcpInstallationEntry(
return [...installations].sort(
(left, right) => MCP_SCOPE_PRIORITY[left.scope] - MCP_SCOPE_PRIORITY[right.scope]
)[0]!;
)[0];
}
/**
@ -195,28 +231,74 @@ export function getExtensionActionDisableReason(options: {
isInstalled: boolean;
cliStatus: Pick<
CliInstallationStatus,
'installed' | 'authLoggedIn' | 'binaryPath' | 'launchError'
'installed' | 'authLoggedIn' | 'binaryPath' | 'launchError' | 'flavor' | 'providers'
> | null;
cliStatusLoading: boolean;
section?: 'plugins' | 'mcp';
}): string | null {
const { isInstalled, cliStatus, cliStatusLoading } = options;
const { isInstalled, cliStatus, cliStatusLoading, section = 'plugins' } = options;
if (cliStatusLoading) {
return 'Checking Claude CLI status...';
return 'Checking runtime status...';
}
if (cliStatus === null) {
return 'Checking Claude CLI availability...';
return 'Checking runtime availability...';
}
if (cliStatus.installed === false) {
if (cliStatus.binaryPath && cliStatus.launchError) {
return 'Claude CLI was found but failed to start. Open the Dashboard to repair or reinstall it.';
return 'The configured runtime was found but failed to start. Open the Dashboard to repair or reinstall it.';
}
return 'Claude CLI required. Install it from the Dashboard.';
return 'The configured runtime is required. Install or repair it from the Dashboard.';
}
if (!isInstalled && !cliStatus.authLoggedIn) {
return 'Claude CLI is installed but not signed in. Open the Dashboard to sign in.';
const providers = cliStatus.providers ?? [];
const isMultimodel = cliStatus.flavor === 'agent_teams_orchestrator';
if (section === 'mcp') {
if (!isMultimodel) {
return null;
}
const mutableProviders = providers.filter((provider) =>
isCliExtensionCapabilityMutable(getCliProviderExtensionCapability(provider, 'mcp'))
);
if (mutableProviders.length > 0) {
return null;
}
const reason = providers
.map((provider) => getCliProviderExtensionCapability(provider, 'mcp').reason)
.find((value): value is string => typeof value === 'string' && value.trim().length > 0);
return reason ?? 'MCP management is not supported by the current runtime.';
}
if (!isMultimodel) {
if (!isInstalled && !cliStatus.authLoggedIn) {
return 'Claude CLI is installed but not signed in. Open the Dashboard to sign in.';
}
return null;
}
const pluginProviders = providers.filter((provider) =>
isCliExtensionCapabilityMutable(getCliProviderExtensionCapability(provider, 'plugins'))
);
if (pluginProviders.length === 0) {
const reason = providers
.map((provider) => getCliProviderExtensionCapability(provider, 'plugins').reason)
.find((value): value is string => typeof value === 'string' && value.trim().length > 0);
return reason ?? 'Plugin installs are not supported by the current runtime.';
}
if (isInstalled) {
return null;
}
const authenticatedProvider = pluginProviders.find((provider) => provider.authenticated);
if (!authenticatedProvider) {
return `${pluginProviders[0]?.displayName ?? 'Anthropic'} is not connected. Open the Dashboard to sign in.`;
}
return null;

View 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;
}
}

View 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';
}

View file

@ -1,5 +1,5 @@
/**
* Detects rate limit messages from Claude.
* Detects rate limit messages from Claude and parses reset time from them.
*/
const RATE_LIMIT_SUBSTRING = "You've hit your limit";
@ -10,3 +10,229 @@ const RATE_LIMIT_SUBSTRING = "You've hit your limit";
export function isRateLimitMessage(text: string): boolean {
return text.includes(RATE_LIMIT_SUBSTRING);
}
// ---------------------------------------------------------------------------
// Reset-time parsing
// ---------------------------------------------------------------------------
/**
* Maps known Claude timezone abbreviations to fixed UTC offsets in minutes.
* We only include zones Claude's API has been observed to emit. When the
* message contains an explicit parenthesized timezone that is NOT in this
* map, the parser returns `null` rather than guessing. When no timezone is
* present at all, the hour:minute is treated as user-local time.
*/
const TIMEZONE_OFFSETS_MIN: Record<string, number> = {
UTC: 0,
GMT: 0,
// North America — standard times
EST: -5 * 60,
CST: -6 * 60,
MST: -7 * 60,
PST: -8 * 60,
// North America — daylight times
EDT: -4 * 60,
CDT: -5 * 60,
MDT: -6 * 60,
PDT: -7 * 60,
};
/**
* Attempts to parse the reset time from a Claude rate-limit message.
*
* Supported formats (case-insensitive):
* - "limit will reset at 3pm (PST)"
* - "limit will reset at 3:30 pm (PST)"
* - "limit will reset at 15:30 UTC"
* - "resets at 3pm" (local time assumed)
* - "resets in 2 hours"
* - "resets in 45 minutes"
*
* Returns `null` when the reset time cannot be extracted reliably. Also returns
* null for text that does not look like a rate-limit message, so the parser is
* safe to call on arbitrary strings.
*
* @param text the full rate-limit message text
* @param now reference "now" used to resolve wall-clock times and relative
* offsets (exposed for testability; defaults to `new Date()`)
*/
export function parseRateLimitResetTime(text: string, now: Date = new Date()): Date | null {
if (!text) return null;
// Defensive gate: only parse text that actually looks like a rate-limit
// message. Prevents false positives from unrelated prose containing
// words like "reset" (e.g. "reset the 5pm meeting").
if (!isRateLimitMessage(text)) return null;
const relative = parseRelativeResetDuration(text);
if (relative !== null) {
return new Date(now.getTime() + relative);
}
return parseAbsoluteResetClockTime(text, now);
}
/**
* Matches trailing qualifiers that shift the reset to a different day.
* When present, we can't reliably resolve the date without more context, so
* the parser bails out. Example: "reset at 3pm (PST) next week" the naive
* "today or tomorrow" rollover would fire in hours instead of a week.
*/
const DAY_SHIFT_QUALIFIER_RE =
/\b(?:next\s+week|next\s+month|tomorrow|yesterday|on\s+(?:mon|tue|wed|thu|fri|sat|sun)[a-z]*)\b/i;
// ---------------------------------------------------------------------------
// Relative durations: "resets in 2 hours", "resets in 45 minutes"
// ---------------------------------------------------------------------------
const RESET_VERB_RE = /\breset(?:s|ting)?\b/i;
const LEADING_FILLER_RE = /^(?:about|around)\s+/i;
const LEADING_TIME_VALUE_RE = /^(\d+(?:\.\d+)?)\s*([a-z]+)\b/i;
function parseRelativeResetDuration(text: string): number | null {
const resetVerbMatch = RESET_VERB_RE.exec(text);
if (!resetVerbMatch) return null;
const afterVerb = text.slice(resetVerbMatch.index + resetVerbMatch[0].length).trimStart();
if (!afterVerb.toLowerCase().startsWith('in')) return null;
let tail = afterVerb.slice(2).trimStart();
if (tail.startsWith('~')) {
tail = tail.slice(1).trimStart();
}
tail = tail.replace(LEADING_FILLER_RE, '');
const match = LEADING_TIME_VALUE_RE.exec(tail);
if (!match) return null;
const amount = Number.parseFloat(match[1]!);
if (!Number.isFinite(amount) || amount < 0) return null;
const unit = match[2]!.toLowerCase();
if (['second', 'seconds', 'sec', 'secs', 's'].includes(unit)) {
return Math.round(amount * 1000);
}
if (['minute', 'minutes', 'min', 'mins', 'm'].includes(unit)) {
return Math.round(amount * 60 * 1000);
}
if (['hour', 'hours', 'hr', 'hrs', 'h'].includes(unit)) {
return Math.round(amount * 60 * 60 * 1000);
}
return null;
}
// ---------------------------------------------------------------------------
// Absolute clock times: "resets at 3pm (PST)", "resets at 15:30 UTC"
// ---------------------------------------------------------------------------
/**
* Captures the clock time + optional timezone abbreviation from phrases like
* "reset at 3pm (PST)" or "resets at 15:30 UTC".
*/
const LEADING_CLOCK_RE = /^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\b/i;
const PAREN_TZ_RE = /^\(([A-Za-z]{2,5})\)/;
const TRAILING_TZ_RE = /^([A-Za-z]{2,5})\b/;
function parseAbsoluteResetClockTime(text: string, now: Date): Date | null {
const resetVerbMatch = RESET_VERB_RE.exec(text);
if (!resetVerbMatch) return null;
let tail = text.slice(resetVerbMatch.index + resetVerbMatch[0].length).trimStart();
if (tail.toLowerCase().startsWith('at ')) {
tail = tail.slice(3).trimStart();
}
const match = LEADING_CLOCK_RE.exec(tail);
if (!match) return null;
tail = tail.slice(match[0].length).trimStart();
const parenthesizedTzMatch = PAREN_TZ_RE.exec(tail);
const bareWordMatch = parenthesizedTzMatch ? null : TRAILING_TZ_RE.exec(tail);
const bareTzMatch =
bareWordMatch && bareWordMatch[1].toUpperCase() in TIMEZONE_OFFSETS_MIN ? bareWordMatch : null;
const tzTokenLength = parenthesizedTzMatch?.[0].length ?? bareTzMatch?.[0].length ?? 0;
// If the text contains a day-shift qualifier ("next week", "on Tuesday",
// etc.), the "today or tomorrow" rollover below would produce a materially
// wrong time. Bail out and let the caller fall back to no auto-resume.
const afterMatch = tail.slice(tzTokenLength);
if (DAY_SHIFT_QUALIFIER_RE.test(afterMatch)) return null;
const hourRaw = Number.parseInt(match[1]!, 10);
const minuteRaw = match[2] ? Number.parseInt(match[2], 10) : 0;
const ampm = match[3]?.toLowerCase() ?? null;
const parenthesizedTz = parenthesizedTzMatch?.[1]?.toUpperCase() ?? '';
const trailingTz = bareTzMatch?.[1]?.toUpperCase() ?? '';
if (!Number.isFinite(hourRaw) || !Number.isFinite(minuteRaw)) return null;
if (minuteRaw < 0 || minuteRaw > 59) return null;
let hour = hourRaw;
if (ampm === 'pm' && hour < 12) hour += 12;
else if (ampm === 'am' && hour === 12) hour = 0;
if (hour < 0 || hour > 23) return null;
// Timezone resolution treats parenthesized vs bare tokens differently.
//
// "reset at 3pm (PST)" — parenthesized, authoritative. Unknown zone
// here means the sender meant a specific zone
// we don't model; bail out rather than guess.
// "reset at 3pm PST" — bare known abbreviation, same effect.
// "reset at 3pm today" — bare unknown word ("TODAY"). This is just a
// trailing word, not a real TZ claim; fall
// back to local time instead of suppressing.
// "reset at 3pm" — no token. Treat as user-local.
let tzOffset: number | null;
if (parenthesizedTz) {
if (!(parenthesizedTz in TIMEZONE_OFFSETS_MIN)) return null;
tzOffset = TIMEZONE_OFFSETS_MIN[parenthesizedTz]!;
} else if (trailingTz && trailingTz in TIMEZONE_OFFSETS_MIN) {
tzOffset = TIMEZONE_OFFSETS_MIN[trailingTz]!;
} else {
tzOffset = null;
}
const candidateSeed =
tzOffset === null
? buildLocalToday(now, hour, minuteRaw)
: buildUtcTodayWithOffset(now, hour, minuteRaw, tzOffset);
let candidate: Date = candidateSeed;
// If the computed time is materially in the past (e.g. "3pm" parsed while
// it's already 4pm), roll forward by one day. A small tolerance prevents
// near-present timestamps — stale messages, clock skew, sub-second drift —
// from being bumped 24 h forward, which would then trip the scheduler's
// 12 h ceiling and silently drop auto-resume altogether. Timestamps within
// `ROLLOVER_TOLERANCE_MS` of now fire immediately after the scheduler's
// own 30 s buffer and `Math.max(0, rawDelayMs)` clamp.
if (candidate.getTime() <= now.getTime() - ROLLOVER_TOLERANCE_MS) {
candidate = new Date(candidate.getTime() + 24 * 60 * 60 * 1000);
}
return candidate;
}
const ROLLOVER_TOLERANCE_MS = 60 * 1000;
function buildLocalToday(now: Date, hour: number, minute: number): Date {
const d = new Date(now);
d.setHours(hour, minute, 0, 0);
return d;
}
function buildUtcTodayWithOffset(
now: Date,
hour: number,
minute: number,
offsetMinutes: number
): Date {
// The caller's "hour:minute" is expressed in the target zone. Anchor the
// calendar date in that zone too — not in UTC — otherwise we get a 24h
// error when the zone-local day differs from UTC's day (e.g. 01:00 UTC is
// still "yesterday" for any negative-offset zone like PST).
const zoned = new Date(now.getTime() + offsetMinutes * 60 * 1000);
const offsetMs = offsetMinutes * 60 * 1000;
return new Date(
Date.UTC(zoned.getUTCFullYear(), zoned.getUTCMonth(), zoned.getUTCDate(), hour, minute, 0, 0) -
offsetMs
);
}

View 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')
);
}

View file

@ -115,6 +115,7 @@ describe('configValidation', () => {
'notifyOnClarifications',
'notifyOnStatusChange',
'notifyOnTeamLaunched',
'autoResumeOnRateLimit',
'statusChangeOnlySolo',
] as const)('accepts boolean %s toggle', (key) => {
const resultOn = validateConfigUpdatePayload('notifications', { [key]: true });
@ -136,6 +137,7 @@ describe('configValidation', () => {
'notifyOnClarifications',
'notifyOnStatusChange',
'notifyOnTeamLaunched',
'autoResumeOnRateLimit',
'statusChangeOnlySolo',
] as const)('rejects non-boolean %s', (key) => {
const result = validateConfigUpdatePayload('notifications', { [key]: 'yes' });

View file

@ -1,5 +1,5 @@
import * as os from 'os';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type {
BoardTaskActivityDetailResult,
BoardTaskActivityEntry,
@ -7,6 +7,8 @@ import type {
BoardTaskExactLogDetailResult,
BoardTaskExactLogSummariesResponse,
InboxMessage,
MessagesPage,
TeamViewSnapshot,
TeamCreateRequest,
TeamProvisioningProgress,
} from '@shared/types/team';
@ -120,6 +122,7 @@ import {
registerTeamHandlers,
removeTeamHandlers,
} from '../../../src/main/ipc/teams';
import { ConfigManager } from '../../../src/main/services/infrastructure/ConfigManager';
describe('ipc teams handlers', () => {
const handlers = new Map<string, (...args: unknown[]) => Promise<unknown>>();
@ -134,7 +137,7 @@ describe('ipc teams handlers', () => {
const service = {
listTeams: vi.fn(async () => [{ teamName: 'my-team', displayName: 'My Team' }]),
getTeamData: vi.fn(async () => ({
getTeamData: vi.fn(async (): Promise<TeamViewSnapshot & { messages?: InboxMessage[] }> => ({
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
@ -147,7 +150,7 @@ describe('ipc teams handlers', () => {
feedRevision: 'rev-1',
messages: [] as InboxMessage[],
})),
getMessagesPage: vi.fn(async () => ({
getMessagesPage: vi.fn(async (..._args: unknown[]): Promise<MessagesPage> => ({
messages: [] as InboxMessage[],
nextCursor: null,
hasMore: false,
@ -216,10 +219,12 @@ describe('ipc teams handlers', () => {
launchTeam: vi.fn(async () => ({ runId: 'run-2' })),
sendMessageToTeam: vi.fn(async () => undefined),
isTeamAlive: vi.fn(() => true),
getCurrentRunId: vi.fn(() => 'run-2' as string | null),
pushLiveLeadProcessMessage: vi.fn(),
relayLeadInboxMessages: vi.fn(async () => 0),
relayMemberInboxMessages: vi.fn(async () => 0),
getLiveLeadProcessMessages: vi.fn(() => [] as InboxMessage[]),
getCurrentLeadSessionId: vi.fn(() => null as string | null),
getAliveTeams: vi.fn(() => ['my-team']),
getLeadActivityState: vi.fn(() => 'idle'),
stopTeam: vi.fn(() => undefined),
@ -275,6 +280,10 @@ describe('ipc teams handlers', () => {
registerTeamHandlers(ipcMain as never);
});
afterEach(() => {
vi.useRealTimers();
});
it('registers all expected handlers', () => {
expect(handlers.has(TEAM_LIST)).toBe(true);
expect(handlers.has(TEAM_GET_DATA)).toBe(true);
@ -828,6 +837,81 @@ describe('ipc teams handlers', () => {
(electron.app as { isPackaged: boolean }).isPackaged = false;
});
it('does not let a live duplicate of the same session rate-limit reply delay auto-resume', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-17T12:00:30.000Z'));
const configManager = ConfigManager.getInstance();
const actualConfig = configManager.getConfig();
const getConfigSpy = vi.spyOn(configManager, 'getConfig').mockImplementation(
() =>
({
...actualConfig,
notifications: {
...actualConfig.notifications,
autoResumeOnRateLimit: true,
},
}) as never
);
try {
provisioningService.isTeamAlive.mockReturnValue(true);
provisioningService.getCurrentLeadSessionId.mockReturnValue('sess-123');
provisioningService.sendMessageToTeam.mockResolvedValue(undefined);
service.getTeamData.mockResolvedValueOnce({
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
messages: [
{
from: 'team-lead',
text: "You've hit your limit. Resets in 5 minutes.",
timestamp: '2026-04-17T12:00:00.000Z',
read: true,
source: 'lead_session' as const,
messageId: 'persisted-rate-limit-1',
leadSessionId: 'sess-123',
},
],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
});
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
{
from: 'team-lead',
text: "You've hit your limit. Resets in 5 minutes.",
timestamp: '2026-04-17T12:00:02.000Z',
read: true,
source: 'lead_process' as const,
messageId: 'live-rate-limit-1',
leadSessionId: 'sess-123',
},
]);
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
const result = (await getDataHandler({} as never, 'my-team')) as {
success: boolean;
data: { messages?: InboxMessage[] };
};
expect(result.success).toBe(true);
expect(result.data.messages).toEqual([
expect.objectContaining({
source: 'lead_session',
messageId: 'persisted-rate-limit-1',
}),
]);
await vi.advanceTimersByTimeAsync(4 * 60 * 1000 + 59 * 1000);
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1100);
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledTimes(1);
} finally {
getConfigSpy.mockRestore();
}
});
it('uses the team-data worker for TEAM_GET_MESSAGES_PAGE when available', async () => {
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
mockTeamDataWorkerClient.getMessagesPage.mockResolvedValueOnce({
@ -965,6 +1049,552 @@ describe('ipc teams handlers', () => {
(electron.app as { isPackaged: boolean }).isPackaged = false;
});
it('rebuilds only the remaining auto-resume delay from persisted rate-limit history', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-17T12:02:00.000Z'));
const configManager = ConfigManager.getInstance();
const actualConfig = configManager.getConfig();
const getConfigSpy = vi.spyOn(configManager, 'getConfig').mockImplementation(
() =>
({
...actualConfig,
notifications: {
...actualConfig.notifications,
autoResumeOnRateLimit: true,
},
}) as never
);
try {
provisioningService.isTeamAlive.mockReturnValue(true);
provisioningService.getCurrentLeadSessionId.mockReturnValue('sess-live');
provisioningService.sendMessageToTeam.mockResolvedValue(undefined);
service.getTeamData.mockResolvedValueOnce({
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
messages: [
{
from: 'team-lead',
text: "You've hit your limit. Resets in 5 minutes.",
timestamp: '2026-04-17T12:00:00.000Z',
read: true,
source: 'lead_session' as const,
leadSessionId: 'sess-live',
messageId: 'rate-limit-1',
},
],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
});
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
const result = (await getDataHandler({} as never, 'my-team')) as {
success: boolean;
data: { messages: { source?: string; text: string }[] };
};
expect(result.success).toBe(true);
await vi.advanceTimersByTimeAsync(3 * 60 * 1000 + 29 * 1000);
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1100);
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledTimes(1);
} finally {
getConfigSpy.mockRestore();
}
});
it('can schedule auto-resume when the setting is enabled after an earlier history scan', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-17T12:02:00.000Z'));
const configManager = ConfigManager.getInstance();
const actualConfig = configManager.getConfig();
let autoResumeEnabled = false;
const getConfigSpy = vi.spyOn(configManager, 'getConfig').mockImplementation(
() =>
({
...actualConfig,
notifications: {
...actualConfig.notifications,
autoResumeOnRateLimit: autoResumeEnabled,
},
}) as never
);
try {
provisioningService.isTeamAlive.mockReturnValue(true);
provisioningService.getCurrentLeadSessionId.mockReturnValue('sess-live');
provisioningService.sendMessageToTeam.mockResolvedValue(undefined);
service.getTeamData.mockResolvedValue({
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
messages: [
{
from: 'team-lead',
text: "You've hit your limit. Resets in 5 minutes.",
timestamp: '2026-04-17T12:00:00.000Z',
read: true,
source: 'lead_session' as const,
leadSessionId: 'sess-live',
messageId: 'rate-limit-enable-later',
},
],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
});
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
const firstResult = (await getDataHandler({} as never, 'my-team')) as {
success: boolean;
};
expect(firstResult.success).toBe(true);
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
autoResumeEnabled = true;
const secondResult = (await getDataHandler({} as never, 'my-team')) as {
success: boolean;
};
expect(secondResult.success).toBe(true);
await vi.advanceTimersByTimeAsync(3 * 60 * 1000 + 29 * 1000);
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1100);
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledTimes(1);
} finally {
getConfigSpy.mockRestore();
}
});
it('retries a previously over-ceiling history message once it becomes schedulable', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-17T00:00:00.000Z'));
const configManager = ConfigManager.getInstance();
const actualConfig = configManager.getConfig();
const getConfigSpy = vi.spyOn(configManager, 'getConfig').mockImplementation(
() =>
({
...actualConfig,
notifications: {
...actualConfig.notifications,
autoResumeOnRateLimit: true,
},
}) as never
);
try {
provisioningService.isTeamAlive.mockReturnValue(true);
provisioningService.getCurrentLeadSessionId.mockReturnValue('sess-live');
provisioningService.sendMessageToTeam.mockResolvedValue(undefined);
service.getTeamData.mockResolvedValue({
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
messages: [
{
from: 'team-lead',
text: "You've hit your limit. Resets at 12:20 UTC.",
timestamp: '2026-04-17T00:00:00.000Z',
read: true,
source: 'lead_session' as const,
leadSessionId: 'sess-live',
messageId: 'rate-limit-over-ceiling',
},
],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
});
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
const firstResult = (await getDataHandler({} as never, 'my-team')) as {
success: boolean;
};
expect(firstResult.success).toBe(true);
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
vi.setSystemTime(new Date('2026-04-17T12:20:00.000Z'));
const secondResult = (await getDataHandler({} as never, 'my-team')) as {
success: boolean;
};
expect(secondResult.success).toBe(true);
await vi.advanceTimersByTimeAsync(29 * 1000);
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1500);
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledTimes(1);
} finally {
warnSpy.mockRestore();
getConfigSpy.mockRestore();
}
});
it('does not rebuild auto-resume from persisted history while the team is offline', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-17T12:02:00.000Z'));
const configManager = ConfigManager.getInstance();
const actualConfig = configManager.getConfig();
const getConfigSpy = vi.spyOn(configManager, 'getConfig').mockImplementation(
() =>
({
...actualConfig,
notifications: {
...actualConfig.notifications,
autoResumeOnRateLimit: true,
},
}) as never
);
try {
provisioningService.isTeamAlive.mockReturnValue(false);
provisioningService.sendMessageToTeam.mockResolvedValue(undefined);
service.getTeamData.mockResolvedValue({
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
messages: [
{
from: 'team-lead',
text: "You've hit your limit. Resets in 5 minutes.",
timestamp: '2026-04-17T12:00:00.000Z',
read: true,
source: 'lead_session' as const,
messageId: 'rate-limit-offline-history',
},
],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
});
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
const result = (await getDataHandler({} as never, 'my-team')) as {
success: boolean;
};
expect(result.success).toBe(true);
// Simulate the user manually starting a fresh run later; stale persisted history
// should not have armed an auto-resume timer while the team was offline.
provisioningService.isTeamAlive.mockReturnValue(true);
await vi.advanceTimersByTimeAsync(3 * 60 * 1000 + 31 * 1000);
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
} finally {
getConfigSpy.mockRestore();
}
});
it('does not rebuild auto-resume from an older lead session after the team was manually restarted', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-17T12:02:00.000Z'));
const configManager = ConfigManager.getInstance();
const actualConfig = configManager.getConfig();
const getConfigSpy = vi.spyOn(configManager, 'getConfig').mockImplementation(
() =>
({
...actualConfig,
notifications: {
...actualConfig.notifications,
autoResumeOnRateLimit: true,
},
}) as never
);
try {
provisioningService.isTeamAlive.mockReturnValue(true);
provisioningService.getCurrentLeadSessionId.mockReturnValue('sess-new');
provisioningService.sendMessageToTeam.mockResolvedValue(undefined);
service.getTeamData.mockResolvedValue({
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
messages: [
{
from: 'team-lead',
text: "You've hit your limit. Resets in 5 minutes.",
timestamp: '2026-04-17T12:00:00.000Z',
read: true,
source: 'lead_session' as const,
leadSessionId: 'sess-old',
messageId: 'rate-limit-old-session',
},
],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
});
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
const result = (await getDataHandler({} as never, 'my-team')) as {
success: boolean;
};
expect(result.success).toBe(true);
await vi.advanceTimersByTimeAsync(3 * 60 * 1000 + 31 * 1000);
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
} finally {
getConfigSpy.mockRestore();
}
});
it('does not arm lead auto-resume from a teammate inbox rate-limit message', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-17T12:02:00.000Z'));
const configManager = ConfigManager.getInstance();
const actualConfig = configManager.getConfig();
const getConfigSpy = vi.spyOn(configManager, 'getConfig').mockImplementation(
() =>
({
...actualConfig,
notifications: {
...actualConfig.notifications,
autoResumeOnRateLimit: true,
},
}) as never
);
try {
provisioningService.isTeamAlive.mockReturnValue(true);
provisioningService.getCurrentLeadSessionId.mockReturnValue('sess-live');
provisioningService.sendMessageToTeam.mockResolvedValue(undefined);
service.getTeamData.mockResolvedValue({
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
messages: [
{
from: 'alice',
to: 'team-lead',
text: "You've hit your limit. Resets in 5 minutes.",
timestamp: '2026-04-17T12:00:00.000Z',
read: false,
messageId: 'member-rate-limit-1',
},
],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
});
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
const result = (await getDataHandler({} as never, 'my-team')) as {
success: boolean;
};
expect(result.success).toBe(true);
await vi.advanceTimersByTimeAsync(3 * 60 * 1000 + 31 * 1000);
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
} finally {
getConfigSpy.mockRestore();
}
});
it('rebuilds capped newest messages through getMessagesPage so live duplicates do not leak back in', async () => {
service.getTeamData.mockResolvedValueOnce({
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
messages: Array.from({ length: 50 }, (_, index) => ({
from: 'alice',
text: `filler-${index}`,
timestamp: `2026-02-23T10:${String(index).padStart(2, '0')}:00.000Z`,
read: true,
source: 'inbox' as const,
messageId: `durable-${index}`,
})),
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
});
service.getMessagesPage.mockResolvedValueOnce({
messages: [
{
from: 'alice',
text: 'filler-0',
timestamp: '2026-02-23T10:00:00.000Z',
read: true,
source: 'inbox' as const,
messageId: 'durable-0',
},
],
nextCursor: null,
hasMore: false,
feedRevision: 'rev-1',
});
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
{
from: 'team-lead',
text: 'Already persisted thought',
timestamp: '2026-02-23T11:00:00.000Z',
read: true,
source: 'lead_process' as const,
messageId: 'live-dup',
leadSessionId: 'lead-1',
},
]);
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
const result = (await getDataHandler({} as never, 'my-team')) as {
success: boolean;
data: { messages?: InboxMessage[] };
};
expect(result.success).toBe(true);
expect(service.getMessagesPage).toHaveBeenCalledWith('my-team', {
limit: 50,
liveMessages: expect.arrayContaining([
expect.objectContaining({
messageId: 'live-dup',
source: 'lead_process',
}),
]),
});
expect(result.data.messages).toHaveLength(50);
});
it('overlays live lead_process messages onto the newest messages page', async () => {
service.getMessagesPage.mockImplementationOnce(async (...args: unknown[]) => {
const { liveMessages = [] } = (args[1] ?? {}) as { liveMessages?: InboxMessage[] };
return {
messages: [
{
from: 'user',
text: 'Ping',
timestamp: '2026-02-23T10:00:00.000Z',
read: true,
source: 'user_sent' as const,
messageId: 'durable-1',
},
...liveMessages,
].sort((left, right) => Date.parse(right.timestamp) - Date.parse(left.timestamp)),
nextCursor: '2026-02-23T10:00:00.000Z|durable-1',
hasMore: true,
feedRevision: 'rev-1',
} satisfies MessagesPage;
});
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
{
from: 'team-lead',
text: 'Команда поднята, приступаю к раздаче задач.',
timestamp: '2026-02-23T10:00:01.000Z',
read: true,
source: 'lead_process' as const,
messageId: 'live-1',
},
]);
const result = (await handlers.get(TEAM_GET_MESSAGES_PAGE)!({} as never, 'my-team', {
limit: 20,
})) as {
success: boolean;
data: { messages: InboxMessage[]; nextCursor: string | null; hasMore: boolean };
};
expect(result.success).toBe(true);
expect(result.data.messages).toHaveLength(2);
expect(result.data.messages[0]?.source).toBe('lead_process');
expect(result.data.messages[0]?.text).toBe('Команда поднята, приступаю к раздаче задач.');
expect(result.data.nextCursor).toBe('2026-02-23T10:00:00.000Z|durable-1');
expect(result.data.hasMore).toBe(true);
expect(service.getMessagesPage).toHaveBeenCalledWith('my-team', {
limit: 20,
cursor: undefined,
liveMessages: expect.arrayContaining([
expect.objectContaining({
source: 'lead_process',
messageId: 'live-1',
}),
]),
});
});
it('dedups live lead thoughts on the newest messages page when durable lead_session already exists', async () => {
service.getMessagesPage.mockImplementationOnce(async (...args: unknown[]) => {
const { liveMessages = [] } = (args[1] ?? {}) as { liveMessages?: InboxMessage[] };
expect(liveMessages).toHaveLength(1);
return {
messages: [
{
from: 'team-lead',
text: 'Hello there',
timestamp: '2026-02-23T10:00:00.000Z',
read: true,
source: 'lead_session' as const,
leadSessionId: 'lead-1',
messageId: 'durable-1',
},
],
nextCursor: null,
hasMore: false,
feedRevision: 'rev-1',
} satisfies MessagesPage;
});
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
{
from: 'team-lead',
text: 'Hello there',
timestamp: '2026-02-23T10:00:01.000Z',
read: true,
source: 'lead_process' as const,
leadSessionId: 'lead-1',
messageId: 'live-1',
},
]);
const result = (await handlers.get(TEAM_GET_MESSAGES_PAGE)!({} as never, 'my-team', {
limit: 20,
})) as {
success: boolean;
data: { messages: InboxMessage[] };
};
expect(result.success).toBe(true);
expect(result.data.messages).toHaveLength(1);
expect(result.data.messages[0]?.source).toBe('lead_session');
});
it('does not overlay live lead_process messages onto older paginated pages', async () => {
service.getMessagesPage.mockResolvedValueOnce({
messages: [
{
from: 'user',
text: 'Older durable message',
timestamp: '2026-02-23T09:59:00.000Z',
read: true,
source: 'user_sent' as const,
messageId: 'durable-older-1',
},
],
nextCursor: null,
hasMore: false,
feedRevision: 'rev-1',
});
const result = (await handlers.get(TEAM_GET_MESSAGES_PAGE)!({} as never, 'my-team', {
limit: 20,
cursor: '2026-02-23T10:00:00.000Z|cursor',
})) as {
success: boolean;
data: { messages: InboxMessage[] };
};
expect(result.success).toBe(true);
expect(provisioningService.getLiveLeadProcessMessages).not.toHaveBeenCalled();
expect(result.data.messages).toHaveLength(1);
expect(result.data.messages[0]?.messageId).toBe('durable-older-1');
});
it('keeps TEAM_GET_DATA read-only and never triggers reconcile side effects', async () => {
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
const result = (await getDataHandler({} as never, 'my-team')) as {

View 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();
});
});

View file

@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { parseMcpDiagnosticsOutput } from '@main/services/extensions/state/McpHealthDiagnosticsService';
import {
McpHealthDiagnosticsService,
parseMcpDiagnosticsJsonOutput,
parseMcpDiagnosticsOutput,
} from '@main/services/extensions/state/McpHealthDiagnosticsService';
describe('parseMcpDiagnosticsOutput', () => {
it('parses mixed MCP health lines from claude mcp list', () => {
@ -12,20 +16,20 @@ browsermcp: npx @browsermcp/mcp@latest - ✓ Connected
tavily-remote-mcp: npx -y mcp-remote https://mcp.tavily.com/mcp/?tavilyApiKey=test - ✗ Failed to connect
alpic: https://mcp.alpic.ai (HTTP) - ! Needs authentication`);
expect(diagnostics).toHaveLength(5);
expect(diagnostics).toHaveLength(3);
expect(diagnostics[0]).toMatchObject({
name: 'plugin:context7:context7',
target: 'npx -y @upstash/context7-mcp',
name: 'browsermcp',
target: 'npx @browsermcp/mcp@latest',
status: 'connected',
statusLabel: 'Connected',
});
expect(diagnostics[3]).toMatchObject({
expect(diagnostics[1]).toMatchObject({
name: 'tavily-remote-mcp',
target: 'npx -y mcp-remote https://mcp.tavily.com/mcp/?tavilyApiKey=test',
target: 'npx -y mcp-remote https://mcp.tavily.com/mcp/?tavilyApiKey=REDACTED',
status: 'failed',
statusLabel: 'Failed to connect',
});
expect(diagnostics[4]).toMatchObject({
expect(diagnostics[2]).toMatchObject({
name: 'alpic',
target: 'https://mcp.alpic.ai (HTTP)',
status: 'needs-authentication',
@ -40,4 +44,78 @@ another log line`);
expect(diagnostics).toEqual([]);
});
it('parses structured multimodel MCP diagnostics JSON', () => {
const diagnostics = parseMcpDiagnosticsJsonOutput(
JSON.stringify({
checkedAt: '2026-04-17T10:00:00.000Z',
diagnostics: [
{
name: 'context7',
target: 'npx -y @upstash/context7-mcp',
status: 'connected',
statusLabel: 'Connected',
},
{
name: 'tavily',
target: 'https://mcp.tavily.com/mcp?token=secret',
scope: 'global',
transport: 'http',
status: 'timeout',
statusLabel: 'Timed out',
},
{
name: 'plugin:context7:context7',
target: 'npx -y @upstash/context7-mcp',
scope: 'dynamic',
transport: 'stdio',
status: 'connected',
statusLabel: 'Connected',
},
],
})
);
expect(diagnostics).toEqual([
expect.objectContaining({
name: 'context7',
status: 'connected',
statusLabel: 'Connected',
}),
expect.objectContaining({
name: 'tavily',
target: 'https://mcp.tavily.com/mcp?token=REDACTED',
scope: 'global',
transport: 'http',
status: 'failed',
statusLabel: 'Timed out',
}),
]);
});
});
describe('McpHealthDiagnosticsService', () => {
it('delegates diagnostics to the active runtime adapter', async () => {
const diagnoseMcp = vi.fn().mockResolvedValue([
{
name: 'context7',
target: 'npx -y @upstash/context7-mcp',
status: 'connected',
statusLabel: 'Connected',
rawLine: 'context7: npx -y @upstash/context7-mcp - Connected',
checkedAt: 1,
},
]);
const service = new McpHealthDiagnosticsService({
flavor: 'agent_teams_orchestrator',
buildManagementCliEnv: vi.fn(),
getInstalledMcp: vi.fn(),
diagnoseMcp,
});
await expect(service.diagnose('/tmp/project-a')).resolves.toEqual([
expect.objectContaining({ name: 'context7' }),
]);
expect(diagnoseMcp).toHaveBeenCalledWith('/tmp/project-a');
});
});

View file

@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { ClaudeExtensionsAdapter } from '@main/services/extensions/runtime/ExtensionsRuntimeAdapter';
import { McpConfigStateReader } from '@main/services/extensions/runtime/McpConfigStateReader';
import { McpInstallationStateService } from '@main/services/extensions/state/McpInstallationStateService';
const TEST_ROOT = path.parse(process.cwd()).root || path.sep;
@ -14,7 +16,21 @@ function normalizeMockPath(filePath: unknown): string {
}
vi.mock('@main/utils/pathDecoder', () => ({
getHomeDir: () => MOCK_HOME_PATH,
getHomeDir: () => {
const cwd = process.cwd();
const windowsRoot = cwd.match(/^[A-Za-z]:[\\/]/)?.[0] ?? null;
const root = windowsRoot ?? '/';
const sep = windowsRoot ? '\\' : '/';
return `${root}tmp${sep}mock-home`.replaceAll('//', '/');
},
getClaudeBasePath: () => {
const cwd = process.cwd();
const windowsRoot = cwd.match(/^[A-Za-z]:[\\/]/)?.[0] ?? null;
const root = windowsRoot ?? '/';
const sep = windowsRoot ? '\\' : '/';
return `${root}tmp${sep}mock-home${sep}.claude`.replaceAll('//', '/');
},
setClaudeBasePathOverride: vi.fn(),
}));
vi.mock('node:fs/promises');
@ -24,7 +40,9 @@ describe('McpInstallationStateService', () => {
const mockedFs = vi.mocked(fs);
beforeEach(() => {
service = new McpInstallationStateService();
service = new McpInstallationStateService(
new ClaudeExtensionsAdapter(new McpConfigStateReader())
);
vi.clearAllMocks();
});
@ -156,5 +174,28 @@ describe('McpInstallationStateService', () => {
]);
expect(mockedFs.readFile).toHaveBeenCalledTimes(4);
});
it('supports multimodel MCP state through the runtime adapter contract', async () => {
const getInstalledMcp = vi
.fn()
.mockResolvedValueOnce([{ name: 'context7', scope: 'user', transport: 'stdio' }])
.mockResolvedValueOnce([{ name: 'repo-mcp', scope: 'project', transport: 'http' }]);
service = new McpInstallationStateService({
flavor: 'agent_teams_orchestrator',
buildManagementCliEnv: vi.fn(),
diagnoseMcp: vi.fn(),
getInstalledMcp,
});
await expect(service.getInstalled('/tmp/project-a')).resolves.toEqual([
{ name: 'context7', scope: 'user', transport: 'stdio' },
]);
await expect(service.getInstalled('/tmp/project-b')).resolves.toEqual([
{ name: 'repo-mcp', scope: 'project', transport: 'http' },
]);
expect(getInstalledMcp).toHaveBeenCalledTimes(2);
expect(getInstalledMcp).toHaveBeenNthCalledWith(1, '/tmp/project-a');
expect(getInstalledMcp).toHaveBeenNthCalledWith(2, '/tmp/project-b');
});
});
});

View file

@ -13,12 +13,16 @@ function normalizeMockPath(filePath: unknown): string {
return String(filePath).replaceAll('\\', '/');
}
// Mock pathDecoder to control ~/.claude path
vi.mock('@main/utils/pathDecoder', () => ({
getClaudeBasePath: () => MOCK_CLAUDE_BASE_PATH,
getClaudeBasePath: () => {
const cwd = process.cwd();
const windowsRoot = cwd.match(/^[A-Za-z]:[\\/]/)?.[0] ?? null;
const root = windowsRoot ?? '/';
const sep = windowsRoot ? '\\' : '/';
return `${root}tmp${sep}mock-claude`;
},
}));
// Mock filesystem
vi.mock('node:fs/promises');
describe('PluginInstallationStateService', () => {

View file

@ -48,6 +48,12 @@ unknown-key: true
message: expect.stringContaining('version'),
})
);
expect(item.issues).toContainEqual(
expect.objectContaining({
code: 'has-scripts',
severity: 'info',
})
);
});
it('marks missing frontmatter as invalid', () => {

View file

@ -8,9 +8,9 @@ describe('SkillRootsResolver', () => {
const roots = resolver.resolve();
expect(roots).toHaveLength(3);
expect(roots).toHaveLength(4);
expect(roots.every((root) => root.scope === 'user')).toBe(true);
expect(roots.map((root) => root.rootKind)).toEqual(['claude', 'cursor', 'agents']);
expect(roots.map((root) => root.rootKind)).toEqual(['claude', 'cursor', 'agents', 'codex']);
});
it('returns project and user roots when project path is provided', () => {
@ -18,8 +18,8 @@ describe('SkillRootsResolver', () => {
const roots = resolver.resolve('/tmp/demo-project');
expect(roots).toHaveLength(6);
expect(roots.filter((root) => root.scope === 'project')).toHaveLength(3);
expect(roots.filter((root) => root.scope === 'user')).toHaveLength(3);
expect(roots).toHaveLength(8);
expect(roots.filter((root) => root.scope === 'project')).toHaveLength(4);
expect(roots.filter((root) => root.scope === 'user')).toHaveLength(4);
});
});

View file

@ -40,6 +40,18 @@ describe('SkillValidator', () => {
expect(result[1].issues.map((issue) => issue.code)).toContain('duplicate-name');
});
it('does not warn when shared and codex-only overlays reuse the same skill name', () => {
const validator = new SkillValidator();
const result = validator.annotateCatalog([
makeSkill({ id: '/a', scope: 'project', rootKind: 'claude' }),
makeSkill({ id: '/b', scope: 'project', rootKind: 'codex' }),
]);
expect(result[0].issues.map((issue) => issue.code)).not.toContain('duplicate-name');
expect(result[1].issues.map((issue) => issue.code)).not.toContain('duplicate-name');
});
it('sorts by validity, scope, root precedence, then name', () => {
const validator = new SkillValidator();
@ -47,6 +59,7 @@ describe('SkillValidator', () => {
makeSkill({ id: '/3', name: 'z-user', scope: 'user', rootKind: 'claude' }),
makeSkill({ id: '/2', name: 'b-project-cursor', scope: 'project', rootKind: 'cursor' }),
makeSkill({ id: '/1', name: 'a-project-claude', scope: 'project', rootKind: 'claude' }),
makeSkill({ id: '/5', name: 'c-project-codex', scope: 'project', rootKind: 'codex' }),
makeSkill({
id: '/4',
name: 'invalid',
@ -55,6 +68,6 @@ describe('SkillValidator', () => {
}),
]);
expect(result.map((item) => item.id)).toEqual(['/1', '/2', '/3', '/4']);
expect(result.map((item) => item.id)).toEqual(['/1', '/2', '/5', '/3', '/4']);
});
});

View file

@ -231,7 +231,7 @@ describe('CliInstallerService', () => {
verificationState: 'verified',
modelVerificationState: 'idle',
statusMessage: null,
models: ['gpt-5.4', 'gpt-5.2-codex'],
models: ['gpt-5.4', 'gpt-5.4-mini'],
modelAvailability: [],
canLoginFromUi: true,
capabilities: { teamLaunch: true, oneShot: true },
@ -267,14 +267,12 @@ describe('CliInstallerService', () => {
if (normalizedArgs === '--version') {
return { stdout: '2.3.4', stderr: '' };
}
if (normalizedArgs.includes('--model gpt-5.4-mini')) {
throw new Error("The 'gpt-5.4-mini' model is not supported in this Codex runtime.");
}
if (normalizedArgs.includes('--model gpt-5.4')) {
return { stdout: 'PONG', stderr: '' };
}
if (normalizedArgs.includes('--model gpt-5.2-codex')) {
throw new Error(
"The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account."
);
}
throw new Error(`Unexpected execCli call: ${normalizedArgs}`);
});
@ -288,9 +286,12 @@ describe('CliInstallerService', () => {
expect(status.providers.find((provider) => provider.providerId === 'codex')?.modelAvailability).toEqual([]);
const verifiedProvider = await service.verifyProviderModels('codex');
expect(verifiedProvider?.modelAvailability).toEqual([
expect.objectContaining({ modelId: 'gpt-5.4', status: 'checking' }),
]);
expect(verifiedProvider?.modelAvailability).toEqual(
expect.arrayContaining([
expect.objectContaining({ modelId: 'gpt-5.4', status: 'checking' }),
expect.objectContaining({ modelId: 'gpt-5.4-mini', status: 'checking' }),
])
);
expect(verifiedProvider?.modelAvailability).not.toEqual(
expect.arrayContaining([expect.objectContaining({ modelId: 'gpt-5.2-codex' })])
);
@ -302,6 +303,10 @@ describe('CliInstallerService', () => {
expect(latestCodexProvider?.modelAvailability).toEqual([
expect.objectContaining({ modelId: 'gpt-5.4', status: 'available' }),
expect.objectContaining({
modelId: 'gpt-5.4-mini',
status: 'unavailable',
}),
]);
});

View file

@ -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