feat(teams): unify provider-aware create and launch flows

This commit is contained in:
iliya 2026-04-02 10:23:14 +03:00
parent bae3609561
commit 3ac46e2861
36 changed files with 2898 additions and 574 deletions

View file

@ -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<string, unknown>) : {};
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,
}),

View file

@ -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<IpcResult<TeamProvisioningPrepareResult>> {
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<string>();
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,
})),
},
};

View file

@ -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}` : '')

View file

@ -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<void> {
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,

View file

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

View file

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

View file

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

View file

@ -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<void> {
}
}
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<TeamCreateRequest['members'][number], 'name' | 'role' | 'workflow'>
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<string, CachedProbeResult>();
const probeInFlightByKey = new Map<string, Promise<ProbeResult | null>>();
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<void> {
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<TeamProvisioningPrepareResult> {
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<void> {
@ -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<ProbeResult | null> {
const cacheKey = createProbeCacheKey(cwd);
const cached = this.getFreshCachedProbeResult(cwd);
private async getCachedOrProbeResult(
cwd: string,
providerId: TeamProviderId | undefined
): Promise<ProbeResult | null> {
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<typeof spawn>;
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<typeof spawn>;
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<string, unknown>;
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<ProvisioningEnvResolution> {
private async buildProvisioningEnv(
providerId: TeamProviderId | undefined = 'anthropic'
): Promise<ProvisioningEnvResolution> {
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');
}

View file

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

View file

@ -702,7 +702,11 @@ export class HttpAPIClient implements ElectronAPI {
deleteDraft: async (_teamName: string): Promise<void> => {
throw new Error('Draft team deletion is not available in browser mode');
},
prepareProvisioning: async (_cwd?: string): Promise<TeamProvisioningPrepareResult> => {
prepareProvisioning: async (
_cwd?: string,
_providerId?: TeamLaunchRequest['providerId'],
_providerIds?: TeamLaunchRequest['providerId'][]
): Promise<TeamProvisioningPrepareResult> => {
throw new Error('Team provisioning is not available in browser mode');
},
createTeam: async (_request: TeamCreateRequest): Promise<TeamCreateResponse> => {
@ -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<void> => {
console.warn('[HttpAPIClient] CLI installer not available in browser mode');

View file

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

View file

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

View file

@ -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<string | null>(null);
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
const [prepareChecks, setPrepareChecks] = useState<ProvisioningProviderCheck[]>([]);
const prepareRequestSeqRef = useRef(0);
const lastAutoDescriptionRef = useRef<string | null>(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<TeamProviderId>(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 = ({
<p className="text-red-300/80">
{prepareMessage ?? 'Failed to prepare environment'}
</p>
{prepareWarnings.length > 0 ? (
{!shouldHideProvisioningProviderStatusList(prepareChecks, prepareMessage) ? (
<ProvisioningProviderStatusList
checks={prepareChecks}
className="mt-1"
suppressDetailsMatching={prepareMessage}
/>
) : null}
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
<div className="space-y-0.5">
{prepareWarnings.map((warning) => (
<p
@ -836,8 +998,7 @@ export const CreateTeamDialog = ({
</div>
) : null}
<p className="text-[11px] text-[var(--color-text-muted)]">
Make sure <span className="font-mono">claude</span> CLI is installed and available
in PATH, then reopen this dialog.
{getProvisioningFailureHint(prepareMessage, prepareChecks)}
</p>
</div>
</div>
@ -892,9 +1053,9 @@ export const CreateTeamDialog = ({
</div>
<div className="md:col-span-2">
<MembersEditorSection
<TeamRosterEditorSection
members={members}
onChange={setMembers}
onMembersChange={setMembers}
fieldError={fieldErrors.members}
validateMemberName={validateMemberNameInline}
showWorkflow
@ -903,35 +1064,53 @@ export const CreateTeamDialog = ({
projectPath={effectiveCwd || null}
taskSuggestions={taskSuggestions}
teamSuggestions={teamMentionSuggestions}
hideContent={soloTeam}
headerExtra={
<div className="space-y-2">
<div className="flex items-center gap-2">
<Checkbox
id="solo-team"
checked={soloTeam}
onCheckedChange={(checked) => setSoloTeam(checked === true)}
/>
<Label
htmlFor="solo-team"
className="cursor-pointer text-xs font-normal text-text-secondary"
>
Solo team
</Label>
</div>
{soloTeam && (
<div className="flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/5 px-3 py-2">
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />
<p className="text-[11px] leading-relaxed text-sky-300">
Only the team lead (main process) will be started &mdash; 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.
</p>
</div>
)}
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={
<div className="flex items-center gap-2">
<Checkbox
id="solo-team"
checked={soloTeam}
onCheckedChange={(checked) => setSoloTeam(checked === true)}
/>
<Label
htmlFor="solo-team"
className="cursor-pointer text-xs font-normal text-text-secondary"
>
Solo team
</Label>
</div>
}
headerBottom={
soloTeam ? (
<div className="flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/5 px-3 py-2">
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />
<p className="text-[11px] leading-relaxed text-sky-300">
Only the team lead (main process) will be started &mdash; 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.
</p>
</div>
) : null
}
/>
</div>
@ -984,7 +1163,7 @@ export const CreateTeamDialog = ({
<OptionalSettingsSection
title="Optional launch settings"
description="Prompt, model, safety, and CLI overrides live here when you need them."
description="Prompt, safety, and CLI overrides live here when you need them."
summary={launchOptionalSummary}
>
<div className="space-y-4">
@ -1017,29 +1196,11 @@ export const CreateTeamDialog = ({
/>
</div>
<div>
<TeamModelSelector
value={selectedModel}
onValueChange={setSelectedModel}
id="create-model"
/>
<EffortLevelSelector
value={selectedEffort}
onValueChange={setSelectedEffort}
id="create-effort"
/>
<LimitContextCheckbox
id="create-limit-context"
checked={limitContext}
onCheckedChange={setLimitContext}
disabled={selectedModel === 'haiku'}
/>
<SkipPermissionsCheckbox
id="create-skip-permissions"
checked={skipPermissions}
onCheckedChange={setSkipPermissions}
/>
</div>
<SkipPermissionsCheckbox
id="create-skip-permissions"
checked={skipPermissions}
onCheckedChange={setSkipPermissions}
/>
<AdvancedCliSection
teamName={advancedKey}
@ -1133,27 +1294,23 @@ export const CreateTeamDialog = ({
<DialogFooter className="pt-4 sm:justify-between">
<div className="min-w-0">
{canCreate && launchTeam && (prepareState === 'idle' || prepareState === 'loading') ? (
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
<span className="inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
<div>
<span>
{prepareMessage ??
(prepareState === 'idle'
? 'Warming up CLI environment...'
: 'Preparing environment...')}
</span>
<p className="mt-0.5 flex items-center gap-1.5 text-[10px] text-[var(--color-text-muted)] opacity-70">
<span>Pre-flight check to catch errors before launch</span>
<button
type="button"
onClick={() => setPrepareState('ready')}
className="rounded px-1.5 py-0.5 text-[10px] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text-secondary)]"
>
Skip
</button>
</p>
<>
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
<span className="inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
<div>
<span>
{prepareMessage ??
(prepareState === 'idle'
? 'Warming up CLI environment...'
: 'Preparing environment...')}
</span>
<p className="mt-0.5 text-[10px] text-[var(--color-text-muted)] opacity-70">
Pre-flight check to catch errors before launch
</p>
</div>
</div>
</div>
<ProvisioningProviderStatusList checks={prepareChecks} className="mt-2" />
</>
) : null}
{canCreate && launchTeam && prepareState === 'ready' ? (
@ -1161,7 +1318,8 @@ export const CreateTeamDialog = ({
<div className="flex items-center gap-1.5 text-xs font-medium text-emerald-400">
<CheckCircle2 className="size-3.5 shrink-0" />
<span>
{prepareWarnings.length > 0
{prepareChecks.some((check) => check.status === 'notes') ||
prepareWarnings.length > 0
? 'CLI environment ready (with notes)'
: 'CLI environment ready'}
</span>
@ -1171,7 +1329,8 @@ export const CreateTeamDialog = ({
{prepareMessage}
</p>
) : null}
{prepareWarnings.length > 0 ? (
<ProvisioningProviderStatusList checks={prepareChecks} className="mt-1" />
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
<div className="mt-0.5 space-y-0.5 pl-5">
{prepareWarnings.map((warning) => (
<p key={warning} className="text-[11px] text-sky-300">
@ -1202,12 +1361,7 @@ export const CreateTeamDialog = ({
</Button>
<Button
size="sm"
disabled={
!canCreate ||
!draftLoaded ||
isSubmitting ||
(launchTeam && prepareState !== 'ready')
}
disabled={!canCreate || !draftLoaded || isSubmitting}
onClick={handleSubmit}
>
{isSubmitting ? (
@ -1215,6 +1369,8 @@ export const CreateTeamDialog = ({
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
Creating...
</>
) : launchTeam && (prepareState === 'idle' || prepareState === 'loading') ? (
'Skip preflight and create'
) : (
'Create'
)}

View file

@ -3,7 +3,7 @@ import { useEffect, useState } from 'react';
import { api } from '@renderer/api';
import {
buildMembersFromDrafts,
createMemberDraft,
createMemberDraftsFromInputs,
MembersEditorSection,
validateMemberNameInline,
} from '@renderer/components/team/members/MembersEditorSection';
@ -17,7 +17,6 @@ import {
DialogTitle,
} from '@renderer/components/ui/dialog';
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { CUSTOM_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
import { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils';
@ -43,24 +42,14 @@ interface EditTeamDialogProps {
currentDescription: string;
currentColor: string;
currentMembers: ResolvedTeamMember[];
isTeamAlive?: boolean;
projectPath?: string | null;
onClose: () => void;
onSaved: () => void;
}
function membersToDrafts(members: ResolvedTeamMember[]) {
const active = members.filter((m) => !m.removedAt);
return active.map((m) => {
const presetRoles: readonly string[] = PRESET_ROLES;
const isPreset = m.role != null && presetRoles.includes(m.role);
const isCustom = m.role != null && m.role.length > 0 && !isPreset;
return createMemberDraft({
name: m.name,
roleSelection: isCustom ? CUSTOM_ROLE : (m.role ?? ''),
customRole: isCustom ? m.role : '',
workflow: m.workflow,
});
});
return createMemberDraftsFromInputs(members);
}
export const EditTeamDialog = ({
@ -70,6 +59,7 @@ export const EditTeamDialog = ({
currentDescription,
currentColor,
currentMembers,
isTeamAlive = false,
projectPath,
onClose,
onSaved,
@ -170,11 +160,18 @@ export const EditTeamDialog = ({
onChange={setMembers}
validateMemberName={validateMemberNameInline}
showWorkflow
showJsonEditor
showJsonEditor={!isTeamAlive}
draftKeyPrefix={`editTeam:${teamName}`}
projectPath={projectPath ?? null}
lockProviderModel={isTeamAlive}
/>
</div>
{isTeamAlive ? (
<p className="text-xs text-amber-300">
Provider and model changes are locked while the team is live. Reconnect the team to
change them safely.
</p>
) : null}
<div>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- Color picker is a group of buttons, not a single input */}
<label className="label-optional mb-1 block text-xs font-medium">

View file

@ -48,8 +48,8 @@ export const EffortLevelSelector: React.FC<EffortLevelSelectorProps> = ({
</div>
</div>
<p className="mt-1 text-[11px] text-[var(--color-text-muted)]">
Controls how much reasoning Claude invests before responding. Default uses Claude&apos;s
standard behavior.
Controls how much reasoning the selected provider invests before responding. Default uses the
provider&apos;s standard behavior for the selected model.
</p>
</div>
);

View file

@ -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<string | null>(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<TeamProviderId>(getStoredTeamProvider);
const [selectedModel, setSelectedModelRaw] = useState(() =>
getStoredTeamModel(getStoredTeamProvider())
);
const [membersDrafts, setMembersDrafts] = useState<MemberDraft[]>([]);
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<string | null>(null);
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
const [prepareChecks, setPrepareChecks] = useState<ProvisioningProviderCheck[]>([]);
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<MentionSuggestion[]>(
() =>
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', '<auto>', '--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', '<previous>');
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
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-red-400" />
<div className="min-w-0 space-y-1">
<p className="font-medium text-red-300">
Claude CLI is not installed launch is blocked
CLI environment is not available launch is blocked
</p>
<p className="text-red-300/80">
{prepareMessage ?? 'Failed to prepare environment'}
</p>
{prepareWarnings.length > 0 ? (
{!shouldHideProvisioningProviderStatusList(prepareChecks, prepareMessage) ? (
<ProvisioningProviderStatusList
checks={prepareChecks}
className="mt-1"
suppressDetailsMatching={prepareMessage}
/>
) : null}
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
<div className="space-y-0.5">
{prepareWarnings.map((warning) => (
<p
@ -768,18 +1001,23 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
) : null}
<div className="flex items-center gap-2 pt-1">
<p className="text-[11px] text-[var(--color-text-muted)]">
Install Claude CLI from the Dashboard, then reopen this dialog.
{getProvisioningFailureHint(prepareMessage, prepareChecks)}
</p>
<button
type="button"
className="shrink-0 rounded bg-blue-600 px-2 py-0.5 text-[11px] font-medium text-white transition-colors hover:bg-blue-500"
onClick={() => {
onClose();
openDashboard();
}}
>
Go to Dashboard
</button>
{(prepareMessage ?? '').toLowerCase().includes('spawn ') ||
prepareChecks.some((check) =>
check.details.some((detail) => detail.toLowerCase().includes('spawn '))
) ? (
<button
type="button"
className="shrink-0 rounded bg-blue-600 px-2 py-0.5 text-[11px] font-medium text-white transition-colors hover:bg-blue-500"
onClick={() => {
onClose();
openDashboard();
}}
>
Go to Dashboard
</button>
) : null}
</div>
</div>
</div>
@ -922,6 +1160,38 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
projectsError={projectsError}
/>
{isLaunch ? (
<TeamRosterEditorSection
members={membersDrafts}
onMembersChange={setMembersDrafts}
validateMemberName={validateMemberNameInline}
showWorkflow
showJsonEditor
draftKeyPrefix={`launchTeam:${effectiveTeamName}`}
projectPath={effectiveCwd || null}
taskSuggestions={taskSuggestions}
teamSuggestions={teamMentionSuggestions}
existingMembers={members}
defaultProviderId={selectedProviderId}
inheritedProviderId={selectedProviderId}
inheritedModel={selectedModel}
inheritedEffort={(selectedEffort as EffortLevel) || undefined}
inheritModelSettingsByDefault
forceInheritedModelSettings={syncModelsWithLead}
modelLockReason="This teammate is synced with the lead model. Turn off sync to set a custom provider, model, or effort."
providerId={selectedProviderId}
model={selectedModel}
effort={(selectedEffort as EffortLevel) || undefined}
limitContext={limitContext}
onProviderChange={setSelectedProviderId}
onModelChange={setSelectedModel}
onEffortChange={setSelectedEffort}
onLimitContextChange={setLimitContext}
syncModelsWithTeammates={syncModelsWithLead}
onSyncModelsWithTeammatesChange={setSyncModelsWithLead}
/>
) : null}
{/*
Launch: optional settings
Schedule: prompt + execution defaults
@ -959,22 +1229,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
</div>
<div>
<TeamModelSelector
value={selectedModel}
onValueChange={setSelectedModel}
id="dialog-model"
/>
<EffortLevelSelector
value={selectedEffort}
onValueChange={setSelectedEffort}
id="dialog-effort"
/>
<LimitContextCheckbox
id="launch-limit-context"
checked={limitContext}
onCheckedChange={setLimitContext}
disabled={selectedModel === 'haiku'}
/>
<SkipPermissionsCheckbox
id="dialog-skip-permissions"
checked={skipPermissions}
@ -1061,6 +1315,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
<div>
<TeamModelSelector
providerId={selectedProviderId}
onProviderChange={setSelectedProviderId}
value={selectedModel}
onValueChange={setSelectedModel}
id="dialog-model"
@ -1137,27 +1393,30 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
{isLaunch ? (
<div className="min-w-0">
{prepareState === 'idle' || prepareState === 'loading' ? (
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
<span className="inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
<div>
<span>
{prepareMessage ??
(prepareState === 'idle'
? 'Warming up CLI environment...'
: 'Preparing environment...')}
</span>
<p className="mt-0.5 flex items-center gap-1.5 text-[10px] text-[var(--color-text-muted)] opacity-70">
<span>Pre-flight check to catch errors before launch</span>
<button
type="button"
onClick={() => setPrepareState('ready')}
className="rounded px-1.5 py-0.5 text-[10px] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text-secondary)]"
>
Skip
</button>
</p>
<>
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
<span className="inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
<div>
<span>
{prepareMessage ??
(prepareState === 'idle'
? 'Warming up CLI environment...'
: 'Preparing environment...')}
</span>
<p className="mt-0.5 flex items-center gap-1.5 text-[10px] text-[var(--color-text-muted)] opacity-70">
<span>Pre-flight check to catch errors before launch</span>
<button
type="button"
onClick={() => setPrepareState('ready')}
className="rounded px-1.5 py-0.5 text-[10px] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text-secondary)]"
>
Skip
</button>
</p>
</div>
</div>
</div>
<ProvisioningProviderStatusList checks={prepareChecks} className="mt-2" />
</>
) : null}
{prepareState === 'ready' ? (
@ -1165,7 +1424,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
<div className="flex items-center gap-1.5 text-xs font-medium text-emerald-400">
<CheckCircle2 className="size-3.5 shrink-0" />
<span>
{prepareWarnings.length > 0
{prepareChecks.some((check) => check.status === 'notes') ||
prepareWarnings.length > 0
? 'CLI environment ready (with notes)'
: 'CLI environment ready'}
</span>
@ -1175,7 +1435,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
{prepareMessage}
</p>
) : null}
{prepareWarnings.length > 0 ? (
<ProvisioningProviderStatusList checks={prepareChecks} className="mt-1" />
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
<div className="mt-0.5 space-y-0.5 pl-5">
{prepareWarnings.map((warning) => (
<p key={warning} className="text-[11px] text-sky-300">

View file

@ -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>
): 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 <Loader2 className="size-3 animate-spin" />;
}
if (status === 'ready') {
return <CheckCircle2 className="size-3" />;
}
if (status === 'notes' || status === 'failed') {
return <AlertTriangle className="size-3" />;
}
return <span className="inline-block size-1.5 rounded-full bg-current opacity-60" />;
}
export function ProvisioningProviderStatusList({
checks,
className = '',
suppressDetailsMatching,
}: {
checks: ProvisioningProviderCheck[];
className?: string;
suppressDetailsMatching?: string | null;
}): React.JSX.Element | null {
if (checks.length === 0) {
return null;
}
return (
<div className={`space-y-1 pl-5 ${className}`.trim()}>
{checks.map((check) => {
const visibleDetails = check.details.filter(
(detail) => detail.trim() !== (suppressDetailsMatching ?? '').trim()
);
return (
<div key={check.providerId}>
<div
className={`flex items-center gap-1.5 text-[11px] ${getStatusColor(check.status)}`}
>
<StatusIcon status={check.status} />
<span>
{getProvisioningProviderLabel(check.providerId)}: {getDisplayStatusText(check)}
</span>
</div>
{visibleDetails.length > 0 ? (
<div className="mt-0.5 space-y-0.5 pl-4">
{visibleDetails.map((detail) => (
<p key={detail} className="text-[10px] text-[var(--color-text-muted)]">
{detail}
</p>
))}
</div>
) : null}
</div>
);
})}
</div>
);
}
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.';
}

View file

@ -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 }) => (
</svg>
);
/** Google Gemini — official sparkle/star mark (Simple Icons) */
const GoogleIcon: React.FC<{ className?: string }> = ({ className }) => (
const GoogleGeminiIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
<path d="M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81" />
</svg>
);
/** Local — server rack icon */
const LocalIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg viewBox="0 0 24 24" fill="none" className={className}>
<rect x="3" y="3" width="18" height="7" rx="1.5" stroke="currentColor" strokeWidth="1.8" />
<rect x="3" y="14" width="18" height="7" rx="1.5" stroke="currentColor" strokeWidth="1.8" />
<circle cx="7" cy="6.5" r="1" fill="currentColor" />
<circle cx="7" cy="17.5" r="1" fill="currentColor" />
<line
x1="10.5"
y1="6.5"
x2="17.5"
y2="6.5"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
/>
<line
x1="10.5"
y1="17.5"
x2="17.5"
y2="17.5"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
/>
<path d="M12 2.25c.62 3.9 1.6 6.57 3.18 8.15 1.58 1.58 4.25 2.56 8.15 3.18-3.9.62-6.57 1.6-8.15 3.18-1.58 1.58-2.56 4.25-3.18 8.15-.62-3.9-1.6-6.57-3.18-8.15-1.58-1.58-4.25-2.56-8.15-3.18 3.9-.62 6.57-1.6 8.15-3.18C10.4 8.82 11.38 6.15 12 2.25Z" />
</svg>
);
@ -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<string, string> = {
'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<TeamModelSelectorProps> = ({
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<HTMLDivElement>(null);
@ -132,29 +178,55 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
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 (
<div className="mb-5">
<Label htmlFor={id} className="label-optional mb-1.5 block">
Model (optional)
</Label>
<div ref={containerRef} className="relative inline-block">
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
{/* Provider button */}
<div ref={containerRef} className="relative space-y-2">
<div className="relative inline-flex">
<button
type="button"
className={cn(
'flex items-center gap-1.5 rounded-[3px] px-2.5 py-1 text-xs font-medium transition-colors',
'mr-0.5 border-r border-[var(--color-border)] pr-2.5',
'flex min-w-[170px] items-center justify-between gap-2 rounded-md border px-3 py-2 text-xs font-medium transition-colors',
dropdownOpen
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text)]'
)}
style={{
borderColor: 'var(--color-border)',
backgroundColor: 'var(--color-surface)',
}}
onClick={() => setDropdownOpen(!dropdownOpen)}
>
<ProviderIcon className="size-3.5" />
<span>{ACTIVE_PROVIDER.label}</span>
<span className="flex items-center gap-2">
<ProviderIcon className="size-3.5" />
<span>{activeProvider.label}</span>
</span>
<ChevronDown
className={cn(
'size-3 transition-transform duration-200',
@ -163,31 +235,116 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
/>
</button>
{/* Model pills */}
{MODEL_OPTIONS.map((opt) => (
{/* Provider dropdown */}
{dropdownOpen && (
<div
className="absolute left-0 top-full z-50 mt-1 min-w-[220px] overflow-hidden rounded-md border py-1 shadow-xl shadow-black/20"
style={{
backgroundColor: 'var(--color-surface-raised)',
borderColor: 'var(--color-border-subtle)',
}}
>
{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 (
<React.Fragment key={provider.id}>
{prevWasActive && !isFirst && (
<div
className="mx-2 my-1 border-t"
style={{ borderColor: 'var(--color-border-subtle)' }}
/>
)}
<button
type="button"
disabled={provider.comingSoon || !isProviderSelectable(provider.id)}
onClick={() => {
if (!provider.comingSoon && isProviderSelectable(provider.id)) {
onProviderChange(provider.id as 'anthropic' | 'codex' | 'gemini');
setDropdownOpen(false);
}
}}
className={cn(
'flex w-full items-center gap-2.5 px-3 py-2 text-left text-xs transition-colors duration-100',
isActive && 'bg-indigo-500/10 text-indigo-400',
(provider.comingSoon || !isProviderSelectable(provider.id)) &&
'cursor-not-allowed opacity-40',
!isActive &&
!provider.comingSoon &&
isProviderSelectable(provider.id) &&
'hover:bg-white/5'
)}
style={
!isActive && !provider.comingSoon && isProviderSelectable(provider.id)
? { color: 'var(--color-text-secondary)' }
: undefined
}
>
<Icon className="size-3.5 shrink-0" />
<span className="flex-1">{provider.label}</span>
{provider.comingSoon && (
<span className="rounded bg-white/5 px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]">
Coming Soon
</span>
)}
{!provider.comingSoon && !isProviderSelectable(provider.id) && (
<span className="rounded bg-white/5 px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]">
Multimodel off
</span>
)}
{isActive && <Check className="size-3.5 shrink-0" />}
</button>
</React.Fragment>
);
})}
</div>
)}
</div>
{!multimodelAvailable && (
<p className="text-[11px] text-[var(--color-text-muted)]">
Codex and Gemini require Multimodel mode.
</p>
)}
<div
className="grid gap-1.5 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-1.5"
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))' }}
>
{modelOptions.map((opt) => (
<button
key={opt.value || '__default__'}
type="button"
id={opt.value === value ? id : undefined}
className={cn(
'flex items-center gap-1 rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
'flex min-h-[44px] items-center justify-center gap-1.5 rounded-md border px-3 py-2 text-center text-xs font-medium transition-colors',
value === opt.value
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]',
!activeProviderSelectable && 'cursor-not-allowed opacity-45'
)}
onClick={() => onValueChange(opt.value)}
style={{
borderColor: value === opt.value ? 'var(--color-border-emphasis)' : 'transparent',
}}
disabled={!activeProviderSelectable}
onClick={() => {
if (!activeProviderSelectable) return;
onValueChange(opt.value);
}}
>
{opt.label}
<span className="leading-tight">{opt.label}</span>
{opt.value === '' && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<Info className="size-3 opacity-40 transition-opacity hover:opacity-70" />
<Info className="size-3 shrink-0 opacity-40 transition-opacity hover:opacity-70" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px] text-xs">
Default model from Claude CLI (/model).
<br />
Currently Sonnet 4.6, but may change with CLI updates.
Uses the runtime default for the selected provider.
</TooltipContent>
</Tooltip>
</TooltipProvider>
@ -195,64 +352,6 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
</button>
))}
</div>
{/* Provider dropdown */}
{dropdownOpen && (
<div
className="absolute bottom-full left-0 z-50 mb-1 min-w-[220px] overflow-hidden rounded-md border py-1 shadow-xl shadow-black/20"
style={{
backgroundColor: 'var(--color-surface-raised)',
borderColor: 'var(--color-border-subtle)',
}}
>
{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 (
<React.Fragment key={provider.id}>
{prevWasActive && !isFirst && (
<div
className="mx-2 my-1 border-t"
style={{ borderColor: 'var(--color-border-subtle)' }}
/>
)}
<button
type="button"
disabled={provider.comingSoon}
onClick={() => {
if (!provider.comingSoon) {
setDropdownOpen(false);
}
}}
className={cn(
'flex w-full items-center gap-2.5 px-3 py-2 text-left text-xs transition-colors duration-100',
isActive && 'bg-indigo-500/10 text-indigo-400',
provider.comingSoon && 'cursor-not-allowed opacity-40',
!isActive && !provider.comingSoon && 'hover:bg-white/5'
)}
style={
!isActive && !provider.comingSoon
? { color: 'var(--color-text-secondary)' }
: undefined
}
>
<Icon className="size-3.5 shrink-0" />
<span className="flex-1">{provider.label}</span>
{provider.comingSoon && (
<span className="rounded bg-white/5 px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]">
Coming Soon
</span>
)}
{isActive && <Check className="size-3.5 shrink-0" />}
</button>
</React.Fragment>
);
})}
</div>
)}
</div>
</div>
);

View file

@ -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 (
<div
className="relative grid grid-cols-1 gap-2 rounded-md p-2 shadow-sm md:grid-cols-[1fr_180px_auto]"
style={{
backgroundColor: isLight
? 'color-mix(in srgb, var(--color-surface-raised) 22%, white 78%)'
: 'var(--color-surface-raised)',
boxShadow: isLight ? '0 1px 2px rgba(15, 23, 42, 0.06)' : '0 1px 2px rgba(0, 0, 0, 0.28)',
}}
>
<div
className="absolute inset-y-0 left-0 w-1 rounded-l-md"
style={{ backgroundColor: leadColorSet.border }}
aria-hidden="true"
/>
<div className="space-y-0.5">
<div className="flex h-8 items-center px-2 text-sm font-medium text-[var(--color-text)]">
lead
</div>
</div>
<div>
<div className="flex h-8 items-center px-2 text-xs text-[var(--color-text-secondary)]">
Team Lead
</div>
</div>
<div className="space-y-1">
<div className="flex flex-col gap-2 xl:flex-row xl:items-center xl:justify-between">
<div className="min-w-0 space-y-1">
<Button
variant="outline"
size="sm"
className="h-8 max-w-[190px] shrink-0 justify-start gap-1 overflow-hidden text-left"
onClick={() => setModelExpanded((prev) => !prev)}
>
{modelExpanded ? (
<ChevronDown className="size-3.5" />
) : (
<ChevronRight className="size-3.5" />
)}
<span className="truncate">Model: {modelButtonLabel}</span>
</Button>
</div>
<div className="flex items-center gap-2 pl-1 xl:pl-0">
<Checkbox
id="sync-models-with-lead"
checked={syncModelsWithTeammates}
onCheckedChange={(checked) => onSyncModelsWithTeammatesChange(checked === true)}
/>
<Label
htmlFor="sync-models-with-lead"
className="cursor-pointer text-xs font-normal text-text-secondary"
>
Синхронизировать модель с тимейтами
</Label>
</div>
</div>
</div>
{modelExpanded ? (
<div className="space-y-2 md:col-span-3">
<TeamModelSelector
providerId={providerId}
onProviderChange={onProviderChange}
value={model}
onValueChange={onModelChange}
id="lead-model"
/>
<EffortLevelSelector
value={effort ?? ''}
onValueChange={onEffortChange}
id="lead-effort"
/>
<LimitContextCheckbox
id="lead-limit-context"
checked={limitContext}
onCheckedChange={onLimitContextChange}
disabled={providerId !== 'anthropic' || model === 'haiku'}
/>
<div className="flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/5 px-3 py-2">
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />
<p className="text-[11px] leading-relaxed text-sky-300">
These settings control the team lead and act as the default runtime for teammates that
do not have their own override.
</p>
</div>
</div>
) : null}
</div>
);
};

View file

@ -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}
/>
</div>
<div className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-sm">
<span className="shrink-0 font-medium text-[var(--color-text)]">
{displayMemberName(member.name)}
</span>
{member.gitBranch ? (
<span className="flex shrink-0 items-center gap-0.5 text-[10px] text-[var(--color-text-muted)]">
<GitBranch size={10} />
{member.gitBranch}
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5 truncate text-sm">
<span className="shrink-0 font-medium text-[var(--color-text)]">
{displayMemberName(member.name)}
</span>
) : null}
{currentTask ? (
<CurrentTaskIndicator
task={currentTask}
borderColor={colors.border}
activityLabel="working on"
onOpenTask={onOpenTask}
/>
) : null}
{reviewTask ? (
<CurrentTaskIndicator
task={reviewTask}
borderColor={colors.border}
activityLabel="reviewing"
onOpenTask={onOpenReviewTask}
/>
) : null}
{!activityTask && isAwaitingReply ? (
<>
<Loader2
className="size-3 shrink-0 animate-spin"
style={{ color: colors.border }}
/>
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
awaiting reply
{member.gitBranch ? (
<span className="flex shrink-0 items-center gap-0.5 text-[10px] text-[var(--color-text-muted)]">
<GitBranch size={10} />
{member.gitBranch}
</span>
</>
) : null}
{currentTask ? (
<CurrentTaskIndicator
task={currentTask}
borderColor={colors.border}
activityLabel="working on"
onOpenTask={onOpenTask}
/>
) : null}
{reviewTask ? (
<CurrentTaskIndicator
task={reviewTask}
borderColor={colors.border}
activityLabel="reviewing"
onOpenTask={onOpenReviewTask}
/>
) : null}
{!activityTask && isAwaitingReply ? (
<>
<Loader2
className="size-3 shrink-0 animate-spin"
style={{ color: colors.border }}
/>
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
awaiting reply
</span>
</>
) : null}
</div>
{runtimeSummary ? (
<div className="mt-0.5 text-[10px] font-medium text-[var(--color-text-muted)]">
{runtimeSummary}
</div>
) : null}
</div>
{(() => {

View file

@ -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 (
<div
className="relative grid grid-cols-1 gap-2 rounded-md p-2 shadow-sm md:grid-cols-[1fr_220px_auto]"
className="relative grid grid-cols-1 gap-2 rounded-md p-2 shadow-sm md:grid-cols-[1fr_180px_auto]"
style={{
backgroundColor: isLight
? 'color-mix(in srgb, var(--color-surface-raised) 22%, white 78%)'
@ -165,48 +200,54 @@ export const MemberDraftRow = ({
inputClassName="h-8 text-xs"
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
{showWorkflow && onWorkflowChange ? (
<div className="space-y-1">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start">
{showWorkflow && onWorkflowChange ? (
<Button
variant="outline"
size="sm"
className="relative h-8 shrink-0 gap-1"
onClick={() => setWorkflowExpanded((prev) => !prev)}
>
{workflowExpanded ? (
<ChevronDown className="size-3.5" />
) : (
<ChevronRight className="size-3.5" />
)}
Workflow
{!workflowExpanded && workflowDraft.value.trim() ? (
<span className="absolute -right-1 -top-1 size-2 rounded-full bg-blue-500" />
) : null}
</Button>
) : null}
<div className="min-w-0 space-y-1">
<Button
variant="outline"
size="sm"
className="h-8 max-w-[190px] shrink-0 justify-start gap-1 overflow-hidden text-left"
disabled={lockProviderModel}
title={lockProviderModel ? modelLockReason : undefined}
onClick={() => setModelExpanded((prev) => !prev)}
>
{modelExpanded ? (
<ChevronDown className="size-3.5" />
) : (
<ChevronRight className="size-3.5" />
)}
<span className="truncate">Model: {modelButtonLabel}</span>
</Button>
</div>
<Button
variant="outline"
size="sm"
className="relative h-8 shrink-0 gap-1"
onClick={() => setWorkflowExpanded((prev) => !prev)}
className="size-8 shrink-0 border-red-500/40 px-0 text-red-300 hover:bg-red-500/10 hover:text-red-200"
aria-label={`Remove ${member.name || `member ${index + 1}`}`}
title="Remove member"
onClick={() => onRemove(member.id)}
>
{workflowExpanded ? (
<ChevronDown className="size-3.5" />
) : (
<ChevronRight className="size-3.5" />
)}
Workflow
{!workflowExpanded && workflowDraft.value.trim() ? (
<span className="absolute -right-1 -top-1 size-2 rounded-full bg-blue-500" />
) : null}
<Trash2 className="size-3.5" />
</Button>
) : null}
<Button
variant="outline"
size="sm"
className="h-8 shrink-0 gap-1"
onClick={() => setModelExpanded((prev) => !prev)}
>
{modelExpanded ? (
<ChevronDown className="size-3.5" />
) : (
<ChevronRight className="size-3.5" />
)}
Model
</Button>
<Button
variant="outline"
size="sm"
className="size-8 shrink-0 border-red-500/40 px-0 text-red-300 hover:bg-red-500/10 hover:text-red-200"
aria-label={`Remove ${member.name || `member ${index + 1}`}`}
title="Remove member"
onClick={() => onRemove(member.id)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</div>
{showWorkflow && onWorkflowChange && workflowExpanded ? (
<div className="space-y-0.5 md:col-span-3">
@ -241,14 +282,38 @@ export const MemberDraftRow = ({
) : null}
{modelExpanded && (
<div className="space-y-2 md:col-span-3">
<div className="pointer-events-none opacity-40">
<TeamModelSelector value="" onValueChange={() => {}} />
</div>
<TeamModelSelector
providerId={effectiveProviderId}
onProviderChange={(providerId) => {
if (lockProviderModel) return;
onProviderChange(member.id, providerId);
}}
value={effectiveModel ?? ''}
onValueChange={(value) => {
if (lockProviderModel) return;
onModelChange(member.id, value);
}}
id={`member-${member.id}-model`}
/>
<EffortLevelSelector
value={effectiveEffort ?? ''}
onValueChange={(value) => {
if (lockProviderModel) return;
onEffortChange(member.id, value);
}}
id={`member-${member.id}-effort`}
/>
{lockProviderModel && (
<p className="text-[11px] text-amber-300">
{modelLockReason ??
'Provider, model, and effort changes are disabled while the team is live. Reconnect the team to apply them safely.'}
</p>
)}
<div className="flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/5 px-3 py-2">
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />
<p className="text-[11px] leading-relaxed text-sky-300">
Claude Code doesn&apos;t support per-member model selection yet &mdash; 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.
</p>
</div>
</div>

View file

@ -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 (
<div className="rounded-md border border-[var(--color-border)] p-4 text-sm text-[var(--color-text-muted)]">
@ -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}

View file

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

View file

@ -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 (
<MembersEditorSection
members={members}
onChange={onMembersChange}
fieldError={fieldError}
validateMemberName={validateMemberName}
showWorkflow={showWorkflow}
showJsonEditor={showJsonEditor}
draftKeyPrefix={draftKeyPrefix}
projectPath={projectPath}
taskSuggestions={taskSuggestions}
teamSuggestions={teamSuggestions}
hideContent={hideMembersContent}
existingMembers={existingMembers}
defaultProviderId={defaultProviderId}
inheritedProviderId={inheritedProviderId}
inheritedModel={inheritedModel}
inheritedEffort={inheritedEffort}
inheritModelSettingsByDefault={inheritModelSettingsByDefault}
lockProviderModel={lockProviderModel}
forceInheritedModelSettings={forceInheritedModelSettings}
modelLockReason={modelLockReason}
headerExtra={
<div className="space-y-3">
{headerTop}
<LeadModelRow
providerId={providerId}
model={model}
effort={effort}
limitContext={limitContext}
onProviderChange={onProviderChange}
onModelChange={onModelChange}
onEffortChange={onEffortChange}
onLimitContextChange={onLimitContextChange}
syncModelsWithTeammates={syncModelsWithTeammates}
onSyncModelsWithTeammatesChange={onSyncModelsWithTeammatesChange}
/>
{headerBottom}
</div>
}
/>
);
};

View file

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

View file

@ -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>): 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<typeof member> => member !== null);

View file

@ -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<MemberDraft[]>([]);
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<MemberDraft[]>([]);
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,

View file

@ -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: '',

View file

@ -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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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,

View file

@ -427,7 +427,11 @@ export interface TeamsAPI {
permanentlyDeleteTeam: (teamName: string) => Promise<void>;
getSavedRequest: (teamName: string) => Promise<TeamCreateRequest | null>;
deleteDraft: (teamName: string) => Promise<void>;
prepareProvisioning: (cwd?: string) => Promise<TeamProvisioningPrepareResult>;
prepareProvisioning: (
cwd?: string,
providerId?: TeamLaunchRequest['providerId'],
providerIds?: TeamLaunchRequest['providerId'][]
) => Promise<TeamProvisioningPrepareResult>;
createTeam: (request: TeamCreateRequest) => Promise<TeamCreateResponse>;
getProvisioningStatus: (runId: string) => Promise<TeamProvisioningProgress>;
cancelProvisioning: (runId: string) => Promise<void>;

View file

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

View file

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

View file

@ -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',
]);
/**

View file

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

View file

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

View file

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