diff --git a/src/main/http/teams.ts b/src/main/http/teams.ts index ed177e9c..77d9feb9 100644 --- a/src/main/http/teams.ts +++ b/src/main/http/teams.ts @@ -89,6 +89,16 @@ function assertOptionalEffort(value: unknown): EffortLevel | undefined { function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest { const payload = body && typeof body === 'object' ? (body as Record) : {}; + const providerId = + payload.providerId === 'codex' + ? 'codex' + : payload.providerId === 'gemini' + ? 'gemini' + : payload.providerId == null || payload.providerId === 'anthropic' + ? 'anthropic' + : (() => { + throw new HttpBadRequestError('providerId must be anthropic, codex, or gemini'); + })(); const prompt = assertOptionalString(payload.prompt, 'prompt'); const model = assertOptionalString(payload.model, 'model'); const effort = assertOptionalEffort(payload.effort); @@ -100,6 +110,7 @@ function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest return { teamName, cwd: assertAbsoluteCwd(payload.cwd), + providerId, ...(prompt && { prompt, }), diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 4848902b..e0e17945 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -101,6 +101,10 @@ import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore'; import { TeamMetaStore } from '../services/team/TeamMetaStore'; import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService'; import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore'; +import { + buildReplaceMembersDiff, + buildReplaceMembersSummaryMessage, +} from '../services/team/memberUpdateNotifications'; import { validateFromField, @@ -534,25 +538,30 @@ async function handleGetData( const tn = validated.value!; const startedAt = Date.now(); let data: TeamData; + setCurrentMainOp('team:getData'); try { - data = await getTeamDataService().getTeamData(tn); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if ( - message === `Team not found: ${tn}` && - getTeamProvisioningService().hasProvisioningRun(tn) - ) { - return { success: false, error: 'TEAM_PROVISIONING' }; - } - // Draft team: team.meta.json exists but config.json doesn't (provisioning failed before TeamCreate) - if (message === `Team not found: ${tn}`) { - const meta = await teamMetaStore.getMeta(tn); - if (meta) { - return { success: false, error: 'TEAM_DRAFT' }; + try { + data = await getTeamDataService().getTeamData(tn); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if ( + message === `Team not found: ${tn}` && + getTeamProvisioningService().hasProvisioningRun(tn) + ) { + return { success: false, error: 'TEAM_PROVISIONING' }; } + // Draft team: team.meta.json exists but config.json doesn't (provisioning failed before TeamCreate) + if (message === `Team not found: ${tn}`) { + const meta = await teamMetaStore.getMeta(tn); + if (meta) { + return { success: false, error: 'TEAM_DRAFT' }; + } + } + logger.error(`[teams:getData] ${message}`); + return { success: false, error: message }; } - logger.error(`[teams:getData] ${message}`); - return { success: false, error: message }; + } finally { + setCurrentMainOp(null); } const getDataMs = Date.now() - startedAt; if (getDataMs >= 1500) { @@ -833,6 +842,32 @@ function isValidEffort(value: unknown): value is EffortLevel { return typeof value === 'string' && VALID_EFFORT_LEVELS.includes(value); } +function parseOptionalMemberProviderId( + value: unknown +): + | { valid: true; value: 'anthropic' | 'codex' | 'gemini' | undefined } + | { valid: false; error: string } { + if (value === undefined || value === null || value === '') { + return { valid: true, value: undefined }; + } + if (value === 'anthropic' || value === 'codex' || value === 'gemini') { + return { valid: true, value }; + } + return { valid: false, error: 'member providerId must be anthropic, codex, or gemini' }; +} + +function parseOptionalMemberEffort( + value: unknown +): { valid: true; value: EffortLevel | undefined } | { valid: false; error: string } { + if (value === undefined || value === null || value === '') { + return { valid: true, value: undefined }; + } + if (isValidEffort(value)) { + return { valid: true, value }; + } + return { valid: false, error: 'member effort must be low, medium, or high' }; +} + async function validateProvisioningRequest( request: unknown ): Promise<{ valid: true; value: TeamCreateRequest } | { valid: false; error: string }> { @@ -884,10 +919,22 @@ async function validateProvisioningRequest( if (workflow !== undefined && typeof workflow !== 'string') { return { valid: false, error: 'member workflow must be string' }; } + const providerValidation = parseOptionalMemberProviderId( + (member as { providerId?: unknown }).providerId + ); + if (!providerValidation.valid) { + return { valid: false, error: providerValidation.error }; + } + const model = (member as { model?: unknown }).model; + if (model !== undefined && typeof model !== 'string') { + return { valid: false, error: 'member model must be string' }; + } members.push({ name: memberName, role: typeof role === 'string' ? role.trim() : undefined, workflow: typeof workflow === 'string' ? workflow.trim() : undefined, + providerId: providerValidation.value, + model: typeof model === 'string' ? model.trim() || undefined : undefined, }); } @@ -953,6 +1000,12 @@ async function validateProvisioningRequest( members, cwd, prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined, + providerId: + payload.providerId === 'codex' + ? 'codex' + : payload.providerId === 'gemini' + ? 'gemini' + : 'anthropic', model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined, effort: isValidEffort(payload.effort) ? payload.effort : undefined, skipPermissions: @@ -1089,6 +1142,16 @@ async function handleLaunchTeam( color: meta?.color, cwd, prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined, + providerId: + payload.providerId === 'codex' + ? 'codex' + : payload.providerId === 'gemini' + ? 'gemini' + : meta?.providerId === 'codex' + ? 'codex' + : meta?.providerId === 'gemini' + ? 'gemini' + : 'anthropic', model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined, effort: isValidEffort(payload.effort) ? payload.effort : undefined, limitContext: typeof payload.limitContext === 'boolean' ? payload.limitContext : undefined, @@ -1100,7 +1163,14 @@ async function handleLaunchTeam( typeof payload.extraCliArgs === 'string' ? payload.extraCliArgs.trim() || undefined : undefined, - members: members.map((m) => ({ name: m.name, role: m.role, workflow: m.workflow })), + members: members.map((m) => ({ + name: m.name, + role: m.role, + workflow: m.workflow, + providerId: m.providerId, + model: m.model, + effort: m.effort, + })), }; return wrapTeamHandler('create', () => @@ -1122,6 +1192,12 @@ async function handleLaunchTeam( teamName: validatedTeamName.value!, cwd, prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined, + providerId: + payload.providerId === 'codex' + ? 'codex' + : payload.providerId === 'gemini' + ? 'gemini' + : 'anthropic', model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined, effort: isValidEffort(payload.effort) ? payload.effort : undefined, clearContext: payload.clearContext === true ? true : undefined, @@ -1174,9 +1250,13 @@ async function handleValidateCliArgs( async function handlePrepareProvisioning( _event: IpcMainInvokeEvent, - cwd: unknown + cwd: unknown, + providerId: unknown, + providerIds: unknown ): Promise> { let validatedCwd: string | undefined; + let validatedProviderId: TeamLaunchRequest['providerId']; + let validatedProviderIds: Array<'anthropic' | 'codex' | 'gemini'> | undefined; if (cwd !== undefined) { if (typeof cwd !== 'string' || cwd.trim().length === 0) { return { success: false, error: 'cwd must be a non-empty string' }; @@ -1186,8 +1266,32 @@ async function handlePrepareProvisioning( return { success: false, error: 'cwd must be an absolute path' }; } } + if (providerId !== undefined) { + if (providerId !== 'anthropic' && providerId !== 'codex' && providerId !== 'gemini') { + return { success: false, error: 'providerId must be anthropic, codex, or gemini' }; + } + validatedProviderId = providerId; + } + if (providerIds !== undefined) { + if (!Array.isArray(providerIds)) { + return { success: false, error: 'providerIds must be an array when provided' }; + } + const normalized: Array<'anthropic' | 'codex' | 'gemini'> = []; + for (const entry of providerIds) { + if (entry !== 'anthropic' && entry !== 'codex' && entry !== 'gemini') { + return { success: false, error: 'providerIds entries must be anthropic, codex, or gemini' }; + } + if (!normalized.includes(entry)) { + normalized.push(entry); + } + } + validatedProviderIds = normalized; + } return wrapTeamHandler('prepareProvisioning', () => - getTeamProvisioningService().prepareForProvisioning(validatedCwd) + getTeamProvisioningService().prepareForProvisioning(validatedCwd, { + providerId: validatedProviderId, + providerIds: validatedProviderIds, + }) ); } @@ -2057,10 +2161,27 @@ async function handleCreateConfig( if (workflow !== undefined && typeof workflow !== 'string') { return { success: false, error: 'member workflow must be string' }; } + const providerValidation = parseOptionalMemberProviderId( + (member as { providerId?: unknown }).providerId + ); + if (!providerValidation.valid) { + return { success: false, error: providerValidation.error }; + } + const model = (member as { model?: unknown }).model; + if (model !== undefined && typeof model !== 'string') { + return { success: false, error: 'member model must be string' }; + } + const effortValidation = parseOptionalMemberEffort((member as { effort?: unknown }).effort); + if (!effortValidation.valid) { + return { success: false, error: effortValidation.error }; + } members.push({ name: memberName, role: typeof role === 'string' ? role.trim() : undefined, workflow: typeof workflow === 'string' ? workflow.trim() : undefined, + providerId: providerValidation.value, + model: typeof model === 'string' ? model.trim() || undefined : undefined, + effort: effortValidation.value, }); } @@ -2286,10 +2407,13 @@ async function handleAddMember( if (!payload || typeof payload !== 'object') { return { success: false, error: 'Invalid payload' }; } - const { name, role, workflow } = payload as { + const { name, role, workflow, providerId, model } = payload as { name?: unknown; role?: unknown; workflow?: unknown; + providerId?: unknown; + model?: unknown; + effort?: unknown; }; const vName = validateTeammateName(name); if (!vName.valid) return { success: false, error: vName.error ?? 'Invalid member name' }; @@ -2299,6 +2423,17 @@ async function handleAddMember( if (workflow !== undefined && typeof workflow !== 'string') { return { success: false, error: 'workflow must be a string' }; } + const providerValidation = parseOptionalMemberProviderId(providerId); + if (!providerValidation.valid) { + return { success: false, error: providerValidation.error }; + } + if (model !== undefined && typeof model !== 'string') { + return { success: false, error: 'model must be a string' }; + } + const effortValidation = parseOptionalMemberEffort((payload as { effort?: unknown }).effort); + if (!effortValidation.valid) { + return { success: false, error: effortValidation.error }; + } return wrapTeamHandler('addMember', async () => { const tn = vTeam.value!; @@ -2307,6 +2442,9 @@ async function handleAddMember( name: memberName, role: role, workflow: typeof workflow === 'string' ? workflow.trim() || undefined : undefined, + providerId: providerValidation.value, + model: typeof model === 'string' ? model.trim() || undefined : undefined, + effort: effortValidation.value, }); // If team is alive, notify the lead to spawn the new teammate @@ -2329,6 +2467,9 @@ async function handleAddMember( name: memberName, ...(typeof role === 'string' ? { role } : {}), ...(typeof workflow === 'string' ? { workflow } : {}), + ...(providerValidation.value ? { providerId: providerValidation.value } : {}), + ...(typeof model === 'string' && model.trim() ? { model: model.trim() } : {}), + ...(effortValidation.value ? { effort: effortValidation.value } : {}), }); try { await provisioning.sendMessageToTeam(tn, spawnMessage); @@ -2355,12 +2496,26 @@ async function handleReplaceMembers( return { success: false, error: 'members must be an array' }; } const seenNames = new Set(); - const members: { name: string; role?: string; workflow?: string }[] = []; + const members: { + name: string; + role?: string; + workflow?: string; + providerId?: 'anthropic' | 'codex' | 'gemini'; + model?: string; + effort?: 'low' | 'medium' | 'high'; + }[] = []; for (const item of payload.members) { if (!item || typeof item !== 'object') { return { success: false, error: 'member must be object' }; } - const m = item as { name?: unknown; role?: unknown; workflow?: unknown }; + const m = item as { + name?: unknown; + role?: unknown; + workflow?: unknown; + providerId?: unknown; + model?: unknown; + effort?: unknown; + }; const vName = validateTeammateName(m.name); if (!vName.valid) return { success: false, error: vName.error ?? 'Invalid member name' }; const name = vName.value!; @@ -2372,15 +2527,73 @@ async function handleReplaceMembers( if (m.workflow !== undefined && typeof m.workflow !== 'string') { return { success: false, error: 'member workflow must be string' }; } + const providerValidation = parseOptionalMemberProviderId( + (m as { providerId?: unknown }).providerId + ); + if (!providerValidation.valid) { + return { success: false, error: providerValidation.error }; + } + if (m.model !== undefined && typeof m.model !== 'string') { + return { success: false, error: 'member model must be string' }; + } + const effortValidation = parseOptionalMemberEffort((m as { effort?: unknown }).effort); + if (!effortValidation.valid) { + return { success: false, error: effortValidation.error }; + } members.push({ name, role: typeof m.role === 'string' ? m.role.trim() : undefined, workflow: typeof m.workflow === 'string' ? m.workflow.trim() : undefined, + providerId: providerValidation.value, + model: typeof m.model === 'string' ? m.model.trim() || undefined : undefined, + effort: effortValidation.value, }); } return wrapTeamHandler('replaceMembers', async () => { - await getTeamDataService().replaceMembers(vTeam.value!, { members }); + const tn = vTeam.value!; + const teamDataService = getTeamDataService(); + const previousMembers = (await teamDataService.getTeamData(tn)).members; + const diff = buildReplaceMembersDiff(previousMembers, members); + + await teamDataService.replaceMembers(tn, { members }); + + const provisioning = getTeamProvisioningService(); + if (!provisioning.isTeamAlive(tn)) { + return; + } + + let leadName = 'team-lead'; + let displayName = tn; + try { + const [resolvedLeadName, resolvedDisplayName] = await Promise.all([ + teamDataService.getLeadMemberName(tn), + teamDataService.getTeamDisplayName(tn), + ]); + leadName = resolvedLeadName || 'team-lead'; + displayName = resolvedDisplayName || tn; + } catch { + // Best-effort: fall back to default lead and team names + } + + for (const addedMember of diff.added) { + const spawnMessage = buildAddMemberSpawnMessage(tn, displayName, leadName, addedMember); + try { + await provisioning.sendMessageToTeam(tn, spawnMessage); + } catch { + logger.warn(`Failed to notify lead about new member "${addedMember.name}" in ${tn}`); + } + } + + const summaryMessage = buildReplaceMembersSummaryMessage(diff); + if (!summaryMessage) { + return; + } + try { + await provisioning.sendMessageToTeam(tn, summaryMessage); + } catch { + logger.warn(`Failed to notify lead about member updates in ${tn}`); + } }); } @@ -3088,6 +3301,7 @@ async function handleGetSavedRequest( color: meta.color, cwd: meta.cwd, prompt: meta.prompt, + providerId: meta.providerId ?? 'anthropic', model: meta.model, effort: meta.effort as TeamCreateRequest['effort'], skipPermissions: meta.skipPermissions, @@ -3098,6 +3312,9 @@ async function handleGetSavedRequest( name: m.name, role: m.role, workflow: m.workflow, + providerId: m.providerId, + model: m.model, + effort: m.effort, })), }, }; diff --git a/src/main/services/infrastructure/EventLoopLagMonitor.ts b/src/main/services/infrastructure/EventLoopLagMonitor.ts index 7af62b24..c47fe9b8 100644 --- a/src/main/services/infrastructure/EventLoopLagMonitor.ts +++ b/src/main/services/infrastructure/EventLoopLagMonitor.ts @@ -27,6 +27,11 @@ export function startEventLoopLagMonitor(): void { // Only report meaningful stalls if (maxMs < 250) return; + // For known IPC/main-thread operations we already emit operation-specific + // timing diagnostics. Suppress the generic event-loop warning to avoid + // duplicate noisy logs that do not add new debugging value. + if (currentOp) return; + logger.warn( `Event loop stall detected: p95=${p95Ms.toFixed(1)}ms max=${maxMs.toFixed(1)}ms` + (currentOp ? ` op=${currentOp}` : '') diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 92bdbf5e..e3bc315f 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -948,6 +948,15 @@ export class TeamDataService { name, role: request.role?.trim() || undefined, workflow: request.workflow?.trim() || undefined, + providerId: + request.providerId === 'codex' || request.providerId === 'gemini' + ? request.providerId + : undefined, + model: request.model?.trim() || undefined, + effort: + request.effort === 'low' || request.effort === 'medium' || request.effort === 'high' + ? request.effort + : undefined, agentType: 'general-purpose', color: getMemberColorByName(name), joinedAt: Date.now(), @@ -977,7 +986,16 @@ export class TeamDataService { async replaceMembers( teamName: string, - request: { members: { name: string; role?: string; workflow?: string }[] } + request: { + members: { + name: string; + role?: string; + workflow?: string; + providerId?: 'anthropic' | 'codex' | 'gemini'; + model?: string; + effort?: 'low' | 'medium' | 'high'; + }[]; + } ): Promise { const existing = await this.membersMetaStore.getMembers(teamName); const existingLead = existing.find(isLeadMember) ?? null; @@ -1003,6 +1021,15 @@ export class TeamDataService { name, role: member.role?.trim() || undefined, workflow: member.workflow?.trim() || undefined, + providerId: + member.providerId === 'codex' || member.providerId === 'gemini' + ? member.providerId + : undefined, + model: member.model?.trim() || undefined, + effort: + member.effort === 'low' || member.effort === 'medium' || member.effort === 'high' + ? member.effort + : undefined, agentType: prev?.agentType ?? 'general-purpose', color: prev?.color ?? getMemberColorByName(name), joinedAt: prev?.joinedAt ?? joinedAt, @@ -1957,6 +1984,16 @@ export class TeamDataService { return name; })(), role: member.role?.trim() || undefined, + workflow: member.workflow?.trim() || undefined, + providerId: + member.providerId === 'codex' || member.providerId === 'gemini' + ? member.providerId + : undefined, + model: member.model?.trim() || undefined, + effort: + member.effort === 'low' || member.effort === 'medium' || member.effort === 'high' + ? member.effort + : undefined, agentType: 'general-purpose', color: getMemberColorByName(member.name.trim()), joinedAt, diff --git a/src/main/services/team/TeamMemberResolver.ts b/src/main/services/team/TeamMemberResolver.ts index c5368981..92832d0e 100644 --- a/src/main/services/team/TeamMemberResolver.ts +++ b/src/main/services/team/TeamMemberResolver.ts @@ -17,6 +17,7 @@ const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([ 'cross_team_list_targets', 'cross_team_get_outbox', ]); +const GENERATED_AGENT_ID_PATTERN = /^a[0-9a-f]{16}$/i; function looksLikeQualifiedExternalRecipient(name: string): boolean { const trimmed = name.trim(); @@ -51,6 +52,10 @@ function looksLikeCrossTeamToolRecipient(name: string): boolean { return CROSS_TEAM_TOOL_RECIPIENT_NAMES.has(name.trim()); } +function looksLikeGeneratedAgentId(name: string): boolean { + return GENERATED_AGENT_ID_PATTERN.test(name.trim()); +} + export class TeamMemberResolver { resolveMembers( config: TeamConfig, @@ -106,13 +111,25 @@ export class TeamMemberResolver { ) { continue; } + if (!explicitNames.has(trimmed.toLowerCase()) && looksLikeGeneratedAgentId(trimmed)) { + continue; + } addName(trimmed); } } const configMemberMap = new Map< string, - { agentType?: string; role?: string; workflow?: string; color?: string; cwd?: string } + { + agentType?: string; + role?: string; + workflow?: string; + providerId?: 'anthropic' | 'codex' | 'gemini'; + model?: string; + effort?: 'low' | 'medium' | 'high'; + color?: string; + cwd?: string; + } >(); if (Array.isArray(config.members)) { for (const m of config.members) { @@ -121,6 +138,9 @@ export class TeamMemberResolver { agentType: m.agentType, role: m.role, workflow: m.workflow, + providerId: m.providerId, + model: m.model, + effort: m.effort, color: m.color, cwd: m.cwd, }); @@ -130,7 +150,16 @@ export class TeamMemberResolver { const metaMemberMap = new Map< string, - { agentType?: string; role?: string; workflow?: string; color?: string; removedAt?: number } + { + agentType?: string; + role?: string; + workflow?: string; + providerId?: 'anthropic' | 'codex' | 'gemini'; + model?: string; + effort?: 'low' | 'medium' | 'high'; + color?: string; + removedAt?: number; + } >(); if (Array.isArray(metaMembers)) { for (const member of metaMembers) { @@ -139,6 +168,9 @@ export class TeamMemberResolver { agentType: member.agentType, role: member.role, workflow: member.workflow, + providerId: member.providerId, + model: member.model, + effort: member.effort, color: member.color, removedAt: member.removedAt, }); @@ -193,6 +225,9 @@ export class TeamMemberResolver { agentType: configMember?.agentType ?? metaMember?.agentType, role: configMember?.role ?? metaMember?.role, workflow: configMember?.workflow ?? metaMember?.workflow, + providerId: configMember?.providerId ?? metaMember?.providerId, + model: configMember?.model ?? metaMember?.model, + effort: configMember?.effort ?? metaMember?.effort, cwd: configMember?.cwd, removedAt: metaMember?.removedAt, }); diff --git a/src/main/services/team/TeamMembersMetaStore.ts b/src/main/services/team/TeamMembersMetaStore.ts index 78492e35..7bc612ef 100644 --- a/src/main/services/team/TeamMembersMetaStore.ts +++ b/src/main/services/team/TeamMembersMetaStore.ts @@ -24,6 +24,15 @@ function normalizeMember(member: TeamMember): TeamMember | null { name: trimmedName, role: typeof member.role === 'string' ? member.role.trim() || undefined : undefined, workflow: typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined, + providerId: + member.providerId === 'codex' || member.providerId === 'gemini' + ? member.providerId + : undefined, + model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined, + effort: + member.effort === 'low' || member.effort === 'medium' || member.effort === 'high' + ? member.effort + : undefined, agentType: typeof member.agentType === 'string' ? member.agentType.trim() || undefined : undefined, color: typeof member.color === 'string' ? member.color.trim() || undefined : undefined, diff --git a/src/main/services/team/TeamMetaStore.ts b/src/main/services/team/TeamMetaStore.ts index 3cc7aaa6..a8bce4dc 100644 --- a/src/main/services/team/TeamMetaStore.ts +++ b/src/main/services/team/TeamMetaStore.ts @@ -18,6 +18,7 @@ export interface TeamMetaFile { color?: string; cwd: string; prompt?: string; + providerId?: 'anthropic' | 'codex' | 'gemini'; model?: string; effort?: string; skipPermissions?: boolean; @@ -82,6 +83,12 @@ export class TeamMetaStore { color: typeof file.color === 'string' ? file.color.trim() || undefined : undefined, cwd: file.cwd.trim(), prompt: typeof file.prompt === 'string' ? file.prompt.trim() || undefined : undefined, + providerId: + file.providerId === 'anthropic' || + file.providerId === 'codex' || + file.providerId === 'gemini' + ? file.providerId + : undefined, model: typeof file.model === 'string' ? file.model.trim() || undefined : undefined, effort: typeof file.effort === 'string' ? file.effort.trim() || undefined : undefined, skipPermissions: typeof file.skipPermissions === 'boolean' ? file.skipPermissions : undefined, @@ -101,6 +108,7 @@ export class TeamMetaStore { color: data.color?.trim() || undefined, cwd: data.cwd.trim(), prompt: data.prompt?.trim() || undefined, + providerId: data.providerId, model: data.model?.trim() || undefined, effort: data.effort?.trim() || undefined, skipPermissions: data.skipPermissions, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 3306ad81..9841a248 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -69,6 +69,8 @@ import { TeamMembersMetaStore } from './TeamMembersMetaStore'; import { TeamMetaStore } from './TeamMetaStore'; import { TeamSentMessagesStore } from './TeamSentMessagesStore'; import { TeamTaskReader } from './TeamTaskReader'; +import { applyProviderRuntimeEnv, resolveTeamProviderId } from '../runtime/providerRuntimeEnv'; +import { resolveGeminiRuntimeAuth } from '../runtime/geminiRuntimeAuth'; /** * Kill a team CLI process using SIGKILL (uncatchable). @@ -106,6 +108,7 @@ import type { ToolApprovalRequest, ToolApprovalSettings, ToolCallMeta, + TeamProviderId, } from '@shared/types'; const logger = createLogger('Service:TeamProvisioning'); @@ -121,6 +124,9 @@ const LOG_PROGRESS_THROTTLE_MS = 300; const UI_LOGS_TAIL_LIMIT = 128 * 1024; const PROBE_CACHE_TTL_MS = 36 * 60 * 60 * 1000; const PREFLIGHT_TIMEOUT_MS = 60000; +const PREFLIGHT_CODEX_TIMEOUT_MS = 20000; +const PREFLIGHT_GEMINI_TIMEOUT_MS = 15000; +const PREFLIGHT_BINARY_TIMEOUT_MS = 8000; const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000; const PREFLIGHT_AUTH_MAX_RETRIES = 2; const FS_MONITOR_POLL_MS = 2000; @@ -143,18 +149,69 @@ const HANDLED_STREAM_JSON_TYPES = new Set([ 'system', ]); const PREFLIGHT_PING_PROMPT = 'Output only the single word PONG.'; -const PREFLIGHT_PING_ARGS = [ - '-p', - PREFLIGHT_PING_PROMPT, - '--output-format', - 'text', - '--model', - 'haiku', - '--max-turns', - '1', - '--no-session-persistence', -] as const; const PREFLIGHT_EXPECTED = 'PONG'; +const PREFLIGHT_CODEX_MODEL = 'gpt-5.4-mini'; +const PREFLIGHT_GEMINI_MODEL = 'gemini-2.5-flash-lite'; + +function getPreflightPingModel(providerId: TeamProviderId | undefined): string { + switch (resolveTeamProviderId(providerId)) { + case 'codex': + return PREFLIGHT_CODEX_MODEL; + case 'gemini': + return PREFLIGHT_GEMINI_MODEL; + case 'anthropic': + default: + return 'haiku'; + } +} + +function getPreflightPingArgs(providerId: TeamProviderId | undefined): string[] { + return [ + '-p', + PREFLIGHT_PING_PROMPT, + '--output-format', + 'text', + '--model', + getPreflightPingModel(providerId), + '--max-turns', + '1', + '--no-session-persistence', + ]; +} + +function getPreflightTimeoutMs(providerId: TeamProviderId | undefined): number { + switch (resolveTeamProviderId(providerId)) { + case 'codex': + return PREFLIGHT_CODEX_TIMEOUT_MS; + case 'gemini': + return PREFLIGHT_GEMINI_TIMEOUT_MS; + case 'anthropic': + default: + return PREFLIGHT_TIMEOUT_MS; + } +} + +function isProbeTimeoutMessage(message: string): boolean { + const lower = message.toLowerCase(); + return ( + lower.includes('timeout running:') || + lower.includes('timed out') || + lower.includes('did not complete') || + lower.includes('etimedout') + ); +} + +function getTeamProviderLabel(providerId: TeamProviderId): string { + switch (providerId) { + case 'codex': + return 'Codex'; + case 'gemini': + return 'Gemini'; + case 'anthropic': + default: + return 'Anthropic'; + } +} type TeamsBaseLocation = 'configured' | 'default'; @@ -257,6 +314,8 @@ interface ProvisioningRun { * watchdog defers to retry messages for progress.message (retries are * more informative than the generic "CLI not responding" stall text). */ lastRetryAt: number; + /** Index of the latest api_retry warning block in provisioningOutputParts. */ + apiRetryWarningIndex: number | null; /** True after emitApiErrorWarning() fires once — prevents duplicate warnings and pre-complete false positives. */ apiErrorWarningEmitted: boolean; fsPhase: 'waiting_config' | 'waiting_members' | 'waiting_tasks' | 'all_files_found'; @@ -351,7 +410,12 @@ interface ProvisioningRun { type LeadActivityState = 'active' | 'idle' | 'offline'; -type ProvisioningAuthSource = 'anthropic_api_key' | 'anthropic_auth_token' | 'none'; +type ProvisioningAuthSource = + | 'anthropic_api_key' + | 'anthropic_auth_token' + | 'codex_runtime' + | 'gemini_runtime' + | 'none'; interface ProvisioningEnvResolution { env: NodeJS.ProcessEnv; @@ -405,6 +469,11 @@ async function ensureCwdExists(cwd: string): Promise { } } +function isMissingCwdSpawnError(message: string): boolean { + const lower = message.toLowerCase(); + return lower.includes('spawn ') && lower.includes(' enoent'); +} + /** @deprecated Use wrapAgentBlock from @shared/constants/agentBlocks instead. */ const wrapInAgentBlock = wrapAgentBlock; @@ -426,10 +495,16 @@ function buildMembersPrompt(members: TeamCreateRequest['members']): string { return members .map((member) => { const rolePart = member.role?.trim() ? ` (role: ${member.role.trim()})` : ''; + const providerPart = + member.providerId && member.providerId !== 'anthropic' + ? ` [provider: ${member.providerId}]` + : ''; + const modelPart = member.model?.trim() ? ` [model: ${member.model.trim()}]` : ''; + const effortPart = member.effort ? ` [effort: ${member.effort}]` : ''; const workflowPart = member.workflow?.trim() ? `\n Workflow/instructions:${formatWorkflowBlock(member.workflow, ' ')}` : ''; - return `- ${member.name}${rolePart}${workflowPart}`; + return `- ${member.name}${rolePart}${providerPart}${modelPart}${effortPart}${workflowPart}`; }) .join('\n'); } @@ -463,13 +538,21 @@ function buildMemberSpawnPrompt( leadName: string ): string { const role = member.role?.trim() || 'team member'; + const providerLine = + member.providerId && member.providerId !== 'anthropic' + ? `\nProvider override for this teammate: ${member.providerId}.` + : ''; + const modelLine = member.model?.trim() + ? `\nModel override for this teammate: ${member.model.trim()}.` + : ''; + const effortLine = member.effort ? `\nEffort override for this teammate: ${member.effort}.` : ''; const workflowBlock = member.workflow?.trim() ? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(member.workflow, '')}` : ''; const actionModeProtocol = protocols.buildActionModeProtocolText( protocols.MEMBER_DELEGATE_DESCRIPTION ); - return `You are ${member.name}, a ${role} on team "${displayName}" (${teamName}).${workflowBlock} + return `You are ${member.name}, a ${role} on team "${displayName}" (${teamName}).${providerLine}${modelLine}${effortLine}${workflowBlock} ${getAgentLanguageInstruction()} Your FIRST action: call MCP tool member_briefing with: @@ -499,6 +582,16 @@ function buildReconnectMemberSpawnPrompt( hasTasks: boolean ): string { const role = member.role?.trim() || 'team member'; + const providerLine = + member.providerId && member.providerId !== 'anthropic' + ? `\n Provider override for this teammate: ${member.providerId}.` + : ''; + const modelLine = member.model?.trim() + ? `\n Model override for this teammate: ${member.model.trim()}.` + : ''; + const effortLine = member.effort + ? `\n Effort override for this teammate: ${member.effort}.` + : ''; const workflowBlock = member.workflow?.trim() ? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(member.workflow, ' ')}` : ''; @@ -506,9 +599,15 @@ function buildReconnectMemberSpawnPrompt( protocols.buildActionModeProtocolText(protocols.MEMBER_DELEGATE_DESCRIPTION), ' ' ); + const providerArgLine = + member.providerId && member.providerId !== 'anthropic' + ? ` - provider: "${member.providerId}"\n` + : ''; + const modelArgLine = member.model?.trim() ? ` - model: "${member.model.trim()}"\n` : ''; + const effortArgLine = member.effort ? ` - effort: "${member.effort}"\n` : ''; return ` For "${member.name}": - - prompt: - You are ${member.name}, a ${role} on team "${teamName}" (${teamName}).${workflowBlock} +${providerArgLine}${modelArgLine}${effortArgLine} - prompt: + You are ${member.name}, a ${role} on team "${teamName}" (${teamName}).${providerLine}${modelLine}${effortLine}${workflowBlock} ${getAgentLanguageInstruction()} The team has been reconnected after a restart. @@ -546,7 +645,10 @@ export function buildAddMemberSpawnMessage( teamName: string, displayName: string, leadName: string, - member: Pick + member: Pick< + TeamCreateRequest['members'][number], + 'name' | 'role' | 'workflow' | 'providerId' | 'model' | 'effort' + > ): string { const roleHint = typeof member.role === 'string' && member.role.trim() @@ -562,15 +664,24 @@ export function buildAddMemberSpawnMessage( name: member.name, ...(member.role ? { role: member.role } : {}), ...(member.workflow ? { workflow: member.workflow } : {}), + ...(member.providerId ? { providerId: member.providerId } : {}), + ...(member.model ? { model: member.model } : {}), + ...(member.effort ? { effort: member.effort } : {}), }, displayName, teamName, leadName ); + const providerPart = + member.providerId && member.providerId !== 'anthropic' + ? `, provider="${member.providerId}"` + : ''; + const modelPart = member.model?.trim() ? `, model="${member.model.trim()}"` : ''; + const effortPart = member.effort ? `, effort="${member.effort}"` : ''; return ( `A new teammate "${member.name}"${roleHint} has been added to the team. ` + - `Please spawn them immediately using the **Agent** tool with team_name="${teamName}", name="${member.name}", subagent_type="general-purpose", and the exact prompt below:${workflowHint}\n\n` + + `Please spawn them immediately using the **Agent** tool with team_name="${teamName}", name="${member.name}", subagent_type="general-purpose"${providerPart}${modelPart}${effortPart}, and the exact prompt below:${workflowHint}\n\n` + indentMultiline(prompt, ' ') ); } @@ -926,15 +1037,18 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string { Per-member spawn instructions: ${request.members - .map( - (m) => ` For “${m.name}”: + .map((m) => { + const providerLine = + m.providerId && m.providerId !== 'anthropic' ? ` - provider: “${m.providerId}”\n` : ''; + const modelLine = m.model?.trim() ? ` - model: “${m.model.trim()}”\n` : ''; + return ` For “${m.name}”: - name: “${m.name}” - - prompt: +${providerLine}${modelLine} - prompt: ${buildMemberSpawnPrompt(m, displayName, request.teamName, leadName) .split('\n') .map((line) => ` ${line}`) - .join('\n')}` - ) + .join('\n')}`; + }) .join('\n\n')}`; const persistentContext = buildPersistentLeadContext({ @@ -1212,8 +1326,8 @@ type AuthWarningSource = 'probe' | 'stdout' | 'stderr' | 'assistant' | 'pre-comp const cachedProbeResults = new Map(); const probeInFlightByKey = new Map>(); -function createProbeCacheKey(cwd: string): string { - return `${path.resolve(cwd)}::${getClaudeBasePath()}`; +function createProbeCacheKey(cwd: string, providerId: TeamProviderId | undefined): string { + return `${path.resolve(cwd)}::${getClaudeBasePath()}::${resolveTeamProviderId(providerId)}`; } function isTransientProbeWarning(warning: string): boolean { @@ -1228,6 +1342,17 @@ function isTransientProbeWarning(warning: string): boolean { ); } +function isBinaryProbeWarning(warning: string): boolean { + const lower = warning.toLowerCase(); + return ( + (lower.includes('spawn ') && lower.includes(' enoent')) || + lower.includes('eacces') || + lower.includes('enoexec') || + lower.includes('bad cpu type in executable') || + lower.includes('image not found') + ); +} + interface PendingInboxRelayCandidate { recipient: string; sourceMessageId: string; @@ -2304,8 +2429,8 @@ export class TeamProvisioningService { async warmup(): Promise { try { const cwd = process.cwd(); - if (this.getFreshCachedProbeResult(cwd)) return; - const result = await this.getCachedOrProbeResult(cwd); + if (this.getFreshCachedProbeResult(cwd, 'anthropic')) return; + const result = await this.getCachedOrProbeResult(cwd, 'anthropic'); if (!result) return; logger.info('CLI warmup completed'); } catch (error) { @@ -2315,32 +2440,26 @@ export class TeamProvisioningService { async prepareForProvisioning( cwd?: string, - opts?: { forceFresh?: boolean } + opts?: { forceFresh?: boolean; providerId?: TeamProviderId; providerIds?: TeamProviderId[] } ): Promise { const targetCwdForValidation = cwd?.trim() || process.cwd(); await this.validatePrepareCwd(targetCwdForValidation); + const providerIds = Array.from( + new Set( + [opts?.providerId, ...(opts?.providerIds ?? [])] + .map((providerId) => resolveTeamProviderId(providerId)) + .filter((providerId): providerId is TeamProviderId => Boolean(providerId)) + ) + ); + if (providerIds.length === 0) { + providerIds.push('anthropic'); + } // Allow callers (e.g. scheduler warm-up) to bypass the 36h probe cache if (opts?.forceFresh) { - this.clearProbeCache(targetCwdForValidation); - } - - const cached = this.getFreshCachedProbeResult(targetCwdForValidation); - if (cached) { - const { warning, authSource } = cached; - const warnings: string[] = []; - if (warning) warnings.push(warning); - const isAuthFailure = warning ? this.isAuthFailureWarning(warning, 'probe') : false; - const ready = !warning || authSource !== 'none' || !isAuthFailure; - return { - ready, - message: ready - ? warnings.length > 0 - ? 'CLI is ready to launch (see notes)' - : 'CLI is warmed up and ready to launch' - : warning || 'CLI is not ready', - warnings: warnings.length > 0 ? warnings : undefined, - }; + for (const providerId of providerIds) { + this.clearProbeCache(targetCwdForValidation, providerId); + } } const targetCwd = cwd?.trim() || process.cwd(); @@ -2349,45 +2468,77 @@ export class TeamProvisioningService { } const warnings: string[] = []; + const blockingMessages: string[] = []; - const probeResult = await this.getCachedOrProbeResult(targetCwd); - if (!probeResult?.claudePath) { - throw new Error('Claude CLI not found; install it or provide a valid path'); - } - - const { authSource } = probeResult; - if (authSource === 'anthropic_api_key') { - logger.info('Auth: using explicit ANTHROPIC_API_KEY'); - } else if (authSource === 'anthropic_auth_token') { - logger.info('Auth: using ANTHROPIC_AUTH_TOKEN mapped to ANTHROPIC_API_KEY'); - } - - if (probeResult.warning) { - const isAuthFailure = this.isAuthFailureWarning(probeResult.warning, 'probe'); - if (authSource === 'none' && isAuthFailure) { - // No auth source + preflight indicates auth failure — block to avoid a confusing hang later. - return { - ready: false, - message: probeResult.warning, - warnings: warnings.length > 0 ? warnings : undefined, - }; + for (const providerId of providerIds) { + const cached = this.getFreshCachedProbeResult(targetCwdForValidation, providerId); + const probeResult = cached ?? (await this.getCachedOrProbeResult(targetCwd, providerId)); + if (!probeResult?.claudePath) { + throw new Error('Claude CLI not found; install it or provide a valid path'); } - // Preflight warnings (including timeouts) should not block provisioning. - warnings.push(probeResult.warning); + + const providerLabel = getTeamProviderLabel(providerId); + const { authSource } = probeResult; + if (authSource === 'anthropic_api_key') { + logger.info(`Auth: using explicit ANTHROPIC_API_KEY for ${providerLabel}`); + } else if (authSource === 'anthropic_auth_token') { + logger.info( + `Auth: using ANTHROPIC_AUTH_TOKEN mapped to ANTHROPIC_API_KEY for ${providerLabel}` + ); + } + + if (!probeResult.warning) { + continue; + } + + const prefixedWarning = + providerIds.length > 1 ? `${providerLabel}: ${probeResult.warning}` : probeResult.warning; + const isAuthFailure = this.isAuthFailureWarning(probeResult.warning, 'probe'); + if ( + (authSource === 'none' || + authSource === 'codex_runtime' || + authSource === 'gemini_runtime') && + isAuthFailure + ) { + blockingMessages.push(prefixedWarning); + } else if (isBinaryProbeWarning(probeResult.warning)) { + blockingMessages.push(prefixedWarning); + } else { + // Preflight warnings (including timeouts) should not block provisioning. + warnings.push(prefixedWarning); + } + } + + if (blockingMessages.length > 0) { + return { + ready: false, + message: + blockingMessages.length === 1 + ? blockingMessages[0]! + : 'Some provider runtimes are not ready', + warnings: blockingMessages.length > 1 ? blockingMessages : undefined, + }; } return { ready: true, message: - warnings.length > 0 - ? 'CLI is ready to launch (see notes)' - : 'CLI is warmed up and ready to launch', + providerIds.length > 1 + ? warnings.length > 0 + ? `Validated ${providerIds.length}/${providerIds.length} provider runtimes (see notes)` + : `Validated ${providerIds.length}/${providerIds.length} provider runtimes` + : warnings.length > 0 + ? 'CLI is ready to launch (see notes)' + : 'CLI is warmed up and ready to launch', warnings: warnings.length > 0 ? warnings : undefined, }; } - private getFreshCachedProbeResult(cwd: string): CachedProbeResult | null { - const cacheKey = createProbeCacheKey(cwd); + private getFreshCachedProbeResult( + cwd: string, + providerId: TeamProviderId | undefined + ): CachedProbeResult | null { + const cacheKey = createProbeCacheKey(cwd, providerId); const cached = cachedProbeResults.get(cacheKey); if (!cached) return null; const ageMs = Date.now() - cached.cachedAtMs; @@ -2398,8 +2549,8 @@ export class TeamProvisioningService { return cached; } - private clearProbeCache(cwd: string): void { - cachedProbeResults.delete(createProbeCacheKey(cwd)); + private clearProbeCache(cwd: string, providerId: TeamProviderId | undefined): void { + cachedProbeResults.delete(createProbeCacheKey(cwd, providerId)); } private async validatePrepareCwd(cwd: string): Promise { @@ -2414,15 +2565,18 @@ export class TeamProvisioningService { } } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return; + throw new Error(`Working directory does not exist: ${cwd}`); } throw error; } } - private async getCachedOrProbeResult(cwd: string): Promise { - const cacheKey = createProbeCacheKey(cwd); - const cached = this.getFreshCachedProbeResult(cwd); + private async getCachedOrProbeResult( + cwd: string, + providerId: TeamProviderId | undefined + ): Promise { + const cacheKey = createProbeCacheKey(cwd, providerId); + const cached = this.getFreshCachedProbeResult(cwd, providerId); if (cached) { return { claudePath: cached.claudePath, @@ -2440,8 +2594,8 @@ export class TeamProvisioningService { const claudePath = await ClaudeBinaryResolver.resolve(); if (!claudePath) return null; - const { env, authSource } = await this.buildProvisioningEnv(); - const probe = await this.probeClaudeRuntime(claudePath, cwd, env); + const { env, authSource } = await this.buildProvisioningEnv(providerId); + const probe = await this.probeClaudeRuntime(claudePath, cwd, env, providerId); const result = { claudePath, authSource, @@ -2451,7 +2605,8 @@ export class TeamProvisioningService { const shouldCache = !probe.warning || (!this.isAuthFailureWarning(probe.warning, 'probe') && - !isTransientProbeWarning(probe.warning)); + !isTransientProbeWarning(probe.warning) && + !isBinaryProbeWarning(probe.warning)); if (shouldCache) { cachedProbeResults.set(cacheKey, { cacheKey, ...result, cachedAtMs: Date.now() }); @@ -2480,8 +2635,14 @@ export class TeamProvisioningService { lower.includes('missing api key') || lower.includes('invalid api key') || lower.includes('authentication failed') || + lower.includes('not configured for runtime use') || + lower.includes('set gemini_api_key') || + lower.includes('google adc credentials') || + lower.includes('google_cloud_project') || + lower.includes('codex provider is not authenticated') || lower.includes('run `claude auth login`') || - lower.includes('claude auth login'); + lower.includes('claude auth login') || + lower.includes('claude-multimodel auth login'); if (hasExplicitCliAuthSignal) { return true; @@ -2515,6 +2676,56 @@ export class TeamProvisioningService { return text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); } + private normalizeApiRetryErrorMessage(text: string): string { + const sanitized = this.sanitizeCliSnippet(text).trim(); + if (!sanitized) { + return sanitized; + } + + const jsonMatch = sanitized.match(/^\d{3}\s+(\{[\s\S]*\})$/); + const jsonCandidate = jsonMatch?.[1] ?? (sanitized.startsWith('{') ? sanitized : null); + if (jsonCandidate) { + try { + const parsed = JSON.parse(jsonCandidate) as { + error?: { message?: unknown }; + message?: unknown; + }; + const nestedMessage = + typeof parsed.error?.message === 'string' + ? parsed.error.message + : typeof parsed.message === 'string' + ? parsed.message + : null; + if (nestedMessage) { + return this.normalizeApiRetryErrorMessage(nestedMessage); + } + } catch { + // Fall through to raw sanitized text. + } + } + + return sanitized + .replace(/^gemini cli backend error:\s*/i, '') + .replace(/^gemini api backend error:\s*/i, '') + .replace(/^api error:\s*\d+\s*/i, '') + .trim(); + } + + private isQuotaRetryMessage(text: string | undefined): boolean { + const lower = (text ?? '').toLowerCase(); + return ( + lower.includes('quota will reset after') || + lower.includes('exhausted your capacity on this model') || + lower.includes('resource exhausted') || + lower.includes('rate limit') || + lower.includes('rate_limit') + ); + } + + private toMarkdownCodeSafe(text: string): string { + return this.sanitizeCliSnippet(text).replace(/```/g, '``\\`'); + } + private extractApiErrorSnippet(text: string): string | null { const match = /api error:\s*\d{3}\b/i.exec(text) ?? /invalid_request_error/i.exec(text); if (match?.index === undefined) return null; @@ -3114,6 +3325,7 @@ export class TeamProvisioningService { stallWarningIndex: null, preStallMessage: null, lastRetryAt: 0, + apiRetryWarningIndex: null, apiErrorWarningEmitted: false, waitingTasksSince: null, provisioningComplete: false, @@ -3162,7 +3374,7 @@ export class TeamProvisioningService { const prompt = buildProvisioningPrompt(request); let child: ReturnType; - const { env: shellEnv } = await this.buildProvisioningEnv(); + const { env: shellEnv } = await this.buildProvisioningEnv(request.providerId); let mcpConfigPath: string; try { mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd); @@ -3207,6 +3419,7 @@ export class TeamProvisioningService { color: request.color, cwd: request.cwd, prompt: request.prompt, + providerId: request.providerId, model: request.model, effort: request.effort, skipPermissions: request.skipPermissions, @@ -3510,6 +3723,7 @@ export class TeamProvisioningService { teamName: request.teamName, members: expectedMemberSpecs, cwd: request.cwd, + providerId: request.providerId, skipPermissions: request.skipPermissions, }; @@ -3557,6 +3771,7 @@ export class TeamProvisioningService { stallWarningIndex: null, preStallMessage: null, lastRetryAt: 0, + apiRetryWarningIndex: null, apiErrorWarningEmitted: false, waitingTasksSince: null, provisioningComplete: false, @@ -3627,7 +3842,7 @@ export class TeamProvisioningService { Boolean(previousSessionId) ); let child: ReturnType; - const { env: shellEnv } = await this.buildProvisioningEnv(); + const { env: shellEnv } = await this.buildProvisioningEnv(request.providerId); let mcpConfigPath: string; try { mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd); @@ -4674,6 +4889,13 @@ export class TeamProvisioningService { const inp = input as Record; const teamName = typeof inp.team_name === 'string' ? inp.team_name.trim() : ''; const memberName = typeof inp.name === 'string' ? inp.name.trim() : ''; + if (teamName && !memberName) { + logger.warn( + `[captureTeamSpawnEvents] Agent call for team "${run.teamName}" is missing name — ` + + `runtime will spawn an ephemeral subagent instead of a persistent teammate` + ); + continue; + } if (!memberName) continue; if (!teamName) { logger.warn( @@ -5577,21 +5799,45 @@ export class TeamProvisioningService { const errorStatus = typeof msg.error_status === 'number' ? msg.error_status : undefined; const errorLabel = typeof msg.error === 'string' ? msg.error.replace(/_/g, ' ') : undefined; const retryDelay = typeof msg.retry_delay_ms === 'number' ? msg.retry_delay_ms : undefined; + const errorMessage = + typeof msg.error_message === 'string' && msg.error_message.trim().length > 0 + ? this.normalizeApiRetryErrorMessage(msg.error_message.trim()) + : undefined; + const looksLikeQuotaRetry = + errorLabel === 'rate limit' || this.isQuotaRetryMessage(errorMessage); - // Use CLI's own error label (e.g. "rate limit") with status code - const statusLabel = errorLabel - ? `${errorLabel}${errorStatus ? ` (${errorStatus})` : ''}` - : `error ${errorStatus ?? 'unknown'}`; + // Use a human label for known quota/rate-limit retries instead of a misleading 500 bucket. + const statusLabel = looksLikeQuotaRetry + ? 'rate limited' + : errorLabel + ? `${errorLabel}${errorStatus ? ` (${errorStatus})` : ''}` + : `error ${errorStatus ?? 'unknown'}`; const delayLabel = retryDelay ? ` — next retry in ${Math.round(retryDelay / 1000)}s` : ''; - const retryText = `API retry ${attempt}/${maxRetries}: ${statusLabel}${delayLabel}`; + const retryText = `API retry ${attempt}/${maxRetries}: ${statusLabel}${ + errorMessage ? ` — ${errorMessage}` : '' + }${delayLabel}`; if (!run.provisioningComplete) { + const warningText = errorMessage + ? `**API retry ${attempt}/${maxRetries}: ${statusLabel}**\n\n\`\`\`\n${this.toMarkdownCodeSafe( + errorMessage + )}\n\`\`\`\n\n${retryDelay ? `Next retry in ${Math.round(retryDelay / 1000)}s.` : 'Retrying...'}` + : `**API retry ${attempt}/${maxRetries}: ${statusLabel}**\n\n${ + retryDelay ? `Next retry in ${Math.round(retryDelay / 1000)}s.` : 'Retrying...' + }`; + if (run.apiRetryWarningIndex != null) { + run.provisioningOutputParts[run.apiRetryWarningIndex] = warningText; + } else { + run.apiRetryWarningIndex = run.provisioningOutputParts.length; + run.provisioningOutputParts.push(warningText); + } run.lastRetryAt = Date.now(); run.progress = { ...run.progress, updatedAt: nowIso(), message: retryText, messageSeverity: 'error' as const, + assistantOutput: run.provisioningOutputParts.join('\n\n'), }; run.onProgress(run.progress); } @@ -7563,7 +7809,9 @@ export class TeamProvisioningService { } } - private async buildProvisioningEnv(): Promise { + private async buildProvisioningEnv( + providerId: TeamProviderId | undefined = 'anthropic' + ): Promise { const shellEnv = await resolveInteractiveShellEnv(); // getHomeDir() uses Electron's app.getPath('home') which handles Unicode // correctly on Windows. Prefer it over process.env which may be garbled. @@ -7605,6 +7853,7 @@ export class TeamProvisioningService { : {}), CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', }; + applyProviderRuntimeEnv(env, providerId); const controlApiBaseUrl = await this.resolveControlApiBaseUrl(); if (controlApiBaseUrl) { @@ -7631,6 +7880,14 @@ export class TeamProvisioningService { env.XDG_STATE_HOME = xdgStateHome; } + if (resolveTeamProviderId(providerId) === 'codex') { + return { env, authSource: 'codex_runtime' }; + } + + if (resolveTeamProviderId(providerId) === 'gemini') { + return { env, authSource: 'gemini_runtime' }; + } + // 1. Explicit ANTHROPIC_API_KEY — works with `-p` mode directly if (typeof env.ANTHROPIC_API_KEY === 'string' && env.ANTHROPIC_API_KEY.trim().length > 0) { return { env, authSource: 'anthropic_api_key' }; @@ -8300,6 +8557,15 @@ export class TeamProvisioningService { name: member.name.trim(), role: member.role?.trim() || undefined, workflow: member.workflow?.trim() || undefined, + providerId: + member.providerId === 'codex' || member.providerId === 'gemini' + ? member.providerId + : undefined, + model: member.model?.trim() || undefined, + effort: + member.effort === 'low' || member.effort === 'medium' || member.effort === 'high' + ? member.effort + : undefined, agentType: 'general-purpose', color: getMemberColorByName(member.name.trim()), joinedAt, @@ -8337,14 +8603,27 @@ export class TeamProvisioningService { const role = typeof member.role === 'string' ? member.role.trim() || undefined : undefined; const workflow = typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined; + const providerId = + member.providerId === 'codex' || member.providerId === 'gemini' + ? member.providerId + : undefined; + const model = + typeof member.model === 'string' ? member.model.trim() || undefined : undefined; + const effort = + member.effort === 'low' || member.effort === 'medium' || member.effort === 'high' + ? member.effort + : undefined; const prev = byName.get(name); if (!prev) { - byName.set(name, { name, role, workflow }); + byName.set(name, { name, role, workflow, providerId, model, effort }); } else { byName.set(name, { ...prev, role: prev.role || role, workflow: prev.workflow || workflow, + providerId: prev.providerId || providerId, + model: prev.model || model, + effort: prev.effort || effort, }); } } @@ -8392,8 +8671,35 @@ export class TeamProvisioningService { return !inboxNameSetLower.has(match[1].toLowerCase()); }); if (inboxNames.length > 0) { - const members = inboxNames.map((name) => ({ name })); - return { members, source: 'inboxes' }; + const configMembers = this.extractTeammateSpecsFromConfig(teamName, configRaw); + const configMembersByName = new Map( + configMembers.map((member) => [member.name.toLowerCase(), member] as const) + ); + const members = inboxNames.map((name) => { + const configMember = configMembersByName.get(name.toLowerCase()); + return { + name, + role: configMember?.role, + workflow: configMember?.workflow, + providerId: configMember?.providerId, + model: configMember?.model, + effort: configMember?.effort, + }; + }); + const memberOverridesUsed = members.some( + (member) => member.providerId || member.model || member.effort + ); + return { + members, + source: 'inboxes', + ...(memberOverridesUsed + ? { + warning: + 'Launch roster was recovered from inboxes and merged with config.json provider/model/effort overrides. ' + + 'Multimodel reconnect is best-effort in this fallback path.', + } + : {}), + }; } } catch (error) { logger.warn( @@ -8439,7 +8745,17 @@ export class TeamProvisioningService { configRaw: string ): TeamCreateRequest['members'] { try { - const parsed = JSON.parse(configRaw) as { members?: { name?: string; agentType?: string }[] }; + const parsed = JSON.parse(configRaw) as { + members?: { + name?: string; + role?: string; + workflow?: string; + agentType?: string; + provider?: string; + model?: string; + effort?: string; + }[]; + }; if (!Array.isArray(parsed.members)) { return []; } @@ -8450,7 +8766,21 @@ export class TeamProvisioningService { if (!member || isLeadMember(member) || lower === 'user') continue; const name = rawName; if (!name) continue; - byName.set(name, { name }); + byName.set(name, { + name, + role: typeof member.role === 'string' ? member.role.trim() || undefined : undefined, + workflow: + typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined, + providerId: + member.provider === 'codex' || member.provider === 'gemini' + ? member.provider + : undefined, + model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined, + effort: + member.effort === 'low' || member.effort === 'medium' || member.effort === 'high' + ? member.effort + : undefined, + }); } // Defense: ignore CLI auto-suffixed duplicates (alice-2) when base name exists. const allNames = Array.from(byName.keys()); @@ -8478,8 +8808,50 @@ export class TeamProvisioningService { private async probeClaudeRuntime( claudePath: string, cwd: string, - env: NodeJS.ProcessEnv + env: NodeJS.ProcessEnv, + providerId: TeamProviderId | undefined = 'anthropic' ): Promise<{ warning?: string }> { + const resolvedProviderId = resolveTeamProviderId(providerId); + try { + const versionProbe = await this.spawnProbe( + claudePath, + ['--version'], + cwd, + env, + PREFLIGHT_BINARY_TIMEOUT_MS + ); + if (versionProbe.exitCode !== 0) { + const errorText = + buildCombinedLogs(versionProbe.stdout, versionProbe.stderr) || + `Claude CLI exited with code ${versionProbe.exitCode ?? 'unknown'} during warm-up`; + return { + warning: `Claude CLI binary failed to start correctly. Details: ${errorText}`, + }; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (isMissingCwdSpawnError(message)) { + return { + warning: `Working directory does not exist: ${cwd}`, + }; + } + return { + warning: `Claude CLI binary failed to start. Details: ${message}`, + }; + } + + if (resolvedProviderId === 'gemini') { + const authState = await resolveGeminiRuntimeAuth(env); + if (authState.authenticated) { + return {}; + } + return { + warning: + authState.statusMessage ?? + 'Gemini provider is not configured for runtime use. Set GEMINI_API_KEY or Google ADC credentials (plus GOOGLE_CLOUD_PROJECT when needed) and retry.', + }; + } + // Stage 1: verify binary works (awaited first for clearer errors) // Important: keep this sequential with Stage 2 to avoid auth/credential-store races // when multiple `claude` processes start simultaneously (most visible on Windows). @@ -8503,10 +8875,10 @@ export class TeamProvisioningService { try { pingProbe = await this.spawnProbe( claudePath, - [...PREFLIGHT_PING_ARGS], + getPreflightPingArgs(providerId), cwd, env, - PREFLIGHT_TIMEOUT_MS, + getPreflightTimeoutMs(providerId), { resolveOnOutputMatch: ({ stdout, stderr }) => { const combined = `${stdout}\n${stderr}`.trim(); @@ -8516,7 +8888,7 @@ export class TeamProvisioningService { ); } catch (error) { const message = error instanceof Error ? error.message : String(error); - if (attempt < PREFLIGHT_AUTH_MAX_RETRIES) { + if (!isProbeTimeoutMessage(message) && attempt < PREFLIGHT_AUTH_MAX_RETRIES) { logger.warn( `Preflight ping failed (attempt ${attempt}/${PREFLIGHT_AUTH_MAX_RETRIES}), ` + `retrying in ${PREFLIGHT_AUTH_RETRY_DELAY_MS}ms: ${message}` @@ -8532,12 +8904,7 @@ export class TeamProvisioningService { } const combinedOutput = buildCombinedLogs(pingProbe.stdout, pingProbe.stderr); - const lowerOutput = combinedOutput.toLowerCase(); - const isAuthFailure = - lowerOutput.includes('not logged in') || - lowerOutput.includes('please run /login') || - lowerOutput.includes('missing api key') || - lowerOutput.includes('invalid api key'); + const isAuthFailure = this.isAuthFailureWarning(combinedOutput, 'probe'); if (isAuthFailure && attempt < PREFLIGHT_AUTH_MAX_RETRIES) { logger.warn( @@ -8550,10 +8917,14 @@ export class TeamProvisioningService { if (isAuthFailure || pingProbe.exitCode !== 0) { const hint = isAuthFailure - ? 'Claude CLI `-p` mode is not authenticated. ' + - 'Run `claude auth login` (or start `claude` and run `/login`) to authenticate. ' + - 'For automation/headless use, set ANTHROPIC_API_KEY.' + - (attempt > 1 ? ` (failed after ${attempt} attempts)` : '') + ? resolvedProviderId === 'codex' + ? 'Codex provider is not authenticated for `-p` mode. ' + + 'Run `claude-multimodel auth login --provider codex` and retry.' + + (attempt > 1 ? ` (failed after ${attempt} attempts)` : '') + : 'Claude CLI `-p` mode is not authenticated. ' + + 'Run `claude auth login` (or start `claude` and run `/login`) to authenticate. ' + + 'For automation/headless use, set ANTHROPIC_API_KEY.' + + (attempt > 1 ? ` (failed after ${attempt} attempts)` : '') : `Claude CLI preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}).`; return { warning: hint }; } @@ -8591,7 +8962,7 @@ export class TeamProvisioningService { return this.helpOutputCache; } const targetCwd = cwd ?? process.cwd(); - const probeResult = await this.getCachedOrProbeResult(targetCwd); + const probeResult = await this.getCachedOrProbeResult(targetCwd, 'anthropic'); if (!probeResult?.claudePath) { throw new Error('Claude CLI not found'); } diff --git a/src/main/services/team/memberUpdateNotifications.ts b/src/main/services/team/memberUpdateNotifications.ts new file mode 100644 index 00000000..c9cbbf77 --- /dev/null +++ b/src/main/services/team/memberUpdateNotifications.ts @@ -0,0 +1,154 @@ +export type MemberDiffInput = { + name: string; + role?: string; + workflow?: string; + providerId?: 'anthropic' | 'codex' | 'gemini'; + model?: string; + removedAt?: number | string | null; +}; + +export type ReplaceMembersDiff = { + added: Array<{ + name: string; + role?: string; + workflow?: string; + providerId?: 'anthropic' | 'codex' | 'gemini'; + model?: string; + }>; + removed: string[]; + updated: Array<{ + name: string; + changes: string[]; + }>; +}; + +function normalizeOptionalText(value: string | undefined): string | undefined { + const normalized = value?.trim(); + return normalized ? normalized : undefined; +} + +function describeRoleChange( + previousRole: string | undefined, + nextRole: string | undefined +): string | null { + if (previousRole === nextRole) { + return null; + } + if (previousRole && nextRole) { + return `role changed from "${previousRole}" to "${nextRole}"`; + } + if (nextRole) { + return `role set to "${nextRole}"`; + } + return 'role cleared'; +} + +function describeWorkflowChange( + previousWorkflow: string | undefined, + nextWorkflow: string | undefined +): string | null { + if (previousWorkflow === nextWorkflow) { + return null; + } + if (previousWorkflow && nextWorkflow) { + return 'workflow instructions were updated'; + } + if (nextWorkflow) { + return 'workflow instructions were added'; + } + return 'workflow instructions were cleared'; +} + +export function buildReplaceMembersDiff( + previousMembers: MemberDiffInput[], + nextMembers: Array<{ + name: string; + role?: string; + workflow?: string; + providerId?: 'anthropic' | 'codex' | 'gemini'; + model?: string; + }> +): ReplaceMembersDiff { + const previousByName = new Map( + previousMembers + .filter((member) => !member.removedAt && member.name.trim().toLowerCase() !== 'team-lead') + .map((member) => [ + member.name.trim().toLowerCase(), + { + name: member.name.trim(), + role: normalizeOptionalText(member.role), + workflow: normalizeOptionalText(member.workflow), + providerId: member.providerId, + model: normalizeOptionalText(member.model), + }, + ]) + ); + const nextByName = new Map( + nextMembers + .filter((member) => member.name.trim().toLowerCase() !== 'team-lead') + .map((member) => [ + member.name.trim().toLowerCase(), + { + name: member.name.trim(), + role: normalizeOptionalText(member.role), + workflow: normalizeOptionalText(member.workflow), + providerId: member.providerId, + model: normalizeOptionalText(member.model), + }, + ]) + ); + + const added = Array.from(nextByName.entries()) + .filter(([name]) => !previousByName.has(name)) + .map(([, member]) => member); + + const removed = Array.from(previousByName.entries()) + .filter(([name]) => !nextByName.has(name)) + .map(([, member]) => member.name) + .sort((a, b) => a.localeCompare(b)); + + const updated = Array.from(nextByName.entries()) + .flatMap(([name, nextMember]) => { + const previousMember = previousByName.get(name); + if (!previousMember) { + return []; + } + const changes = [ + describeRoleChange(previousMember.role, nextMember.role), + describeWorkflowChange(previousMember.workflow, nextMember.workflow), + ].filter((value): value is string => value !== null); + if (changes.length === 0) { + return []; + } + return [{ name: nextMember.name, changes }]; + }) + .sort((a, b) => a.name.localeCompare(b.name)); + + return { added, removed, updated }; +} + +export function buildReplaceMembersSummaryMessage(diff: ReplaceMembersDiff): string | null { + const lines: string[] = []; + + for (const name of diff.removed) { + lines.push( + `- Teammate "${name}" was removed from the team. Stop assigning them new work and reassign any active tasks if needed.` + ); + } + + for (const update of diff.updated) { + lines.push( + `- Teammate "${update.name}" was updated: ${update.changes.join('; ')}. Please send them refreshed instructions so their live behavior matches the new config.` + ); + } + + if (lines.length === 0) { + return null; + } + + return ( + 'The user updated the live team roster.\n' + + 'Apply these changes to the running team now:\n' + + lines.join('\n') + ); +} diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index d24b976c..0c74c587 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -702,7 +702,11 @@ export class HttpAPIClient implements ElectronAPI { deleteDraft: async (_teamName: string): Promise => { throw new Error('Draft team deletion is not available in browser mode'); }, - prepareProvisioning: async (_cwd?: string): Promise => { + prepareProvisioning: async ( + _cwd?: string, + _providerId?: TeamLaunchRequest['providerId'], + _providerIds?: TeamLaunchRequest['providerId'][] + ): Promise => { throw new Error('Team provisioning is not available in browser mode'); }, createTeam: async (_request: TeamCreateRequest): Promise => { @@ -1057,6 +1061,11 @@ export class HttpAPIClient implements ElectronAPI { cliInstaller: CliInstallerAPI = { getStatus: async () => ({ + flavor: 'claude', + displayName: 'Claude CLI', + supportsSelfUpdate: true, + showVersionDetails: true, + showBinaryPath: true, installed: false, installedVersion: null, binaryPath: null, @@ -1064,6 +1073,7 @@ export class HttpAPIClient implements ElectronAPI { updateAvailable: false, authLoggedIn: false, authMethod: null, + providers: [], }), install: async (): Promise => { console.warn('[HttpAPIClient] CLI installer not available in browser mode'); diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index da7dec5a..89369941 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -135,6 +135,9 @@ function areResolvedMembersEqual( prevMember.agentType !== nextMember.agentType || prevMember.role !== nextMember.role || prevMember.workflow !== nextMember.workflow || + prevMember.providerId !== nextMember.providerId || + prevMember.model !== nextMember.model || + prevMember.effort !== nextMember.effort || prevMember.cwd !== nextMember.cwd || prevMember.gitBranch !== nextMember.gitBranch || prevMember.removedAt !== nextMember.removedAt @@ -1616,6 +1619,7 @@ export const TeamDetailView = ({ isTeamAlive={data.isAlive} isTeamProvisioning={isTeamProvisioning} leadActivity={leadActivityByTeam[teamName]} + launchParams={launchParams} onMemberClick={setSelectedMember} onSendMessage={(member) => { setSendDialogRecipient(member.name); @@ -1976,6 +1980,7 @@ export const TeamDetailView = ({ currentDescription={data.config.description ?? ''} currentColor={data.config.color ?? ''} currentMembers={data.members.filter((m) => !isLeadMember(m))} + isTeamAlive={data.isAlive && !isTeamProvisioning} projectPath={data.config.projectPath} onClose={() => setEditDialogOpen(false)} onSaved={() => void selectTeam(teamName)} @@ -1998,6 +2003,9 @@ export const TeamDetailView = ({ name: entry.name, role: entry.role, workflow: entry.workflow, + providerId: entry.providerId, + model: entry.model, + effort: entry.effort, }); } setAddMemberDialogOpen(false); diff --git a/src/renderer/components/team/dialogs/AddMemberDialog.tsx b/src/renderer/components/team/dialogs/AddMemberDialog.tsx index 3fab05b1..26eb4187 100644 --- a/src/renderer/components/team/dialogs/AddMemberDialog.tsx +++ b/src/renderer/components/team/dialogs/AddMemberDialog.tsx @@ -19,11 +19,15 @@ import { import { Loader2 } from 'lucide-react'; import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes'; +import type { EffortLevel, TeamProviderId } from '@shared/types'; export interface AddMemberEntry { name: string; role?: string; workflow?: string; + providerId?: TeamProviderId; + model?: string; + effort?: EffortLevel; } interface AddMemberDialogProps { @@ -116,6 +120,9 @@ export const AddMemberDialog = ({ name: m.name, role: m.role, workflow: m.workflow, + providerId: m.providerId, + model: m.model, + effort: m.effort, })) ); }; diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 1d2d7550..a6689fb0 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -1,14 +1,15 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { buildMemberDraftColorMap, buildMemberDraftSuggestions, buildMembersFromDrafts, + clearMemberModelOverrides, createMemberDraft, - MembersEditorSection, validateMemberNameInline, } from '@renderer/components/team/members/MembersEditorSection'; +import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection'; import { AutoResizeTextarea } from '@renderer/components/ui/auto-resize-textarea'; import { Button } from '@renderer/components/ui/button'; import { Checkbox } from '@renderer/components/ui/checkbox'; @@ -36,12 +37,19 @@ import { normalizePath } from '@renderer/utils/pathNormalize'; import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react'; import { AdvancedCliSection } from './AdvancedCliSection'; -import { EffortLevelSelector } from './EffortLevelSelector'; -import { LimitContextCheckbox } from './LimitContextCheckbox'; import { OptionalSettingsSection } from './OptionalSettingsSection'; +import { + createInitialProviderChecks, + failIncompleteProviderChecks, + getProvisioningFailureHint, + ProvisioningProviderStatusList, + shouldHideProvisioningProviderStatusList, + updateProviderCheck, + type ProvisioningProviderCheck, +} from './ProvisioningProviderStatusList'; import { ProjectPathSelector } from './ProjectPathSelector'; import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox'; -import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector'; +import { computeEffectiveTeamModel } from './TeamModelSelector'; import { getNextSuggestedTeamName } from './teamNameSets'; const TEAM_COLOR_NAMES = [ @@ -59,10 +67,45 @@ import type { EffortLevel, Project, TeamCreateRequest, + TeamProviderId, TeamProvisioningMemberInput, TeamProvisioningPrepareResult, } from '@shared/types'; +function getStoredTeamProvider(): TeamProviderId { + const stored = localStorage.getItem('team:lastSelectedProvider'); + return stored === 'codex' || stored === 'gemini' ? stored : 'anthropic'; +} + +function getStoredTeamModel(providerId: TeamProviderId): string { + const stored = localStorage.getItem(`team:lastSelectedModel:${providerId}`); + if (stored === null) { + return providerId === 'anthropic' ? 'opus' : ''; + } + return stored === '__default__' ? '' : stored; +} + +function isEphemeralRenderedProjectPath(projectPath: string | null | undefined): boolean { + const normalized = normalizePath(projectPath ?? '').toLowerCase(); + return ( + normalized.includes('rendered_mcp_') || + normalized.includes('rendered_mcp_config') || + normalized.includes('/portable-mcp-live') + ); +} + +function getProviderLabel(providerId: TeamProviderId): string { + switch (providerId) { + case 'codex': + return 'Codex'; + case 'gemini': + return 'Gemini'; + case 'anthropic': + default: + return 'Anthropic'; + } +} + export interface TeamCopyData { teamName: string; description?: string; @@ -230,6 +273,8 @@ export const CreateTeamDialog = ({ setTeamName, members, setMembers, + syncModelsWithLead, + setSyncModelsWithLead, cwdMode, setCwdMode, selectedProjectPath, @@ -258,6 +303,7 @@ export const CreateTeamDialog = ({ const [prepareState, setPrepareState] = useState<'idle' | 'loading' | 'ready' | 'failed'>('idle'); const [prepareMessage, setPrepareMessage] = useState(null); const [prepareWarnings, setPrepareWarnings] = useState([]); + const [prepareChecks, setPrepareChecks] = useState([]); const prepareRequestSeqRef = useRef(0); const lastAutoDescriptionRef = useRef(null); const [fieldErrors, setFieldErrors] = useState<{ @@ -267,11 +313,11 @@ export const CreateTeamDialog = ({ }>({}); const [isSubmitting, setIsSubmitting] = useState(false); const [conflictDismissed, setConflictDismissed] = useState(false); - const [selectedModel, setSelectedModelRaw] = useState(() => { - const stored = localStorage.getItem('team:lastSelectedModel'); - if (stored === null) return 'opus'; - return stored === '__default__' ? '' : stored; - }); + const [selectedProviderId, setSelectedProviderIdRaw] = + useState(getStoredTeamProvider); + const [selectedModel, setSelectedModelRaw] = useState(() => + getStoredTeamModel(getStoredTeamProvider()) + ); const [limitContext, setLimitContextRaw] = useState( () => localStorage.getItem('team:lastLimitContext') === 'true' ); @@ -289,6 +335,17 @@ export const CreateTeamDialog = ({ const [worktreeName, setWorktreeNameRaw] = useState(''); const [customArgs, setCustomArgsRaw] = useState(''); + useEffect(() => { + const legacyTeamModel = localStorage.getItem('team:lastSelectedModel'); + if ( + legacyTeamModel != null && + localStorage.getItem('team:lastSelectedModel:anthropic') == null + ) { + localStorage.setItem('team:lastSelectedModel:anthropic', legacyTeamModel); + } + localStorage.removeItem('team:lastSelectedModel'); + }, []); + // Re-read localStorage when advancedKey changes useEffect(() => { const storedEnabled = @@ -301,7 +358,17 @@ export const CreateTeamDialog = ({ const setSelectedModel = (value: string): void => { setSelectedModelRaw(value); - localStorage.setItem('team:lastSelectedModel', value); + localStorage.setItem(`team:lastSelectedModel:${selectedProviderId}`, value); + }; + + const setSelectedProviderId = (value: TeamProviderId): void => { + setSelectedProviderIdRaw(value); + localStorage.setItem('team:lastSelectedProvider', value); + if (value !== 'anthropic') { + setLimitContextRaw(false); + localStorage.setItem('team:lastLimitContext', 'false'); + } + setSelectedModelRaw(getStoredTeamModel(value)); }; const setLimitContext = (value: boolean): void => { @@ -343,6 +410,7 @@ export const CreateTeamDialog = ({ setPrepareState('idle'); setPrepareMessage(null); setPrepareWarnings([]); + setPrepareChecks([]); setConflictDismissed(false); }; @@ -371,6 +439,25 @@ export const CreateTeamDialog = ({ } }, [open, clearProvisioningError, dialogTeamNameKey]); + const effectiveMemberDrafts = useMemo( + () => (syncModelsWithLead ? members.map(clearMemberModelOverrides) : members), + [members, syncModelsWithLead] + ); + + const selectedMemberProviders = useMemo(() => { + if (soloTeam || syncModelsWithLead) { + return [selectedProviderId]; + } + return Array.from( + new Set([ + selectedProviderId, + ...members.flatMap((member) => + member.providerId === 'codex' || member.providerId === 'gemini' ? [member.providerId] : [] + ), + ]) + ); + }, [members, selectedProviderId, soloTeam, syncModelsWithLead]); + useEffect(() => { if (!open || !canCreate || !launchTeam) { return; @@ -379,6 +466,7 @@ export const CreateTeamDialog = ({ if (typeof api.teams.prepareProvisioning !== 'function') { setPrepareState('failed'); setPrepareWarnings([]); + setPrepareChecks([]); setPrepareMessage( 'Current preload version does not support team:prepareProvisioning. Restart the dev app.' ); @@ -388,6 +476,7 @@ export const CreateTeamDialog = ({ if (!effectiveCwd) { setPrepareState('idle'); setPrepareWarnings([]); + setPrepareChecks([]); setPrepareMessage('Select a working directory to validate the launch environment.'); return; } @@ -395,26 +484,75 @@ export const CreateTeamDialog = ({ let cancelled = false; const requestSeq = ++prepareRequestSeqRef.current; setPrepareState('loading'); - setPrepareMessage('Warming up CLI environment...'); + setPrepareMessage('Checking selected providers...'); setPrepareWarnings([]); + setPrepareChecks(createInitialProviderChecks(selectedMemberProviders)); // Defer so file list fetch (triggered by project select) can run first const timer = setTimeout(() => { void (async () => { + let checks = createInitialProviderChecks(selectedMemberProviders); + let anyFailure = false; + let anyNotes = false; + const collectedWarnings: string[] = []; + try { - const prepResult: TeamProvisioningPrepareResult = - await api.teams.prepareProvisioning(effectiveCwd); + for (const providerId of selectedMemberProviders) { + checks = updateProviderCheck(checks, providerId, { + status: 'checking', + details: [], + }); + if (!cancelled && prepareRequestSeqRef.current === requestSeq) { + setPrepareChecks(checks); + setPrepareMessage(`Checking ${getProviderLabel(providerId)} runtime...`); + } + + const prepResult: TeamProvisioningPrepareResult = await api.teams.prepareProvisioning( + effectiveCwd, + providerId, + [providerId] + ); + const detailLines = [ + ...(prepResult.warnings ?? []).filter(Boolean), + ...(!prepResult.ready && prepResult.message ? [prepResult.message] : []), + ]; + if (prepResult.warnings?.length) { + anyNotes = true; + collectedWarnings.push( + ...prepResult.warnings.map( + (warning) => `${getProviderLabel(providerId)}: ${warning}` + ) + ); + } + if (!prepResult.ready) { + anyFailure = true; + } + checks = updateProviderCheck(checks, providerId, { + status: !prepResult.ready ? 'failed' : detailLines.length > 0 ? 'notes' : 'ready', + details: detailLines, + }); + if (!cancelled && prepareRequestSeqRef.current === requestSeq) { + setPrepareChecks(checks); + } + } if (cancelled || prepareRequestSeqRef.current !== requestSeq) return; - setPrepareState(prepResult.ready ? 'ready' : 'failed'); - setPrepareMessage(prepResult.message); - setPrepareWarnings(prepResult.warnings ?? []); + setPrepareState(anyFailure ? 'failed' : 'ready'); + setPrepareMessage( + anyFailure + ? 'Some selected providers need attention.' + : anyNotes + ? 'Selected providers are ready with notes.' + : 'Selected providers are ready.' + ); + setPrepareWarnings(collectedWarnings); } catch (error) { if (cancelled || prepareRequestSeqRef.current !== requestSeq) return; + const failureMessage = + error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment'; setPrepareState('failed'); setPrepareWarnings([]); - setPrepareMessage( - error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment' - ); + setPrepareChecks(failIncompleteProviderChecks(checks, failureMessage)); + setPrepareMessage(failureMessage); } })(); }, 250); @@ -423,7 +561,7 @@ export const CreateTeamDialog = ({ cancelled = true; clearTimeout(timer); }; - }, [open, canCreate, launchTeam, effectiveCwd]); + }, [open, canCreate, launchTeam, effectiveCwd, selectedProviderId, selectedMemberProviders]); useEffect(() => { if (!open) { @@ -446,6 +584,7 @@ export const CreateTeamDialog = ({ // display and select it. if ( defaultProjectPath && + !isEphemeralRenderedProjectPath(defaultProjectPath) && !nextProjects.some((p) => normalizePath(p.path) === defaultProjectPath) ) { const folderName = @@ -497,9 +636,15 @@ export const CreateTeamDialog = ({ roleSelection: isCustom ? CUSTOM_ROLE : (m.role ?? ''), customRole: isCustom ? m.role : '', workflow: m.workflow, + providerId: m.providerId, + model: m.model ?? '', + effort: m.effort, }); }) ); + setSyncModelsWithLead( + !initialData.members.some((member) => member.providerId || member.model || member.effort) + ); return; } @@ -559,7 +704,7 @@ export const CreateTeamDialog = ({ if (selectedProjectPath || projects.length === 0) { return; } - if (defaultProjectPath) { + if (defaultProjectPath && !isEphemeralRenderedProjectPath(defaultProjectPath)) { const match = projects.find((p) => normalizePath(p.path) === defaultProjectPath); if (match) { setSelectedProjectPath(match.path); @@ -569,6 +714,16 @@ export const CreateTeamDialog = ({ setSelectedProjectPath(projects[0].path); }, [open, cwdMode, projects, selectedProjectPath, defaultProjectPath]); + useEffect(() => { + if (!open || cwdMode !== 'project' || !selectedProjectPath) { + return; + } + if (!isEphemeralRenderedProjectPath(selectedProjectPath)) { + return; + } + setSelectedProjectPath(''); + }, [open, cwdMode, selectedProjectPath, setSelectedProjectPath]); + useFileListCacheWarmer(effectiveCwd || null); const { suggestions: taskSuggestions } = useTaskSuggestions(null); @@ -587,8 +742,8 @@ export const CreateTeamDialog = ({ ); const effectiveModel = useMemo( - () => computeEffectiveTeamModel(selectedModel, limitContext), - [selectedModel, limitContext] + () => computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId), + [selectedModel, limitContext, selectedProviderId] ); const sanitizedTeamName = sanitizeTeamName(teamName.trim()); @@ -601,9 +756,10 @@ export const CreateTeamDialog = ({ teamName: sanitizedTeamName, description: description.trim() || undefined, color: teamColor || undefined, - members: soloTeam ? [] : buildMembersFromDrafts(members), + members: soloTeam ? [] : buildMembersFromDrafts(effectiveMemberDrafts), cwd: effectiveCwd, prompt: prompt.trim() || undefined, + providerId: selectedProviderId, model: effectiveModel, effort: (selectedEffort as EffortLevel) || undefined, limitContext, @@ -616,9 +772,10 @@ export const CreateTeamDialog = ({ description, teamColor, soloTeam, - members, + effectiveMemberDrafts, effectiveCwd, prompt, + selectedProviderId, effectiveModel, selectedEffort, limitContext, @@ -643,23 +800,11 @@ export const CreateTeamDialog = ({ const launchOptionalSummary = useMemo(() => { const summary: string[] = []; if (prompt.trim()) summary.push('Lead prompt'); - if (selectedModel) summary.push(`Model: ${selectedModel}`); - if (selectedEffort) summary.push(`Effort: ${selectedEffort}`); - if (limitContext) summary.push('Limited to 200K context'); if (skipPermissions) summary.push('Auto-approve tools'); if (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`); if (customArgs.trim()) summary.push('Custom CLI args'); return summary; - }, [ - prompt, - selectedModel, - selectedEffort, - limitContext, - skipPermissions, - worktreeEnabled, - worktreeName, - customArgs, - ]); + }, [prompt, skipPermissions, worktreeEnabled, worktreeName, customArgs]); const teamDetailsSummary = useMemo(() => { const summary: string[] = []; @@ -668,6 +813,16 @@ export const CreateTeamDialog = ({ return summary; }, [description, teamColor]); + const handleSyncModelsWithLeadChange = useCallback( + (checked: boolean): void => { + setSyncModelsWithLead(checked); + if (checked) { + setMembers(members.map(clearMemberModelOverrides)); + } + }, + [members, setMembers, setSyncModelsWithLead] + ); + const activeError = localError ?? provisioningErrorsByTeam[request.teamName] ?? null; const canOpenExistingTeam = activeError?.includes('Team already exists') === true && request.teamName.length > 0; @@ -822,7 +977,14 @@ export const CreateTeamDialog = ({

{prepareMessage ?? 'Failed to prepare environment'}

- {prepareWarnings.length > 0 ? ( + {!shouldHideProvisioningProviderStatusList(prepareChecks, prepareMessage) ? ( + + ) : null} + {prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
{prepareWarnings.map((warning) => (

) : null}

- Make sure claude CLI is installed and available - in PATH, then reopen this dialog. + {getProvisioningFailureHint(prepareMessage, prepareChecks)}

@@ -892,9 +1053,9 @@ export const CreateTeamDialog = ({
- -
- setSoloTeam(checked === true)} - /> - -
- {soloTeam && ( -
- -

- Only the team lead (main process) will be started — no teammates will - be spawned. Works like a regular Claude session but with access to the task - board for planning. Saves tokens by avoiding teammate coordination overhead. - You can add members later from the team settings. -

-
- )} + defaultProviderId={selectedProviderId} + inheritedProviderId={selectedProviderId} + inheritedModel={selectedModel} + inheritedEffort={(selectedEffort as EffortLevel) || undefined} + inheritModelSettingsByDefault + lockProviderModel={syncModelsWithLead} + forceInheritedModelSettings={syncModelsWithLead} + modelLockReason="This teammate is synced with the lead model. Turn off sync to set a custom provider, model, or effort." + hideMembersContent={soloTeam} + providerId={selectedProviderId} + model={selectedModel} + effort={(selectedEffort as EffortLevel) || undefined} + limitContext={limitContext} + onProviderChange={setSelectedProviderId} + onModelChange={setSelectedModel} + onEffortChange={setSelectedEffort} + onLimitContextChange={setLimitContext} + syncModelsWithTeammates={syncModelsWithLead} + onSyncModelsWithTeammatesChange={handleSyncModelsWithLeadChange} + headerTop={ +
+ setSoloTeam(checked === true)} + /> +
} + headerBottom={ + soloTeam ? ( +
+ +

+ Only the team lead (main process) will be started — no teammates will be + spawned. Works like a regular Claude session but with access to the task board + for planning. Saves tokens by avoiding teammate coordination overhead. You can + add members later from the team settings. +

+
+ ) : null + } />
@@ -984,7 +1163,7 @@ export const CreateTeamDialog = ({
@@ -1017,29 +1196,11 @@ export const CreateTeamDialog = ({ />
-
- - - - -
+
{canCreate && launchTeam && (prepareState === 'idle' || prepareState === 'loading') ? ( -
- -
- - {prepareMessage ?? - (prepareState === 'idle' - ? 'Warming up CLI environment...' - : 'Preparing environment...')} - -

- Pre-flight check to catch errors before launch - -

+ <> +
+ +
+ + {prepareMessage ?? + (prepareState === 'idle' + ? 'Warming up CLI environment...' + : 'Preparing environment...')} + +

+ Pre-flight check to catch errors before launch +

+
-
+ + ) : null} {canCreate && launchTeam && prepareState === 'ready' ? ( @@ -1161,7 +1318,8 @@ export const CreateTeamDialog = ({
- {prepareWarnings.length > 0 + {prepareChecks.some((check) => check.status === 'notes') || + prepareWarnings.length > 0 ? 'CLI environment ready (with notes)' : 'CLI environment ready'} @@ -1171,7 +1329,8 @@ export const CreateTeamDialog = ({ {prepareMessage}

) : null} - {prepareWarnings.length > 0 ? ( + + {prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
{prepareWarnings.map((warning) => (

@@ -1202,12 +1361,7 @@ export const CreateTeamDialog = ({

+ {isTeamAlive ? ( +

+ Provider and model changes are locked while the team is live. Reconnect the team to + change them safely. +

+ ) : null}
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- Color picker is a group of buttons, not a single input */}

- Controls how much reasoning Claude invests before responding. Default uses Claude's - standard behavior. + Controls how much reasoning the selected provider invests before responding. Default uses the + provider's standard behavior for the selected model.

); diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 78ab5652..ed22b46b 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -1,7 +1,15 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; -import { LimitContextCheckbox } from '@renderer/components/team/dialogs/LimitContextCheckbox'; +import { + buildMemberDraftColorMap, + buildMemberDraftSuggestions, + buildMembersFromDrafts, + clearMemberModelOverrides, + createMemberDraftsFromInputs, + validateMemberNameInline, +} from '@renderer/components/team/members/MembersEditorSection'; +import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection'; import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox'; import { Button } from '@renderer/components/ui/button'; import { Checkbox } from '@renderer/components/ui/checkbox'; @@ -21,10 +29,10 @@ import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer'; +import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; +import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; -import { formatAgentRole } from '@renderer/utils/formatAgentRole'; -import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { normalizePath } from '@renderer/utils/pathNormalize'; import { nameColorSet } from '@renderer/utils/projectColor'; import { @@ -43,10 +51,20 @@ import { CronScheduleInput } from '../schedule/CronScheduleInput'; import { AdvancedCliSection } from './AdvancedCliSection'; import { EffortLevelSelector } from './EffortLevelSelector'; import { OptionalSettingsSection } from './OptionalSettingsSection'; +import { + createInitialProviderChecks, + failIncompleteProviderChecks, + getProvisioningFailureHint, + ProvisioningProviderStatusList, + shouldHideProvisioningProviderStatusList, + updateProviderCheck, + type ProvisioningProviderCheck, +} from './ProvisioningProviderStatusList'; import { ProjectPathSelector } from './ProjectPathSelector'; import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector'; import type { ActiveTeamRef } from './CreateTeamDialog'; +import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { CreateScheduleInput, @@ -56,6 +74,7 @@ import type { Schedule, ScheduleLaunchConfig, TeamLaunchRequest, + TeamProviderId, TeamProvisioningPrepareResult, UpdateSchedulePatch, } from '@shared/types'; @@ -104,6 +123,31 @@ function getLocalTimezone(): string { } } +function getStoredTeamProvider(): TeamProviderId { + const stored = localStorage.getItem('team:lastSelectedProvider'); + return stored === 'codex' || stored === 'gemini' ? stored : 'anthropic'; +} + +function getStoredTeamModel(providerId: TeamProviderId): string { + const stored = localStorage.getItem(`team:lastSelectedModel:${providerId}`); + if (stored === null) { + return providerId === 'anthropic' ? 'opus' : ''; + } + return stored === '__default__' ? '' : stored; +} + +function getProviderLabel(providerId: TeamProviderId): string { + switch (providerId) { + case 'codex': + return 'Codex'; + case 'gemini': + return 'Gemini'; + case 'anthropic': + default: + return 'Anthropic'; + } +} + // ============================================================================= // Component // ============================================================================= @@ -157,11 +201,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const [localError, setLocalError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); - const [selectedModel, setSelectedModelRaw] = useState(() => { - const stored = localStorage.getItem('team:lastSelectedModel'); - if (stored === null) return 'opus'; - return stored === '__default__' ? '' : stored; - }); + const [selectedProviderId, setSelectedProviderIdRaw] = + useState(getStoredTeamProvider); + const [selectedModel, setSelectedModelRaw] = useState(() => + getStoredTeamModel(getStoredTeamProvider()) + ); + const [membersDrafts, setMembersDrafts] = useState([]); + const [syncModelsWithLead, setSyncModelsWithLead] = useState(false); const [skipPermissions, setSkipPermissionsRaw] = useState( () => localStorage.getItem('team:lastSkipPermissions') !== 'false' ); @@ -182,7 +228,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const [prepareState, setPrepareState] = useState<'idle' | 'loading' | 'ready' | 'failed'>('idle'); const [prepareMessage, setPrepareMessage] = useState(null); const [prepareWarnings, setPrepareWarnings] = useState([]); + const [prepareChecks, setPrepareChecks] = useState([]); const prepareRequestSeqRef = useRef(0); + const storeMembers = useStore((s) => s.selectedTeamData?.members ?? []); + const members = isLaunch ? props.members : storeMembers; // Advanced CLI section state (with localStorage persistence) const [worktreeEnabled, setWorktreeEnabledRaw] = useState( @@ -208,6 +257,24 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const [warmUpMinutes, setWarmUpMinutes] = useState(15); const [maxTurns, setMaxTurns] = useState(50); const [maxBudgetUsd, setMaxBudgetUsd] = useState(''); + const effectiveMemberDrafts = useMemo( + () => (syncModelsWithLead ? membersDrafts.map(clearMemberModelOverrides) : membersDrafts), + [membersDrafts, syncModelsWithLead] + ); + const selectedMemberProviders = useMemo( + () => + Array.from( + new Set([ + selectedProviderId, + ...effectiveMemberDrafts.flatMap((member) => + member.providerId === 'codex' || member.providerId === 'gemini' + ? [member.providerId] + : [] + ), + ]) + ), + [effectiveMemberDrafts, selectedProviderId] + ); // Schedule store actions const createSchedule = useStore((s) => s.createSchedule); @@ -234,9 +301,19 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen localStorage.setItem(`team:lastCustomArgs:${effectiveTeamName}`, value); }; + const setSelectedProviderId = (value: TeamProviderId): void => { + setSelectedProviderIdRaw(value); + localStorage.setItem('team:lastSelectedProvider', value); + if (value !== 'anthropic') { + setLimitContextRaw(false); + localStorage.setItem('team:lastLimitContext', 'false'); + } + setSelectedModelRaw(getStoredTeamModel(value)); + }; + const setSelectedModel = (value: string): void => { setSelectedModelRaw(value); - localStorage.setItem('team:lastSelectedModel', value); + localStorage.setItem(`team:lastSelectedModel:${selectedProviderId}`, value); }; const setLimitContext = (value: boolean): void => { @@ -259,9 +336,19 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen // --------------------------------------------------------------------------- useEffect(() => { + const legacyTeamModel = localStorage.getItem('team:lastSelectedModel'); + if ( + legacyTeamModel != null && + localStorage.getItem('team:lastSelectedModel:anthropic') == null + ) { + localStorage.setItem('team:lastSelectedModel:anthropic', legacyTeamModel); + } + localStorage.removeItem('team:lastSelectedModel'); + for (const suffix of ['lastSelectedModel', 'lastSelectedEffort']) { const schedKey = `schedule:${suffix}`; - const teamKey = `team:${suffix}`; + const teamKey = + suffix === 'lastSelectedModel' ? 'team:lastSelectedModel:anthropic' : `team:${suffix}`; const schedVal = localStorage.getItem(schedKey); if (schedVal != null && localStorage.getItem(teamKey) == null) { localStorage.setItem(teamKey, schedVal); @@ -280,11 +367,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen setPrepareState('idle'); setPrepareMessage(null); setPrepareWarnings([]); + setPrepareChecks([]); setCwdMode('project'); setSelectedProjectPath(''); setCustomCwd(''); setClearContext(false); setConflictDismissed(false); + setMembersDrafts([]); + setSyncModelsWithLead(false); chipDraft.clearChipDraft(); // Schedule fields setSelectedTeamName(''); @@ -311,6 +401,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen promptDraft.setValue(schedule.launchConfig.prompt); setCustomCwd(schedule.launchConfig.cwd); setCwdMode('custom'); + setSelectedProviderIdRaw(schedule.launchConfig.providerId ?? 'anthropic'); setSelectedModelRaw(schedule.launchConfig.model ?? ''); setSkipPermissionsRaw(schedule.launchConfig.skipPermissions !== false); setSelectedEffortRaw(schedule.launchConfig.effort ?? ''); @@ -326,7 +417,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen setCwdMode('project'); setSelectedProjectPath(''); setCustomCwd(''); - setSelectedModelRaw('opus'); + setSelectedProviderIdRaw(getStoredTeamProvider()); + setSelectedModelRaw(getStoredTeamModel(getStoredTeamProvider())); setSelectedEffortRaw('medium'); } @@ -335,6 +427,62 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, isSchedule, schedule?.id]); + useEffect(() => { + if (!open || !isLaunch) return; + + let cancelled = false; + void (async () => { + let savedRequest = null; + try { + savedRequest = effectiveTeamName + ? await api.teams.getSavedRequest(effectiveTeamName) + : null; + } catch { + savedRequest = null; + } + if (cancelled) return; + + const nextProviderId = + savedRequest?.providerId === 'codex' || savedRequest?.providerId === 'gemini' + ? savedRequest.providerId + : 'anthropic'; + const providerFromSaved = Boolean(savedRequest?.providerId); + const nextMembersSource = + members.length > 0 + ? members + : savedRequest?.members && savedRequest.members.length > 0 + ? savedRequest.members + : []; + const storedEffort = localStorage.getItem('team:lastSelectedEffort'); + + setMembersDrafts(createMemberDraftsFromInputs(nextMembersSource)); + setSyncModelsWithLead( + !nextMembersSource.some((member) => member.providerId || member.model || member.effort) + ); + setSelectedProviderIdRaw(providerFromSaved ? nextProviderId : getStoredTeamProvider()); + setSelectedModelRaw( + typeof savedRequest?.model === 'string' + ? savedRequest.model + : getStoredTeamModel(providerFromSaved ? nextProviderId : getStoredTeamProvider()) + ); + setSelectedEffortRaw( + savedRequest?.effort ?? (storedEffort === null ? 'medium' : storedEffort) + ); + setLimitContextRaw( + savedRequest?.limitContext === true || + localStorage.getItem('team:lastLimitContext') === 'true' + ); + setSkipPermissionsRaw( + savedRequest?.skipPermissions ?? + localStorage.getItem('team:lastSkipPermissions') !== 'false' + ); + })(); + + return () => { + cancelled = true; + }; + }, [open, isLaunch, effectiveTeamName, members]); + // --------------------------------------------------------------------------- // Launch-only effects // --------------------------------------------------------------------------- @@ -355,6 +503,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen if (typeof api.teams.prepareProvisioning !== 'function') { setPrepareState('failed'); setPrepareWarnings([]); + setPrepareChecks([]); setPrepareMessage( 'Current preload version does not support team:prepareProvisioning. Restart the dev app.' ); @@ -364,6 +513,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen if (!effectiveCwd) { setPrepareState('idle'); setPrepareWarnings([]); + setPrepareChecks([]); setPrepareMessage('Select a working directory to validate the launch environment.'); return; } @@ -371,31 +521,78 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen let cancelled = false; const requestSeq = ++prepareRequestSeqRef.current; setPrepareState('loading'); - setPrepareMessage('Warming up CLI environment...'); + setPrepareMessage('Checking selected providers...'); setPrepareWarnings([]); + setPrepareChecks(createInitialProviderChecks(selectedMemberProviders)); void (async () => { + let checks = createInitialProviderChecks(selectedMemberProviders); + let anyFailure = false; + let anyNotes = false; + const collectedWarnings: string[] = []; + try { - const prepResult: TeamProvisioningPrepareResult = - await api.teams.prepareProvisioning(effectiveCwd); + for (const providerId of selectedMemberProviders) { + checks = updateProviderCheck(checks, providerId, { + status: 'checking', + details: [], + }); + if (!cancelled && prepareRequestSeqRef.current === requestSeq) { + setPrepareChecks(checks); + setPrepareMessage(`Checking ${getProviderLabel(providerId)} runtime...`); + } + + const prepResult: TeamProvisioningPrepareResult = await api.teams.prepareProvisioning( + effectiveCwd, + providerId, + [providerId] + ); + const detailLines = [ + ...(prepResult.warnings ?? []).filter(Boolean), + ...(!prepResult.ready && prepResult.message ? [prepResult.message] : []), + ]; + if (prepResult.warnings?.length) { + anyNotes = true; + collectedWarnings.push( + ...prepResult.warnings.map((warning) => `${getProviderLabel(providerId)}: ${warning}`) + ); + } + if (!prepResult.ready) { + anyFailure = true; + } + checks = updateProviderCheck(checks, providerId, { + status: !prepResult.ready ? 'failed' : detailLines.length > 0 ? 'notes' : 'ready', + details: detailLines, + }); + if (!cancelled && prepareRequestSeqRef.current === requestSeq) { + setPrepareChecks(checks); + } + } if (cancelled || prepareRequestSeqRef.current !== requestSeq) return; - setPrepareState(prepResult.ready ? 'ready' : 'failed'); - setPrepareMessage(prepResult.message); - setPrepareWarnings(prepResult.warnings ?? []); + setPrepareState(anyFailure ? 'failed' : 'ready'); + setPrepareMessage( + anyFailure + ? 'Some selected providers need attention.' + : anyNotes + ? 'Selected providers are ready with notes.' + : 'Selected providers are ready.' + ); + setPrepareWarnings(collectedWarnings); } catch (error) { if (cancelled || prepareRequestSeqRef.current !== requestSeq) return; + const failureMessage = + error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment'; setPrepareState('failed'); setPrepareWarnings([]); - setPrepareMessage( - error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment' - ); + setPrepareChecks(failIncompleteProviderChecks(checks, failureMessage)); + setPrepareMessage(failureMessage); } })(); return () => { cancelled = true; }; - }, [open, isLaunch, effectiveCwd]); + }, [open, isLaunch, effectiveCwd, selectedProviderId, selectedMemberProviders]); // --------------------------------------------------------------------------- // Shared effects: projects @@ -490,19 +687,15 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen // Mention suggestions (shared — from props in launch, from store in schedule) // --------------------------------------------------------------------------- - const storeMembers = useStore((s) => s.selectedTeamData?.members ?? []); - const members = isLaunch ? props.members : storeMembers; - - const colorMap = useMemo(() => buildMemberColorMap(members), [members]); + const { suggestions: taskSuggestions } = useTaskSuggestions(null); + const { suggestions: teamMentionSuggestions } = useTeamSuggestions(null); + const memberColorMap = useMemo( + () => buildMemberDraftColorMap(membersDrafts, members), + [membersDrafts, members] + ); const mentionSuggestions = useMemo( - () => - members.map((m) => ({ - id: m.name, - name: m.name, - subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined, - color: colorMap.get(m.name), - })), - [members, colorMap] + () => buildMemberDraftSuggestions(membersDrafts, memberColorMap), + [memberColorMap, membersDrafts] ); // --------------------------------------------------------------------------- @@ -516,21 +709,30 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen args.push('--verbose', '--setting-sources', 'user,project,local'); args.push('--mcp-config', '', '--disallowedTools', 'TeamDelete,TodoWrite'); if (skipPermissions) args.push('--dangerously-skip-permissions'); - const model = computeEffectiveTeamModel(selectedModel, limitContext); + const model = computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId); if (model) args.push('--model', model); if (selectedEffort) args.push('--effort', selectedEffort); if (!clearContext) args.push('--resume', ''); return args; - }, [isLaunch, skipPermissions, selectedModel, limitContext, selectedEffort, clearContext]); + }, [ + isLaunch, + skipPermissions, + selectedModel, + limitContext, + selectedEffort, + clearContext, + selectedProviderId, + ]); const launchOptionalSummary = useMemo(() => { if (!isLaunch) return []; const summary: string[] = []; if (promptDraft.value.trim()) summary.push('Lead prompt'); + summary.push(`Provider: ${getProviderLabel(selectedProviderId)}`); if (selectedModel) summary.push(`Model: ${selectedModel}`); if (selectedEffort) summary.push(`Effort: ${selectedEffort}`); - if (limitContext) summary.push('Limited to 200K context'); + if (selectedProviderId === 'anthropic' && limitContext) summary.push('Limited to 200K context'); if (skipPermissions) summary.push('Auto-approve tools'); if (clearContext) summary.push('Fresh session'); if (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`); @@ -540,6 +742,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen isLaunch, promptDraft.value, selectedModel, + selectedProviderId, selectedEffort, limitContext, skipPermissions, @@ -584,17 +787,39 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen setLocalError('Select working directory (cwd)'); return; } + if ( + isLaunch && + membersDrafts.some( + (member) => !member.name.trim() || validateMemberNameInline(member.name.trim()) !== null + ) + ) { + setLocalError('Fix member names before launch'); + return; + } + if (isLaunch) { + const activeNames = membersDrafts + .map((member) => member.name.trim().toLowerCase()) + .filter(Boolean); + if (new Set(activeNames).size !== activeNames.length) { + setLocalError('Member names must be unique before launch'); + return; + } + } setLocalError(null); setIsSubmitting(true); void (async () => { try { if (isLaunch) { + await api.teams.replaceMembers(effectiveTeamName, { + members: buildMembersFromDrafts(effectiveMemberDrafts), + }); await props.onLaunch({ teamName: effectiveTeamName, cwd: effectiveCwd, prompt: promptDraft.value.trim() || undefined, - model: computeEffectiveTeamModel(selectedModel, limitContext), + providerId: selectedProviderId, + model: computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId), effort: (selectedEffort as EffortLevel) || undefined, limitContext, clearContext: clearContext || undefined, @@ -610,6 +835,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const launchConfig: ScheduleLaunchConfig = { cwd: effectiveCwd, prompt: promptDraft.value.trim(), + providerId: selectedProviderId, model: selectedModel || undefined, effort: (selectedEffort as EffortLevel) || undefined, skipPermissions, @@ -748,12 +974,19 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen

- Claude CLI is not installed — launch is blocked + CLI environment is not available — launch is blocked

{prepareMessage ?? 'Failed to prepare environment'}

- {prepareWarnings.length > 0 ? ( + {!shouldHideProvisioningProviderStatusList(prepareChecks, prepareMessage) ? ( + + ) : null} + {prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
{prepareWarnings.map((warning) => (

- Install Claude CLI from the Dashboard, then reopen this dialog. + {getProvisioningFailureHint(prepareMessage, prepareChecks)}

- + {(prepareMessage ?? '').toLowerCase().includes('spawn ') || + prepareChecks.some((check) => + check.details.some((detail) => detail.toLowerCase().includes('spawn ')) + ) ? ( + + ) : null}
@@ -922,6 +1160,38 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen projectsError={projectsError} /> + {isLaunch ? ( + + ) : null} + {/* ═══════════════════════════════════════════════════════════════════ Launch: optional settings Schedule: prompt + execution defaults @@ -959,22 +1229,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
- - - {prepareState === 'idle' || prepareState === 'loading' ? ( -
- -
- - {prepareMessage ?? - (prepareState === 'idle' - ? 'Warming up CLI environment...' - : 'Preparing environment...')} - -

- Pre-flight check to catch errors before launch - -

+ <> +
+ +
+ + {prepareMessage ?? + (prepareState === 'idle' + ? 'Warming up CLI environment...' + : 'Preparing environment...')} + +

+ Pre-flight check to catch errors before launch + +

+
-
+ + ) : null} {prepareState === 'ready' ? ( @@ -1165,7 +1424,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
- {prepareWarnings.length > 0 + {prepareChecks.some((check) => check.status === 'notes') || + prepareWarnings.length > 0 ? 'CLI environment ready (with notes)' : 'CLI environment ready'} @@ -1175,7 +1435,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen {prepareMessage}

) : null} - {prepareWarnings.length > 0 ? ( + + {prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
{prepareWarnings.map((warning) => (

diff --git a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx new file mode 100644 index 00000000..8c4ca9eb --- /dev/null +++ b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx @@ -0,0 +1,258 @@ +import React from 'react'; + +import type { TeamProviderId } from '@shared/types'; +import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react'; + +export type ProvisioningProviderCheckStatus = 'pending' | 'checking' | 'ready' | 'notes' | 'failed'; + +export interface ProvisioningProviderCheck { + providerId: TeamProviderId; + status: ProvisioningProviderCheckStatus; + details: string[]; +} + +export function getProvisioningProviderLabel(providerId: TeamProviderId): string { + switch (providerId) { + case 'codex': + return 'Codex'; + case 'gemini': + return 'Gemini'; + case 'anthropic': + default: + return 'Anthropic'; + } +} + +export function createInitialProviderChecks( + providerIds: TeamProviderId[] +): ProvisioningProviderCheck[] { + return providerIds.map((providerId) => ({ + providerId, + status: 'pending', + details: [], + })); +} + +export function updateProviderCheck( + checks: ProvisioningProviderCheck[], + providerId: TeamProviderId, + patch: Partial +): ProvisioningProviderCheck[] { + return checks.map((check) => + check.providerId === providerId + ? { + ...check, + ...patch, + } + : check + ); +} + +export function failIncompleteProviderChecks( + checks: ProvisioningProviderCheck[], + detail: string +): ProvisioningProviderCheck[] { + return checks.map((check) => + check.status === 'ready' || check.status === 'notes' || check.status === 'failed' + ? check + : { + ...check, + status: 'failed', + details: check.details.length > 0 ? check.details : [detail], + } + ); +} + +function getStatusLabel(status: ProvisioningProviderCheckStatus): string { + switch (status) { + case 'checking': + return 'checking...'; + case 'ready': + return 'OK'; + case 'notes': + return 'OK (notes)'; + case 'failed': + return 'ERR'; + case 'pending': + default: + return 'queued'; + } +} + +function summarizeDetail(detail: string, status: ProvisioningProviderCheckStatus): string | null { + const lower = detail.toLowerCase(); + + if (lower.includes('spawn ') && lower.includes(' enoent')) { + return 'CLI binary missing'; + } + if (lower.includes('working directory does not exist:')) { + return 'Working directory missing'; + } + if ( + lower.includes('eacces') || + lower.includes('enoexec') || + lower.includes('bad cpu type in executable') || + lower.includes('image not found') + ) { + return 'CLI binary could not be started'; + } + if (lower.includes('preflight check for `claude -p` did not complete')) { + return 'CLI preflight did not complete'; + } + if (lower.includes('not authenticated') || lower.includes('not logged in')) { + return 'Authentication required'; + } + if (lower.includes('provider is not configured for runtime use')) { + return 'Runtime provider is not configured'; + } + if (lower.includes('claude cli binary failed to start')) { + return 'CLI binary could not be started'; + } + if (lower.includes('claude cli preflight check failed')) { + return 'CLI preflight failed'; + } + + if (status === 'notes') { + return 'Ready with notes'; + } + if (status === 'failed') { + return 'Needs attention'; + } + return null; +} + +function getDisplayStatusText(check: ProvisioningProviderCheck): string { + const summary = check.details.find(Boolean) + ? summarizeDetail(check.details[0]!, check.status) + : null; + return summary ?? getStatusLabel(check.status); +} + +export function shouldHideProvisioningProviderStatusList( + checks: ProvisioningProviderCheck[], + message: string | null | undefined +): boolean { + const normalizedMessage = (message ?? '').trim().toLowerCase(); + if (!normalizedMessage || checks.length === 0) { + return false; + } + + return checks.every((check) => { + if (check.status !== 'failed') { + return false; + } + + const summary = getDisplayStatusText(check).toLowerCase(); + const visibleDetails = check.details.filter( + (detail) => detail.trim().toLowerCase() !== normalizedMessage + ); + + return summary === 'working directory missing' && visibleDetails.length === 0; + }); +} + +function getStatusColor(status: ProvisioningProviderCheckStatus): string { + switch (status) { + case 'ready': + return 'text-emerald-400'; + case 'notes': + return 'text-sky-300'; + case 'failed': + return 'text-red-300'; + case 'checking': + return 'text-[var(--color-text-secondary)]'; + case 'pending': + default: + return 'text-[var(--color-text-muted)]'; + } +} + +function StatusIcon({ status }: { status: ProvisioningProviderCheckStatus }): React.JSX.Element { + if (status === 'checking') { + return ; + } + if (status === 'ready') { + return ; + } + if (status === 'notes' || status === 'failed') { + return ; + } + return ; +} + +export function ProvisioningProviderStatusList({ + checks, + className = '', + suppressDetailsMatching, +}: { + checks: ProvisioningProviderCheck[]; + className?: string; + suppressDetailsMatching?: string | null; +}): React.JSX.Element | null { + if (checks.length === 0) { + return null; + } + + return ( +

+ {checks.map((check) => { + const visibleDetails = check.details.filter( + (detail) => detail.trim() !== (suppressDetailsMatching ?? '').trim() + ); + + return ( +
+
+ + + {getProvisioningProviderLabel(check.providerId)}: {getDisplayStatusText(check)} + +
+ {visibleDetails.length > 0 ? ( +
+ {visibleDetails.map((detail) => ( +

+ {detail} +

+ ))} +
+ ) : null} +
+ ); + })} +
+ ); +} + +export function getProvisioningFailureHint( + message: string | null | undefined, + checks: ProvisioningProviderCheck[] +): string { + const combined = [message ?? '', ...checks.flatMap((check) => check.details)] + .join('\n') + .toLowerCase(); + + if (combined.includes('working directory does not exist:')) { + return 'Choose an existing working directory, then reopen this dialog.'; + } + if (combined.includes('not authenticated') || combined.includes('not logged in')) { + return 'Authenticate the required provider in Claude CLI, then reopen this dialog.'; + } + if (combined.includes('provider is not configured for runtime use')) { + return 'Configure the selected provider runtime, then reopen this dialog.'; + } + if ( + combined.includes('spawn ') || + combined.includes(' enoent') || + combined.includes('eacces') || + combined.includes('enoexec') || + combined.includes('bad cpu type in executable') || + combined.includes('image not found') + ) { + return 'Make sure the local Claude CLI binary exists and can be started, then reopen this dialog.'; + } + + return 'Resolve the issue above, then reopen this dialog.'; +} diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx index b97c4043..c1003bcb 100644 --- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx +++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Label } from '@renderer/components/ui/label'; import { @@ -8,6 +8,7 @@ import { TooltipTrigger, } from '@renderer/components/ui/tooltip'; import { cn } from '@renderer/lib/utils'; +import { useStore } from '@renderer/store'; import { Check, ChevronDown, Info } from 'lucide-react'; // --- Provider SVG Icons (real brand logos from Simple Icons, monochrome currentColor) --- @@ -26,38 +27,9 @@ const OpenAIIcon: React.FC<{ className?: string }> = ({ className }) => ( ); -/** Google Gemini — official sparkle/star mark (Simple Icons) */ -const GoogleIcon: React.FC<{ className?: string }> = ({ className }) => ( +const GoogleGeminiIcon: React.FC<{ className?: string }> = ({ className }) => ( - - -); - -/** Local — server rack icon */ -const LocalIcon: React.FC<{ className?: string }> = ({ className }) => ( - - - - - - - + ); @@ -72,22 +44,88 @@ interface ProviderDef { const PROVIDERS: ProviderDef[] = [ { id: 'anthropic', label: 'Anthropic', icon: AnthropicIcon, comingSoon: false }, - { id: 'openai', label: 'OpenAI', icon: OpenAIIcon, comingSoon: true }, - { id: 'google', label: 'Google', icon: GoogleIcon, comingSoon: true }, - { id: 'local', label: 'Local', icon: LocalIcon, comingSoon: true }, + { id: 'codex', label: 'Codex', icon: OpenAIIcon, comingSoon: false }, + { id: 'gemini', label: 'Gemini', icon: GoogleGeminiIcon, comingSoon: false }, ]; -const ACTIVE_PROVIDER = PROVIDERS[0]; - -// --- Model options (Anthropic only for now) --- - -const MODEL_OPTIONS = [ +const ANTHROPIC_MODEL_OPTIONS = [ { value: '', label: 'Default' }, { value: 'opus', label: 'Opus 4.6' }, { value: 'sonnet', label: 'Sonnet 4.6' }, { value: 'haiku', label: 'Haiku 4.5' }, ] as const; +const CODEX_MODEL_OPTIONS = [ + { value: '', label: 'Default' }, + { value: 'gpt-5.4', label: 'GPT-5.4' }, + { value: 'gpt-5.4-mini', label: 'GPT-5.4 Mini' }, + { value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex' }, + { value: 'gpt-5.3-codex-spark', label: 'GPT-5.3 Codex Spark' }, + { value: 'gpt-5.2', label: 'GPT-5.2' }, + { value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' }, + { value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini' }, + { value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' }, +] as const; + +const GEMINI_MODEL_OPTIONS = [ + { value: '', label: 'Default' }, + { value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' }, + { value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' }, + { value: 'gemini-2.5-flash-lite', label: 'Gemini 2.5 Flash Lite' }, +] as const; + +const MODEL_LABEL_OVERRIDES: Record = { + 'claude-sonnet-4-6': 'Sonnet 4.6', + 'claude-sonnet-4-6[1m]': 'Sonnet 4.6 (1M)', + 'claude-opus-4-6': 'Opus 4.6', + 'claude-opus-4-6[1m]': 'Opus 4.6 (1M)', + 'claude-haiku-4-5-20251001': 'Haiku 4.5', + 'gpt-5.4': 'GPT-5.4', + 'gpt-5.4-mini': 'GPT-5.4 Mini', + 'gpt-5.3-codex': 'GPT-5.3 Codex', + 'gpt-5.3-codex-spark': 'GPT-5.3 Spark', + 'gpt-5.2-codex': 'GPT-5.2 Codex', + 'gpt-5.2': 'GPT-5.2', + 'gpt-5.1-codex-mini': 'GPT-5.1 Mini', + 'gpt-5.1-codex-max': 'GPT-5.1 Max', + 'gemini-2.5-pro': 'Gemini 2.5 Pro', + 'gemini-2.5-flash': 'Gemini 2.5 Flash', + 'gemini-2.5-flash-lite': 'Gemini 2.5 Flash Lite', +}; + +export function getTeamModelLabel(model: string): string { + return MODEL_LABEL_OVERRIDES[model] ?? model; +} + +export function getTeamProviderLabel(providerId: 'anthropic' | 'codex' | 'gemini'): string { + switch (providerId) { + case 'codex': + return 'Codex'; + case 'gemini': + return 'Gemini'; + case 'anthropic': + default: + return 'Anthropic'; + } +} + +export function getTeamEffortLabel(effort: string): string { + const trimmed = effort.trim(); + if (!trimmed) return 'Default'; + return trimmed.charAt(0).toUpperCase() + trimmed.slice(1); +} + +export function formatTeamModelSummary( + providerId: 'anthropic' | 'codex' | 'gemini', + model: string, + effort?: string +): string { + const providerLabel = getTeamProviderLabel(providerId); + const modelLabel = model.trim() ? getTeamModelLabel(model.trim()) : 'Default'; + const effortLabel = effort?.trim() ? getTeamEffortLabel(effort) : ''; + return [providerLabel, modelLabel, effortLabel].filter(Boolean).join(' · '); +} + /** * Computes the effective model string for team provisioning. * By default adds [1m] suffix for 1M context (Opus/Sonnet). @@ -96,25 +134,33 @@ const MODEL_OPTIONS = [ */ export function computeEffectiveTeamModel( selectedModel: string, - limitContext: boolean + limitContext: boolean, + providerId: 'anthropic' | 'codex' | 'gemini' = 'anthropic' ): string | undefined { const base = selectedModel || undefined; + if (providerId !== 'anthropic') return base; if (limitContext) return base; if (base === 'haiku') return base; return base ? `${base}[1m]` : 'opus[1m]'; } export interface TeamModelSelectorProps { + providerId: 'anthropic' | 'codex' | 'gemini'; + onProviderChange: (providerId: 'anthropic' | 'codex' | 'gemini') => void; value: string; onValueChange: (value: string) => void; id?: string; } export const TeamModelSelector: React.FC = ({ + providerId, + onProviderChange, value, onValueChange, id, }) => { + const cliStatus = useStore((s) => s.cliStatus); + const multimodelAvailable = cliStatus?.flavor === 'free-code'; const [dropdownOpen, setDropdownOpen] = useState(false); const containerRef = useRef(null); @@ -132,29 +178,55 @@ export const TeamModelSelector: React.FC = ({ return () => document.removeEventListener('mousedown', handleClickOutside); }, [dropdownOpen]); - const ProviderIcon = ACTIVE_PROVIDER.icon; + const activeProvider = PROVIDERS.find((provider) => provider.id === providerId) ?? PROVIDERS[0]; + const ProviderIcon = activeProvider.icon; + const isProviderSelectable = (candidateProviderId: string): boolean => + multimodelAvailable || candidateProviderId === 'anthropic'; + const activeProviderSelectable = isProviderSelectable(providerId); + const runtimeModels = + cliStatus?.providers.find((provider) => provider.providerId === providerId)?.models ?? []; + const modelOptions = useMemo(() => { + const fallback = + providerId === 'codex' + ? CODEX_MODEL_OPTIONS + : providerId === 'gemini' + ? GEMINI_MODEL_OPTIONS + : ANTHROPIC_MODEL_OPTIONS; + if (runtimeModels.length === 0) { + return [...fallback]; + } + const dynamicOptions = runtimeModels.map((model) => ({ + value: model, + label: getTeamModelLabel(model), + })); + return [{ value: '', label: 'Default' }, ...dynamicOptions]; + }, [providerId, runtimeModels]); return (
-
-
- {/* Provider button */} +
+
- {/* Model pills */} - {MODEL_OPTIONS.map((opt) => ( + {/* Provider dropdown */} + {dropdownOpen && ( +
+ {PROVIDERS.map((provider, index) => { + const Icon = provider.icon; + const isActive = provider.id === activeProvider.id; + const isFirst = index === 0; + const prevWasActive = index > 0 && !PROVIDERS[index - 1].comingSoon; + + return ( + + {prevWasActive && !isFirst && ( +
+ )} + + + ); + })} +
+ )} +
+ {!multimodelAvailable && ( +

+ Codex and Gemini require Multimodel mode. +

+ )} + +
+ {modelOptions.map((opt) => ( ))}
- - {/* Provider dropdown */} - {dropdownOpen && ( -
- {PROVIDERS.map((provider, index) => { - const Icon = provider.icon; - const isActive = provider.id === ACTIVE_PROVIDER.id; - const isFirst = index === 0; - const prevWasActive = index > 0 && !PROVIDERS[index - 1].comingSoon; - - return ( - - {prevWasActive && !isFirst && ( -
- )} - - - ); - })} -
- )}
); diff --git a/src/renderer/components/team/members/LeadModelRow.tsx b/src/renderer/components/team/members/LeadModelRow.tsx new file mode 100644 index 00000000..3321f7cc --- /dev/null +++ b/src/renderer/components/team/members/LeadModelRow.tsx @@ -0,0 +1,138 @@ +import React, { useState } from 'react'; + +import { Checkbox } from '@renderer/components/ui/checkbox'; +import { Label } from '@renderer/components/ui/label'; +import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector'; +import { LimitContextCheckbox } from '@renderer/components/team/dialogs/LimitContextCheckbox'; +import { + getTeamModelLabel, + TeamModelSelector, +} from '@renderer/components/team/dialogs/TeamModelSelector'; +import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { useTheme } from '@renderer/hooks/useTheme'; +import { getMemberColorByName } from '@shared/constants/memberColors'; +import { ChevronDown, ChevronRight, Info } from 'lucide-react'; + +import { Button } from '../../ui/button'; + +import type { EffortLevel, TeamProviderId } from '@shared/types'; + +interface LeadModelRowProps { + providerId: TeamProviderId; + model: string; + effort?: EffortLevel; + limitContext: boolean; + onProviderChange: (providerId: TeamProviderId) => void; + onModelChange: (model: string) => void; + onEffortChange: (effort: string) => void; + onLimitContextChange: (value: boolean) => void; + syncModelsWithTeammates: boolean; + onSyncModelsWithTeammatesChange: (value: boolean) => void; +} + +export const LeadModelRow = ({ + providerId, + model, + effort, + limitContext, + onProviderChange, + onModelChange, + onEffortChange, + onLimitContextChange, + syncModelsWithTeammates, + onSyncModelsWithTeammatesChange, +}: LeadModelRowProps): React.JSX.Element => { + const { isLight } = useTheme(); + const [modelExpanded, setModelExpanded] = useState(false); + const leadColorSet = getTeamColorSet(getMemberColorByName('lead')); + const modelButtonLabel = model.trim() ? getTeamModelLabel(model.trim()) : 'Default'; + + return ( +
+ + ); +}; diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index c47d2ef8..85c53fed 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -26,6 +26,7 @@ import type { interface MemberCardProps { member: ResolvedTeamMember; memberColor: string; + runtimeSummary?: string; taskCounts?: TaskStatusCounts | null; isTeamAlive?: boolean; isTeamProvisioning?: boolean; @@ -46,6 +47,7 @@ interface MemberCardProps { export const MemberCard = ({ member, memberColor, + runtimeSummary, taskCounts, isTeamAlive, isTeamProvisioning, @@ -131,42 +133,49 @@ export const MemberCard = ({ aria-label={presenceLabel} />
-
- - {displayMemberName(member.name)} - - {member.gitBranch ? ( - - - {member.gitBranch} +
+
+ + {displayMemberName(member.name)} - ) : null} - {currentTask ? ( - - ) : null} - {reviewTask ? ( - - ) : null} - {!activityTask && isAwaitingReply ? ( - <> - - - awaiting reply + {member.gitBranch ? ( + + + {member.gitBranch} - + ) : null} + {currentTask ? ( + + ) : null} + {reviewTask ? ( + + ) : null} + {!activityTask && isAwaitingReply ? ( + <> + + + awaiting reply + + + ) : null} +
+ {runtimeSummary ? ( +
+ {runtimeSummary} +
) : null}
{(() => { diff --git a/src/renderer/components/team/members/MemberDraftRow.tsx b/src/renderer/components/team/members/MemberDraftRow.tsx index f27d0055..1cff48df 100644 --- a/src/renderer/components/team/members/MemberDraftRow.tsx +++ b/src/renderer/components/team/members/MemberDraftRow.tsx @@ -1,6 +1,10 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { TeamModelSelector } from '@renderer/components/team/dialogs/TeamModelSelector'; +import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector'; +import { + getTeamModelLabel, + TeamModelSelector, +} from '@renderer/components/team/dialogs/TeamModelSelector'; import { RoleSelect } from '@renderer/components/team/RoleSelect'; import { Button } from '@renderer/components/ui/button'; import { Input } from '@renderer/components/ui/input'; @@ -16,6 +20,7 @@ import { ChevronDown, ChevronRight, Info, Trash2 } from 'lucide-react'; import type { MemberDraft } from './membersEditorTypes'; import type { InlineChip } from '@renderer/types/inlineChip'; import type { MentionSuggestion } from '@renderer/types/mention'; +import type { EffortLevel, TeamProviderId } from '@shared/types'; interface MemberDraftRowProps { member: MemberDraft; @@ -29,11 +34,20 @@ interface MemberDraftRowProps { showWorkflow?: boolean; onWorkflowChange?: (id: string, workflow: string) => void; onWorkflowChipsChange?: (id: string, chips: InlineChip[]) => void; + onProviderChange: (id: string, providerId: TeamProviderId) => void; + onModelChange: (id: string, model: string) => void; + onEffortChange: (id: string, effort: string) => void; + inheritedProviderId?: TeamProviderId; + inheritedModel?: string; + inheritedEffort?: EffortLevel; draftKeyPrefix?: string; projectPath?: string | null; mentionSuggestions?: MentionSuggestion[]; taskSuggestions?: MentionSuggestion[]; teamSuggestions?: MentionSuggestion[]; + lockProviderModel?: boolean; + forceInheritedModelSettings?: boolean; + modelLockReason?: string; } export const MemberDraftRow = ({ @@ -48,11 +62,20 @@ export const MemberDraftRow = ({ showWorkflow = false, onWorkflowChange, onWorkflowChipsChange, + onProviderChange, + onModelChange, + onEffortChange, + inheritedProviderId = 'anthropic', + inheritedModel = '', + inheritedEffort, draftKeyPrefix, projectPath, mentionSuggestions = [], taskSuggestions, teamSuggestions, + lockProviderModel = false, + forceInheritedModelSettings = false, + modelLockReason, }: MemberDraftRowProps): React.JSX.Element => { const { isLight } = useTheme(); const memberColorSet = getTeamColorSet( @@ -122,10 +145,22 @@ export const MemberDraftRow = ({ const suggestionsExcludingSelf = mentionSuggestions.filter( (s) => s.name.toLowerCase() !== member.name.trim().toLowerCase() ); + const effectiveProviderId = forceInheritedModelSettings + ? inheritedProviderId + : (member.providerId ?? inheritedProviderId); + const effectiveModel = forceInheritedModelSettings + ? inheritedModel + : (member.model ?? inheritedModel); + const effectiveEffort = forceInheritedModelSettings + ? inheritedEffort + : (member.effort ?? inheritedEffort); + const modelButtonLabel = effectiveModel?.trim() + ? getTeamModelLabel(effectiveModel.trim()) + : 'Default'; return (
-
- {showWorkflow && onWorkflowChange ? ( +
+
+ {showWorkflow && onWorkflowChange ? ( + + ) : null} +
+ +
- ) : null} - - +
{showWorkflow && onWorkflowChange && workflowExpanded ? (
@@ -241,14 +282,38 @@ export const MemberDraftRow = ({ ) : null} {modelExpanded && (
-
- {}} /> -
+ { + if (lockProviderModel) return; + onProviderChange(member.id, providerId); + }} + value={effectiveModel ?? ''} + onValueChange={(value) => { + if (lockProviderModel) return; + onModelChange(member.id, value); + }} + id={`member-${member.id}-model`} + /> + { + if (lockProviderModel) return; + onEffortChange(member.id, value); + }} + id={`member-${member.id}-effort`} + /> + {lockProviderModel && ( +

+ {modelLockReason ?? + 'Provider, model, and effort changes are disabled while the team is live. Reconnect the team to apply them safely.'} +

+ )}

- Claude Code doesn't support per-member model selection yet — all teammates - inherit the team launch model. We plan to solve this via a local proxy. + If this teammate uses a different provider than the lead, they will be started in a + separate process automatically.

diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index efdfc083..07906fde 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -1,5 +1,11 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +import { + getTeamEffortLabel, + getTeamModelLabel, + getTeamProviderLabel, +} from '@renderer/components/team/dialogs/TeamModelSelector'; +import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; @@ -27,6 +33,7 @@ interface MemberListProps { isTeamAlive?: boolean; isTeamProvisioning?: boolean; leadActivity?: LeadActivityState; + launchParams?: TeamLaunchParams; onMemberClick?: (member: ResolvedTeamMember) => void; onSendMessage?: (member: ResolvedTeamMember) => void; onAssignTask?: (member: ResolvedTeamMember) => void; @@ -42,6 +49,7 @@ export const MemberList = ({ isTeamAlive, isTeamProvisioning, leadActivity, + launchParams, onMemberClick, onSendMessage, onAssignTask, @@ -77,6 +85,45 @@ export const MemberList = ({ const removedMembers = members.filter((m) => m.removedAt); const colorMap = buildMemberColorMap(members); + const buildRuntimeSummary = useCallback( + (member: ResolvedTeamMember): string | undefined => { + const hasMemberOverride = Boolean(member.providerId || member.model || member.effort); + if (!hasMemberOverride && launchParams) { + return undefined; + } + + const defaultProvider = launchParams?.providerId ?? 'anthropic'; + const memberProvider = member.providerId ?? defaultProvider; + const defaultModel = launchParams?.model?.trim() || ''; + const memberModel = member.model?.trim() || ''; + const defaultEffort = launchParams?.effort; + const memberEffort = member.effort; + + const showProvider = + !launchParams || Boolean(member.providerId && memberProvider !== defaultProvider); + const showModel = !launchParams + ? Boolean(memberModel) + : Boolean(memberModel && memberModel !== defaultModel); + const showEffort = !launchParams + ? Boolean(memberEffort) + : Boolean(memberEffort && memberEffort !== defaultEffort); + + const parts: string[] = []; + if (showProvider) { + parts.push(getTeamProviderLabel(memberProvider)); + } + if (showModel) { + parts.push(getTeamModelLabel(memberModel)); + } + if (showEffort && memberEffort) { + parts.push(getTeamEffortLabel(memberEffort)); + } + + return parts.length > 0 ? parts.join(' · ') : undefined; + }, + [launchParams] + ); + if (members.length === 0) { return (
@@ -111,6 +158,7 @@ export const MemberList = ({ reviewTask={isRemoved ? null : reviewTask} isAwaitingReply={isRemoved ? false : awaitingReply} isRemoved={isRemoved} + runtimeSummary={isRemoved ? buildRuntimeSummary(member) : buildRuntimeSummary(member)} spawnStatus={isRemoved ? undefined : spawnEntry?.status} spawnError={isRemoved ? undefined : spawnEntry?.error} onOpenTask={!isRemoved && currentTask ? () => onOpenTask?.(currentTask) : undefined} diff --git a/src/renderer/components/team/members/MembersEditorSection.tsx b/src/renderer/components/team/members/MembersEditorSection.tsx index 608fbb54..d9fab145 100644 --- a/src/renderer/components/team/members/MembersEditorSection.tsx +++ b/src/renderer/components/team/members/MembersEditorSection.tsx @@ -20,6 +20,7 @@ import { import type { MemberDraft } from './membersEditorTypes'; import type { InlineChip } from '@renderer/types/inlineChip'; import type { MentionSuggestion } from '@renderer/types/mention'; +import type { EffortLevel, TeamProviderId } from '@shared/types'; function membersToJsonText(drafts: MemberDraft[]): string { const arr = drafts @@ -30,6 +31,9 @@ function membersToJsonText(drafts: MemberDraft[]): string { if (role) obj.role = role; const workflow = getWorkflowForExport(d); if (workflow) obj.workflow = workflow; + if (d.providerId && d.providerId !== 'anthropic') obj.providerId = d.providerId; + if (d.model?.trim()) obj.model = d.model.trim(); + if (d.effort) obj.effort = d.effort; return obj; }); return JSON.stringify(arr, null, 2); @@ -42,6 +46,13 @@ function parseJsonToDrafts(text: string): MemberDraft[] { const name = typeof item.name === 'string' ? item.name : ''; const role = typeof item.role === 'string' ? item.role.trim() : ''; const workflow = typeof item.workflow === 'string' ? item.workflow.trim() : ''; + const providerId: TeamProviderId = + item.providerId === 'codex' || item.providerId === 'gemini' ? item.providerId : 'anthropic'; + const model = typeof item.model === 'string' ? item.model.trim() : ''; + const effort: EffortLevel | undefined = + item.effort === 'low' || item.effort === 'medium' || item.effort === 'high' + ? item.effort + : undefined; const presetRoles: readonly string[] = PRESET_ROLES; const isPreset = presetRoles.includes(role); return createMemberDraft({ @@ -49,6 +60,9 @@ function parseJsonToDrafts(text: string): MemberDraft[] { roleSelection: role ? (isPreset ? role : CUSTOM_ROLE) : '', customRole: role && !isPreset ? role : '', workflow: workflow || undefined, + providerId, + model, + effort, }); }); } @@ -74,6 +88,16 @@ export interface MembersEditorSectionProps { hideContent?: boolean; /** Existing team members — used to reserve their colors so drafts get the next available ones */ existingMembers?: readonly { name: string; color?: string; removedAt?: number | string | null }[]; + /** Default provider to use for newly added member rows. */ + defaultProviderId?: TeamProviderId; + /** When true, provider/model controls stay read-only for existing rows. */ + lockProviderModel?: boolean; + inheritedProviderId?: TeamProviderId; + inheritedModel?: string; + inheritedEffort?: EffortLevel; + inheritModelSettingsByDefault?: boolean; + forceInheritedModelSettings?: boolean; + modelLockReason?: string; } export const MembersEditorSection = ({ @@ -90,6 +114,14 @@ export const MembersEditorSection = ({ headerExtra, hideContent = false, existingMembers, + defaultProviderId = 'anthropic', + lockProviderModel = false, + inheritedProviderId, + inheritedModel, + inheritedEffort, + inheritModelSettingsByDefault = false, + forceInheritedModelSettings = false, + modelLockReason, }: MembersEditorSectionProps): React.JSX.Element => { const [jsonEditorOpen, setJsonEditorOpen] = useState(false); const [jsonText, setJsonText] = useState(''); @@ -150,13 +182,52 @@ export const MembersEditorSection = ({ onChange(members.map((c) => (c.id === memberId ? { ...c, workflowChips } : c))); }; + const updateMemberProvider = (memberId: string, providerId: TeamProviderId): void => { + onChange( + members.map((c) => + c.id === memberId + ? { + ...c, + providerId, + model: c.providerId === providerId ? c.model : '', + } + : c + ) + ); + }; + + const updateMemberModel = (memberId: string, model: string): void => { + onChange(members.map((c) => (c.id === memberId ? { ...c, model } : c))); + }; + + const updateMemberEffort = (memberId: string, effort: string): void => { + onChange( + members.map((c) => + c.id === memberId + ? { + ...c, + effort: + effort === 'low' || effort === 'medium' || effort === 'high' ? effort : undefined, + } + : c + ) + ); + }; + const removeMember = (memberId: string): void => { onChange(members.filter((c) => c.id !== memberId)); }; const addMember = (): void => { const suggestedName = getNextSuggestedMemberName(members.map((member) => member.name)); - onChange([...members, createMemberDraft({ name: suggestedName })]); + onChange([ + ...members, + createMemberDraft( + inheritModelSettingsByDefault + ? { name: suggestedName } + : { name: suggestedName, providerId: defaultProviderId } + ), + ]); }; const names = members.map((m) => m.name.trim().toLowerCase()).filter(Boolean); @@ -207,11 +278,20 @@ export const MembersEditorSection = ({ showWorkflow={showWorkflow} onWorkflowChange={showWorkflow ? updateMemberWorkflow : undefined} onWorkflowChipsChange={showWorkflow ? updateMemberWorkflowChips : undefined} + onProviderChange={updateMemberProvider} + onModelChange={updateMemberModel} + onEffortChange={updateMemberEffort} + inheritedProviderId={inheritedProviderId} + inheritedModel={inheritedModel} + inheritedEffort={inheritedEffort} + forceInheritedModelSettings={forceInheritedModelSettings} draftKeyPrefix={draftKeyPrefix} projectPath={projectPath} mentionSuggestions={mentionSuggestions} taskSuggestions={taskSuggestions} teamSuggestions={teamSuggestions} + lockProviderModel={lockProviderModel} + modelLockReason={modelLockReason} /> ))} {jsonEditorOpen && showJsonEditor ? ( @@ -243,7 +323,9 @@ export { buildMemberDraftColorMap, buildMemberDraftSuggestions, buildMembersFromDrafts, + clearMemberModelOverrides, createMemberDraft, + createMemberDraftsFromInputs, getMemberDraftRole, validateMemberNameInline, } from './membersEditorUtils'; diff --git a/src/renderer/components/team/members/TeamRosterEditorSection.tsx b/src/renderer/components/team/members/TeamRosterEditorSection.tsx new file mode 100644 index 00000000..8f467b5d --- /dev/null +++ b/src/renderer/components/team/members/TeamRosterEditorSection.tsx @@ -0,0 +1,121 @@ +import React from 'react'; + +import { MembersEditorSection } from './MembersEditorSection'; +import { LeadModelRow } from './LeadModelRow'; + +import type { MemberDraft } from './membersEditorTypes'; +import type { MentionSuggestion } from '@renderer/types/mention'; +import type { EffortLevel, TeamProviderId } from '@shared/types'; + +interface TeamRosterEditorSectionProps { + members: MemberDraft[]; + onMembersChange: (members: MemberDraft[]) => void; + fieldError?: string; + validateMemberName?: (name: string) => string | null; + showWorkflow?: boolean; + showJsonEditor?: boolean; + draftKeyPrefix?: string; + projectPath?: string | null; + taskSuggestions?: MentionSuggestion[]; + teamSuggestions?: MentionSuggestion[]; + hideMembersContent?: boolean; + existingMembers?: readonly { name: string; color?: string; removedAt?: number | string | null }[]; + defaultProviderId?: TeamProviderId; + inheritedProviderId: TeamProviderId; + inheritedModel: string; + inheritedEffort?: EffortLevel; + inheritModelSettingsByDefault?: boolean; + forceInheritedModelSettings?: boolean; + lockProviderModel?: boolean; + modelLockReason?: string; + providerId: TeamProviderId; + model: string; + effort?: EffortLevel; + limitContext: boolean; + onProviderChange: (providerId: TeamProviderId) => void; + onModelChange: (model: string) => void; + onEffortChange: (effort: string) => void; + onLimitContextChange: (value: boolean) => void; + syncModelsWithTeammates: boolean; + onSyncModelsWithTeammatesChange: (value: boolean) => void; + headerTop?: React.ReactNode; + headerBottom?: React.ReactNode; +} + +export const TeamRosterEditorSection = ({ + members, + onMembersChange, + fieldError, + validateMemberName, + showWorkflow = false, + showJsonEditor = true, + draftKeyPrefix, + projectPath, + taskSuggestions, + teamSuggestions, + hideMembersContent = false, + existingMembers, + defaultProviderId = 'anthropic', + inheritedProviderId, + inheritedModel, + inheritedEffort, + inheritModelSettingsByDefault = false, + forceInheritedModelSettings = false, + lockProviderModel = false, + modelLockReason, + providerId, + model, + effort, + limitContext, + onProviderChange, + onModelChange, + onEffortChange, + onLimitContextChange, + syncModelsWithTeammates, + onSyncModelsWithTeammatesChange, + headerTop, + headerBottom, +}: TeamRosterEditorSectionProps): React.JSX.Element => { + return ( + + {headerTop} + + {headerBottom} +
+ } + /> + ); +}; diff --git a/src/renderer/components/team/members/membersEditorTypes.ts b/src/renderer/components/team/members/membersEditorTypes.ts index f4649308..d73ac590 100644 --- a/src/renderer/components/team/members/membersEditorTypes.ts +++ b/src/renderer/components/team/members/membersEditorTypes.ts @@ -1,4 +1,5 @@ import type { InlineChip } from '@renderer/types/inlineChip'; +import type { EffortLevel, TeamProviderId } from '@shared/types'; export interface MemberDraft { id: string; @@ -7,6 +8,9 @@ export interface MemberDraft { customRole: string; workflow?: string; workflowChips?: InlineChip[]; + providerId?: TeamProviderId; + model?: string; + effort?: EffortLevel; } export interface MembersEditorValue { diff --git a/src/renderer/components/team/members/membersEditorUtils.ts b/src/renderer/components/team/members/membersEditorUtils.ts index 8bafddc2..906f0a2c 100644 --- a/src/renderer/components/team/members/membersEditorUtils.ts +++ b/src/renderer/components/team/members/membersEditorUtils.ts @@ -1,10 +1,10 @@ -import { CUSTOM_ROLE, NO_ROLE } from '@renderer/constants/teamRoles'; +import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles'; import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import type { MemberDraft } from './membersEditorTypes'; import type { MentionSuggestion } from '@renderer/types/mention'; -import type { TeamProvisioningMemberInput } from '@shared/types'; +import type { EffortLevel, TeamProvisioningMemberInput, TeamProviderId } from '@shared/types'; function isValidMemberName(name: string): boolean { if (name.length < 1 || name.length > 128) return false; @@ -33,9 +33,60 @@ export function createMemberDraft(initial?: Partial): MemberDraft { roleSelection: initial?.roleSelection ?? '', customRole: initial?.customRole ?? '', workflow: initial?.workflow, + providerId: initial?.providerId, + model: initial?.model ?? '', + effort: initial?.effort, }; } +export function createMemberDraftsFromInputs( + members: readonly { + name: string; + role?: string; + workflow?: string; + providerId?: TeamProviderId; + model?: string; + effort?: EffortLevel; + removedAt?: number | string | null; + }[] +): MemberDraft[] { + return members + .filter((member) => !member.removedAt) + .map((member) => { + const role = typeof member.role === 'string' ? member.role.trim() : ''; + const presetRoles: readonly string[] = PRESET_ROLES; + const isPreset = presetRoles.includes(role); + return createMemberDraft({ + name: member.name, + roleSelection: role ? (isPreset ? role : CUSTOM_ROLE) : '', + customRole: role && !isPreset ? role : '', + workflow: member.workflow, + providerId: + member.providerId === 'codex' || member.providerId === 'gemini' + ? member.providerId + : 'anthropic', + model: member.model ?? '', + effort: normalizeDraftEffort(member.effort), + }); + }); +} + +export function clearMemberModelOverrides(member: MemberDraft): MemberDraft { + return { + ...member, + providerId: undefined, + model: '', + effort: undefined, + }; +} + +function normalizeDraftEffort(value: string | undefined): EffortLevel | undefined { + if (value === 'low' || value === 'medium' || value === 'high') { + return value; + } + return undefined; +} + interface ExistingMemberColorInput { name: string; color?: string; @@ -113,6 +164,21 @@ export function buildMembersFromDrafts(members: MemberDraft[]): TeamProvisioning const result: TeamProvisioningMemberInput = { name, role }; const workflow = getWorkflowForExport(member); if (workflow) result.workflow = workflow; + const providerId: TeamProviderId = + member.providerId === 'codex' || member.providerId === 'gemini' + ? member.providerId + : 'anthropic'; + if (providerId !== 'anthropic') { + result.providerId = providerId; + } + const model = member.model?.trim(); + if (model) { + result.model = model; + } + const effort = normalizeDraftEffort(member.effort); + if (effort) { + result.effort = effort; + } return result; }) .filter((member): member is NonNullable => member !== null); diff --git a/src/renderer/hooks/useCreateTeamDraft.ts b/src/renderer/hooks/useCreateTeamDraft.ts index e537b3b4..5eaadf2b 100644 --- a/src/renderer/hooks/useCreateTeamDraft.ts +++ b/src/renderer/hooks/useCreateTeamDraft.ts @@ -33,6 +33,8 @@ export interface UseCreateTeamDraftResult { setTeamName: (v: string) => void; members: MemberDraft[]; setMembers: (v: MemberDraft[]) => void; + syncModelsWithLead: boolean; + setSyncModelsWithLead: (v: boolean) => void; cwdMode: 'project' | 'custom'; setCwdMode: (v: 'project' | 'custom') => void; selectedProjectPath: string; @@ -63,13 +65,18 @@ const DEBOUNCE_MS = 400; // --------------------------------------------------------------------------- function serializeMembers(members: MemberDraft[]): SerializedMemberDraft[] { - return members.map(({ id, name, roleSelection, customRole, workflow }) => ({ - id, - name, - roleSelection, - customRole, - workflow, - })); + return members.map( + ({ id, name, roleSelection, customRole, workflow, providerId, model, effort }) => ({ + id, + name, + roleSelection, + customRole, + workflow, + providerId, + model, + effort, + }) + ); } function deserializeMembers(serialized: SerializedMemberDraft[]): MemberDraft[] { @@ -80,6 +87,9 @@ function deserializeMembers(serialized: SerializedMemberDraft[]): MemberDraft[] roleSelection: m.roleSelection, customRole: m.customRole, workflow: m.workflow, + providerId: m.providerId, + model: m.model, + effort: m.effort, }) ); } @@ -92,6 +102,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult { // ── State ────────────────────────────────────────────────────────────── const [teamName, setTeamNameState] = useState(''); const [members, setMembersState] = useState([]); + const [syncModelsWithLead, setSyncModelsWithLeadState] = useState(true); const [cwdMode, setCwdModeState] = useState<'project' | 'custom'>('project'); const [selectedProjectPath, setSelectedProjectPathState] = useState(''); const [customCwd, setCustomCwdState] = useState(''); @@ -103,6 +114,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult { // ── Refs (latest values for debounced callbacks) ─────────────────────── const teamNameRef = useRef(''); const membersRef = useRef([]); + const syncModelsWithLeadRef = useRef(true); const cwdModeRef = useRef<'project' | 'custom'>('project'); const selectedProjectPathRef = useRef(''); const customCwdRef = useRef(''); @@ -128,6 +140,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult { version: 1, teamName: teamNameRef.current, members: serializeMembers(membersRef.current), + syncModelsWithLead: syncModelsWithLeadRef.current, cwdMode: cwdModeRef.current, selectedProjectPath: selectedProjectPathRef.current, customCwd: customCwdRef.current, @@ -187,6 +200,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult { teamNameRef.current = snap.teamName; membersRef.current = deserialized; + syncModelsWithLeadRef.current = snap.syncModelsWithLead ?? true; cwdModeRef.current = snap.cwdMode; selectedProjectPathRef.current = snap.selectedProjectPath; customCwdRef.current = snap.customCwd; @@ -196,6 +210,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult { setTeamNameState(snap.teamName); setMembersState(deserialized); + setSyncModelsWithLeadState(snap.syncModelsWithLead ?? true); setCwdModeState(snap.cwdMode); setSelectedProjectPathState(snap.selectedProjectPath); setCustomCwdState(snap.customCwd); @@ -260,6 +275,16 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult { [scheduleSave] ); + const setSyncModelsWithLead = useCallback( + (v: boolean) => { + userTouchedRef.current = true; + syncModelsWithLeadRef.current = v; + setSyncModelsWithLeadState(v); + scheduleSave(); + }, + [scheduleSave] + ); + const setCwdMode = useCallback( (v: 'project' | 'custom') => { userTouchedRef.current = true; @@ -334,6 +359,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult { teamNameRef.current = ''; membersRef.current = []; + syncModelsWithLeadRef.current = true; cwdModeRef.current = 'project'; selectedProjectPathRef.current = ''; customCwdRef.current = ''; @@ -343,6 +369,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult { setTeamNameState(''); setMembersState([]); + setSyncModelsWithLeadState(true); setCwdModeState('project'); setSelectedProjectPathState(''); setCustomCwdState(''); @@ -358,6 +385,8 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult { setTeamName, members, setMembers, + syncModelsWithLead, + setSyncModelsWithLead, cwdMode, setCwdMode, selectedProjectPath, diff --git a/src/renderer/services/createTeamDraftStorage.ts b/src/renderer/services/createTeamDraftStorage.ts index 98eb52cc..eb616f60 100644 --- a/src/renderer/services/createTeamDraftStorage.ts +++ b/src/renderer/services/createTeamDraftStorage.ts @@ -25,12 +25,16 @@ export interface SerializedMemberDraft { roleSelection: string; customRole: string; workflow?: string; + providerId?: 'anthropic' | 'codex' | 'gemini'; + model?: string; + effort?: 'low' | 'medium' | 'high'; } export interface CreateTeamDraftSnapshot { version: number; teamName: string; members: SerializedMemberDraft[]; + syncModelsWithLead?: boolean; cwdMode: 'project' | 'custom'; selectedProjectPath: string; customCwd: string; @@ -57,7 +61,16 @@ function isValidMember(m: unknown): m is SerializedMemberDraft { typeof obj.id === 'string' && typeof obj.name === 'string' && typeof obj.roleSelection === 'string' && - typeof obj.customRole === 'string' + typeof obj.customRole === 'string' && + (obj.providerId === undefined || + obj.providerId === 'anthropic' || + obj.providerId === 'codex' || + obj.providerId === 'gemini') && + (obj.model === undefined || typeof obj.model === 'string') && + (obj.effort === undefined || + obj.effort === 'low' || + obj.effort === 'medium' || + obj.effort === 'high') ); } @@ -70,6 +83,7 @@ function isValidSnapshot(data: unknown): data is CreateTeamDraftSnapshot { typeof obj.teamName === 'string' && Array.isArray(obj.members) && obj.members.every(isValidMember) && + (obj.syncModelsWithLead === undefined || typeof obj.syncModelsWithLead === 'boolean') && (obj.cwdMode === 'project' || obj.cwdMode === 'custom') && typeof obj.selectedProjectPath === 'string' && typeof obj.customCwd === 'string' && @@ -154,6 +168,7 @@ function emptySnapshot(): CreateTeamDraftSnapshot { version: SNAPSHOT_VERSION, teamName: '', members: [], + syncModelsWithLead: true, cwdMode: 'project', selectedProjectPath: '', customCwd: '', diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index c4e1952d..71818524 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -559,6 +559,7 @@ export interface GlobalTaskDetailState { /** Per-team launch parameters shown in the header badge. */ export interface TeamLaunchParams { + providerId?: 'anthropic' | 'codex' | 'gemini'; model?: string; // 'opus' | 'sonnet' | 'haiku' effort?: EffortLevel; limitContext?: boolean; @@ -1949,6 +1950,7 @@ export const createTeamSlice: StateCreator = (set, // Persist per-team launch params (model, effort, limit context) const baseModel = extractBaseModel(request.model); const params: TeamLaunchParams = { + providerId: request.providerId ?? 'anthropic', model: baseModel || 'default', effort: request.effort, limitContext: request.limitContext ?? false, @@ -2125,6 +2127,7 @@ export const createTeamSlice: StateCreator = (set, // Persist per-team launch params (model, effort, limit context) const baseModel = extractBaseModel(request.model); const params: TeamLaunchParams = { + providerId: request.providerId ?? 'anthropic', model: baseModel || 'default', effort: request.effort, limitContext: request.limitContext ?? false, diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index e52ab6ea..e7994209 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -427,7 +427,11 @@ export interface TeamsAPI { permanentlyDeleteTeam: (teamName: string) => Promise; getSavedRequest: (teamName: string) => Promise; deleteDraft: (teamName: string) => Promise; - prepareProvisioning: (cwd?: string) => Promise; + prepareProvisioning: ( + cwd?: string, + providerId?: TeamLaunchRequest['providerId'], + providerIds?: TeamLaunchRequest['providerId'][] + ) => Promise; createTeam: (request: TeamCreateRequest) => Promise; getProvisioningStatus: (runId: string) => Promise; cancelProvisioning: (runId: string) => Promise; diff --git a/src/shared/types/schedule.ts b/src/shared/types/schedule.ts index 606b81eb..9f76b1b1 100644 --- a/src/shared/types/schedule.ts +++ b/src/shared/types/schedule.ts @@ -5,7 +5,7 @@ * Repository Pattern abstraction allows swapping storage backend (JSON → sql.js/Drizzle). */ -import type { EffortLevel } from './team'; +import type { EffortLevel, TeamProviderId } from './team'; // ============================================================================= // Schedule Status Types @@ -49,6 +49,7 @@ export interface Schedule { export interface ScheduleLaunchConfig { cwd: string; prompt: string; + providerId?: TeamProviderId; model?: string; effort?: EffortLevel; skipPermissions?: boolean; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index b8f4e817..c833eec0 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -5,6 +5,9 @@ export interface TeamMember { role?: string; /** Per-agent workflow/instructions injected into spawn prompt. */ workflow?: string; + providerId?: TeamProviderId; + model?: string; + effort?: EffortLevel; color?: string; joinedAt?: number; cwd?: string; @@ -471,6 +474,9 @@ export interface ResolvedTeamMember { agentType?: string; role?: string; workflow?: string; + providerId?: TeamProviderId; + model?: string; + effort?: EffortLevel; cwd?: string; /** Set only when member's git branch differs from the lead's branch. */ gitBranch?: string; @@ -503,11 +509,13 @@ export interface TeamData { } export type EffortLevel = 'low' | 'medium' | 'high'; +export type TeamProviderId = 'anthropic' | 'codex' | 'gemini'; export interface TeamLaunchRequest { teamName: string; cwd: string; prompt?: string; + providerId?: TeamProviderId; model?: string; effort?: EffortLevel; /** When true, context window is limited to 200K tokens instead of the default. */ @@ -633,6 +641,9 @@ export interface TeamProvisioningMemberInput { role?: string; /** Per-agent workflow/instructions injected into spawn prompt. */ workflow?: string; + providerId?: TeamProviderId; + model?: string; + effort?: EffortLevel; } export interface TeamCreateRequest { @@ -643,6 +654,7 @@ export interface TeamCreateRequest { members: TeamProvisioningMemberInput[]; cwd: string; prompt?: string; + providerId?: TeamProviderId; model?: string; effort?: EffortLevel; /** When true, context window is limited to 200K tokens instead of the default. */ @@ -780,6 +792,9 @@ export interface AddMemberRequest { name: string; role?: string; workflow?: string; + providerId?: TeamProviderId; + model?: string; + effort?: EffortLevel; } export interface RemoveMemberRequest { diff --git a/src/shared/utils/cliArgsParser.ts b/src/shared/utils/cliArgsParser.ts index 5adae22c..5a22ac91 100644 --- a/src/shared/utils/cliArgsParser.ts +++ b/src/shared/utils/cliArgsParser.ts @@ -24,6 +24,12 @@ export const PROTECTED_CLI_FLAGS = new Set([ '--mcp-config', '--disallowedTools', '--verbose', + '--model', + '--effort', + '--resume', + '--permission-mode', + '--permission-prompt-tool', + '--dangerously-skip-permissions', ]); /** diff --git a/test/main/services/team/TeamMemberResolver.test.ts b/test/main/services/team/TeamMemberResolver.test.ts index 24ec3686..e0718abf 100644 --- a/test/main/services/team/TeamMemberResolver.test.ts +++ b/test/main/services/team/TeamMemberResolver.test.ts @@ -91,6 +91,28 @@ describe('TeamMemberResolver', () => { expect(names).not.toContain('dream-team.team-lead'); }); + it('ignores leaked generated agent ids from inbox file names', () => { + const resolver = new TeamMemberResolver(); + const config: TeamConfig = { + name: 'Team', + members: [{ name: 'team-lead', agentType: 'team-lead', role: 'lead' }], + }; + const metaMembers: TeamConfig['members'] = [ + { name: 'alice', agentType: 'general-purpose' }, + { name: 'bob', agentType: 'general-purpose' }, + ]; + const inboxNames = ['a3975f80d37fbcea1', 'alice', 'a68a8f6a643e59bfd']; + + const members = resolver.resolveMembers(config, metaMembers, inboxNames, [], []); + const names = members.map((m) => m.name); + + expect(names).toContain('alice'); + expect(names).toContain('bob'); + expect(names).toContain('team-lead'); + expect(names).not.toContain('a3975f80d37fbcea1'); + expect(names).not.toContain('a68a8f6a643e59bfd'); + }); + it('keeps dotted names when they are explicitly configured members', () => { const resolver = new TeamMemberResolver(); const config: TeamConfig = { diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index 41285f18..e5d7874e 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -79,6 +79,38 @@ describe('TeamProvisioningService prepare/auth behavior', () => { expect(probeSpy.mock.calls[1]?.[1]).toBe(cwdB); }); + it('checks each unique provider during multi-provider prepare and blocks on provider auth failure', async () => { + const svc = new TeamProvisioningService(); + const getCachedOrProbeResult = vi.spyOn(svc as any, 'getCachedOrProbeResult'); + getCachedOrProbeResult.mockImplementation(async (_cwd: unknown, providerId: unknown) => { + if (providerId === 'codex') { + return { + claudePath: '/fake/claude', + authSource: 'none', + warning: 'Not logged in to Codex runtime', + }; + } + return { + claudePath: '/fake/claude', + authSource: 'oauth_token', + }; + }); + + const result = await svc.prepareForProvisioning(tempRoot, { + forceFresh: true, + providerId: 'anthropic', + providerIds: ['codex', 'anthropic'], + }); + + expect(result.ready).toBe(false); + expect(result.message).toBe('Codex: Not logged in to Codex runtime'); + expect(getCachedOrProbeResult).toHaveBeenCalledTimes(2); + expect(getCachedOrProbeResult.mock.calls.map((call) => call[1])).toEqual([ + 'anthropic', + 'codex', + ]); + }); + it('maps ANTHROPIC_AUTH_TOKEN into ANTHROPIC_API_KEY for headless preflight', async () => { const svc = new TeamProvisioningService(); vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({ diff --git a/test/main/services/team/TeamProvisioningServiceRoster.test.ts b/test/main/services/team/TeamProvisioningServiceRoster.test.ts index 53e69e78..aa86048d 100644 --- a/test/main/services/team/TeamProvisioningServiceRoster.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRoster.test.ts @@ -59,6 +59,27 @@ describe('TeamProvisioningService (launch roster discovery)', () => { expect(result.members.map((m: { name: string }) => m.name)).toEqual(['alice-2']); }); + it('inbox fallback merges provider/model overrides from config for multimodel reconnect', async () => { + const svc = new TeamProvisioningService( + {} as never, + { listInboxNames: vi.fn(async () => ['bob']) } as never, + { getMembers: vi.fn(async () => []) } as never, + {} as never + ); + + const configRaw = JSON.stringify({ + name: 't', + members: [{ name: 'bob', role: 'reviewer', provider: 'codex', model: 'gpt-5.4' }], + }); + + const result = await (svc as unknown as any).resolveLaunchExpectedMembers('t', configRaw); + expect(result.source).toBe('inboxes'); + expect(result.members).toEqual([ + { name: 'bob', role: 'reviewer', workflow: undefined, providerId: 'codex', model: 'gpt-5.4' }, + ]); + expect(result.warning).toContain('best-effort'); + }); + it('members.meta.json fallback never returns reserved names (user/team-lead)', async () => { const svc = new TeamProvisioningService( {} as never,