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 { function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest {
const payload = body && typeof body === 'object' ? (body as Record<string, unknown>) : {}; 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 prompt = assertOptionalString(payload.prompt, 'prompt');
const model = assertOptionalString(payload.model, 'model'); const model = assertOptionalString(payload.model, 'model');
const effort = assertOptionalEffort(payload.effort); const effort = assertOptionalEffort(payload.effort);
@ -100,6 +110,7 @@ function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest
return { return {
teamName, teamName,
cwd: assertAbsoluteCwd(payload.cwd), cwd: assertAbsoluteCwd(payload.cwd),
providerId,
...(prompt && { ...(prompt && {
prompt, prompt,
}), }),

View file

@ -101,6 +101,10 @@ import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore';
import { TeamMetaStore } from '../services/team/TeamMetaStore'; import { TeamMetaStore } from '../services/team/TeamMetaStore';
import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService'; import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService';
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore'; import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
import {
buildReplaceMembersDiff,
buildReplaceMembersSummaryMessage,
} from '../services/team/memberUpdateNotifications';
import { import {
validateFromField, validateFromField,
@ -534,6 +538,8 @@ async function handleGetData(
const tn = validated.value!; const tn = validated.value!;
const startedAt = Date.now(); const startedAt = Date.now();
let data: TeamData; let data: TeamData;
setCurrentMainOp('team:getData');
try {
try { try {
data = await getTeamDataService().getTeamData(tn); data = await getTeamDataService().getTeamData(tn);
} catch (error) { } catch (error) {
@ -554,6 +560,9 @@ async function handleGetData(
logger.error(`[teams:getData] ${message}`); logger.error(`[teams:getData] ${message}`);
return { success: false, error: message }; return { success: false, error: message };
} }
} finally {
setCurrentMainOp(null);
}
const getDataMs = Date.now() - startedAt; const getDataMs = Date.now() - startedAt;
if (getDataMs >= 1500) { if (getDataMs >= 1500) {
logger.warn(`[teams:getData] slow team=${tn} ms=${getDataMs}`); logger.warn(`[teams:getData] slow team=${tn} ms=${getDataMs}`);
@ -833,6 +842,32 @@ function isValidEffort(value: unknown): value is EffortLevel {
return typeof value === 'string' && VALID_EFFORT_LEVELS.includes(value); 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( async function validateProvisioningRequest(
request: unknown request: unknown
): Promise<{ valid: true; value: TeamCreateRequest } | { valid: false; error: string }> { ): Promise<{ valid: true; value: TeamCreateRequest } | { valid: false; error: string }> {
@ -884,10 +919,22 @@ async function validateProvisioningRequest(
if (workflow !== undefined && typeof workflow !== 'string') { if (workflow !== undefined && typeof workflow !== 'string') {
return { valid: false, error: 'member workflow must be 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({ members.push({
name: memberName, name: memberName,
role: typeof role === 'string' ? role.trim() : undefined, role: typeof role === 'string' ? role.trim() : undefined,
workflow: typeof workflow === 'string' ? workflow.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, members,
cwd, cwd,
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined, 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, model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
effort: isValidEffort(payload.effort) ? payload.effort : undefined, effort: isValidEffort(payload.effort) ? payload.effort : undefined,
skipPermissions: skipPermissions:
@ -1089,6 +1142,16 @@ async function handleLaunchTeam(
color: meta?.color, color: meta?.color,
cwd, cwd,
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined, 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, model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
effort: isValidEffort(payload.effort) ? payload.effort : undefined, effort: isValidEffort(payload.effort) ? payload.effort : undefined,
limitContext: typeof payload.limitContext === 'boolean' ? payload.limitContext : undefined, limitContext: typeof payload.limitContext === 'boolean' ? payload.limitContext : undefined,
@ -1100,7 +1163,14 @@ async function handleLaunchTeam(
typeof payload.extraCliArgs === 'string' typeof payload.extraCliArgs === 'string'
? payload.extraCliArgs.trim() || undefined ? payload.extraCliArgs.trim() || undefined
: 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', () => return wrapTeamHandler('create', () =>
@ -1122,6 +1192,12 @@ async function handleLaunchTeam(
teamName: validatedTeamName.value!, teamName: validatedTeamName.value!,
cwd, cwd,
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined, 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, model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
effort: isValidEffort(payload.effort) ? payload.effort : undefined, effort: isValidEffort(payload.effort) ? payload.effort : undefined,
clearContext: payload.clearContext === true ? true : undefined, clearContext: payload.clearContext === true ? true : undefined,
@ -1174,9 +1250,13 @@ async function handleValidateCliArgs(
async function handlePrepareProvisioning( async function handlePrepareProvisioning(
_event: IpcMainInvokeEvent, _event: IpcMainInvokeEvent,
cwd: unknown cwd: unknown,
providerId: unknown,
providerIds: unknown
): Promise<IpcResult<TeamProvisioningPrepareResult>> { ): Promise<IpcResult<TeamProvisioningPrepareResult>> {
let validatedCwd: string | undefined; let validatedCwd: string | undefined;
let validatedProviderId: TeamLaunchRequest['providerId'];
let validatedProviderIds: Array<'anthropic' | 'codex' | 'gemini'> | undefined;
if (cwd !== undefined) { if (cwd !== undefined) {
if (typeof cwd !== 'string' || cwd.trim().length === 0) { if (typeof cwd !== 'string' || cwd.trim().length === 0) {
return { success: false, error: 'cwd must be a non-empty string' }; 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' }; 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', () => 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') { if (workflow !== undefined && typeof workflow !== 'string') {
return { success: false, error: 'member workflow must be 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({ members.push({
name: memberName, name: memberName,
role: typeof role === 'string' ? role.trim() : undefined, role: typeof role === 'string' ? role.trim() : undefined,
workflow: typeof workflow === 'string' ? workflow.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') { if (!payload || typeof payload !== 'object') {
return { success: false, error: 'Invalid payload' }; return { success: false, error: 'Invalid payload' };
} }
const { name, role, workflow } = payload as { const { name, role, workflow, providerId, model } = payload as {
name?: unknown; name?: unknown;
role?: unknown; role?: unknown;
workflow?: unknown; workflow?: unknown;
providerId?: unknown;
model?: unknown;
effort?: unknown;
}; };
const vName = validateTeammateName(name); const vName = validateTeammateName(name);
if (!vName.valid) return { success: false, error: vName.error ?? 'Invalid member 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') { if (workflow !== undefined && typeof workflow !== 'string') {
return { success: false, error: 'workflow must be a 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 () => { return wrapTeamHandler('addMember', async () => {
const tn = vTeam.value!; const tn = vTeam.value!;
@ -2307,6 +2442,9 @@ async function handleAddMember(
name: memberName, name: memberName,
role: role, role: role,
workflow: typeof workflow === 'string' ? workflow.trim() || undefined : undefined, 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 // If team is alive, notify the lead to spawn the new teammate
@ -2329,6 +2467,9 @@ async function handleAddMember(
name: memberName, name: memberName,
...(typeof role === 'string' ? { role } : {}), ...(typeof role === 'string' ? { role } : {}),
...(typeof workflow === 'string' ? { workflow } : {}), ...(typeof workflow === 'string' ? { workflow } : {}),
...(providerValidation.value ? { providerId: providerValidation.value } : {}),
...(typeof model === 'string' && model.trim() ? { model: model.trim() } : {}),
...(effortValidation.value ? { effort: effortValidation.value } : {}),
}); });
try { try {
await provisioning.sendMessageToTeam(tn, spawnMessage); await provisioning.sendMessageToTeam(tn, spawnMessage);
@ -2355,12 +2496,26 @@ async function handleReplaceMembers(
return { success: false, error: 'members must be an array' }; return { success: false, error: 'members must be an array' };
} }
const seenNames = new Set<string>(); 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) { for (const item of payload.members) {
if (!item || typeof item !== 'object') { if (!item || typeof item !== 'object') {
return { success: false, error: 'member must be 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); const vName = validateTeammateName(m.name);
if (!vName.valid) return { success: false, error: vName.error ?? 'Invalid member name' }; if (!vName.valid) return { success: false, error: vName.error ?? 'Invalid member name' };
const name = vName.value!; const name = vName.value!;
@ -2372,15 +2527,73 @@ async function handleReplaceMembers(
if (m.workflow !== undefined && typeof m.workflow !== 'string') { if (m.workflow !== undefined && typeof m.workflow !== 'string') {
return { success: false, error: 'member workflow must be 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({ members.push({
name, name,
role: typeof m.role === 'string' ? m.role.trim() : undefined, role: typeof m.role === 'string' ? m.role.trim() : undefined,
workflow: typeof m.workflow === 'string' ? m.workflow.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 () => { 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, color: meta.color,
cwd: meta.cwd, cwd: meta.cwd,
prompt: meta.prompt, prompt: meta.prompt,
providerId: meta.providerId ?? 'anthropic',
model: meta.model, model: meta.model,
effort: meta.effort as TeamCreateRequest['effort'], effort: meta.effort as TeamCreateRequest['effort'],
skipPermissions: meta.skipPermissions, skipPermissions: meta.skipPermissions,
@ -3098,6 +3312,9 @@ async function handleGetSavedRequest(
name: m.name, name: m.name,
role: m.role, role: m.role,
workflow: m.workflow, 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 // Only report meaningful stalls
if (maxMs < 250) return; 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( logger.warn(
`Event loop stall detected: p95=${p95Ms.toFixed(1)}ms max=${maxMs.toFixed(1)}ms` + `Event loop stall detected: p95=${p95Ms.toFixed(1)}ms max=${maxMs.toFixed(1)}ms` +
(currentOp ? ` op=${currentOp}` : '') (currentOp ? ` op=${currentOp}` : '')

View file

@ -948,6 +948,15 @@ export class TeamDataService {
name, name,
role: request.role?.trim() || undefined, role: request.role?.trim() || undefined,
workflow: request.workflow?.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', agentType: 'general-purpose',
color: getMemberColorByName(name), color: getMemberColorByName(name),
joinedAt: Date.now(), joinedAt: Date.now(),
@ -977,7 +986,16 @@ export class TeamDataService {
async replaceMembers( async replaceMembers(
teamName: string, 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> { ): Promise<void> {
const existing = await this.membersMetaStore.getMembers(teamName); const existing = await this.membersMetaStore.getMembers(teamName);
const existingLead = existing.find(isLeadMember) ?? null; const existingLead = existing.find(isLeadMember) ?? null;
@ -1003,6 +1021,15 @@ export class TeamDataService {
name, name,
role: member.role?.trim() || undefined, role: member.role?.trim() || undefined,
workflow: member.workflow?.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', agentType: prev?.agentType ?? 'general-purpose',
color: prev?.color ?? getMemberColorByName(name), color: prev?.color ?? getMemberColorByName(name),
joinedAt: prev?.joinedAt ?? joinedAt, joinedAt: prev?.joinedAt ?? joinedAt,
@ -1957,6 +1984,16 @@ export class TeamDataService {
return name; return name;
})(), })(),
role: member.role?.trim() || undefined, 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', agentType: 'general-purpose',
color: getMemberColorByName(member.name.trim()), color: getMemberColorByName(member.name.trim()),
joinedAt, joinedAt,

View file

@ -17,6 +17,7 @@ const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([
'cross_team_list_targets', 'cross_team_list_targets',
'cross_team_get_outbox', 'cross_team_get_outbox',
]); ]);
const GENERATED_AGENT_ID_PATTERN = /^a[0-9a-f]{16}$/i;
function looksLikeQualifiedExternalRecipient(name: string): boolean { function looksLikeQualifiedExternalRecipient(name: string): boolean {
const trimmed = name.trim(); const trimmed = name.trim();
@ -51,6 +52,10 @@ function looksLikeCrossTeamToolRecipient(name: string): boolean {
return CROSS_TEAM_TOOL_RECIPIENT_NAMES.has(name.trim()); 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 { export class TeamMemberResolver {
resolveMembers( resolveMembers(
config: TeamConfig, config: TeamConfig,
@ -106,13 +111,25 @@ export class TeamMemberResolver {
) { ) {
continue; continue;
} }
if (!explicitNames.has(trimmed.toLowerCase()) && looksLikeGeneratedAgentId(trimmed)) {
continue;
}
addName(trimmed); addName(trimmed);
} }
} }
const configMemberMap = new Map< const configMemberMap = new Map<
string, 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)) { if (Array.isArray(config.members)) {
for (const m of config.members) { for (const m of config.members) {
@ -121,6 +138,9 @@ export class TeamMemberResolver {
agentType: m.agentType, agentType: m.agentType,
role: m.role, role: m.role,
workflow: m.workflow, workflow: m.workflow,
providerId: m.providerId,
model: m.model,
effort: m.effort,
color: m.color, color: m.color,
cwd: m.cwd, cwd: m.cwd,
}); });
@ -130,7 +150,16 @@ export class TeamMemberResolver {
const metaMemberMap = new Map< const metaMemberMap = new Map<
string, 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)) { if (Array.isArray(metaMembers)) {
for (const member of metaMembers) { for (const member of metaMembers) {
@ -139,6 +168,9 @@ export class TeamMemberResolver {
agentType: member.agentType, agentType: member.agentType,
role: member.role, role: member.role,
workflow: member.workflow, workflow: member.workflow,
providerId: member.providerId,
model: member.model,
effort: member.effort,
color: member.color, color: member.color,
removedAt: member.removedAt, removedAt: member.removedAt,
}); });
@ -193,6 +225,9 @@ export class TeamMemberResolver {
agentType: configMember?.agentType ?? metaMember?.agentType, agentType: configMember?.agentType ?? metaMember?.agentType,
role: configMember?.role ?? metaMember?.role, role: configMember?.role ?? metaMember?.role,
workflow: configMember?.workflow ?? metaMember?.workflow, workflow: configMember?.workflow ?? metaMember?.workflow,
providerId: configMember?.providerId ?? metaMember?.providerId,
model: configMember?.model ?? metaMember?.model,
effort: configMember?.effort ?? metaMember?.effort,
cwd: configMember?.cwd, cwd: configMember?.cwd,
removedAt: metaMember?.removedAt, removedAt: metaMember?.removedAt,
}); });

View file

@ -24,6 +24,15 @@ function normalizeMember(member: TeamMember): TeamMember | null {
name: trimmedName, name: trimmedName,
role: typeof member.role === 'string' ? member.role.trim() || undefined : undefined, role: typeof member.role === 'string' ? member.role.trim() || undefined : undefined,
workflow: typeof member.workflow === 'string' ? member.workflow.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: agentType:
typeof member.agentType === 'string' ? member.agentType.trim() || undefined : undefined, typeof member.agentType === 'string' ? member.agentType.trim() || undefined : undefined,
color: typeof member.color === 'string' ? member.color.trim() || undefined : undefined, color: typeof member.color === 'string' ? member.color.trim() || undefined : undefined,

View file

@ -18,6 +18,7 @@ export interface TeamMetaFile {
color?: string; color?: string;
cwd: string; cwd: string;
prompt?: string; prompt?: string;
providerId?: 'anthropic' | 'codex' | 'gemini';
model?: string; model?: string;
effort?: string; effort?: string;
skipPermissions?: boolean; skipPermissions?: boolean;
@ -82,6 +83,12 @@ export class TeamMetaStore {
color: typeof file.color === 'string' ? file.color.trim() || undefined : undefined, color: typeof file.color === 'string' ? file.color.trim() || undefined : undefined,
cwd: file.cwd.trim(), cwd: file.cwd.trim(),
prompt: typeof file.prompt === 'string' ? file.prompt.trim() || undefined : undefined, 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, model: typeof file.model === 'string' ? file.model.trim() || undefined : undefined,
effort: typeof file.effort === 'string' ? file.effort.trim() || undefined : undefined, effort: typeof file.effort === 'string' ? file.effort.trim() || undefined : undefined,
skipPermissions: typeof file.skipPermissions === 'boolean' ? file.skipPermissions : undefined, skipPermissions: typeof file.skipPermissions === 'boolean' ? file.skipPermissions : undefined,
@ -101,6 +108,7 @@ export class TeamMetaStore {
color: data.color?.trim() || undefined, color: data.color?.trim() || undefined,
cwd: data.cwd.trim(), cwd: data.cwd.trim(),
prompt: data.prompt?.trim() || undefined, prompt: data.prompt?.trim() || undefined,
providerId: data.providerId,
model: data.model?.trim() || undefined, model: data.model?.trim() || undefined,
effort: data.effort?.trim() || undefined, effort: data.effort?.trim() || undefined,
skipPermissions: data.skipPermissions, skipPermissions: data.skipPermissions,

View file

@ -69,6 +69,8 @@ import { TeamMembersMetaStore } from './TeamMembersMetaStore';
import { TeamMetaStore } from './TeamMetaStore'; import { TeamMetaStore } from './TeamMetaStore';
import { TeamSentMessagesStore } from './TeamSentMessagesStore'; import { TeamSentMessagesStore } from './TeamSentMessagesStore';
import { TeamTaskReader } from './TeamTaskReader'; import { TeamTaskReader } from './TeamTaskReader';
import { applyProviderRuntimeEnv, resolveTeamProviderId } from '../runtime/providerRuntimeEnv';
import { resolveGeminiRuntimeAuth } from '../runtime/geminiRuntimeAuth';
/** /**
* Kill a team CLI process using SIGKILL (uncatchable). * Kill a team CLI process using SIGKILL (uncatchable).
@ -106,6 +108,7 @@ import type {
ToolApprovalRequest, ToolApprovalRequest,
ToolApprovalSettings, ToolApprovalSettings,
ToolCallMeta, ToolCallMeta,
TeamProviderId,
} from '@shared/types'; } from '@shared/types';
const logger = createLogger('Service:TeamProvisioning'); const logger = createLogger('Service:TeamProvisioning');
@ -121,6 +124,9 @@ const LOG_PROGRESS_THROTTLE_MS = 300;
const UI_LOGS_TAIL_LIMIT = 128 * 1024; const UI_LOGS_TAIL_LIMIT = 128 * 1024;
const PROBE_CACHE_TTL_MS = 36 * 60 * 60 * 1000; const PROBE_CACHE_TTL_MS = 36 * 60 * 60 * 1000;
const PREFLIGHT_TIMEOUT_MS = 60000; 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_RETRY_DELAY_MS = 2000;
const PREFLIGHT_AUTH_MAX_RETRIES = 2; const PREFLIGHT_AUTH_MAX_RETRIES = 2;
const FS_MONITOR_POLL_MS = 2000; const FS_MONITOR_POLL_MS = 2000;
@ -143,18 +149,69 @@ const HANDLED_STREAM_JSON_TYPES = new Set([
'system', 'system',
]); ]);
const PREFLIGHT_PING_PROMPT = 'Output only the single word PONG.'; const PREFLIGHT_PING_PROMPT = 'Output only the single word PONG.';
const PREFLIGHT_PING_ARGS = [ 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', '-p',
PREFLIGHT_PING_PROMPT, PREFLIGHT_PING_PROMPT,
'--output-format', '--output-format',
'text', 'text',
'--model', '--model',
'haiku', getPreflightPingModel(providerId),
'--max-turns', '--max-turns',
'1', '1',
'--no-session-persistence', '--no-session-persistence',
] as const; ];
const PREFLIGHT_EXPECTED = 'PONG'; }
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'; type TeamsBaseLocation = 'configured' | 'default';
@ -257,6 +314,8 @@ interface ProvisioningRun {
* watchdog defers to retry messages for progress.message (retries are * watchdog defers to retry messages for progress.message (retries are
* more informative than the generic "CLI not responding" stall text). */ * more informative than the generic "CLI not responding" stall text). */
lastRetryAt: number; 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. */ /** True after emitApiErrorWarning() fires once — prevents duplicate warnings and pre-complete false positives. */
apiErrorWarningEmitted: boolean; apiErrorWarningEmitted: boolean;
fsPhase: 'waiting_config' | 'waiting_members' | 'waiting_tasks' | 'all_files_found'; fsPhase: 'waiting_config' | 'waiting_members' | 'waiting_tasks' | 'all_files_found';
@ -351,7 +410,12 @@ interface ProvisioningRun {
type LeadActivityState = 'active' | 'idle' | 'offline'; 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 { interface ProvisioningEnvResolution {
env: NodeJS.ProcessEnv; 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. */ /** @deprecated Use wrapAgentBlock from @shared/constants/agentBlocks instead. */
const wrapInAgentBlock = wrapAgentBlock; const wrapInAgentBlock = wrapAgentBlock;
@ -426,10 +495,16 @@ function buildMembersPrompt(members: TeamCreateRequest['members']): string {
return members return members
.map((member) => { .map((member) => {
const rolePart = member.role?.trim() ? ` (role: ${member.role.trim()})` : ''; 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() const workflowPart = member.workflow?.trim()
? `\n Workflow/instructions:${formatWorkflowBlock(member.workflow, ' ')}` ? `\n Workflow/instructions:${formatWorkflowBlock(member.workflow, ' ')}`
: ''; : '';
return `- ${member.name}${rolePart}${workflowPart}`; return `- ${member.name}${rolePart}${providerPart}${modelPart}${effortPart}${workflowPart}`;
}) })
.join('\n'); .join('\n');
} }
@ -463,13 +538,21 @@ function buildMemberSpawnPrompt(
leadName: string leadName: string
): string { ): string {
const role = member.role?.trim() || 'team member'; 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() const workflowBlock = member.workflow?.trim()
? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(member.workflow, '')}` ? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(member.workflow, '')}`
: ''; : '';
const actionModeProtocol = protocols.buildActionModeProtocolText( const actionModeProtocol = protocols.buildActionModeProtocolText(
protocols.MEMBER_DELEGATE_DESCRIPTION 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()} ${getAgentLanguageInstruction()}
Your FIRST action: call MCP tool member_briefing with: Your FIRST action: call MCP tool member_briefing with:
@ -499,6 +582,16 @@ function buildReconnectMemberSpawnPrompt(
hasTasks: boolean hasTasks: boolean
): string { ): string {
const role = member.role?.trim() || 'team member'; 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() const workflowBlock = member.workflow?.trim()
? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(member.workflow, ' ')}` ? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(member.workflow, ' ')}`
: ''; : '';
@ -506,9 +599,15 @@ function buildReconnectMemberSpawnPrompt(
protocols.buildActionModeProtocolText(protocols.MEMBER_DELEGATE_DESCRIPTION), 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}": return ` For "${member.name}":
- prompt: ${providerArgLine}${modelArgLine}${effortArgLine} - prompt:
You are ${member.name}, a ${role} on team "${teamName}" (${teamName}).${workflowBlock} You are ${member.name}, a ${role} on team "${teamName}" (${teamName}).${providerLine}${modelLine}${effortLine}${workflowBlock}
${getAgentLanguageInstruction()} ${getAgentLanguageInstruction()}
The team has been reconnected after a restart. The team has been reconnected after a restart.
@ -546,7 +645,10 @@ export function buildAddMemberSpawnMessage(
teamName: string, teamName: string,
displayName: string, displayName: string,
leadName: string, leadName: string,
member: Pick<TeamCreateRequest['members'][number], 'name' | 'role' | 'workflow'> member: Pick<
TeamCreateRequest['members'][number],
'name' | 'role' | 'workflow' | 'providerId' | 'model' | 'effort'
>
): string { ): string {
const roleHint = const roleHint =
typeof member.role === 'string' && member.role.trim() typeof member.role === 'string' && member.role.trim()
@ -562,15 +664,24 @@ export function buildAddMemberSpawnMessage(
name: member.name, name: member.name,
...(member.role ? { role: member.role } : {}), ...(member.role ? { role: member.role } : {}),
...(member.workflow ? { workflow: member.workflow } : {}), ...(member.workflow ? { workflow: member.workflow } : {}),
...(member.providerId ? { providerId: member.providerId } : {}),
...(member.model ? { model: member.model } : {}),
...(member.effort ? { effort: member.effort } : {}),
}, },
displayName, displayName,
teamName, teamName,
leadName 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 ( return (
`A new teammate "${member.name}"${roleHint} has been added to the team. ` + `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, ' ') indentMultiline(prompt, ' ')
); );
} }
@ -926,15 +1037,18 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string {
Per-member spawn instructions: Per-member spawn instructions:
${request.members ${request.members
.map( .map((m) => {
(m) => ` For “${m.name}”: 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} - name: ${m.name}
- prompt: ${providerLine}${modelLine} - prompt:
${buildMemberSpawnPrompt(m, displayName, request.teamName, leadName) ${buildMemberSpawnPrompt(m, displayName, request.teamName, leadName)
.split('\n') .split('\n')
.map((line) => ` ${line}`) .map((line) => ` ${line}`)
.join('\n')}` .join('\n')}`;
) })
.join('\n\n')}`; .join('\n\n')}`;
const persistentContext = buildPersistentLeadContext({ const persistentContext = buildPersistentLeadContext({
@ -1212,8 +1326,8 @@ type AuthWarningSource = 'probe' | 'stdout' | 'stderr' | 'assistant' | 'pre-comp
const cachedProbeResults = new Map<string, CachedProbeResult>(); const cachedProbeResults = new Map<string, CachedProbeResult>();
const probeInFlightByKey = new Map<string, Promise<ProbeResult | null>>(); const probeInFlightByKey = new Map<string, Promise<ProbeResult | null>>();
function createProbeCacheKey(cwd: string): string { function createProbeCacheKey(cwd: string, providerId: TeamProviderId | undefined): string {
return `${path.resolve(cwd)}::${getClaudeBasePath()}`; return `${path.resolve(cwd)}::${getClaudeBasePath()}::${resolveTeamProviderId(providerId)}`;
} }
function isTransientProbeWarning(warning: string): boolean { 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 { interface PendingInboxRelayCandidate {
recipient: string; recipient: string;
sourceMessageId: string; sourceMessageId: string;
@ -2304,8 +2429,8 @@ export class TeamProvisioningService {
async warmup(): Promise<void> { async warmup(): Promise<void> {
try { try {
const cwd = process.cwd(); const cwd = process.cwd();
if (this.getFreshCachedProbeResult(cwd)) return; if (this.getFreshCachedProbeResult(cwd, 'anthropic')) return;
const result = await this.getCachedOrProbeResult(cwd); const result = await this.getCachedOrProbeResult(cwd, 'anthropic');
if (!result) return; if (!result) return;
logger.info('CLI warmup completed'); logger.info('CLI warmup completed');
} catch (error) { } catch (error) {
@ -2315,32 +2440,26 @@ export class TeamProvisioningService {
async prepareForProvisioning( async prepareForProvisioning(
cwd?: string, cwd?: string,
opts?: { forceFresh?: boolean } opts?: { forceFresh?: boolean; providerId?: TeamProviderId; providerIds?: TeamProviderId[] }
): Promise<TeamProvisioningPrepareResult> { ): Promise<TeamProvisioningPrepareResult> {
const targetCwdForValidation = cwd?.trim() || process.cwd(); const targetCwdForValidation = cwd?.trim() || process.cwd();
await this.validatePrepareCwd(targetCwdForValidation); 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 // Allow callers (e.g. scheduler warm-up) to bypass the 36h probe cache
if (opts?.forceFresh) { if (opts?.forceFresh) {
this.clearProbeCache(targetCwdForValidation); for (const providerId of providerIds) {
this.clearProbeCache(targetCwdForValidation, providerId);
} }
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,
};
} }
const targetCwd = cwd?.trim() || process.cwd(); const targetCwd = cwd?.trim() || process.cwd();
@ -2349,45 +2468,77 @@ export class TeamProvisioningService {
} }
const warnings: string[] = []; const warnings: string[] = [];
const blockingMessages: string[] = [];
const probeResult = await this.getCachedOrProbeResult(targetCwd); for (const providerId of providerIds) {
const cached = this.getFreshCachedProbeResult(targetCwdForValidation, providerId);
const probeResult = cached ?? (await this.getCachedOrProbeResult(targetCwd, providerId));
if (!probeResult?.claudePath) { if (!probeResult?.claudePath) {
throw new Error('Claude CLI not found; install it or provide a valid path'); throw new Error('Claude CLI not found; install it or provide a valid path');
} }
const providerLabel = getTeamProviderLabel(providerId);
const { authSource } = probeResult; const { authSource } = probeResult;
if (authSource === 'anthropic_api_key') { if (authSource === 'anthropic_api_key') {
logger.info('Auth: using explicit ANTHROPIC_API_KEY'); logger.info(`Auth: using explicit ANTHROPIC_API_KEY for ${providerLabel}`);
} else if (authSource === 'anthropic_auth_token') { } else if (authSource === 'anthropic_auth_token') {
logger.info('Auth: using ANTHROPIC_AUTH_TOKEN mapped to ANTHROPIC_API_KEY'); logger.info(
`Auth: using ANTHROPIC_AUTH_TOKEN mapped to ANTHROPIC_API_KEY for ${providerLabel}`
);
} }
if (probeResult.warning) { if (!probeResult.warning) {
continue;
}
const prefixedWarning =
providerIds.length > 1 ? `${providerLabel}: ${probeResult.warning}` : probeResult.warning;
const isAuthFailure = this.isAuthFailureWarning(probeResult.warning, 'probe'); const isAuthFailure = this.isAuthFailureWarning(probeResult.warning, 'probe');
if (authSource === 'none' && isAuthFailure) { if (
// No auth source + preflight indicates auth failure — block to avoid a confusing hang later. (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 { return {
ready: false, ready: false,
message: probeResult.warning, message:
warnings: warnings.length > 0 ? warnings : undefined, blockingMessages.length === 1
? blockingMessages[0]!
: 'Some provider runtimes are not ready',
warnings: blockingMessages.length > 1 ? blockingMessages : undefined,
}; };
} }
// Preflight warnings (including timeouts) should not block provisioning.
warnings.push(probeResult.warning);
}
return { return {
ready: true, ready: true,
message: message:
warnings.length > 0 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 ready to launch (see notes)'
: 'CLI is warmed up and ready to launch', : 'CLI is warmed up and ready to launch',
warnings: warnings.length > 0 ? warnings : undefined, warnings: warnings.length > 0 ? warnings : undefined,
}; };
} }
private getFreshCachedProbeResult(cwd: string): CachedProbeResult | null { private getFreshCachedProbeResult(
const cacheKey = createProbeCacheKey(cwd); cwd: string,
providerId: TeamProviderId | undefined
): CachedProbeResult | null {
const cacheKey = createProbeCacheKey(cwd, providerId);
const cached = cachedProbeResults.get(cacheKey); const cached = cachedProbeResults.get(cacheKey);
if (!cached) return null; if (!cached) return null;
const ageMs = Date.now() - cached.cachedAtMs; const ageMs = Date.now() - cached.cachedAtMs;
@ -2398,8 +2549,8 @@ export class TeamProvisioningService {
return cached; return cached;
} }
private clearProbeCache(cwd: string): void { private clearProbeCache(cwd: string, providerId: TeamProviderId | undefined): void {
cachedProbeResults.delete(createProbeCacheKey(cwd)); cachedProbeResults.delete(createProbeCacheKey(cwd, providerId));
} }
private async validatePrepareCwd(cwd: string): Promise<void> { private async validatePrepareCwd(cwd: string): Promise<void> {
@ -2414,15 +2565,18 @@ export class TeamProvisioningService {
} }
} catch (error) { } catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') { if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return; throw new Error(`Working directory does not exist: ${cwd}`);
} }
throw error; throw error;
} }
} }
private async getCachedOrProbeResult(cwd: string): Promise<ProbeResult | null> { private async getCachedOrProbeResult(
const cacheKey = createProbeCacheKey(cwd); cwd: string,
const cached = this.getFreshCachedProbeResult(cwd); providerId: TeamProviderId | undefined
): Promise<ProbeResult | null> {
const cacheKey = createProbeCacheKey(cwd, providerId);
const cached = this.getFreshCachedProbeResult(cwd, providerId);
if (cached) { if (cached) {
return { return {
claudePath: cached.claudePath, claudePath: cached.claudePath,
@ -2440,8 +2594,8 @@ export class TeamProvisioningService {
const claudePath = await ClaudeBinaryResolver.resolve(); const claudePath = await ClaudeBinaryResolver.resolve();
if (!claudePath) return null; if (!claudePath) return null;
const { env, authSource } = await this.buildProvisioningEnv(); const { env, authSource } = await this.buildProvisioningEnv(providerId);
const probe = await this.probeClaudeRuntime(claudePath, cwd, env); const probe = await this.probeClaudeRuntime(claudePath, cwd, env, providerId);
const result = { const result = {
claudePath, claudePath,
authSource, authSource,
@ -2451,7 +2605,8 @@ export class TeamProvisioningService {
const shouldCache = const shouldCache =
!probe.warning || !probe.warning ||
(!this.isAuthFailureWarning(probe.warning, 'probe') && (!this.isAuthFailureWarning(probe.warning, 'probe') &&
!isTransientProbeWarning(probe.warning)); !isTransientProbeWarning(probe.warning) &&
!isBinaryProbeWarning(probe.warning));
if (shouldCache) { if (shouldCache) {
cachedProbeResults.set(cacheKey, { cacheKey, ...result, cachedAtMs: Date.now() }); cachedProbeResults.set(cacheKey, { cacheKey, ...result, cachedAtMs: Date.now() });
@ -2480,8 +2635,14 @@ export class TeamProvisioningService {
lower.includes('missing api key') || lower.includes('missing api key') ||
lower.includes('invalid api key') || lower.includes('invalid api key') ||
lower.includes('authentication failed') || 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('run `claude auth login`') ||
lower.includes('claude auth login'); lower.includes('claude auth login') ||
lower.includes('claude-multimodel auth login');
if (hasExplicitCliAuthSignal) { if (hasExplicitCliAuthSignal) {
return true; return true;
@ -2515,6 +2676,56 @@ export class TeamProvisioningService {
return text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); 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 { private extractApiErrorSnippet(text: string): string | null {
const match = /api error:\s*\d{3}\b/i.exec(text) ?? /invalid_request_error/i.exec(text); const match = /api error:\s*\d{3}\b/i.exec(text) ?? /invalid_request_error/i.exec(text);
if (match?.index === undefined) return null; if (match?.index === undefined) return null;
@ -3114,6 +3325,7 @@ export class TeamProvisioningService {
stallWarningIndex: null, stallWarningIndex: null,
preStallMessage: null, preStallMessage: null,
lastRetryAt: 0, lastRetryAt: 0,
apiRetryWarningIndex: null,
apiErrorWarningEmitted: false, apiErrorWarningEmitted: false,
waitingTasksSince: null, waitingTasksSince: null,
provisioningComplete: false, provisioningComplete: false,
@ -3162,7 +3374,7 @@ export class TeamProvisioningService {
const prompt = buildProvisioningPrompt(request); const prompt = buildProvisioningPrompt(request);
let child: ReturnType<typeof spawn>; let child: ReturnType<typeof spawn>;
const { env: shellEnv } = await this.buildProvisioningEnv(); const { env: shellEnv } = await this.buildProvisioningEnv(request.providerId);
let mcpConfigPath: string; let mcpConfigPath: string;
try { try {
mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd); mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd);
@ -3207,6 +3419,7 @@ export class TeamProvisioningService {
color: request.color, color: request.color,
cwd: request.cwd, cwd: request.cwd,
prompt: request.prompt, prompt: request.prompt,
providerId: request.providerId,
model: request.model, model: request.model,
effort: request.effort, effort: request.effort,
skipPermissions: request.skipPermissions, skipPermissions: request.skipPermissions,
@ -3510,6 +3723,7 @@ export class TeamProvisioningService {
teamName: request.teamName, teamName: request.teamName,
members: expectedMemberSpecs, members: expectedMemberSpecs,
cwd: request.cwd, cwd: request.cwd,
providerId: request.providerId,
skipPermissions: request.skipPermissions, skipPermissions: request.skipPermissions,
}; };
@ -3557,6 +3771,7 @@ export class TeamProvisioningService {
stallWarningIndex: null, stallWarningIndex: null,
preStallMessage: null, preStallMessage: null,
lastRetryAt: 0, lastRetryAt: 0,
apiRetryWarningIndex: null,
apiErrorWarningEmitted: false, apiErrorWarningEmitted: false,
waitingTasksSince: null, waitingTasksSince: null,
provisioningComplete: false, provisioningComplete: false,
@ -3627,7 +3842,7 @@ export class TeamProvisioningService {
Boolean(previousSessionId) Boolean(previousSessionId)
); );
let child: ReturnType<typeof spawn>; let child: ReturnType<typeof spawn>;
const { env: shellEnv } = await this.buildProvisioningEnv(); const { env: shellEnv } = await this.buildProvisioningEnv(request.providerId);
let mcpConfigPath: string; let mcpConfigPath: string;
try { try {
mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd); mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd);
@ -4674,6 +4889,13 @@ export class TeamProvisioningService {
const inp = input as Record<string, unknown>; const inp = input as Record<string, unknown>;
const teamName = typeof inp.team_name === 'string' ? inp.team_name.trim() : ''; const teamName = typeof inp.team_name === 'string' ? inp.team_name.trim() : '';
const memberName = typeof inp.name === 'string' ? inp.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 (!memberName) continue;
if (!teamName) { if (!teamName) {
logger.warn( logger.warn(
@ -5577,21 +5799,45 @@ export class TeamProvisioningService {
const errorStatus = typeof msg.error_status === 'number' ? msg.error_status : undefined; const errorStatus = typeof msg.error_status === 'number' ? msg.error_status : undefined;
const errorLabel = typeof msg.error === 'string' ? msg.error.replace(/_/g, ' ') : 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 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 // Use a human label for known quota/rate-limit retries instead of a misleading 500 bucket.
const statusLabel = errorLabel const statusLabel = looksLikeQuotaRetry
? 'rate limited'
: errorLabel
? `${errorLabel}${errorStatus ? ` (${errorStatus})` : ''}` ? `${errorLabel}${errorStatus ? ` (${errorStatus})` : ''}`
: `error ${errorStatus ?? 'unknown'}`; : `error ${errorStatus ?? 'unknown'}`;
const delayLabel = retryDelay ? ` — next retry in ${Math.round(retryDelay / 1000)}s` : ''; 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) { 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.lastRetryAt = Date.now();
run.progress = { run.progress = {
...run.progress, ...run.progress,
updatedAt: nowIso(), updatedAt: nowIso(),
message: retryText, message: retryText,
messageSeverity: 'error' as const, messageSeverity: 'error' as const,
assistantOutput: run.provisioningOutputParts.join('\n\n'),
}; };
run.onProgress(run.progress); 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(); const shellEnv = await resolveInteractiveShellEnv();
// getHomeDir() uses Electron's app.getPath('home') which handles Unicode // getHomeDir() uses Electron's app.getPath('home') which handles Unicode
// correctly on Windows. Prefer it over process.env which may be garbled. // 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', CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
}; };
applyProviderRuntimeEnv(env, providerId);
const controlApiBaseUrl = await this.resolveControlApiBaseUrl(); const controlApiBaseUrl = await this.resolveControlApiBaseUrl();
if (controlApiBaseUrl) { if (controlApiBaseUrl) {
@ -7631,6 +7880,14 @@ export class TeamProvisioningService {
env.XDG_STATE_HOME = xdgStateHome; 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 // 1. Explicit ANTHROPIC_API_KEY — works with `-p` mode directly
if (typeof env.ANTHROPIC_API_KEY === 'string' && env.ANTHROPIC_API_KEY.trim().length > 0) { if (typeof env.ANTHROPIC_API_KEY === 'string' && env.ANTHROPIC_API_KEY.trim().length > 0) {
return { env, authSource: 'anthropic_api_key' }; return { env, authSource: 'anthropic_api_key' };
@ -8300,6 +8557,15 @@ export class TeamProvisioningService {
name: member.name.trim(), name: member.name.trim(),
role: member.role?.trim() || undefined, role: member.role?.trim() || undefined,
workflow: member.workflow?.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', agentType: 'general-purpose',
color: getMemberColorByName(member.name.trim()), color: getMemberColorByName(member.name.trim()),
joinedAt, joinedAt,
@ -8337,14 +8603,27 @@ export class TeamProvisioningService {
const role = typeof member.role === 'string' ? member.role.trim() || undefined : undefined; const role = typeof member.role === 'string' ? member.role.trim() || undefined : undefined;
const workflow = const workflow =
typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined; 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); const prev = byName.get(name);
if (!prev) { if (!prev) {
byName.set(name, { name, role, workflow }); byName.set(name, { name, role, workflow, providerId, model, effort });
} else { } else {
byName.set(name, { byName.set(name, {
...prev, ...prev,
role: prev.role || role, role: prev.role || role,
workflow: prev.workflow || workflow, 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()); return !inboxNameSetLower.has(match[1].toLowerCase());
}); });
if (inboxNames.length > 0) { if (inboxNames.length > 0) {
const members = inboxNames.map((name) => ({ name })); const configMembers = this.extractTeammateSpecsFromConfig(teamName, configRaw);
return { members, source: 'inboxes' }; 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) { } catch (error) {
logger.warn( logger.warn(
@ -8439,7 +8745,17 @@ export class TeamProvisioningService {
configRaw: string configRaw: string
): TeamCreateRequest['members'] { ): TeamCreateRequest['members'] {
try { 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)) { if (!Array.isArray(parsed.members)) {
return []; return [];
} }
@ -8450,7 +8766,21 @@ export class TeamProvisioningService {
if (!member || isLeadMember(member) || lower === 'user') continue; if (!member || isLeadMember(member) || lower === 'user') continue;
const name = rawName; const name = rawName;
if (!name) continue; 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. // Defense: ignore CLI auto-suffixed duplicates (alice-2) when base name exists.
const allNames = Array.from(byName.keys()); const allNames = Array.from(byName.keys());
@ -8478,8 +8808,50 @@ export class TeamProvisioningService {
private async probeClaudeRuntime( private async probeClaudeRuntime(
claudePath: string, claudePath: string,
cwd: string, cwd: string,
env: NodeJS.ProcessEnv env: NodeJS.ProcessEnv,
providerId: TeamProviderId | undefined = 'anthropic'
): Promise<{ warning?: string }> { ): 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) // Stage 1: verify binary works (awaited first for clearer errors)
// Important: keep this sequential with Stage 2 to avoid auth/credential-store races // Important: keep this sequential with Stage 2 to avoid auth/credential-store races
// when multiple `claude` processes start simultaneously (most visible on Windows). // when multiple `claude` processes start simultaneously (most visible on Windows).
@ -8503,10 +8875,10 @@ export class TeamProvisioningService {
try { try {
pingProbe = await this.spawnProbe( pingProbe = await this.spawnProbe(
claudePath, claudePath,
[...PREFLIGHT_PING_ARGS], getPreflightPingArgs(providerId),
cwd, cwd,
env, env,
PREFLIGHT_TIMEOUT_MS, getPreflightTimeoutMs(providerId),
{ {
resolveOnOutputMatch: ({ stdout, stderr }) => { resolveOnOutputMatch: ({ stdout, stderr }) => {
const combined = `${stdout}\n${stderr}`.trim(); const combined = `${stdout}\n${stderr}`.trim();
@ -8516,7 +8888,7 @@ export class TeamProvisioningService {
); );
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(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( logger.warn(
`Preflight ping failed (attempt ${attempt}/${PREFLIGHT_AUTH_MAX_RETRIES}), ` + `Preflight ping failed (attempt ${attempt}/${PREFLIGHT_AUTH_MAX_RETRIES}), ` +
`retrying in ${PREFLIGHT_AUTH_RETRY_DELAY_MS}ms: ${message}` `retrying in ${PREFLIGHT_AUTH_RETRY_DELAY_MS}ms: ${message}`
@ -8532,12 +8904,7 @@ export class TeamProvisioningService {
} }
const combinedOutput = buildCombinedLogs(pingProbe.stdout, pingProbe.stderr); const combinedOutput = buildCombinedLogs(pingProbe.stdout, pingProbe.stderr);
const lowerOutput = combinedOutput.toLowerCase(); const isAuthFailure = this.isAuthFailureWarning(combinedOutput, 'probe');
const isAuthFailure =
lowerOutput.includes('not logged in') ||
lowerOutput.includes('please run /login') ||
lowerOutput.includes('missing api key') ||
lowerOutput.includes('invalid api key');
if (isAuthFailure && attempt < PREFLIGHT_AUTH_MAX_RETRIES) { if (isAuthFailure && attempt < PREFLIGHT_AUTH_MAX_RETRIES) {
logger.warn( logger.warn(
@ -8550,7 +8917,11 @@ export class TeamProvisioningService {
if (isAuthFailure || pingProbe.exitCode !== 0) { if (isAuthFailure || pingProbe.exitCode !== 0) {
const hint = isAuthFailure const hint = isAuthFailure
? 'Claude CLI `-p` mode is not authenticated. ' + ? 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. ' + 'Run `claude auth login` (or start `claude` and run `/login`) to authenticate. ' +
'For automation/headless use, set ANTHROPIC_API_KEY.' + 'For automation/headless use, set ANTHROPIC_API_KEY.' +
(attempt > 1 ? ` (failed after ${attempt} attempts)` : '') (attempt > 1 ? ` (failed after ${attempt} attempts)` : '')
@ -8591,7 +8962,7 @@ export class TeamProvisioningService {
return this.helpOutputCache; return this.helpOutputCache;
} }
const targetCwd = cwd ?? process.cwd(); const targetCwd = cwd ?? process.cwd();
const probeResult = await this.getCachedOrProbeResult(targetCwd); const probeResult = await this.getCachedOrProbeResult(targetCwd, 'anthropic');
if (!probeResult?.claudePath) { if (!probeResult?.claudePath) {
throw new Error('Claude CLI not found'); 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> => { deleteDraft: async (_teamName: string): Promise<void> => {
throw new Error('Draft team deletion is not available in browser mode'); 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'); throw new Error('Team provisioning is not available in browser mode');
}, },
createTeam: async (_request: TeamCreateRequest): Promise<TeamCreateResponse> => { createTeam: async (_request: TeamCreateRequest): Promise<TeamCreateResponse> => {
@ -1057,6 +1061,11 @@ export class HttpAPIClient implements ElectronAPI {
cliInstaller: CliInstallerAPI = { cliInstaller: CliInstallerAPI = {
getStatus: async () => ({ getStatus: async () => ({
flavor: 'claude',
displayName: 'Claude CLI',
supportsSelfUpdate: true,
showVersionDetails: true,
showBinaryPath: true,
installed: false, installed: false,
installedVersion: null, installedVersion: null,
binaryPath: null, binaryPath: null,
@ -1064,6 +1073,7 @@ export class HttpAPIClient implements ElectronAPI {
updateAvailable: false, updateAvailable: false,
authLoggedIn: false, authLoggedIn: false,
authMethod: null, authMethod: null,
providers: [],
}), }),
install: async (): Promise<void> => { install: async (): Promise<void> => {
console.warn('[HttpAPIClient] CLI installer not available in browser mode'); console.warn('[HttpAPIClient] CLI installer not available in browser mode');

View file

@ -135,6 +135,9 @@ function areResolvedMembersEqual(
prevMember.agentType !== nextMember.agentType || prevMember.agentType !== nextMember.agentType ||
prevMember.role !== nextMember.role || prevMember.role !== nextMember.role ||
prevMember.workflow !== nextMember.workflow || prevMember.workflow !== nextMember.workflow ||
prevMember.providerId !== nextMember.providerId ||
prevMember.model !== nextMember.model ||
prevMember.effort !== nextMember.effort ||
prevMember.cwd !== nextMember.cwd || prevMember.cwd !== nextMember.cwd ||
prevMember.gitBranch !== nextMember.gitBranch || prevMember.gitBranch !== nextMember.gitBranch ||
prevMember.removedAt !== nextMember.removedAt prevMember.removedAt !== nextMember.removedAt
@ -1616,6 +1619,7 @@ export const TeamDetailView = ({
isTeamAlive={data.isAlive} isTeamAlive={data.isAlive}
isTeamProvisioning={isTeamProvisioning} isTeamProvisioning={isTeamProvisioning}
leadActivity={leadActivityByTeam[teamName]} leadActivity={leadActivityByTeam[teamName]}
launchParams={launchParams}
onMemberClick={setSelectedMember} onMemberClick={setSelectedMember}
onSendMessage={(member) => { onSendMessage={(member) => {
setSendDialogRecipient(member.name); setSendDialogRecipient(member.name);
@ -1976,6 +1980,7 @@ export const TeamDetailView = ({
currentDescription={data.config.description ?? ''} currentDescription={data.config.description ?? ''}
currentColor={data.config.color ?? ''} currentColor={data.config.color ?? ''}
currentMembers={data.members.filter((m) => !isLeadMember(m))} currentMembers={data.members.filter((m) => !isLeadMember(m))}
isTeamAlive={data.isAlive && !isTeamProvisioning}
projectPath={data.config.projectPath} projectPath={data.config.projectPath}
onClose={() => setEditDialogOpen(false)} onClose={() => setEditDialogOpen(false)}
onSaved={() => void selectTeam(teamName)} onSaved={() => void selectTeam(teamName)}
@ -1998,6 +2003,9 @@ export const TeamDetailView = ({
name: entry.name, name: entry.name,
role: entry.role, role: entry.role,
workflow: entry.workflow, workflow: entry.workflow,
providerId: entry.providerId,
model: entry.model,
effort: entry.effort,
}); });
} }
setAddMemberDialogOpen(false); setAddMemberDialogOpen(false);

View file

@ -19,11 +19,15 @@ import {
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes'; import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
import type { EffortLevel, TeamProviderId } from '@shared/types';
export interface AddMemberEntry { export interface AddMemberEntry {
name: string; name: string;
role?: string; role?: string;
workflow?: string; workflow?: string;
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
} }
interface AddMemberDialogProps { interface AddMemberDialogProps {
@ -116,6 +120,9 @@ export const AddMemberDialog = ({
name: m.name, name: m.name,
role: m.role, role: m.role,
workflow: m.workflow, 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 { api } from '@renderer/api';
import { import {
buildMemberDraftColorMap, buildMemberDraftColorMap,
buildMemberDraftSuggestions, buildMemberDraftSuggestions,
buildMembersFromDrafts, buildMembersFromDrafts,
clearMemberModelOverrides,
createMemberDraft, createMemberDraft,
MembersEditorSection,
validateMemberNameInline, validateMemberNameInline,
} from '@renderer/components/team/members/MembersEditorSection'; } from '@renderer/components/team/members/MembersEditorSection';
import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection';
import { AutoResizeTextarea } from '@renderer/components/ui/auto-resize-textarea'; import { AutoResizeTextarea } from '@renderer/components/ui/auto-resize-textarea';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox'; 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 { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
import { AdvancedCliSection } from './AdvancedCliSection'; import { AdvancedCliSection } from './AdvancedCliSection';
import { EffortLevelSelector } from './EffortLevelSelector';
import { LimitContextCheckbox } from './LimitContextCheckbox';
import { OptionalSettingsSection } from './OptionalSettingsSection'; import { OptionalSettingsSection } from './OptionalSettingsSection';
import {
createInitialProviderChecks,
failIncompleteProviderChecks,
getProvisioningFailureHint,
ProvisioningProviderStatusList,
shouldHideProvisioningProviderStatusList,
updateProviderCheck,
type ProvisioningProviderCheck,
} from './ProvisioningProviderStatusList';
import { ProjectPathSelector } from './ProjectPathSelector'; import { ProjectPathSelector } from './ProjectPathSelector';
import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox'; import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox';
import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector'; import { computeEffectiveTeamModel } from './TeamModelSelector';
import { getNextSuggestedTeamName } from './teamNameSets'; import { getNextSuggestedTeamName } from './teamNameSets';
const TEAM_COLOR_NAMES = [ const TEAM_COLOR_NAMES = [
@ -59,10 +67,45 @@ import type {
EffortLevel, EffortLevel,
Project, Project,
TeamCreateRequest, TeamCreateRequest,
TeamProviderId,
TeamProvisioningMemberInput, TeamProvisioningMemberInput,
TeamProvisioningPrepareResult, TeamProvisioningPrepareResult,
} from '@shared/types'; } 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 { export interface TeamCopyData {
teamName: string; teamName: string;
description?: string; description?: string;
@ -230,6 +273,8 @@ export const CreateTeamDialog = ({
setTeamName, setTeamName,
members, members,
setMembers, setMembers,
syncModelsWithLead,
setSyncModelsWithLead,
cwdMode, cwdMode,
setCwdMode, setCwdMode,
selectedProjectPath, selectedProjectPath,
@ -258,6 +303,7 @@ export const CreateTeamDialog = ({
const [prepareState, setPrepareState] = useState<'idle' | 'loading' | 'ready' | 'failed'>('idle'); const [prepareState, setPrepareState] = useState<'idle' | 'loading' | 'ready' | 'failed'>('idle');
const [prepareMessage, setPrepareMessage] = useState<string | null>(null); const [prepareMessage, setPrepareMessage] = useState<string | null>(null);
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]); const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
const [prepareChecks, setPrepareChecks] = useState<ProvisioningProviderCheck[]>([]);
const prepareRequestSeqRef = useRef(0); const prepareRequestSeqRef = useRef(0);
const lastAutoDescriptionRef = useRef<string | null>(null); const lastAutoDescriptionRef = useRef<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<{ const [fieldErrors, setFieldErrors] = useState<{
@ -267,11 +313,11 @@ export const CreateTeamDialog = ({
}>({}); }>({});
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [conflictDismissed, setConflictDismissed] = useState(false); const [conflictDismissed, setConflictDismissed] = useState(false);
const [selectedModel, setSelectedModelRaw] = useState(() => { const [selectedProviderId, setSelectedProviderIdRaw] =
const stored = localStorage.getItem('team:lastSelectedModel'); useState<TeamProviderId>(getStoredTeamProvider);
if (stored === null) return 'opus'; const [selectedModel, setSelectedModelRaw] = useState(() =>
return stored === '__default__' ? '' : stored; getStoredTeamModel(getStoredTeamProvider())
}); );
const [limitContext, setLimitContextRaw] = useState( const [limitContext, setLimitContextRaw] = useState(
() => localStorage.getItem('team:lastLimitContext') === 'true' () => localStorage.getItem('team:lastLimitContext') === 'true'
); );
@ -289,6 +335,17 @@ export const CreateTeamDialog = ({
const [worktreeName, setWorktreeNameRaw] = useState(''); const [worktreeName, setWorktreeNameRaw] = useState('');
const [customArgs, setCustomArgsRaw] = 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 // Re-read localStorage when advancedKey changes
useEffect(() => { useEffect(() => {
const storedEnabled = const storedEnabled =
@ -301,7 +358,17 @@ export const CreateTeamDialog = ({
const setSelectedModel = (value: string): void => { const setSelectedModel = (value: string): void => {
setSelectedModelRaw(value); 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 => { const setLimitContext = (value: boolean): void => {
@ -343,6 +410,7 @@ export const CreateTeamDialog = ({
setPrepareState('idle'); setPrepareState('idle');
setPrepareMessage(null); setPrepareMessage(null);
setPrepareWarnings([]); setPrepareWarnings([]);
setPrepareChecks([]);
setConflictDismissed(false); setConflictDismissed(false);
}; };
@ -371,6 +439,25 @@ export const CreateTeamDialog = ({
} }
}, [open, clearProvisioningError, dialogTeamNameKey]); }, [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(() => { useEffect(() => {
if (!open || !canCreate || !launchTeam) { if (!open || !canCreate || !launchTeam) {
return; return;
@ -379,6 +466,7 @@ export const CreateTeamDialog = ({
if (typeof api.teams.prepareProvisioning !== 'function') { if (typeof api.teams.prepareProvisioning !== 'function') {
setPrepareState('failed'); setPrepareState('failed');
setPrepareWarnings([]); setPrepareWarnings([]);
setPrepareChecks([]);
setPrepareMessage( setPrepareMessage(
'Current preload version does not support team:prepareProvisioning. Restart the dev app.' 'Current preload version does not support team:prepareProvisioning. Restart the dev app.'
); );
@ -388,6 +476,7 @@ export const CreateTeamDialog = ({
if (!effectiveCwd) { if (!effectiveCwd) {
setPrepareState('idle'); setPrepareState('idle');
setPrepareWarnings([]); setPrepareWarnings([]);
setPrepareChecks([]);
setPrepareMessage('Select a working directory to validate the launch environment.'); setPrepareMessage('Select a working directory to validate the launch environment.');
return; return;
} }
@ -395,26 +484,75 @@ export const CreateTeamDialog = ({
let cancelled = false; let cancelled = false;
const requestSeq = ++prepareRequestSeqRef.current; const requestSeq = ++prepareRequestSeqRef.current;
setPrepareState('loading'); setPrepareState('loading');
setPrepareMessage('Warming up CLI environment...'); setPrepareMessage('Checking selected providers...');
setPrepareWarnings([]); setPrepareWarnings([]);
setPrepareChecks(createInitialProviderChecks(selectedMemberProviders));
// Defer so file list fetch (triggered by project select) can run first // Defer so file list fetch (triggered by project select) can run first
const timer = setTimeout(() => { const timer = setTimeout(() => {
void (async () => { void (async () => {
let checks = createInitialProviderChecks(selectedMemberProviders);
let anyFailure = false;
let anyNotes = false;
const collectedWarnings: string[] = [];
try { try {
const prepResult: TeamProvisioningPrepareResult = for (const providerId of selectedMemberProviders) {
await api.teams.prepareProvisioning(effectiveCwd); 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; if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
setPrepareState(prepResult.ready ? 'ready' : 'failed'); setPrepareState(anyFailure ? 'failed' : 'ready');
setPrepareMessage(prepResult.message); setPrepareMessage(
setPrepareWarnings(prepResult.warnings ?? []); anyFailure
? 'Some selected providers need attention.'
: anyNotes
? 'Selected providers are ready with notes.'
: 'Selected providers are ready.'
);
setPrepareWarnings(collectedWarnings);
} catch (error) { } catch (error) {
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return; if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
const failureMessage =
error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment';
setPrepareState('failed'); setPrepareState('failed');
setPrepareWarnings([]); setPrepareWarnings([]);
setPrepareMessage( setPrepareChecks(failIncompleteProviderChecks(checks, failureMessage));
error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment' setPrepareMessage(failureMessage);
);
} }
})(); })();
}, 250); }, 250);
@ -423,7 +561,7 @@ export const CreateTeamDialog = ({
cancelled = true; cancelled = true;
clearTimeout(timer); clearTimeout(timer);
}; };
}, [open, canCreate, launchTeam, effectiveCwd]); }, [open, canCreate, launchTeam, effectiveCwd, selectedProviderId, selectedMemberProviders]);
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
@ -446,6 +584,7 @@ export const CreateTeamDialog = ({
// display and select it. // display and select it.
if ( if (
defaultProjectPath && defaultProjectPath &&
!isEphemeralRenderedProjectPath(defaultProjectPath) &&
!nextProjects.some((p) => normalizePath(p.path) === defaultProjectPath) !nextProjects.some((p) => normalizePath(p.path) === defaultProjectPath)
) { ) {
const folderName = const folderName =
@ -497,9 +636,15 @@ export const CreateTeamDialog = ({
roleSelection: isCustom ? CUSTOM_ROLE : (m.role ?? ''), roleSelection: isCustom ? CUSTOM_ROLE : (m.role ?? ''),
customRole: isCustom ? m.role : '', customRole: isCustom ? m.role : '',
workflow: m.workflow, workflow: m.workflow,
providerId: m.providerId,
model: m.model ?? '',
effort: m.effort,
}); });
}) })
); );
setSyncModelsWithLead(
!initialData.members.some((member) => member.providerId || member.model || member.effort)
);
return; return;
} }
@ -559,7 +704,7 @@ export const CreateTeamDialog = ({
if (selectedProjectPath || projects.length === 0) { if (selectedProjectPath || projects.length === 0) {
return; return;
} }
if (defaultProjectPath) { if (defaultProjectPath && !isEphemeralRenderedProjectPath(defaultProjectPath)) {
const match = projects.find((p) => normalizePath(p.path) === defaultProjectPath); const match = projects.find((p) => normalizePath(p.path) === defaultProjectPath);
if (match) { if (match) {
setSelectedProjectPath(match.path); setSelectedProjectPath(match.path);
@ -569,6 +714,16 @@ export const CreateTeamDialog = ({
setSelectedProjectPath(projects[0].path); setSelectedProjectPath(projects[0].path);
}, [open, cwdMode, projects, selectedProjectPath, defaultProjectPath]); }, [open, cwdMode, projects, selectedProjectPath, defaultProjectPath]);
useEffect(() => {
if (!open || cwdMode !== 'project' || !selectedProjectPath) {
return;
}
if (!isEphemeralRenderedProjectPath(selectedProjectPath)) {
return;
}
setSelectedProjectPath('');
}, [open, cwdMode, selectedProjectPath, setSelectedProjectPath]);
useFileListCacheWarmer(effectiveCwd || null); useFileListCacheWarmer(effectiveCwd || null);
const { suggestions: taskSuggestions } = useTaskSuggestions(null); const { suggestions: taskSuggestions } = useTaskSuggestions(null);
@ -587,8 +742,8 @@ export const CreateTeamDialog = ({
); );
const effectiveModel = useMemo( const effectiveModel = useMemo(
() => computeEffectiveTeamModel(selectedModel, limitContext), () => computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId),
[selectedModel, limitContext] [selectedModel, limitContext, selectedProviderId]
); );
const sanitizedTeamName = sanitizeTeamName(teamName.trim()); const sanitizedTeamName = sanitizeTeamName(teamName.trim());
@ -601,9 +756,10 @@ export const CreateTeamDialog = ({
teamName: sanitizedTeamName, teamName: sanitizedTeamName,
description: description.trim() || undefined, description: description.trim() || undefined,
color: teamColor || undefined, color: teamColor || undefined,
members: soloTeam ? [] : buildMembersFromDrafts(members), members: soloTeam ? [] : buildMembersFromDrafts(effectiveMemberDrafts),
cwd: effectiveCwd, cwd: effectiveCwd,
prompt: prompt.trim() || undefined, prompt: prompt.trim() || undefined,
providerId: selectedProviderId,
model: effectiveModel, model: effectiveModel,
effort: (selectedEffort as EffortLevel) || undefined, effort: (selectedEffort as EffortLevel) || undefined,
limitContext, limitContext,
@ -616,9 +772,10 @@ export const CreateTeamDialog = ({
description, description,
teamColor, teamColor,
soloTeam, soloTeam,
members, effectiveMemberDrafts,
effectiveCwd, effectiveCwd,
prompt, prompt,
selectedProviderId,
effectiveModel, effectiveModel,
selectedEffort, selectedEffort,
limitContext, limitContext,
@ -643,23 +800,11 @@ export const CreateTeamDialog = ({
const launchOptionalSummary = useMemo(() => { const launchOptionalSummary = useMemo(() => {
const summary: string[] = []; const summary: string[] = [];
if (prompt.trim()) summary.push('Lead prompt'); 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 (skipPermissions) summary.push('Auto-approve tools');
if (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`); if (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`);
if (customArgs.trim()) summary.push('Custom CLI args'); if (customArgs.trim()) summary.push('Custom CLI args');
return summary; return summary;
}, [ }, [prompt, skipPermissions, worktreeEnabled, worktreeName, customArgs]);
prompt,
selectedModel,
selectedEffort,
limitContext,
skipPermissions,
worktreeEnabled,
worktreeName,
customArgs,
]);
const teamDetailsSummary = useMemo(() => { const teamDetailsSummary = useMemo(() => {
const summary: string[] = []; const summary: string[] = [];
@ -668,6 +813,16 @@ export const CreateTeamDialog = ({
return summary; return summary;
}, [description, teamColor]); }, [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 activeError = localError ?? provisioningErrorsByTeam[request.teamName] ?? null;
const canOpenExistingTeam = const canOpenExistingTeam =
activeError?.includes('Team already exists') === true && request.teamName.length > 0; activeError?.includes('Team already exists') === true && request.teamName.length > 0;
@ -822,7 +977,14 @@ export const CreateTeamDialog = ({
<p className="text-red-300/80"> <p className="text-red-300/80">
{prepareMessage ?? 'Failed to prepare environment'} {prepareMessage ?? 'Failed to prepare environment'}
</p> </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"> <div className="space-y-0.5">
{prepareWarnings.map((warning) => ( {prepareWarnings.map((warning) => (
<p <p
@ -836,8 +998,7 @@ export const CreateTeamDialog = ({
</div> </div>
) : null} ) : null}
<p className="text-[11px] text-[var(--color-text-muted)]"> <p className="text-[11px] text-[var(--color-text-muted)]">
Make sure <span className="font-mono">claude</span> CLI is installed and available {getProvisioningFailureHint(prepareMessage, prepareChecks)}
in PATH, then reopen this dialog.
</p> </p>
</div> </div>
</div> </div>
@ -892,9 +1053,9 @@ export const CreateTeamDialog = ({
</div> </div>
<div className="md:col-span-2"> <div className="md:col-span-2">
<MembersEditorSection <TeamRosterEditorSection
members={members} members={members}
onChange={setMembers} onMembersChange={setMembers}
fieldError={fieldErrors.members} fieldError={fieldErrors.members}
validateMemberName={validateMemberNameInline} validateMemberName={validateMemberNameInline}
showWorkflow showWorkflow
@ -903,9 +1064,26 @@ export const CreateTeamDialog = ({
projectPath={effectiveCwd || null} projectPath={effectiveCwd || null}
taskSuggestions={taskSuggestions} taskSuggestions={taskSuggestions}
teamSuggestions={teamMentionSuggestions} teamSuggestions={teamMentionSuggestions}
hideContent={soloTeam} defaultProviderId={selectedProviderId}
headerExtra={ inheritedProviderId={selectedProviderId}
<div className="space-y-2"> 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"> <div className="flex items-center gap-2">
<Checkbox <Checkbox
id="solo-team" id="solo-team"
@ -919,18 +1097,19 @@ export const CreateTeamDialog = ({
Solo team Solo team
</Label> </Label>
</div> </div>
{soloTeam && ( }
headerBottom={
soloTeam ? (
<div className="flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/5 px-3 py-2"> <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" /> <Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />
<p className="text-[11px] leading-relaxed text-sky-300"> <p className="text-[11px] leading-relaxed text-sky-300">
Only the team lead (main process) will be started &mdash; no teammates will Only the team lead (main process) will be started &mdash; no teammates will be
be spawned. Works like a regular Claude session but with access to the task spawned. Works like a regular Claude session but with access to the task board
board for planning. Saves tokens by avoiding teammate coordination overhead. for planning. Saves tokens by avoiding teammate coordination overhead. You can
You can add members later from the team settings. add members later from the team settings.
</p> </p>
</div> </div>
)} ) : null
</div>
} }
/> />
</div> </div>
@ -984,7 +1163,7 @@ export const CreateTeamDialog = ({
<OptionalSettingsSection <OptionalSettingsSection
title="Optional launch settings" 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} summary={launchOptionalSummary}
> >
<div className="space-y-4"> <div className="space-y-4">
@ -1017,29 +1196,11 @@ export const CreateTeamDialog = ({
/> />
</div> </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 <SkipPermissionsCheckbox
id="create-skip-permissions" id="create-skip-permissions"
checked={skipPermissions} checked={skipPermissions}
onCheckedChange={setSkipPermissions} onCheckedChange={setSkipPermissions}
/> />
</div>
<AdvancedCliSection <AdvancedCliSection
teamName={advancedKey} teamName={advancedKey}
@ -1133,6 +1294,7 @@ export const CreateTeamDialog = ({
<DialogFooter className="pt-4 sm:justify-between"> <DialogFooter className="pt-4 sm:justify-between">
<div className="min-w-0"> <div className="min-w-0">
{canCreate && launchTeam && (prepareState === 'idle' || prepareState === 'loading') ? ( {canCreate && launchTeam && (prepareState === 'idle' || prepareState === 'loading') ? (
<>
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]"> <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" /> <span className="inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
<div> <div>
@ -1142,18 +1304,13 @@ export const CreateTeamDialog = ({
? 'Warming up CLI environment...' ? 'Warming up CLI environment...'
: 'Preparing environment...')} : 'Preparing environment...')}
</span> </span>
<p className="mt-0.5 flex items-center gap-1.5 text-[10px] text-[var(--color-text-muted)] opacity-70"> <p className="mt-0.5 text-[10px] text-[var(--color-text-muted)] opacity-70">
<span>Pre-flight check to catch errors before launch</span> Pre-flight check to catch errors before launch
<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> </p>
</div> </div>
</div> </div>
<ProvisioningProviderStatusList checks={prepareChecks} className="mt-2" />
</>
) : null} ) : null}
{canCreate && launchTeam && prepareState === 'ready' ? ( {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"> <div className="flex items-center gap-1.5 text-xs font-medium text-emerald-400">
<CheckCircle2 className="size-3.5 shrink-0" /> <CheckCircle2 className="size-3.5 shrink-0" />
<span> <span>
{prepareWarnings.length > 0 {prepareChecks.some((check) => check.status === 'notes') ||
prepareWarnings.length > 0
? 'CLI environment ready (with notes)' ? 'CLI environment ready (with notes)'
: 'CLI environment ready'} : 'CLI environment ready'}
</span> </span>
@ -1171,7 +1329,8 @@ export const CreateTeamDialog = ({
{prepareMessage} {prepareMessage}
</p> </p>
) : null} ) : 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"> <div className="mt-0.5 space-y-0.5 pl-5">
{prepareWarnings.map((warning) => ( {prepareWarnings.map((warning) => (
<p key={warning} className="text-[11px] text-sky-300"> <p key={warning} className="text-[11px] text-sky-300">
@ -1202,12 +1361,7 @@ export const CreateTeamDialog = ({
</Button> </Button>
<Button <Button
size="sm" size="sm"
disabled={ disabled={!canCreate || !draftLoaded || isSubmitting}
!canCreate ||
!draftLoaded ||
isSubmitting ||
(launchTeam && prepareState !== 'ready')
}
onClick={handleSubmit} onClick={handleSubmit}
> >
{isSubmitting ? ( {isSubmitting ? (
@ -1215,6 +1369,8 @@ export const CreateTeamDialog = ({
<Loader2 className="mr-1.5 size-3.5 animate-spin" /> <Loader2 className="mr-1.5 size-3.5 animate-spin" />
Creating... Creating...
</> </>
) : launchTeam && (prepareState === 'idle' || prepareState === 'loading') ? (
'Skip preflight and create'
) : ( ) : (
'Create' 'Create'
)} )}

View file

@ -3,7 +3,7 @@ import { useEffect, useState } from 'react';
import { api } from '@renderer/api'; import { api } from '@renderer/api';
import { import {
buildMembersFromDrafts, buildMembersFromDrafts,
createMemberDraft, createMemberDraftsFromInputs,
MembersEditorSection, MembersEditorSection,
validateMemberNameInline, validateMemberNameInline,
} from '@renderer/components/team/members/MembersEditorSection'; } from '@renderer/components/team/members/MembersEditorSection';
@ -17,7 +17,6 @@ import {
DialogTitle, DialogTitle,
} from '@renderer/components/ui/dialog'; } from '@renderer/components/ui/dialog';
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { CUSTOM_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer'; import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
@ -43,24 +42,14 @@ interface EditTeamDialogProps {
currentDescription: string; currentDescription: string;
currentColor: string; currentColor: string;
currentMembers: ResolvedTeamMember[]; currentMembers: ResolvedTeamMember[];
isTeamAlive?: boolean;
projectPath?: string | null; projectPath?: string | null;
onClose: () => void; onClose: () => void;
onSaved: () => void; onSaved: () => void;
} }
function membersToDrafts(members: ResolvedTeamMember[]) { function membersToDrafts(members: ResolvedTeamMember[]) {
const active = members.filter((m) => !m.removedAt); return createMemberDraftsFromInputs(members);
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,
});
});
} }
export const EditTeamDialog = ({ export const EditTeamDialog = ({
@ -70,6 +59,7 @@ export const EditTeamDialog = ({
currentDescription, currentDescription,
currentColor, currentColor,
currentMembers, currentMembers,
isTeamAlive = false,
projectPath, projectPath,
onClose, onClose,
onSaved, onSaved,
@ -170,11 +160,18 @@ export const EditTeamDialog = ({
onChange={setMembers} onChange={setMembers}
validateMemberName={validateMemberNameInline} validateMemberName={validateMemberNameInline}
showWorkflow showWorkflow
showJsonEditor showJsonEditor={!isTeamAlive}
draftKeyPrefix={`editTeam:${teamName}`} draftKeyPrefix={`editTeam:${teamName}`}
projectPath={projectPath ?? null} projectPath={projectPath ?? null}
lockProviderModel={isTeamAlive}
/> />
</div> </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> <div>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- Color picker is a group of buttons, not a single input */} {/* 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"> <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>
</div> </div>
<p className="mt-1 text-[11px] text-[var(--color-text-muted)]"> <p className="mt-1 text-[11px] text-[var(--color-text-muted)]">
Controls how much reasoning Claude invests before responding. Default uses Claude&apos;s Controls how much reasoning the selected provider invests before responding. Default uses the
standard behavior. provider&apos;s standard behavior for the selected model.
</p> </p>
</div> </div>
); );

View file

@ -1,7 +1,15 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api'; 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 { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox'; import { Checkbox } from '@renderer/components/ui/checkbox';
@ -21,10 +29,10 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer'; 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 { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { normalizePath } from '@renderer/utils/pathNormalize'; import { normalizePath } from '@renderer/utils/pathNormalize';
import { nameColorSet } from '@renderer/utils/projectColor'; import { nameColorSet } from '@renderer/utils/projectColor';
import { import {
@ -43,10 +51,20 @@ import { CronScheduleInput } from '../schedule/CronScheduleInput';
import { AdvancedCliSection } from './AdvancedCliSection'; import { AdvancedCliSection } from './AdvancedCliSection';
import { EffortLevelSelector } from './EffortLevelSelector'; import { EffortLevelSelector } from './EffortLevelSelector';
import { OptionalSettingsSection } from './OptionalSettingsSection'; import { OptionalSettingsSection } from './OptionalSettingsSection';
import {
createInitialProviderChecks,
failIncompleteProviderChecks,
getProvisioningFailureHint,
ProvisioningProviderStatusList,
shouldHideProvisioningProviderStatusList,
updateProviderCheck,
type ProvisioningProviderCheck,
} from './ProvisioningProviderStatusList';
import { ProjectPathSelector } from './ProjectPathSelector'; import { ProjectPathSelector } from './ProjectPathSelector';
import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector'; import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector';
import type { ActiveTeamRef } from './CreateTeamDialog'; import type { ActiveTeamRef } from './CreateTeamDialog';
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
import type { MentionSuggestion } from '@renderer/types/mention'; import type { MentionSuggestion } from '@renderer/types/mention';
import type { import type {
CreateScheduleInput, CreateScheduleInput,
@ -56,6 +74,7 @@ import type {
Schedule, Schedule,
ScheduleLaunchConfig, ScheduleLaunchConfig,
TeamLaunchRequest, TeamLaunchRequest,
TeamProviderId,
TeamProvisioningPrepareResult, TeamProvisioningPrepareResult,
UpdateSchedulePatch, UpdateSchedulePatch,
} from '@shared/types'; } 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 // Component
// ============================================================================= // =============================================================================
@ -157,11 +201,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
const [localError, setLocalError] = useState<string | null>(null); const [localError, setLocalError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [selectedModel, setSelectedModelRaw] = useState(() => { const [selectedProviderId, setSelectedProviderIdRaw] =
const stored = localStorage.getItem('team:lastSelectedModel'); useState<TeamProviderId>(getStoredTeamProvider);
if (stored === null) return 'opus'; const [selectedModel, setSelectedModelRaw] = useState(() =>
return stored === '__default__' ? '' : stored; getStoredTeamModel(getStoredTeamProvider())
}); );
const [membersDrafts, setMembersDrafts] = useState<MemberDraft[]>([]);
const [syncModelsWithLead, setSyncModelsWithLead] = useState(false);
const [skipPermissions, setSkipPermissionsRaw] = useState( const [skipPermissions, setSkipPermissionsRaw] = useState(
() => localStorage.getItem('team:lastSkipPermissions') !== 'false' () => 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 [prepareState, setPrepareState] = useState<'idle' | 'loading' | 'ready' | 'failed'>('idle');
const [prepareMessage, setPrepareMessage] = useState<string | null>(null); const [prepareMessage, setPrepareMessage] = useState<string | null>(null);
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]); const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
const [prepareChecks, setPrepareChecks] = useState<ProvisioningProviderCheck[]>([]);
const prepareRequestSeqRef = useRef(0); const prepareRequestSeqRef = useRef(0);
const storeMembers = useStore((s) => s.selectedTeamData?.members ?? []);
const members = isLaunch ? props.members : storeMembers;
// Advanced CLI section state (with localStorage persistence) // Advanced CLI section state (with localStorage persistence)
const [worktreeEnabled, setWorktreeEnabledRaw] = useState( const [worktreeEnabled, setWorktreeEnabledRaw] = useState(
@ -208,6 +257,24 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
const [warmUpMinutes, setWarmUpMinutes] = useState(15); const [warmUpMinutes, setWarmUpMinutes] = useState(15);
const [maxTurns, setMaxTurns] = useState(50); const [maxTurns, setMaxTurns] = useState(50);
const [maxBudgetUsd, setMaxBudgetUsd] = useState(''); 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 // Schedule store actions
const createSchedule = useStore((s) => s.createSchedule); const createSchedule = useStore((s) => s.createSchedule);
@ -234,9 +301,19 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
localStorage.setItem(`team:lastCustomArgs:${effectiveTeamName}`, value); 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 => { const setSelectedModel = (value: string): void => {
setSelectedModelRaw(value); setSelectedModelRaw(value);
localStorage.setItem('team:lastSelectedModel', value); localStorage.setItem(`team:lastSelectedModel:${selectedProviderId}`, value);
}; };
const setLimitContext = (value: boolean): void => { const setLimitContext = (value: boolean): void => {
@ -259,9 +336,19 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
useEffect(() => { 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']) { for (const suffix of ['lastSelectedModel', 'lastSelectedEffort']) {
const schedKey = `schedule:${suffix}`; const schedKey = `schedule:${suffix}`;
const teamKey = `team:${suffix}`; const teamKey =
suffix === 'lastSelectedModel' ? 'team:lastSelectedModel:anthropic' : `team:${suffix}`;
const schedVal = localStorage.getItem(schedKey); const schedVal = localStorage.getItem(schedKey);
if (schedVal != null && localStorage.getItem(teamKey) == null) { if (schedVal != null && localStorage.getItem(teamKey) == null) {
localStorage.setItem(teamKey, schedVal); localStorage.setItem(teamKey, schedVal);
@ -280,11 +367,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
setPrepareState('idle'); setPrepareState('idle');
setPrepareMessage(null); setPrepareMessage(null);
setPrepareWarnings([]); setPrepareWarnings([]);
setPrepareChecks([]);
setCwdMode('project'); setCwdMode('project');
setSelectedProjectPath(''); setSelectedProjectPath('');
setCustomCwd(''); setCustomCwd('');
setClearContext(false); setClearContext(false);
setConflictDismissed(false); setConflictDismissed(false);
setMembersDrafts([]);
setSyncModelsWithLead(false);
chipDraft.clearChipDraft(); chipDraft.clearChipDraft();
// Schedule fields // Schedule fields
setSelectedTeamName(''); setSelectedTeamName('');
@ -311,6 +401,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
promptDraft.setValue(schedule.launchConfig.prompt); promptDraft.setValue(schedule.launchConfig.prompt);
setCustomCwd(schedule.launchConfig.cwd); setCustomCwd(schedule.launchConfig.cwd);
setCwdMode('custom'); setCwdMode('custom');
setSelectedProviderIdRaw(schedule.launchConfig.providerId ?? 'anthropic');
setSelectedModelRaw(schedule.launchConfig.model ?? ''); setSelectedModelRaw(schedule.launchConfig.model ?? '');
setSkipPermissionsRaw(schedule.launchConfig.skipPermissions !== false); setSkipPermissionsRaw(schedule.launchConfig.skipPermissions !== false);
setSelectedEffortRaw(schedule.launchConfig.effort ?? ''); setSelectedEffortRaw(schedule.launchConfig.effort ?? '');
@ -326,7 +417,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
setCwdMode('project'); setCwdMode('project');
setSelectedProjectPath(''); setSelectedProjectPath('');
setCustomCwd(''); setCustomCwd('');
setSelectedModelRaw('opus'); setSelectedProviderIdRaw(getStoredTeamProvider());
setSelectedModelRaw(getStoredTeamModel(getStoredTeamProvider()));
setSelectedEffortRaw('medium'); setSelectedEffortRaw('medium');
} }
@ -335,6 +427,62 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, isSchedule, schedule?.id]); }, [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 // Launch-only effects
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -355,6 +503,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
if (typeof api.teams.prepareProvisioning !== 'function') { if (typeof api.teams.prepareProvisioning !== 'function') {
setPrepareState('failed'); setPrepareState('failed');
setPrepareWarnings([]); setPrepareWarnings([]);
setPrepareChecks([]);
setPrepareMessage( setPrepareMessage(
'Current preload version does not support team:prepareProvisioning. Restart the dev app.' '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) { if (!effectiveCwd) {
setPrepareState('idle'); setPrepareState('idle');
setPrepareWarnings([]); setPrepareWarnings([]);
setPrepareChecks([]);
setPrepareMessage('Select a working directory to validate the launch environment.'); setPrepareMessage('Select a working directory to validate the launch environment.');
return; return;
} }
@ -371,31 +521,78 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
let cancelled = false; let cancelled = false;
const requestSeq = ++prepareRequestSeqRef.current; const requestSeq = ++prepareRequestSeqRef.current;
setPrepareState('loading'); setPrepareState('loading');
setPrepareMessage('Warming up CLI environment...'); setPrepareMessage('Checking selected providers...');
setPrepareWarnings([]); setPrepareWarnings([]);
setPrepareChecks(createInitialProviderChecks(selectedMemberProviders));
void (async () => { void (async () => {
let checks = createInitialProviderChecks(selectedMemberProviders);
let anyFailure = false;
let anyNotes = false;
const collectedWarnings: string[] = [];
try { try {
const prepResult: TeamProvisioningPrepareResult = for (const providerId of selectedMemberProviders) {
await api.teams.prepareProvisioning(effectiveCwd); 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; if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
setPrepareState(prepResult.ready ? 'ready' : 'failed'); setPrepareState(anyFailure ? 'failed' : 'ready');
setPrepareMessage(prepResult.message); setPrepareMessage(
setPrepareWarnings(prepResult.warnings ?? []); anyFailure
? 'Some selected providers need attention.'
: anyNotes
? 'Selected providers are ready with notes.'
: 'Selected providers are ready.'
);
setPrepareWarnings(collectedWarnings);
} catch (error) { } catch (error) {
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return; if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
const failureMessage =
error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment';
setPrepareState('failed'); setPrepareState('failed');
setPrepareWarnings([]); setPrepareWarnings([]);
setPrepareMessage( setPrepareChecks(failIncompleteProviderChecks(checks, failureMessage));
error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment' setPrepareMessage(failureMessage);
);
} }
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [open, isLaunch, effectiveCwd]); }, [open, isLaunch, effectiveCwd, selectedProviderId, selectedMemberProviders]);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Shared effects: projects // 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) // Mention suggestions (shared — from props in launch, from store in schedule)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const storeMembers = useStore((s) => s.selectedTeamData?.members ?? []); const { suggestions: taskSuggestions } = useTaskSuggestions(null);
const members = isLaunch ? props.members : storeMembers; const { suggestions: teamMentionSuggestions } = useTeamSuggestions(null);
const memberColorMap = useMemo(
const colorMap = useMemo(() => buildMemberColorMap(members), [members]); () => buildMemberDraftColorMap(membersDrafts, members),
[membersDrafts, members]
);
const mentionSuggestions = useMemo<MentionSuggestion[]>( const mentionSuggestions = useMemo<MentionSuggestion[]>(
() => () => buildMemberDraftSuggestions(membersDrafts, memberColorMap),
members.map((m) => ({ [memberColorMap, membersDrafts]
id: m.name,
name: m.name,
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
color: colorMap.get(m.name),
})),
[members, colorMap]
); );
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -516,21 +709,30 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
args.push('--verbose', '--setting-sources', 'user,project,local'); args.push('--verbose', '--setting-sources', 'user,project,local');
args.push('--mcp-config', '<auto>', '--disallowedTools', 'TeamDelete,TodoWrite'); args.push('--mcp-config', '<auto>', '--disallowedTools', 'TeamDelete,TodoWrite');
if (skipPermissions) args.push('--dangerously-skip-permissions'); 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 (model) args.push('--model', model);
if (selectedEffort) args.push('--effort', selectedEffort); if (selectedEffort) args.push('--effort', selectedEffort);
if (!clearContext) args.push('--resume', '<previous>'); if (!clearContext) args.push('--resume', '<previous>');
return args; return args;
}, [isLaunch, skipPermissions, selectedModel, limitContext, selectedEffort, clearContext]); }, [
isLaunch,
skipPermissions,
selectedModel,
limitContext,
selectedEffort,
clearContext,
selectedProviderId,
]);
const launchOptionalSummary = useMemo(() => { const launchOptionalSummary = useMemo(() => {
if (!isLaunch) return []; if (!isLaunch) return [];
const summary: string[] = []; const summary: string[] = [];
if (promptDraft.value.trim()) summary.push('Lead prompt'); if (promptDraft.value.trim()) summary.push('Lead prompt');
summary.push(`Provider: ${getProviderLabel(selectedProviderId)}`);
if (selectedModel) summary.push(`Model: ${selectedModel}`); if (selectedModel) summary.push(`Model: ${selectedModel}`);
if (selectedEffort) summary.push(`Effort: ${selectedEffort}`); 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 (skipPermissions) summary.push('Auto-approve tools');
if (clearContext) summary.push('Fresh session'); if (clearContext) summary.push('Fresh session');
if (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`); if (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`);
@ -540,6 +742,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
isLaunch, isLaunch,
promptDraft.value, promptDraft.value,
selectedModel, selectedModel,
selectedProviderId,
selectedEffort, selectedEffort,
limitContext, limitContext,
skipPermissions, skipPermissions,
@ -584,17 +787,39 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
setLocalError('Select working directory (cwd)'); setLocalError('Select working directory (cwd)');
return; 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); setLocalError(null);
setIsSubmitting(true); setIsSubmitting(true);
void (async () => { void (async () => {
try { try {
if (isLaunch) { if (isLaunch) {
await api.teams.replaceMembers(effectiveTeamName, {
members: buildMembersFromDrafts(effectiveMemberDrafts),
});
await props.onLaunch({ await props.onLaunch({
teamName: effectiveTeamName, teamName: effectiveTeamName,
cwd: effectiveCwd, cwd: effectiveCwd,
prompt: promptDraft.value.trim() || undefined, prompt: promptDraft.value.trim() || undefined,
model: computeEffectiveTeamModel(selectedModel, limitContext), providerId: selectedProviderId,
model: computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId),
effort: (selectedEffort as EffortLevel) || undefined, effort: (selectedEffort as EffortLevel) || undefined,
limitContext, limitContext,
clearContext: clearContext || undefined, clearContext: clearContext || undefined,
@ -610,6 +835,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
const launchConfig: ScheduleLaunchConfig = { const launchConfig: ScheduleLaunchConfig = {
cwd: effectiveCwd, cwd: effectiveCwd,
prompt: promptDraft.value.trim(), prompt: promptDraft.value.trim(),
providerId: selectedProviderId,
model: selectedModel || undefined, model: selectedModel || undefined,
effort: (selectedEffort as EffortLevel) || undefined, effort: (selectedEffort as EffortLevel) || undefined,
skipPermissions, 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" /> <AlertTriangle className="mt-0.5 size-4 shrink-0 text-red-400" />
<div className="min-w-0 space-y-1"> <div className="min-w-0 space-y-1">
<p className="font-medium text-red-300"> <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>
<p className="text-red-300/80"> <p className="text-red-300/80">
{prepareMessage ?? 'Failed to prepare environment'} {prepareMessage ?? 'Failed to prepare environment'}
</p> </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"> <div className="space-y-0.5">
{prepareWarnings.map((warning) => ( {prepareWarnings.map((warning) => (
<p <p
@ -768,8 +1001,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
) : null} ) : null}
<div className="flex items-center gap-2 pt-1"> <div className="flex items-center gap-2 pt-1">
<p className="text-[11px] text-[var(--color-text-muted)]"> <p className="text-[11px] text-[var(--color-text-muted)]">
Install Claude CLI from the Dashboard, then reopen this dialog. {getProvisioningFailureHint(prepareMessage, prepareChecks)}
</p> </p>
{(prepareMessage ?? '').toLowerCase().includes('spawn ') ||
prepareChecks.some((check) =>
check.details.some((detail) => detail.toLowerCase().includes('spawn '))
) ? (
<button <button
type="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" className="shrink-0 rounded bg-blue-600 px-2 py-0.5 text-[11px] font-medium text-white transition-colors hover:bg-blue-500"
@ -780,6 +1017,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
> >
Go to Dashboard Go to Dashboard
</button> </button>
) : null}
</div> </div>
</div> </div>
</div> </div>
@ -922,6 +1160,38 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
projectsError={projectsError} 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 Launch: optional settings
Schedule: prompt + execution defaults Schedule: prompt + execution defaults
@ -959,22 +1229,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
</div> </div>
<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 <SkipPermissionsCheckbox
id="dialog-skip-permissions" id="dialog-skip-permissions"
checked={skipPermissions} checked={skipPermissions}
@ -1061,6 +1315,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
<div> <div>
<TeamModelSelector <TeamModelSelector
providerId={selectedProviderId}
onProviderChange={setSelectedProviderId}
value={selectedModel} value={selectedModel}
onValueChange={setSelectedModel} onValueChange={setSelectedModel}
id="dialog-model" id="dialog-model"
@ -1137,6 +1393,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
{isLaunch ? ( {isLaunch ? (
<div className="min-w-0"> <div className="min-w-0">
{prepareState === 'idle' || prepareState === 'loading' ? ( {prepareState === 'idle' || prepareState === 'loading' ? (
<>
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]"> <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" /> <span className="inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
<div> <div>
@ -1158,6 +1415,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
</p> </p>
</div> </div>
</div> </div>
<ProvisioningProviderStatusList checks={prepareChecks} className="mt-2" />
</>
) : null} ) : null}
{prepareState === 'ready' ? ( {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"> <div className="flex items-center gap-1.5 text-xs font-medium text-emerald-400">
<CheckCircle2 className="size-3.5 shrink-0" /> <CheckCircle2 className="size-3.5 shrink-0" />
<span> <span>
{prepareWarnings.length > 0 {prepareChecks.some((check) => check.status === 'notes') ||
prepareWarnings.length > 0
? 'CLI environment ready (with notes)' ? 'CLI environment ready (with notes)'
: 'CLI environment ready'} : 'CLI environment ready'}
</span> </span>
@ -1175,7 +1435,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
{prepareMessage} {prepareMessage}
</p> </p>
) : null} ) : 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"> <div className="mt-0.5 space-y-0.5 pl-5">
{prepareWarnings.map((warning) => ( {prepareWarnings.map((warning) => (
<p key={warning} className="text-[11px] text-sky-300"> <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 { Label } from '@renderer/components/ui/label';
import { import {
@ -8,6 +8,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from '@renderer/components/ui/tooltip'; } from '@renderer/components/ui/tooltip';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { Check, ChevronDown, Info } from 'lucide-react'; import { Check, ChevronDown, Info } from 'lucide-react';
// --- Provider SVG Icons (real brand logos from Simple Icons, monochrome currentColor) --- // --- Provider SVG Icons (real brand logos from Simple Icons, monochrome currentColor) ---
@ -26,38 +27,9 @@ const OpenAIIcon: React.FC<{ className?: string }> = ({ className }) => (
</svg> </svg>
); );
/** Google Gemini — official sparkle/star mark (Simple Icons) */ const GoogleGeminiIcon: React.FC<{ className?: string }> = ({ className }) => (
const GoogleIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg viewBox="0 0 24 24" fill="currentColor" className={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" /> <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>
);
/** 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"
/>
</svg> </svg>
); );
@ -72,22 +44,88 @@ interface ProviderDef {
const PROVIDERS: ProviderDef[] = [ const PROVIDERS: ProviderDef[] = [
{ id: 'anthropic', label: 'Anthropic', icon: AnthropicIcon, comingSoon: false }, { id: 'anthropic', label: 'Anthropic', icon: AnthropicIcon, comingSoon: false },
{ id: 'openai', label: 'OpenAI', icon: OpenAIIcon, comingSoon: true }, { id: 'codex', label: 'Codex', icon: OpenAIIcon, comingSoon: false },
{ id: 'google', label: 'Google', icon: GoogleIcon, comingSoon: true }, { id: 'gemini', label: 'Gemini', icon: GoogleGeminiIcon, comingSoon: false },
{ id: 'local', label: 'Local', icon: LocalIcon, comingSoon: true },
]; ];
const ACTIVE_PROVIDER = PROVIDERS[0]; const ANTHROPIC_MODEL_OPTIONS = [
// --- Model options (Anthropic only for now) ---
const MODEL_OPTIONS = [
{ value: '', label: 'Default' }, { value: '', label: 'Default' },
{ value: 'opus', label: 'Opus 4.6' }, { value: 'opus', label: 'Opus 4.6' },
{ value: 'sonnet', label: 'Sonnet 4.6' }, { value: 'sonnet', label: 'Sonnet 4.6' },
{ value: 'haiku', label: 'Haiku 4.5' }, { value: 'haiku', label: 'Haiku 4.5' },
] as const; ] 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. * Computes the effective model string for team provisioning.
* By default adds [1m] suffix for 1M context (Opus/Sonnet). * By default adds [1m] suffix for 1M context (Opus/Sonnet).
@ -96,25 +134,33 @@ const MODEL_OPTIONS = [
*/ */
export function computeEffectiveTeamModel( export function computeEffectiveTeamModel(
selectedModel: string, selectedModel: string,
limitContext: boolean limitContext: boolean,
providerId: 'anthropic' | 'codex' | 'gemini' = 'anthropic'
): string | undefined { ): string | undefined {
const base = selectedModel || undefined; const base = selectedModel || undefined;
if (providerId !== 'anthropic') return base;
if (limitContext) return base; if (limitContext) return base;
if (base === 'haiku') return base; if (base === 'haiku') return base;
return base ? `${base}[1m]` : 'opus[1m]'; return base ? `${base}[1m]` : 'opus[1m]';
} }
export interface TeamModelSelectorProps { export interface TeamModelSelectorProps {
providerId: 'anthropic' | 'codex' | 'gemini';
onProviderChange: (providerId: 'anthropic' | 'codex' | 'gemini') => void;
value: string; value: string;
onValueChange: (value: string) => void; onValueChange: (value: string) => void;
id?: string; id?: string;
} }
export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
providerId,
onProviderChange,
value, value,
onValueChange, onValueChange,
id, id,
}) => { }) => {
const cliStatus = useStore((s) => s.cliStatus);
const multimodelAvailable = cliStatus?.flavor === 'free-code';
const [dropdownOpen, setDropdownOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@ -132,29 +178,55 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, [dropdownOpen]); }, [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 ( return (
<div className="mb-5"> <div className="mb-5">
<Label htmlFor={id} className="label-optional mb-1.5 block"> <Label htmlFor={id} className="label-optional mb-1.5 block">
Model (optional) Model (optional)
</Label> </Label>
<div ref={containerRef} className="relative inline-block"> <div ref={containerRef} className="relative space-y-2">
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5"> <div className="relative inline-flex">
{/* Provider button */}
<button <button
type="button" type="button"
className={cn( className={cn(
'flex items-center gap-1.5 rounded-[3px] px-2.5 py-1 text-xs font-medium transition-colors', 'flex min-w-[170px] items-center justify-between gap-2 rounded-md border px-3 py-2 text-xs font-medium transition-colors',
'mr-0.5 border-r border-[var(--color-border)] pr-2.5',
dropdownOpen dropdownOpen
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]' ? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
: 'text-[var(--color-text-secondary)] hover: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)} onClick={() => setDropdownOpen(!dropdownOpen)}
> >
<span className="flex items-center gap-2">
<ProviderIcon className="size-3.5" /> <ProviderIcon className="size-3.5" />
<span>{ACTIVE_PROVIDER.label}</span> <span>{activeProvider.label}</span>
</span>
<ChevronDown <ChevronDown
className={cn( className={cn(
'size-3 transition-transform duration-200', 'size-3 transition-transform duration-200',
@ -163,43 +235,10 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
/> />
</button> </button>
{/* Model pills */}
{MODEL_OPTIONS.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',
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)]'
)}
onClick={() => onValueChange(opt.value)}
>
{opt.label}
{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" />
</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.
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</button>
))}
</div>
{/* Provider dropdown */} {/* Provider dropdown */}
{dropdownOpen && ( {dropdownOpen && (
<div <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" 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={{ style={{
backgroundColor: 'var(--color-surface-raised)', backgroundColor: 'var(--color-surface-raised)',
borderColor: 'var(--color-border-subtle)', borderColor: 'var(--color-border-subtle)',
@ -207,7 +246,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
> >
{PROVIDERS.map((provider, index) => { {PROVIDERS.map((provider, index) => {
const Icon = provider.icon; const Icon = provider.icon;
const isActive = provider.id === ACTIVE_PROVIDER.id; const isActive = provider.id === activeProvider.id;
const isFirst = index === 0; const isFirst = index === 0;
const prevWasActive = index > 0 && !PROVIDERS[index - 1].comingSoon; const prevWasActive = index > 0 && !PROVIDERS[index - 1].comingSoon;
@ -221,20 +260,25 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
)} )}
<button <button
type="button" type="button"
disabled={provider.comingSoon} disabled={provider.comingSoon || !isProviderSelectable(provider.id)}
onClick={() => { onClick={() => {
if (!provider.comingSoon) { if (!provider.comingSoon && isProviderSelectable(provider.id)) {
onProviderChange(provider.id as 'anthropic' | 'codex' | 'gemini');
setDropdownOpen(false); setDropdownOpen(false);
} }
}} }}
className={cn( className={cn(
'flex w-full items-center gap-2.5 px-3 py-2 text-left text-xs transition-colors duration-100', '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', isActive && 'bg-indigo-500/10 text-indigo-400',
provider.comingSoon && 'cursor-not-allowed opacity-40', (provider.comingSoon || !isProviderSelectable(provider.id)) &&
!isActive && !provider.comingSoon && 'hover:bg-white/5' 'cursor-not-allowed opacity-40',
!isActive &&
!provider.comingSoon &&
isProviderSelectable(provider.id) &&
'hover:bg-white/5'
)} )}
style={ style={
!isActive && !provider.comingSoon !isActive && !provider.comingSoon && isProviderSelectable(provider.id)
? { color: 'var(--color-text-secondary)' } ? { color: 'var(--color-text-secondary)' }
: undefined : undefined
} }
@ -246,6 +290,11 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
Coming Soon Coming Soon
</span> </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" />} {isActive && <Check className="size-3.5 shrink-0" />}
</button> </button>
</React.Fragment> </React.Fragment>
@ -254,6 +303,56 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
</div> </div>
)} )}
</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 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)]',
!activeProviderSelectable && 'cursor-not-allowed opacity-45'
)}
style={{
borderColor: value === opt.value ? 'var(--color-border-emphasis)' : 'transparent',
}}
disabled={!activeProviderSelectable}
onClick={() => {
if (!activeProviderSelectable) return;
onValueChange(opt.value);
}}
>
<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 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 />
Uses the runtime default for the selected provider.
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</button>
))}
</div>
</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 { interface MemberCardProps {
member: ResolvedTeamMember; member: ResolvedTeamMember;
memberColor: string; memberColor: string;
runtimeSummary?: string;
taskCounts?: TaskStatusCounts | null; taskCounts?: TaskStatusCounts | null;
isTeamAlive?: boolean; isTeamAlive?: boolean;
isTeamProvisioning?: boolean; isTeamProvisioning?: boolean;
@ -46,6 +47,7 @@ interface MemberCardProps {
export const MemberCard = ({ export const MemberCard = ({
member, member,
memberColor, memberColor,
runtimeSummary,
taskCounts, taskCounts,
isTeamAlive, isTeamAlive,
isTeamProvisioning, isTeamProvisioning,
@ -131,7 +133,8 @@ export const MemberCard = ({
aria-label={presenceLabel} aria-label={presenceLabel}
/> />
</div> </div>
<div className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-sm"> <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)]"> <span className="shrink-0 font-medium text-[var(--color-text)]">
{displayMemberName(member.name)} {displayMemberName(member.name)}
</span> </span>
@ -169,6 +172,12 @@ export const MemberCard = ({
</> </>
) : null} ) : null}
</div> </div>
{runtimeSummary ? (
<div className="mt-0.5 text-[10px] font-medium text-[var(--color-text-muted)]">
{runtimeSummary}
</div>
) : null}
</div>
{(() => { {(() => {
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType); const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
return roleLabel ? ( return roleLabel ? (

View file

@ -1,6 +1,10 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; 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 { RoleSelect } from '@renderer/components/team/RoleSelect';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Input } from '@renderer/components/ui/input'; 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 { MemberDraft } from './membersEditorTypes';
import type { InlineChip } from '@renderer/types/inlineChip'; import type { InlineChip } from '@renderer/types/inlineChip';
import type { MentionSuggestion } from '@renderer/types/mention'; import type { MentionSuggestion } from '@renderer/types/mention';
import type { EffortLevel, TeamProviderId } from '@shared/types';
interface MemberDraftRowProps { interface MemberDraftRowProps {
member: MemberDraft; member: MemberDraft;
@ -29,11 +34,20 @@ interface MemberDraftRowProps {
showWorkflow?: boolean; showWorkflow?: boolean;
onWorkflowChange?: (id: string, workflow: string) => void; onWorkflowChange?: (id: string, workflow: string) => void;
onWorkflowChipsChange?: (id: string, chips: InlineChip[]) => 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; draftKeyPrefix?: string;
projectPath?: string | null; projectPath?: string | null;
mentionSuggestions?: MentionSuggestion[]; mentionSuggestions?: MentionSuggestion[];
taskSuggestions?: MentionSuggestion[]; taskSuggestions?: MentionSuggestion[];
teamSuggestions?: MentionSuggestion[]; teamSuggestions?: MentionSuggestion[];
lockProviderModel?: boolean;
forceInheritedModelSettings?: boolean;
modelLockReason?: string;
} }
export const MemberDraftRow = ({ export const MemberDraftRow = ({
@ -48,11 +62,20 @@ export const MemberDraftRow = ({
showWorkflow = false, showWorkflow = false,
onWorkflowChange, onWorkflowChange,
onWorkflowChipsChange, onWorkflowChipsChange,
onProviderChange,
onModelChange,
onEffortChange,
inheritedProviderId = 'anthropic',
inheritedModel = '',
inheritedEffort,
draftKeyPrefix, draftKeyPrefix,
projectPath, projectPath,
mentionSuggestions = [], mentionSuggestions = [],
taskSuggestions, taskSuggestions,
teamSuggestions, teamSuggestions,
lockProviderModel = false,
forceInheritedModelSettings = false,
modelLockReason,
}: MemberDraftRowProps): React.JSX.Element => { }: MemberDraftRowProps): React.JSX.Element => {
const { isLight } = useTheme(); const { isLight } = useTheme();
const memberColorSet = getTeamColorSet( const memberColorSet = getTeamColorSet(
@ -122,10 +145,22 @@ export const MemberDraftRow = ({
const suggestionsExcludingSelf = mentionSuggestions.filter( const suggestionsExcludingSelf = mentionSuggestions.filter(
(s) => s.name.toLowerCase() !== member.name.trim().toLowerCase() (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 ( return (
<div <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={{ style={{
backgroundColor: isLight backgroundColor: isLight
? 'color-mix(in srgb, var(--color-surface-raised) 22%, white 78%)' ? 'color-mix(in srgb, var(--color-surface-raised) 22%, white 78%)'
@ -165,7 +200,8 @@ export const MemberDraftRow = ({
inputClassName="h-8 text-xs" inputClassName="h-8 text-xs"
/> />
</div> </div>
<div className="flex flex-col gap-2 sm:flex-row"> <div className="space-y-1">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start">
{showWorkflow && onWorkflowChange ? ( {showWorkflow && onWorkflowChange ? (
<Button <Button
variant="outline" variant="outline"
@ -184,10 +220,13 @@ export const MemberDraftRow = ({
) : null} ) : null}
</Button> </Button>
) : null} ) : null}
<div className="min-w-0 space-y-1">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="h-8 shrink-0 gap-1" 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)} onClick={() => setModelExpanded((prev) => !prev)}
> >
{modelExpanded ? ( {modelExpanded ? (
@ -195,8 +234,9 @@ export const MemberDraftRow = ({
) : ( ) : (
<ChevronRight className="size-3.5" /> <ChevronRight className="size-3.5" />
)} )}
Model <span className="truncate">Model: {modelButtonLabel}</span>
</Button> </Button>
</div>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@ -208,6 +248,7 @@ export const MemberDraftRow = ({
<Trash2 className="size-3.5" /> <Trash2 className="size-3.5" />
</Button> </Button>
</div> </div>
</div>
{showWorkflow && onWorkflowChange && workflowExpanded ? ( {showWorkflow && onWorkflowChange && workflowExpanded ? (
<div className="space-y-0.5 md:col-span-3"> <div className="space-y-0.5 md:col-span-3">
<label <label
@ -241,14 +282,38 @@ export const MemberDraftRow = ({
) : null} ) : null}
{modelExpanded && ( {modelExpanded && (
<div className="space-y-2 md:col-span-3"> <div className="space-y-2 md:col-span-3">
<div className="pointer-events-none opacity-40"> <TeamModelSelector
<TeamModelSelector value="" onValueChange={() => {}} /> providerId={effectiveProviderId}
</div> 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"> <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" /> <Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />
<p className="text-[11px] leading-relaxed text-sky-300"> <p className="text-[11px] leading-relaxed text-sky-300">
Claude Code doesn&apos;t support per-member model selection yet &mdash; all teammates If this teammate uses a different provider than the lead, they will be started in a
inherit the team launch model. We plan to solve this via a local proxy. separate process automatically.
</p> </p>
</div> </div>
</div> </div>

View file

@ -1,5 +1,11 @@
import { useCallback, useEffect, useRef, useState } from 'react'; 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 { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
@ -27,6 +33,7 @@ interface MemberListProps {
isTeamAlive?: boolean; isTeamAlive?: boolean;
isTeamProvisioning?: boolean; isTeamProvisioning?: boolean;
leadActivity?: LeadActivityState; leadActivity?: LeadActivityState;
launchParams?: TeamLaunchParams;
onMemberClick?: (member: ResolvedTeamMember) => void; onMemberClick?: (member: ResolvedTeamMember) => void;
onSendMessage?: (member: ResolvedTeamMember) => void; onSendMessage?: (member: ResolvedTeamMember) => void;
onAssignTask?: (member: ResolvedTeamMember) => void; onAssignTask?: (member: ResolvedTeamMember) => void;
@ -42,6 +49,7 @@ export const MemberList = ({
isTeamAlive, isTeamAlive,
isTeamProvisioning, isTeamProvisioning,
leadActivity, leadActivity,
launchParams,
onMemberClick, onMemberClick,
onSendMessage, onSendMessage,
onAssignTask, onAssignTask,
@ -77,6 +85,45 @@ export const MemberList = ({
const removedMembers = members.filter((m) => m.removedAt); const removedMembers = members.filter((m) => m.removedAt);
const colorMap = buildMemberColorMap(members); 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) { if (members.length === 0) {
return ( return (
<div className="rounded-md border border-[var(--color-border)] p-4 text-sm text-[var(--color-text-muted)]"> <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} reviewTask={isRemoved ? null : reviewTask}
isAwaitingReply={isRemoved ? false : awaitingReply} isAwaitingReply={isRemoved ? false : awaitingReply}
isRemoved={isRemoved} isRemoved={isRemoved}
runtimeSummary={isRemoved ? buildRuntimeSummary(member) : buildRuntimeSummary(member)}
spawnStatus={isRemoved ? undefined : spawnEntry?.status} spawnStatus={isRemoved ? undefined : spawnEntry?.status}
spawnError={isRemoved ? undefined : spawnEntry?.error} spawnError={isRemoved ? undefined : spawnEntry?.error}
onOpenTask={!isRemoved && currentTask ? () => onOpenTask?.(currentTask) : undefined} onOpenTask={!isRemoved && currentTask ? () => onOpenTask?.(currentTask) : undefined}

View file

@ -20,6 +20,7 @@ import {
import type { MemberDraft } from './membersEditorTypes'; import type { MemberDraft } from './membersEditorTypes';
import type { InlineChip } from '@renderer/types/inlineChip'; import type { InlineChip } from '@renderer/types/inlineChip';
import type { MentionSuggestion } from '@renderer/types/mention'; import type { MentionSuggestion } from '@renderer/types/mention';
import type { EffortLevel, TeamProviderId } from '@shared/types';
function membersToJsonText(drafts: MemberDraft[]): string { function membersToJsonText(drafts: MemberDraft[]): string {
const arr = drafts const arr = drafts
@ -30,6 +31,9 @@ function membersToJsonText(drafts: MemberDraft[]): string {
if (role) obj.role = role; if (role) obj.role = role;
const workflow = getWorkflowForExport(d); const workflow = getWorkflowForExport(d);
if (workflow) obj.workflow = workflow; 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 obj;
}); });
return JSON.stringify(arr, null, 2); return JSON.stringify(arr, null, 2);
@ -42,6 +46,13 @@ function parseJsonToDrafts(text: string): MemberDraft[] {
const name = typeof item.name === 'string' ? item.name : ''; const name = typeof item.name === 'string' ? item.name : '';
const role = typeof item.role === 'string' ? item.role.trim() : ''; const role = typeof item.role === 'string' ? item.role.trim() : '';
const workflow = typeof item.workflow === 'string' ? item.workflow.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 presetRoles: readonly string[] = PRESET_ROLES;
const isPreset = presetRoles.includes(role); const isPreset = presetRoles.includes(role);
return createMemberDraft({ return createMemberDraft({
@ -49,6 +60,9 @@ function parseJsonToDrafts(text: string): MemberDraft[] {
roleSelection: role ? (isPreset ? role : CUSTOM_ROLE) : '', roleSelection: role ? (isPreset ? role : CUSTOM_ROLE) : '',
customRole: role && !isPreset ? role : '', customRole: role && !isPreset ? role : '',
workflow: workflow || undefined, workflow: workflow || undefined,
providerId,
model,
effort,
}); });
}); });
} }
@ -74,6 +88,16 @@ export interface MembersEditorSectionProps {
hideContent?: boolean; hideContent?: boolean;
/** Existing team members — used to reserve their colors so drafts get the next available ones */ /** 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 }[]; 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 = ({ export const MembersEditorSection = ({
@ -90,6 +114,14 @@ export const MembersEditorSection = ({
headerExtra, headerExtra,
hideContent = false, hideContent = false,
existingMembers, existingMembers,
defaultProviderId = 'anthropic',
lockProviderModel = false,
inheritedProviderId,
inheritedModel,
inheritedEffort,
inheritModelSettingsByDefault = false,
forceInheritedModelSettings = false,
modelLockReason,
}: MembersEditorSectionProps): React.JSX.Element => { }: MembersEditorSectionProps): React.JSX.Element => {
const [jsonEditorOpen, setJsonEditorOpen] = useState(false); const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
const [jsonText, setJsonText] = useState(''); const [jsonText, setJsonText] = useState('');
@ -150,13 +182,52 @@ export const MembersEditorSection = ({
onChange(members.map((c) => (c.id === memberId ? { ...c, workflowChips } : c))); 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 => { const removeMember = (memberId: string): void => {
onChange(members.filter((c) => c.id !== memberId)); onChange(members.filter((c) => c.id !== memberId));
}; };
const addMember = (): void => { const addMember = (): void => {
const suggestedName = getNextSuggestedMemberName(members.map((member) => member.name)); 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); const names = members.map((m) => m.name.trim().toLowerCase()).filter(Boolean);
@ -207,11 +278,20 @@ export const MembersEditorSection = ({
showWorkflow={showWorkflow} showWorkflow={showWorkflow}
onWorkflowChange={showWorkflow ? updateMemberWorkflow : undefined} onWorkflowChange={showWorkflow ? updateMemberWorkflow : undefined}
onWorkflowChipsChange={showWorkflow ? updateMemberWorkflowChips : undefined} onWorkflowChipsChange={showWorkflow ? updateMemberWorkflowChips : undefined}
onProviderChange={updateMemberProvider}
onModelChange={updateMemberModel}
onEffortChange={updateMemberEffort}
inheritedProviderId={inheritedProviderId}
inheritedModel={inheritedModel}
inheritedEffort={inheritedEffort}
forceInheritedModelSettings={forceInheritedModelSettings}
draftKeyPrefix={draftKeyPrefix} draftKeyPrefix={draftKeyPrefix}
projectPath={projectPath} projectPath={projectPath}
mentionSuggestions={mentionSuggestions} mentionSuggestions={mentionSuggestions}
taskSuggestions={taskSuggestions} taskSuggestions={taskSuggestions}
teamSuggestions={teamSuggestions} teamSuggestions={teamSuggestions}
lockProviderModel={lockProviderModel}
modelLockReason={modelLockReason}
/> />
))} ))}
{jsonEditorOpen && showJsonEditor ? ( {jsonEditorOpen && showJsonEditor ? (
@ -243,7 +323,9 @@ export {
buildMemberDraftColorMap, buildMemberDraftColorMap,
buildMemberDraftSuggestions, buildMemberDraftSuggestions,
buildMembersFromDrafts, buildMembersFromDrafts,
clearMemberModelOverrides,
createMemberDraft, createMemberDraft,
createMemberDraftsFromInputs,
getMemberDraftRole, getMemberDraftRole,
validateMemberNameInline, validateMemberNameInline,
} from './membersEditorUtils'; } 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 { InlineChip } from '@renderer/types/inlineChip';
import type { EffortLevel, TeamProviderId } from '@shared/types';
export interface MemberDraft { export interface MemberDraft {
id: string; id: string;
@ -7,6 +8,9 @@ export interface MemberDraft {
customRole: string; customRole: string;
workflow?: string; workflow?: string;
workflowChips?: InlineChip[]; workflowChips?: InlineChip[];
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
} }
export interface MembersEditorValue { 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 { serializeChipsWithText } from '@renderer/types/inlineChip';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import type { MemberDraft } from './membersEditorTypes'; import type { MemberDraft } from './membersEditorTypes';
import type { MentionSuggestion } from '@renderer/types/mention'; 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 { function isValidMemberName(name: string): boolean {
if (name.length < 1 || name.length > 128) return false; if (name.length < 1 || name.length > 128) return false;
@ -33,9 +33,60 @@ export function createMemberDraft(initial?: Partial<MemberDraft>): MemberDraft {
roleSelection: initial?.roleSelection ?? '', roleSelection: initial?.roleSelection ?? '',
customRole: initial?.customRole ?? '', customRole: initial?.customRole ?? '',
workflow: initial?.workflow, 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 { interface ExistingMemberColorInput {
name: string; name: string;
color?: string; color?: string;
@ -113,6 +164,21 @@ export function buildMembersFromDrafts(members: MemberDraft[]): TeamProvisioning
const result: TeamProvisioningMemberInput = { name, role }; const result: TeamProvisioningMemberInput = { name, role };
const workflow = getWorkflowForExport(member); const workflow = getWorkflowForExport(member);
if (workflow) result.workflow = workflow; 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; return result;
}) })
.filter((member): member is NonNullable<typeof member> => member !== null); .filter((member): member is NonNullable<typeof member> => member !== null);

View file

@ -33,6 +33,8 @@ export interface UseCreateTeamDraftResult {
setTeamName: (v: string) => void; setTeamName: (v: string) => void;
members: MemberDraft[]; members: MemberDraft[];
setMembers: (v: MemberDraft[]) => void; setMembers: (v: MemberDraft[]) => void;
syncModelsWithLead: boolean;
setSyncModelsWithLead: (v: boolean) => void;
cwdMode: 'project' | 'custom'; cwdMode: 'project' | 'custom';
setCwdMode: (v: 'project' | 'custom') => void; setCwdMode: (v: 'project' | 'custom') => void;
selectedProjectPath: string; selectedProjectPath: string;
@ -63,13 +65,18 @@ const DEBOUNCE_MS = 400;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function serializeMembers(members: MemberDraft[]): SerializedMemberDraft[] { function serializeMembers(members: MemberDraft[]): SerializedMemberDraft[] {
return members.map(({ id, name, roleSelection, customRole, workflow }) => ({ return members.map(
({ id, name, roleSelection, customRole, workflow, providerId, model, effort }) => ({
id, id,
name, name,
roleSelection, roleSelection,
customRole, customRole,
workflow, workflow,
})); providerId,
model,
effort,
})
);
} }
function deserializeMembers(serialized: SerializedMemberDraft[]): MemberDraft[] { function deserializeMembers(serialized: SerializedMemberDraft[]): MemberDraft[] {
@ -80,6 +87,9 @@ function deserializeMembers(serialized: SerializedMemberDraft[]): MemberDraft[]
roleSelection: m.roleSelection, roleSelection: m.roleSelection,
customRole: m.customRole, customRole: m.customRole,
workflow: m.workflow, workflow: m.workflow,
providerId: m.providerId,
model: m.model,
effort: m.effort,
}) })
); );
} }
@ -92,6 +102,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
// ── State ────────────────────────────────────────────────────────────── // ── State ──────────────────────────────────────────────────────────────
const [teamName, setTeamNameState] = useState(''); const [teamName, setTeamNameState] = useState('');
const [members, setMembersState] = useState<MemberDraft[]>([]); const [members, setMembersState] = useState<MemberDraft[]>([]);
const [syncModelsWithLead, setSyncModelsWithLeadState] = useState(true);
const [cwdMode, setCwdModeState] = useState<'project' | 'custom'>('project'); const [cwdMode, setCwdModeState] = useState<'project' | 'custom'>('project');
const [selectedProjectPath, setSelectedProjectPathState] = useState(''); const [selectedProjectPath, setSelectedProjectPathState] = useState('');
const [customCwd, setCustomCwdState] = useState(''); const [customCwd, setCustomCwdState] = useState('');
@ -103,6 +114,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
// ── Refs (latest values for debounced callbacks) ─────────────────────── // ── Refs (latest values for debounced callbacks) ───────────────────────
const teamNameRef = useRef(''); const teamNameRef = useRef('');
const membersRef = useRef<MemberDraft[]>([]); const membersRef = useRef<MemberDraft[]>([]);
const syncModelsWithLeadRef = useRef(true);
const cwdModeRef = useRef<'project' | 'custom'>('project'); const cwdModeRef = useRef<'project' | 'custom'>('project');
const selectedProjectPathRef = useRef(''); const selectedProjectPathRef = useRef('');
const customCwdRef = useRef(''); const customCwdRef = useRef('');
@ -128,6 +140,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
version: 1, version: 1,
teamName: teamNameRef.current, teamName: teamNameRef.current,
members: serializeMembers(membersRef.current), members: serializeMembers(membersRef.current),
syncModelsWithLead: syncModelsWithLeadRef.current,
cwdMode: cwdModeRef.current, cwdMode: cwdModeRef.current,
selectedProjectPath: selectedProjectPathRef.current, selectedProjectPath: selectedProjectPathRef.current,
customCwd: customCwdRef.current, customCwd: customCwdRef.current,
@ -187,6 +200,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
teamNameRef.current = snap.teamName; teamNameRef.current = snap.teamName;
membersRef.current = deserialized; membersRef.current = deserialized;
syncModelsWithLeadRef.current = snap.syncModelsWithLead ?? true;
cwdModeRef.current = snap.cwdMode; cwdModeRef.current = snap.cwdMode;
selectedProjectPathRef.current = snap.selectedProjectPath; selectedProjectPathRef.current = snap.selectedProjectPath;
customCwdRef.current = snap.customCwd; customCwdRef.current = snap.customCwd;
@ -196,6 +210,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
setTeamNameState(snap.teamName); setTeamNameState(snap.teamName);
setMembersState(deserialized); setMembersState(deserialized);
setSyncModelsWithLeadState(snap.syncModelsWithLead ?? true);
setCwdModeState(snap.cwdMode); setCwdModeState(snap.cwdMode);
setSelectedProjectPathState(snap.selectedProjectPath); setSelectedProjectPathState(snap.selectedProjectPath);
setCustomCwdState(snap.customCwd); setCustomCwdState(snap.customCwd);
@ -260,6 +275,16 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
[scheduleSave] [scheduleSave]
); );
const setSyncModelsWithLead = useCallback(
(v: boolean) => {
userTouchedRef.current = true;
syncModelsWithLeadRef.current = v;
setSyncModelsWithLeadState(v);
scheduleSave();
},
[scheduleSave]
);
const setCwdMode = useCallback( const setCwdMode = useCallback(
(v: 'project' | 'custom') => { (v: 'project' | 'custom') => {
userTouchedRef.current = true; userTouchedRef.current = true;
@ -334,6 +359,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
teamNameRef.current = ''; teamNameRef.current = '';
membersRef.current = []; membersRef.current = [];
syncModelsWithLeadRef.current = true;
cwdModeRef.current = 'project'; cwdModeRef.current = 'project';
selectedProjectPathRef.current = ''; selectedProjectPathRef.current = '';
customCwdRef.current = ''; customCwdRef.current = '';
@ -343,6 +369,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
setTeamNameState(''); setTeamNameState('');
setMembersState([]); setMembersState([]);
setSyncModelsWithLeadState(true);
setCwdModeState('project'); setCwdModeState('project');
setSelectedProjectPathState(''); setSelectedProjectPathState('');
setCustomCwdState(''); setCustomCwdState('');
@ -358,6 +385,8 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
setTeamName, setTeamName,
members, members,
setMembers, setMembers,
syncModelsWithLead,
setSyncModelsWithLead,
cwdMode, cwdMode,
setCwdMode, setCwdMode,
selectedProjectPath, selectedProjectPath,

View file

@ -25,12 +25,16 @@ export interface SerializedMemberDraft {
roleSelection: string; roleSelection: string;
customRole: string; customRole: string;
workflow?: string; workflow?: string;
providerId?: 'anthropic' | 'codex' | 'gemini';
model?: string;
effort?: 'low' | 'medium' | 'high';
} }
export interface CreateTeamDraftSnapshot { export interface CreateTeamDraftSnapshot {
version: number; version: number;
teamName: string; teamName: string;
members: SerializedMemberDraft[]; members: SerializedMemberDraft[];
syncModelsWithLead?: boolean;
cwdMode: 'project' | 'custom'; cwdMode: 'project' | 'custom';
selectedProjectPath: string; selectedProjectPath: string;
customCwd: string; customCwd: string;
@ -57,7 +61,16 @@ function isValidMember(m: unknown): m is SerializedMemberDraft {
typeof obj.id === 'string' && typeof obj.id === 'string' &&
typeof obj.name === 'string' && typeof obj.name === 'string' &&
typeof obj.roleSelection === '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' && typeof obj.teamName === 'string' &&
Array.isArray(obj.members) && Array.isArray(obj.members) &&
obj.members.every(isValidMember) && obj.members.every(isValidMember) &&
(obj.syncModelsWithLead === undefined || typeof obj.syncModelsWithLead === 'boolean') &&
(obj.cwdMode === 'project' || obj.cwdMode === 'custom') && (obj.cwdMode === 'project' || obj.cwdMode === 'custom') &&
typeof obj.selectedProjectPath === 'string' && typeof obj.selectedProjectPath === 'string' &&
typeof obj.customCwd === 'string' && typeof obj.customCwd === 'string' &&
@ -154,6 +168,7 @@ function emptySnapshot(): CreateTeamDraftSnapshot {
version: SNAPSHOT_VERSION, version: SNAPSHOT_VERSION,
teamName: '', teamName: '',
members: [], members: [],
syncModelsWithLead: true,
cwdMode: 'project', cwdMode: 'project',
selectedProjectPath: '', selectedProjectPath: '',
customCwd: '', customCwd: '',

View file

@ -559,6 +559,7 @@ export interface GlobalTaskDetailState {
/** Per-team launch parameters shown in the header badge. */ /** Per-team launch parameters shown in the header badge. */
export interface TeamLaunchParams { export interface TeamLaunchParams {
providerId?: 'anthropic' | 'codex' | 'gemini';
model?: string; // 'opus' | 'sonnet' | 'haiku' model?: string; // 'opus' | 'sonnet' | 'haiku'
effort?: EffortLevel; effort?: EffortLevel;
limitContext?: boolean; limitContext?: boolean;
@ -1949,6 +1950,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
// Persist per-team launch params (model, effort, limit context) // Persist per-team launch params (model, effort, limit context)
const baseModel = extractBaseModel(request.model); const baseModel = extractBaseModel(request.model);
const params: TeamLaunchParams = { const params: TeamLaunchParams = {
providerId: request.providerId ?? 'anthropic',
model: baseModel || 'default', model: baseModel || 'default',
effort: request.effort, effort: request.effort,
limitContext: request.limitContext ?? false, limitContext: request.limitContext ?? false,
@ -2125,6 +2127,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
// Persist per-team launch params (model, effort, limit context) // Persist per-team launch params (model, effort, limit context)
const baseModel = extractBaseModel(request.model); const baseModel = extractBaseModel(request.model);
const params: TeamLaunchParams = { const params: TeamLaunchParams = {
providerId: request.providerId ?? 'anthropic',
model: baseModel || 'default', model: baseModel || 'default',
effort: request.effort, effort: request.effort,
limitContext: request.limitContext ?? false, limitContext: request.limitContext ?? false,

View file

@ -427,7 +427,11 @@ export interface TeamsAPI {
permanentlyDeleteTeam: (teamName: string) => Promise<void>; permanentlyDeleteTeam: (teamName: string) => Promise<void>;
getSavedRequest: (teamName: string) => Promise<TeamCreateRequest | null>; getSavedRequest: (teamName: string) => Promise<TeamCreateRequest | null>;
deleteDraft: (teamName: string) => Promise<void>; 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>; createTeam: (request: TeamCreateRequest) => Promise<TeamCreateResponse>;
getProvisioningStatus: (runId: string) => Promise<TeamProvisioningProgress>; getProvisioningStatus: (runId: string) => Promise<TeamProvisioningProgress>;
cancelProvisioning: (runId: string) => Promise<void>; cancelProvisioning: (runId: string) => Promise<void>;

View file

@ -5,7 +5,7 @@
* Repository Pattern abstraction allows swapping storage backend (JSON sql.js/Drizzle). * 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 // Schedule Status Types
@ -49,6 +49,7 @@ export interface Schedule {
export interface ScheduleLaunchConfig { export interface ScheduleLaunchConfig {
cwd: string; cwd: string;
prompt: string; prompt: string;
providerId?: TeamProviderId;
model?: string; model?: string;
effort?: EffortLevel; effort?: EffortLevel;
skipPermissions?: boolean; skipPermissions?: boolean;

View file

@ -5,6 +5,9 @@ export interface TeamMember {
role?: string; role?: string;
/** Per-agent workflow/instructions injected into spawn prompt. */ /** Per-agent workflow/instructions injected into spawn prompt. */
workflow?: string; workflow?: string;
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
color?: string; color?: string;
joinedAt?: number; joinedAt?: number;
cwd?: string; cwd?: string;
@ -471,6 +474,9 @@ export interface ResolvedTeamMember {
agentType?: string; agentType?: string;
role?: string; role?: string;
workflow?: string; workflow?: string;
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
cwd?: string; cwd?: string;
/** Set only when member's git branch differs from the lead's branch. */ /** Set only when member's git branch differs from the lead's branch. */
gitBranch?: string; gitBranch?: string;
@ -503,11 +509,13 @@ export interface TeamData {
} }
export type EffortLevel = 'low' | 'medium' | 'high'; export type EffortLevel = 'low' | 'medium' | 'high';
export type TeamProviderId = 'anthropic' | 'codex' | 'gemini';
export interface TeamLaunchRequest { export interface TeamLaunchRequest {
teamName: string; teamName: string;
cwd: string; cwd: string;
prompt?: string; prompt?: string;
providerId?: TeamProviderId;
model?: string; model?: string;
effort?: EffortLevel; effort?: EffortLevel;
/** When true, context window is limited to 200K tokens instead of the default. */ /** When true, context window is limited to 200K tokens instead of the default. */
@ -633,6 +641,9 @@ export interface TeamProvisioningMemberInput {
role?: string; role?: string;
/** Per-agent workflow/instructions injected into spawn prompt. */ /** Per-agent workflow/instructions injected into spawn prompt. */
workflow?: string; workflow?: string;
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
} }
export interface TeamCreateRequest { export interface TeamCreateRequest {
@ -643,6 +654,7 @@ export interface TeamCreateRequest {
members: TeamProvisioningMemberInput[]; members: TeamProvisioningMemberInput[];
cwd: string; cwd: string;
prompt?: string; prompt?: string;
providerId?: TeamProviderId;
model?: string; model?: string;
effort?: EffortLevel; effort?: EffortLevel;
/** When true, context window is limited to 200K tokens instead of the default. */ /** When true, context window is limited to 200K tokens instead of the default. */
@ -780,6 +792,9 @@ export interface AddMemberRequest {
name: string; name: string;
role?: string; role?: string;
workflow?: string; workflow?: string;
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
} }
export interface RemoveMemberRequest { export interface RemoveMemberRequest {

View file

@ -24,6 +24,12 @@ export const PROTECTED_CLI_FLAGS = new Set([
'--mcp-config', '--mcp-config',
'--disallowedTools', '--disallowedTools',
'--verbose', '--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'); 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', () => { it('keeps dotted names when they are explicitly configured members', () => {
const resolver = new TeamMemberResolver(); const resolver = new TeamMemberResolver();
const config: TeamConfig = { const config: TeamConfig = {

View file

@ -79,6 +79,38 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
expect(probeSpy.mock.calls[1]?.[1]).toBe(cwdB); 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 () => { it('maps ANTHROPIC_AUTH_TOKEN into ANTHROPIC_API_KEY for headless preflight', async () => {
const svc = new TeamProvisioningService(); const svc = new TeamProvisioningService();
vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({ 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']); 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 () => { it('members.meta.json fallback never returns reserved names (user/team-lead)', async () => {
const svc = new TeamProvisioningService( const svc = new TeamProvisioningService(
{} as never, {} as never,