chore: checkpoint workspace before relaunch flow

This commit is contained in:
777genius 2026-04-19 16:08:38 +03:00
parent fbf299f276
commit 1e2241aead
33 changed files with 4078 additions and 299 deletions

View file

@ -0,0 +1,312 @@
# Codex Native Runtime Integration Decision
**Status**: Decision
**Date**: 2026-04-19
**Owner repos**:
- `claude_team`
- `agent_teams_orchestrator`
- `plugin-kit-ai`
## Purpose
Record the chosen direction for improving Codex integration in the multimodel runtime without losing native Codex capabilities such as plugins, skills, and MCP.
## Chosen Plan Assessment
- Chosen plan: normalized internal event/log layer plus staged `Codex-native` backend lane
- Assessment: `🎯 9 🛡️ 9 🧠 7`
- Estimated first serious wave: `2200-4500` lines across `agent_teams_orchestrator`, `claude_team`, and `plugin-kit-ai`
## Current Reality
Today, `Codex` inside our multimodel runtime is **not** executed through the real Codex runtime.
Instead, the current path is:
- `claude_team`
- `agent_teams_orchestrator`
- internal Codex backend
- OpenAI Responses API
In practice this means:
- the orchestrator keeps Anthropic-style streaming semantics
- `Codex` is treated as a model backend, not as a native runtime
- native Codex plugins are not honestly end-to-end supported
- current `Codex` capability support is limited by our adapter, not by the real Codex runtime
## What We Learned
After deep code and docs analysis, the most important conclusions are:
1. `@openai/codex-sdk` and `codex exec --json` are the real official execution seam for embedded Codex runtime usage.
2. `codex exec` supports API-key mode, so API-key mode itself is not the blocker.
3. `Codex` native plugins, apps, skills, and MCP are part of the real Codex runtime flow.
4. Our current `agent_teams_orchestrator` query loop is deeply coupled to Anthropic-style events and tool semantics.
5. A full drop-in swap from the current Codex adapter to `@openai/codex-sdk / codex exec` would not be a safe transport-only change. It would change runtime semantics.
6. `plugin-kit-ai` is a good fit for plugin management and native plugin placement.
7. `codex app-server` is promising for richer control-plane features, but should not be the foundation of the first production rollout for plugin management.
## Chosen Direction
We will **not** force Codex into the current Anthropic-shaped runtime contract.
We will instead:
- add a new **internal normalized event/log layer**
- keep execution semantics provider-native where needed
- add a separate **Codex-native runtime lane**
- use `plugin-kit-ai` for plugin management and native plugin placement
In practical terms:
- current Codex path stays available as the fallback/default path at first
- real Codex runtime execution becomes a separate lane instead of a drop-in replacement
- unified logs come from normalization, not from pretending every provider has Anthropic-native runtime semantics
## Decision Summary
### We are doing this
- keep the current Codex adapter path as the fallback/default path initially
- introduce a new `Codex-native` backend lane using `@openai/codex-sdk / codex exec`
- introduce a normalized internal event/log format for all providers
- map Anthropic, Gemini, and future Codex-native events into that normalized format
- keep unified logging, transcript projection, analytics, and UI-facing event handling on top of the normalized layer
- use `plugin-kit-ai` for:
- install
- update
- remove
- repair
- discover
- catalog
- native Codex plugin placement through native marketplace/filesystem layout
### We are not doing this
- not replacing the whole multimodel runtime in one shot
- not forcing real Codex runtime execution into fake Anthropic transport semantics
- not pretending a full `@openai/codex-sdk / codex exec` swap is a drop-in backend replacement
- not making `app-server plugin/*` the first production seam
## Why We Chose This
### Main benefit
This path gives us both:
- unified internal logs/events
- a real path to native Codex runtime capabilities
without requiring a full rewrite of the current multimodel runtime.
### Main reason against a direct full swap
The current orchestrator is deeply coupled to Anthropic-shaped runtime behavior:
- `tool_use`
- `tool_result`
- `content_block_start`
- `input_json_delta`
- `message_delta`
- current permission and sandbox flow
- current synthetic tool/result handling
- current transcript persistence and resume logic
`codex exec` emits a different event model:
- `thread.started`
- `turn.started`
- `turn.completed`
- `turn.failed`
- `item.started`
- `item.updated`
- `item.completed`
and item types such as:
- `agent_message`
- `reasoning`
- `command_execution`
- `file_change`
- `mcp_tool_call`
That is not just a different wire format. It is a different runtime shape.
## What Changes Per Repo
### `agent_teams_orchestrator`
This repo takes the biggest change.
We want to:
- introduce a provider-neutral normalized event/log model
- add adapter mappers from current Anthropic/Gemini style streams into that model
- add a separate `Codex-native` backend lane through `@openai/codex-sdk / codex exec`
- keep the current Codex adapter path alive as fallback during migration
- avoid forcing `codex exec` events into fake `tool_use/tool_result` transport semantics
We do **not** want to:
- replace the current Codex backend in one shot
- rewrite all providers around Codex-native semantics
- make transcript/log normalization depend on Anthropic wire events
### `claude_team`
This repo should stay relatively stable compared with the orchestrator.
We want to:
- keep one multimodel runtime concept
- stay capability-aware per provider/backend lane
- consume normalized runtime/log DTOs rather than assuming one provider-shaped event model
- integrate plugin management through `plugin-kit-ai`
- keep Codex plugin support gated behind the real Codex-native lane
We do **not** want to:
- invent a fake Codex plugin support state while execution still goes through the old adapter lane
- force UI logic to infer runtime truth from provider labels alone
### `plugin-kit-ai`
This repo remains the management engine, not the execution engine.
We want to:
- use it for catalog
- use it for discover
- use it for install/update/remove/repair
- use it for native Codex plugin placement through native marketplace/filesystem layout
We do **not** want to:
- make it responsible for running Codex plugins inside sessions
- blur installation and execution into one concern
## Target Architecture
### Runtime execution
- `Anthropic` can continue on the current path for now
- `Gemini` can continue on the current path for now
- `Codex-native` gets a dedicated backend lane through `@openai/codex-sdk / codex exec`
### Internal normalization
All runtime backends must project into a shared internal event/log model.
The normalized layer should represent concepts such as:
- turn started
- assistant text
- reasoning
- command execution
- MCP call
- file change
- approval request
- turn completed
- turn failed
The normalized format is the source of truth for:
- logs
- transcript projection
- analytics
- UI-facing activity/event summaries
The normalized format is **not** required to preserve provider-native wire semantics.
## Codex Plugins Strategy
For Codex plugins we want:
- native Codex runtime execution
- native Codex marketplace/filesystem placement
- provider-aware plugin management in `claude_team`
Therefore:
- `plugin-kit-ai` is the management engine
- real Codex runtime is the execution engine
This is important because plugin installation and plugin execution are different concerns.
Installing a native Codex plugin is not enough by itself if the session still runs through our current Responses API adapter path.
## App Server Position
`codex app-server` remains relevant, but not as the first critical path for this migration.
It is better positioned as a later control-plane enhancement for things like:
- auth state
- MCP status and OAuth flows
- skills/config inspection
- external config import
For the first production rollout, it should not be the hard dependency for plugin lifecycle management.
## Implementation Phases
### Phase 1
- design and introduce the normalized internal event/log layer
- keep current backends working
- define the internal mapping contract clearly
### Phase 2
- add a `Codex-native` backend lane through `@openai/codex-sdk / codex exec`
- keep the current Codex adapter as fallback
- validate API-key mode, working directory behavior, sandbox mode, approval policy, thread resume, and streaming
### Phase 3
- integrate `plugin-kit-ai` for provider-aware plugin management
- add native Codex plugin placement through native marketplace/filesystem model
- keep current UI provider-aware and capability-aware
### Phase 4
- optionally add selective `codex app-server` control-plane integration where it provides clear value
## Main Risks And Guardrails
### Risk 1 - treating `codex-sdk/exec` as a transport-only swap
This is the most dangerous mistake.
Guardrail:
- treat `Codex-native` as a separate runtime lane
- normalize logs/events above it
- do not assume the current Anthropic-shaped tool loop can be preserved unchanged
### Risk 2 - claiming Codex plugin support too early
Installing native Codex plugins is not enough if execution still runs through the current adapter path.
Guardrail:
- only advertise Codex plugin support when the session actually runs through the Codex-native lane
### Risk 3 - overcommitting to `app-server` too early
`codex app-server` is useful, but it should not become a hard dependency for the first production plugin rollout.
Guardrail:
- use it later for selective control-plane features
- do not block the first migration on `app-server plugin/*`
## Practical Rule
If we need **unified logs**, we normalize events.
If we need **native Codex capabilities**, we do not fake Codex into Anthropic runtime semantics.
That is the core architectural rule for this migration.

View file

@ -1074,6 +1074,9 @@ async function handleUpdateConfig(
}
return wrapTeamHandler('updateConfig', async () => {
const tn = validated.value!;
const teamDataService = getTeamDataService();
const previousDisplayName = await teamDataService.getTeamDisplayName(tn).catch(() => tn);
const requestedName = typeof name === 'string' ? name.trim() : '';
const result = await getTeamDataService().updateConfig(tn, {
name,
description,
@ -1084,10 +1087,10 @@ async function handleUpdateConfig(
}
// Notify running lead about the rename so it stays aware of current team name
if (typeof name === 'string' && name.trim()) {
if (requestedName && requestedName !== (previousDisplayName?.trim() || tn)) {
const provisioning = getTeamProvisioningService();
if (provisioning.isTeamAlive(tn)) {
const msg = `The team has been renamed to "${name.trim()}". Please use this name when referring to the team going forward.`;
const msg = `The team has been renamed to "${requestedName}". Please use this name when referring to the team going forward.`;
try {
await provisioning.sendMessageToTeam(tn, msg);
} catch {

View file

@ -12,9 +12,10 @@ import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSema
import { isLeadMember } from '@shared/utils/leadDetection';
import { createLogger } from '@shared/utils/logger';
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
import { parseNumericSuffixName, validateTeamMemberNameFormat } from '@shared/utils/teamMemberName';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary';
import * as agentTeamsControllerModule from 'agent-teams-controller';
@ -130,6 +131,16 @@ interface FileWatchReconcileDiagnostics {
lastPressureLogAt: number;
}
function applyDistinctRosterColors<T extends { name: string; color?: string; removedAt?: number }>(
members: readonly T[]
): T[] {
const colorMap = buildTeamMemberColorMap(members, { preferProvidedColors: false });
return members.map((member) => ({
...member,
color: colorMap.get(member.name) ?? member.color ?? getMemberColorByName(member.name),
}));
}
function normalizePassiveUserReplyLinkText(value: string | undefined): string {
if (typeof value !== 'string') return '';
return value
@ -1161,7 +1172,7 @@ export class TeamDataService {
role: configMember.role,
workflow: configMember.workflow,
agentType: configMember.agentType ?? 'general-purpose',
color: configMember.color ?? getMemberColorByName(configMember.name.trim()),
color: configMember.color,
joinedAt: configMember.joinedAt ?? Date.now(),
cwd: configMember.cwd,
};
@ -1176,13 +1187,13 @@ export class TeamDataService {
member = {
name: memberName,
agentType: 'general-purpose',
color: getMemberColorByName(memberName),
joinedAt: Date.now(),
};
}
members.push(member);
await this.membersMetaStore.writeMembers(teamName, members);
const nextMembers = applyDistinctRosterColors([...members, member]);
member = nextMembers.find((m) => m.name === memberName) ?? member;
await this.membersMetaStore.writeMembers(teamName, nextMembers);
}
return { members, member };
@ -1193,6 +1204,13 @@ export class TeamDataService {
if (!name) {
throw new Error('Member name cannot be empty');
}
const formatError = validateTeamMemberNameFormat(name);
if (formatError) {
throw new Error(`Member name "${name}" is invalid: ${formatError}`);
}
if (name.toLowerCase() === 'user') {
throw new Error('Member name "user" is reserved');
}
const suffixInfo = parseNumericSuffixName(name);
if (suffixInfo && suffixInfo.suffix >= 2) {
throw new Error(
@ -1224,12 +1242,11 @@ export class TeamDataService {
? request.effort
: undefined,
agentType: 'general-purpose',
color: getMemberColorByName(name),
joinedAt: Date.now(),
};
members.push(newMember);
await this.membersMetaStore.writeMembers(teamName, members);
const nextMembers = applyDistinctRosterColors([...members, newMember]);
await this.membersMetaStore.writeMembers(teamName, nextMembers);
}
async updateMemberRole(
@ -1269,36 +1286,48 @@ export class TeamDataService {
const joinedAt = Date.now();
const nextByName = new Set<string>();
const nextActive: TeamMember[] = request.members.map((member) => {
const name = member.name.trim();
if (!name) throw new Error('Member name cannot be empty');
if (name.toLowerCase() === 'team-lead') {
throw new Error('Member name "team-lead" is reserved');
}
const suffixInfo = parseNumericSuffixName(name);
if (suffixInfo && suffixInfo.suffix >= 2) {
throw new Error(
`Member name "${name}" is not allowed (reserved for Claude CLI auto-suffix). Use "${suffixInfo.base}" instead.`
);
}
nextByName.add(name.toLowerCase());
const prev = existingByName.get(name.toLowerCase());
return {
name,
role: member.role?.trim() || undefined,
workflow: member.workflow?.trim() || undefined,
providerId: normalizeOptionalTeamProviderId(member.providerId),
model: member.model?.trim() || undefined,
effort:
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
? member.effort
: undefined,
agentType: prev?.agentType ?? 'general-purpose',
color: prev?.color ?? getMemberColorByName(name),
joinedAt: prev?.joinedAt ?? joinedAt,
removedAt: undefined,
};
});
const nextActive = applyDistinctRosterColors(
request.members.map((member) => {
const name = member.name.trim();
if (!name) throw new Error('Member name cannot be empty');
const formatError = validateTeamMemberNameFormat(name);
if (formatError) {
throw new Error(`Member name "${name}" is invalid: ${formatError}`);
}
if (name.toLowerCase() === 'user') {
throw new Error('Member name "user" is reserved');
}
if (name.toLowerCase() === 'team-lead') {
throw new Error('Member name "team-lead" is reserved');
}
if (nextByName.has(name.toLowerCase())) {
throw new Error(`Member "${name}" already exists`);
}
const suffixInfo = parseNumericSuffixName(name);
if (suffixInfo && suffixInfo.suffix >= 2) {
throw new Error(
`Member name "${name}" is not allowed (reserved for Claude CLI auto-suffix). Use "${suffixInfo.base}" instead.`
);
}
nextByName.add(name.toLowerCase());
const prev = existingByName.get(name.toLowerCase());
return {
name,
role: member.role?.trim() || undefined,
workflow: member.workflow?.trim() || undefined,
providerId: normalizeOptionalTeamProviderId(member.providerId),
model: member.model?.trim() || undefined,
effort:
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
? member.effort
: undefined,
agentType: prev?.agentType ?? 'general-purpose',
color: prev?.color,
joinedAt: prev?.joinedAt ?? joinedAt,
removedAt: undefined,
};
})
);
// Preserve/mark removed members so stale inbox files don't resurrect them in the UI.
const nextRemoved: TeamMember[] = [];
@ -2322,12 +2351,18 @@ export class TeamDataService {
createdAt: joinedAt,
});
await this.membersMetaStore.writeMembers(
request.teamName,
const membersToWrite = applyDistinctRosterColors(
request.members.map((member) => ({
name: (() => {
const name = member.name.trim();
if (!name) throw new Error('Member name cannot be empty');
const formatError = validateTeamMemberNameFormat(name);
if (formatError) {
throw new Error(`Member name "${name}" is invalid: ${formatError}`);
}
if (name.toLowerCase() === 'user') {
throw new Error('Member name "user" is reserved');
}
if (name.toLowerCase() === 'team-lead')
throw new Error('Member name "team-lead" is reserved');
const suffixInfo = parseNumericSuffixName(name);
@ -2346,11 +2381,11 @@ export class TeamDataService {
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
? member.effort
: undefined,
agentType: 'general-purpose',
color: getMemberColorByName(member.name.trim()),
agentType: 'general-purpose' as const,
joinedAt,
}))
);
await this.membersMetaStore.writeMembers(request.teamName, membersToWrite);
}
async reconcileTeamArtifacts(

View file

@ -3,6 +3,7 @@ import {
createCliAutoSuffixNameGuard,
createCliProvisionerNameGuard,
} from '@shared/utils/teamMemberName';
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
import type { TeamConfig, TeamMember, TeamMemberSnapshot, TeamTaskWithKanban } from '@shared/types';
@ -262,6 +263,11 @@ export class TeamMemberResolver {
}
return aStableId.localeCompare(bStableId);
});
return members;
const colorMap = buildTeamMemberColorMap(members, { preferProvidedColors: false });
return members.map((member) => ({
...member,
color: colorMap.get(member.name) ?? member.color ?? getMemberColorByName(member.name),
}));
}
}

View file

@ -38,6 +38,7 @@ import { getMemberColorByName } from '@shared/constants/memberColors';
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
import { resolveLanguageName } from '@shared/utils/agentLanguage';
import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults';
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
import { parseCliArgs } from '@shared/utils/cliArgsParser';
import { deriveContextMetrics, inferContextWindowTokens } from '@shared/utils/contextMetrics';
import {
@ -224,6 +225,16 @@ const PROBE_CACHE_TTL_MS = 36 * 60 * 60 * 1000;
const PREFLIGHT_BINARY_TIMEOUT_MS = 8000;
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
function applyDistinctProvisioningMemberColors<
T extends { name: string; color?: string; removedAt?: number },
>(members: readonly T[]): T[] {
const colorMap = buildTeamMemberColorMap(members, { preferProvidedColors: false });
return members.map((member) => ({
...member,
color: colorMap.get(member.name) ?? member.color ?? getMemberColorByName(member.name),
}));
}
const FS_MONITOR_POLL_MS = 2000;
const TASK_WAIT_FALLBACK_MS = 15_000;
const STALL_CHECK_INTERVAL_MS = 10_000;
@ -727,6 +738,8 @@ interface ProvisioningRun {
>;
/** Agent tool_use_id -> teammate name for persistent teammate spawns. */
memberSpawnToolUseIds: Map<string, string>;
/** Explicit restart requests awaiting teammate rejoin or failure. */
pendingMemberRestarts: Map<string, PendingMemberRestartContext>;
/** Per-member latest processed lead-inbox bootstrap signal cursor for the current live run. */
memberSpawnLeadInboxCursorByMember: Map<string, MemberSpawnInboxCursor>;
/** Highest accepted deterministic bootstrap event sequence for this run. */
@ -821,6 +834,64 @@ interface LiveTeamAgentRuntimeMetadata {
tmuxPaneId?: string;
}
function escapeRegexLiteral(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function commandContainsCliArgValue(command: string, argName: string, value: string): boolean {
const normalizedCommand = command.trim();
const normalizedValue = value.trim();
if (!normalizedCommand || !normalizedValue) {
return false;
}
const pattern = new RegExp(
`(?:^|\\s)${escapeRegexLiteral(argName)}(?:=|\\s+)${escapeRegexLiteral(normalizedValue)}(?:\\s|$)`
);
return pattern.test(normalizedCommand);
}
function isNeverSpawnedDuringLaunchReason(reason?: string): boolean {
return reason?.trim() === 'Teammate was never spawned during launch.';
}
function isLaunchGraceWindowFailureReason(reason?: string): boolean {
return reason?.trim() === 'Teammate did not join within the launch grace window.';
}
function isConfigRegistrationFailureReason(reason?: string): boolean {
return (
reason?.trim() ===
'Teammate was not registered in config.json during launch. Persistent spawn failed.'
);
}
function isAutoClearableLaunchFailureReason(reason?: string): boolean {
return (
isNeverSpawnedDuringLaunchReason(reason) ||
isLaunchGraceWindowFailureReason(reason) ||
isConfigRegistrationFailureReason(reason)
);
}
function buildRestartStillRunningReason(memberName: string): string {
return (
`Restart for teammate "${memberName}" was skipped because the previous runtime still appears ` +
`to be active. The requested settings may not have been applied.`
);
}
function buildRestartGraceTimeoutReason(memberName: string): string {
return `Teammate "${memberName}" did not rejoin within the restart grace window.`;
}
interface PendingMemberRestartContext {
requestedAt: string;
desired: Pick<
TeamCreateRequest['members'][number],
'name' | 'role' | 'workflow' | 'providerId' | 'model' | 'effort'
>;
}
function normalizeTeamAgentRuntimeBackendType(
value: string | undefined,
isLead: boolean
@ -1628,7 +1699,9 @@ export function buildRestartMemberSpawnMessage(
return (
`Teammate "${member.name}"${roleHint} was restarted from the UI. ` +
`Please respawn 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. ` +
`This is a restart of an existing persistent teammate, not a new teammate.${workflowHint ? workflowHint : ''}\n\n` +
`This is a restart of an existing persistent teammate, not a new teammate. ` +
`If the Agent tool returns duplicate_skipped with reason bootstrap_pending, treat that as a pending restart and wait for teammate check-in. ` +
`If it returns duplicate_skipped with reason already_running, do not report success - it means the previous runtime still appears active and the restart may not have applied.${workflowHint ? workflowHint : ''}\n\n` +
indentMultiline(prompt, ' ')
);
}
@ -3919,6 +3992,7 @@ export class TeamProvisioningService {
const spawnedMemberName = run.memberSpawnToolUseIds.get(toolUseId);
if (spawnedMemberName) {
run.memberSpawnToolUseIds.delete(toolUseId);
const pendingRestart = run.pendingMemberRestarts.get(spawnedMemberName);
if (isError) {
const resultPreview = extractToolResultPreview(resultContent);
this.handleMemberSpawnFailure(run, spawnedMemberName, resultPreview);
@ -3928,8 +4002,25 @@ export class TeamProvisioningService {
const detail =
parsedStatus.reason === 'already_running'
? 'duplicate spawn skipped - already running'
: 'duplicate spawn skipped - teammate already online';
: 'duplicate spawn skipped - teammate bootstrap still pending';
this.appendMemberBootstrapDiagnostic(run, spawnedMemberName, detail);
if (parsedStatus.reason === 'already_running') {
if (pendingRestart) {
run.pendingMemberRestarts.delete(spawnedMemberName);
this.setMemberSpawnStatus(
run,
spawnedMemberName,
'error',
buildRestartStillRunningReason(spawnedMemberName)
);
return;
}
this.agentRuntimeSnapshotCache.delete(run.teamName);
this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName);
this.setMemberSpawnStatus(run, spawnedMemberName, 'online', undefined, 'process');
} else {
this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting');
}
return;
}
@ -3947,11 +4038,16 @@ export class TeamProvisioningService {
memberName: string,
resultPreview?: string
): void {
const pendingRestart = run.pendingMemberRestarts.get(memberName);
const reason =
(typeof resultPreview === 'string' && resultPreview.trim().length > 0
? resultPreview.trim()
: 'Teammate spawn failed immediately after launch.') || 'Teammate spawn failed.';
const message = `Teammate "${memberName}" failed to start: ${reason}`;
const message = pendingRestart
? `Failed to restart teammate "${memberName}": ${reason}`
: `Teammate "${memberName}" failed to start: ${reason}`;
run.pendingMemberRestarts.delete(memberName);
this.setMemberSpawnStatus(run, memberName, 'error', message);
@ -4016,6 +4112,21 @@ export class TeamProvisioningService {
heartbeatAt?: string
): void {
const prev = run.memberSpawnStatuses.get(memberName) ?? createInitialMemberSpawnStatusEntry();
if (
status === 'waiting' &&
!prev.hardFailure &&
(prev.bootstrapConfirmed || prev.runtimeAlive)
) {
this.setMemberSpawnStatus(
run,
memberName,
'online',
undefined,
prev.livenessSource,
prev.lastHeartbeatAt
);
return;
}
const updatedAt = nowIso();
const next: MemberSpawnStatusEntry = {
...prev,
@ -4024,13 +4135,26 @@ export class TeamProvisioningService {
};
if (status === 'spawning') {
next.launchState = 'starting';
} else if (status === 'waiting') {
next.agentToolAccepted = true;
next.agentToolAccepted = false;
next.runtimeAlive = false;
next.bootstrapConfirmed = false;
next.hardFailure = false;
next.error = undefined;
next.hardFailureReason = undefined;
next.livenessSource = undefined;
next.firstSpawnAcceptedAt = undefined;
next.lastHeartbeatAt = undefined;
next.launchState = 'starting';
} else if (status === 'waiting') {
next.agentToolAccepted = true;
next.runtimeAlive = false;
next.bootstrapConfirmed = false;
next.hardFailure = false;
next.error = undefined;
next.hardFailureReason = undefined;
next.livenessSource = undefined;
next.firstSpawnAcceptedAt = prev.firstSpawnAcceptedAt ?? updatedAt;
next.lastHeartbeatAt = undefined;
next.launchState = 'runtime_pending_bootstrap';
} else if (status === 'online') {
next.agentToolAccepted = true;
@ -4078,6 +4202,9 @@ export class TeamProvisioningService {
}
run.memberSpawnStatuses.set(memberName, next);
if ((status === 'online' && next.bootstrapConfirmed) || status === 'offline') {
run.pendingMemberRestarts.delete(memberName);
}
this.syncMemberLaunchGraceCheck(run, memberName, next);
if (status === 'spawning') {
@ -4316,8 +4443,17 @@ export class TeamProvisioningService {
const config = await this.configReader.getConfig(teamName);
const configuredMembers = config?.members ?? [];
const configuredMember = configuredMembers.find(
(member) => member?.name?.trim() === memberName
let metaMembers: Awaited<ReturnType<TeamMembersMetaStore['getMembers']>> = [];
try {
metaMembers = await this.membersMetaStore.getMembers(teamName);
} catch {
metaMembers = [];
}
const configuredMember = this.resolveEffectiveConfiguredMember(
configuredMembers,
metaMembers,
memberName
);
if (!configuredMember) {
throw new Error(`Member "${memberName}" is not configured in team "${teamName}"`);
@ -4328,6 +4464,9 @@ export class TeamProvisioningService {
if (isLeadMember({ name: memberName, agentType: configuredMember.agentType })) {
throw new Error('Lead restart is not supported from member controls');
}
if (run.pendingMemberRestarts.has(memberName)) {
throw new Error(`Restart for teammate "${memberName}" is already in progress`);
}
const persistedRuntimeMembers = this.readPersistedRuntimeMembers(teamName).filter((member) => {
const candidateName = typeof member.name === 'string' ? member.name.trim() : '';
@ -4414,15 +4553,25 @@ export class TeamProvisioningService {
this.setMemberSpawnStatus(run, memberName, 'offline');
this.setMemberSpawnStatus(run, memberName, 'spawning');
this.appendMemberBootstrapDiagnostic(run, memberName, 'manual restart requested from UI');
run.pendingMemberRestarts.set(memberName, {
requestedAt: nowIso(),
desired: {
name: configuredMember.name,
role: configuredMember.role,
workflow: configuredMember.workflow,
providerId: configuredMember.providerId,
model: configuredMember.model,
effort: configuredMember.effort,
},
});
const leadName =
configuredMembers.find((member) => isLeadMember(member))?.name?.trim() || 'team-lead';
const leadName = this.resolveLeadMemberName(configuredMembers, metaMembers);
const restartMessage = buildRestartMemberSpawnMessage(
teamName,
config?.name?.trim() || teamName,
leadName,
{
name: memberName,
name: configuredMember.name,
role: configuredMember.role,
workflow: configuredMember.workflow,
providerId: configuredMember.providerId,
@ -4434,6 +4583,7 @@ export class TeamProvisioningService {
try {
await this.sendMessageToRun(run, restartMessage);
} catch (error) {
run.pendingMemberRestarts.delete(memberName);
this.setMemberSpawnStatus(
run,
memberName,
@ -4510,11 +4660,17 @@ export class TeamProvisioningService {
) {
return;
}
const restartPending = run.pendingMemberRestarts.has(memberName);
if (restartPending) {
run.pendingMemberRestarts.delete(memberName);
}
this.setMemberSpawnStatus(
run,
memberName,
'error',
'Teammate did not join within the launch grace window.'
restartPending
? buildRestartGraceTimeoutReason(memberName)
: 'Teammate did not join within the launch grace window.'
);
}
@ -5931,6 +6087,7 @@ export class TeamProvisioningService {
request.members.map((m) => [m.name, createInitialMemberSpawnStatusEntry()])
),
memberSpawnToolUseIds: new Map(),
pendingMemberRestarts: new Map(),
memberSpawnLeadInboxCursorByMember: new Map(),
lastDeterministicBootstrapSeq: 0,
lastMemberSpawnAuditAt: 0,
@ -6050,8 +6207,7 @@ export class TeamProvisioningService {
limitContext: request.limitContext,
createdAt: Date.now(),
});
await this.membersMetaStore.writeMembers(
request.teamName,
const membersToWrite = applyDistinctProvisioningMemberColors(
effectiveMemberSpecs.map((m) => ({
name: m.name.trim(),
role: m.role?.trim() || undefined,
@ -6063,10 +6219,10 @@ export class TeamProvisioningService {
? m.effort
: undefined,
agentType: 'general-purpose' as const,
color: getMemberColorByName(m.name.trim()),
joinedAt: Date.now(),
}))
);
await this.membersMetaStore.writeMembers(request.teamName, membersToWrite);
if (request.skipPermissions === false) {
await this.seedTeammateOperationalPermissionRules(request.teamName, request.cwd);
}
@ -6512,6 +6668,7 @@ export class TeamProvisioningService {
expectedMembers.map((name) => [name, createInitialMemberSpawnStatusEntry()])
),
memberSpawnToolUseIds: new Map(),
pendingMemberRestarts: new Map(),
memberSpawnLeadInboxCursorByMember: new Map(),
lastDeterministicBootstrapSeq: 0,
lastMemberSpawnAuditAt: 0,
@ -7979,13 +8136,29 @@ export class TeamProvisioningService {
const nextStatuses = { ...statuses };
for (const [memberName, metadata] of runtimeByMember.entries()) {
const current = nextStatuses[memberName];
if (!current || !metadata.model) {
if (!current) {
continue;
}
nextStatuses[memberName] = {
const nextEntry: MemberSpawnStatusEntry = {
...current,
runtimeModel: metadata.model,
...(metadata.model ? { runtimeModel: metadata.model } : {}),
};
const failureReason = current.hardFailureReason ?? current.error;
if (
metadata.alive &&
current.launchState === 'failed_to_start' &&
isAutoClearableLaunchFailureReason(failureReason)
) {
nextEntry.status = 'online';
nextEntry.agentToolAccepted = true;
nextEntry.runtimeAlive = true;
nextEntry.hardFailure = false;
nextEntry.hardFailureReason = undefined;
nextEntry.error = undefined;
nextEntry.livenessSource = current.bootstrapConfirmed ? current.livenessSource : 'process';
nextEntry.launchState = deriveMemberLaunchState(nextEntry);
}
nextStatuses[memberName] = nextEntry;
}
return nextStatuses;
}
@ -8033,6 +8206,87 @@ export class TeamProvisioningService {
return undefined;
}
private resolveEffectiveConfiguredMember(
configuredMembers: TeamConfig['members'] | undefined,
metaMembers: Awaited<ReturnType<TeamMembersMetaStore['getMembers']>>,
memberName: string
): {
name: string;
role?: string;
workflow?: string;
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
agentType?: string;
removedAt?: number | string;
} | null {
const configuredMember = (configuredMembers ?? []).find((member) => {
const candidateName = typeof member?.name === 'string' ? member.name.trim() : '';
return candidateName.length > 0 && matchesTeamMemberIdentity(candidateName, memberName);
});
const metaMember = metaMembers.find((member) => {
const candidateName = member.name?.trim() ?? '';
return candidateName.length > 0 && matchesTeamMemberIdentity(candidateName, memberName);
});
if (!configuredMember && !metaMember) {
return null;
}
const name =
metaMember?.name?.trim() || configuredMember?.name?.trim() || memberName.trim() || memberName;
const role = metaMember?.role?.trim() || configuredMember?.role?.trim() || undefined;
const workflow =
metaMember?.workflow?.trim() || configuredMember?.workflow?.trim() || undefined;
const providerId =
normalizeTeamMemberProviderId(metaMember?.providerId) ??
normalizeTeamMemberProviderId(configuredMember?.providerId);
const model = metaMember?.model?.trim() || configuredMember?.model?.trim() || undefined;
const effort =
metaMember?.effort === 'low' ||
metaMember?.effort === 'medium' ||
metaMember?.effort === 'high'
? metaMember.effort
: configuredMember?.effort === 'low' ||
configuredMember?.effort === 'medium' ||
configuredMember?.effort === 'high'
? configuredMember.effort
: undefined;
const agentType =
metaMember?.agentType?.trim() || configuredMember?.agentType?.trim() || undefined;
const removedAt = metaMember?.removedAt ?? configuredMember?.removedAt;
return {
name,
...(role ? { role } : {}),
...(workflow ? { workflow } : {}),
...(providerId ? { providerId } : {}),
...(model ? { model } : {}),
...(effort ? { effort } : {}),
...(agentType ? { agentType } : {}),
...(removedAt != null ? { removedAt } : {}),
};
}
private resolveLeadMemberName(
configuredMembers: TeamConfig['members'] | undefined,
metaMembers: Awaited<ReturnType<TeamMembersMetaStore['getMembers']>>
): string {
const configuredLead = (configuredMembers ?? []).find((member) => isLeadMember(member));
const configuredLeadName = configuredLead?.name?.trim();
if (configuredLeadName) {
return configuredLeadName;
}
const metaLead = metaMembers.find((member) => isLeadMember(member));
const metaLeadName = metaLead?.name?.trim();
if (metaLeadName) {
return metaLeadName;
}
return 'team-lead';
}
private findEffectiveRunMemberModel(
run: ProvisioningRun | null,
memberName: string
@ -8174,13 +8428,28 @@ export class TeamProvisioningService {
}
}
const unresolvedAgentIds = [...metadataByMember.values()]
.map((metadata) => metadata.agentId?.trim() ?? '')
.filter((agentId) => agentId.length > 0);
const processPidByAgentId =
unresolvedAgentIds.length > 0
? this.findLiveProcessPidByAgentId(teamName, unresolvedAgentIds)
: new Map<string, number>();
for (const [memberName, metadata] of metadataByMember.entries()) {
const paneId = metadata.tmuxPaneId?.trim() ?? '';
const backendType = metadata.backendType;
const panePid = paneId ? panePidById.get(paneId) : undefined;
const processPid = metadata.agentId ? processPidByAgentId.get(metadata.agentId) : undefined;
const resolvedPid =
typeof panePid === 'number' && panePid > 0
? panePid
: typeof processPid === 'number' && processPid > 0
? processPid
: undefined;
const status = this.findTrackedMemberSpawnStatus(run, memberName);
const alive =
typeof panePid === 'number' && panePid > 0
typeof resolvedPid === 'number' && resolvedPid > 0
? true
: backendType === 'tmux'
? false
@ -8188,7 +8457,7 @@ export class TeamProvisioningService {
metadataByMember.set(memberName, {
...metadata,
alive,
...(typeof panePid === 'number' && panePid > 0 ? { pid: panePid } : {}),
...(typeof resolvedPid === 'number' && resolvedPid > 0 ? { pid: resolvedPid } : {}),
});
}
@ -8236,6 +8505,46 @@ export class TeamProvisioningService {
return rows;
}
private findLiveProcessPidByAgentId(
teamName: string,
agentIds: readonly string[]
): Map<string, number> {
const normalizedAgentIds = [
...new Set(agentIds.map((agentId) => agentId.trim()).filter(Boolean)),
];
if (normalizedAgentIds.length === 0) {
return new Map();
}
const rows = this.readUnixProcessTableRows();
if (rows.length === 0) {
return new Map();
}
const pidByAgentId = new Map<string, number>();
for (const row of rows) {
if (
!commandContainsCliArgValue(row.command, '--team-name', teamName) ||
!row.command.includes('--agent-id')
) {
continue;
}
for (const agentId of normalizedAgentIds) {
if (!commandContainsCliArgValue(row.command, '--agent-id', agentId)) {
continue;
}
const currentPid = pidByAgentId.get(agentId);
if (!currentPid || row.pid > currentPid) {
pidByAgentId.set(agentId, row.pid);
}
break;
}
}
return pidByAgentId;
}
private async readProcessRssBytesByPid(pids: readonly number[]): Promise<Map<number, number>> {
const uniquePids = [...new Set(pids.filter((pid) => Number.isFinite(pid) && pid > 0))];
if (uniquePids.length === 0) {
@ -8444,6 +8753,7 @@ export class TeamProvisioningService {
const nextMembers = { ...persisted.members };
const now = nowIso();
for (const expected of persisted.expectedMembers) {
const bootstrapMember = bootstrapSnapshot?.members[expected];
const current = nextMembers[expected] ?? {
name: expected,
launchState: 'starting',
@ -8453,6 +8763,20 @@ export class TeamProvisioningService {
hardFailure: false,
lastEvaluatedAt: now,
};
if (bootstrapMember?.agentToolAccepted && !current.agentToolAccepted) {
current.agentToolAccepted = true;
current.firstSpawnAcceptedAt =
current.firstSpawnAcceptedAt ?? bootstrapMember.firstSpawnAcceptedAt;
}
if (bootstrapMember?.runtimeAlive && !current.runtimeAlive) {
current.runtimeAlive = true;
current.lastRuntimeAliveAt =
current.lastRuntimeAliveAt ?? bootstrapMember.lastRuntimeAliveAt;
}
if (bootstrapMember?.bootstrapConfirmed && !current.bootstrapConfirmed) {
current.bootstrapConfirmed = true;
current.lastHeartbeatAt = current.lastHeartbeatAt ?? bootstrapMember.lastHeartbeatAt;
}
const matchedRuntimeNames = [...configMembers].filter((name) => {
if (name === expected) return true;
const parsed = parseNumericSuffixName(name);
@ -8499,6 +8823,21 @@ export class TeamProvisioningService {
: current.sources?.configDrift,
inboxHeartbeat: heartbeatMessage != null ? true : current.sources?.inboxHeartbeat,
};
const bootstrapProvesSpawnAcceptance =
bootstrapMember?.agentToolAccepted === true ||
typeof bootstrapMember?.firstSpawnAcceptedAt === 'string';
const currentProvesSpawnAcceptance =
current.agentToolAccepted === true || typeof current.firstSpawnAcceptedAt === 'string';
if (
isNeverSpawnedDuringLaunchReason(current.hardFailureReason) &&
(bootstrapProvesSpawnAcceptance || currentProvesSpawnAcceptance)
) {
current.hardFailure = false;
current.hardFailureReason = undefined;
if (current.sources) {
current.sources.hardFailureSignal = undefined;
}
}
if (heartbeatReason) {
current.hardFailure = true;
current.hardFailureReason = heartbeatReason;
@ -13044,8 +13383,7 @@ export class TeamProvisioningService {
const joinedAt = Date.now();
try {
await this.membersMetaStore.writeMembers(
teamName,
const membersToWrite = applyDistinctProvisioningMemberColors(
teammateMembers.map((member) => ({
name: member.name.trim(),
role: member.role?.trim() || undefined,
@ -13056,11 +13394,11 @@ export class TeamProvisioningService {
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
? member.effort
: undefined,
agentType: 'general-purpose',
color: getMemberColorByName(member.name.trim()),
agentType: 'general-purpose' as const,
joinedAt,
}))
);
await this.membersMetaStore.writeMembers(teamName, membersToWrite);
} catch (error) {
logger.warn(
`[${teamName}] Failed to persist members.meta.json: ${

View file

@ -4,6 +4,7 @@ import { parentPort } from 'node:worker_threads';
import { normalizePersistedLaunchSnapshot } from '@main/services/team/TeamLaunchStateEvaluator';
import { isLeadMember } from '@shared/utils/leadDetection';
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
interface ListTeamsPayload {
teamsDir: string;
@ -593,6 +594,11 @@ async function listTeams(
dropCliProvisionerMembers(memberMap);
const members = Array.from(memberMap.values());
const memberColors = buildTeamMemberColorMap(members, { preferProvidedColors: false });
const coloredMembers = members.map((member) => ({
...member,
color: memberColors.get(member.name) ?? member.color,
}));
const launchStateSummary =
(await readLaunchState(payload.teamsDir, teamName)) ??
(() => {
@ -623,7 +629,7 @@ async function listTeams(
memberCount: memberMap.size,
taskCount: 0,
lastActivity: null,
...(members.length > 0 ? { members } : {}),
...(coloredMembers.length > 0 ? { members: coloredMembers } : {}),
...(color ? { color } : {}),
...(projectPath ? { projectPath } : {}),
...(leadSessionId ? { leadSessionId } : {}),

View file

@ -40,6 +40,7 @@ import {
import { createChipFromSelection } from '@renderer/utils/chipUtils';
import { sumContextInjectionTokens } from '@renderer/utils/contextMath';
import { formatProjectPath } from '@renderer/utils/pathDisplay';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize';
import { nameColorSet } from '@renderer/utils/projectColor';
import { resolveProjectIdByPath } from '@renderer/utils/projectLookup';
@ -1537,6 +1538,10 @@ export const TeamDetailView = ({
return nextMember;
});
}, [leadBranch, members, trackedBranches]);
const resolvedMemberColorMap = useMemo(
() => buildMemberColorMap(membersWithLiveBranches),
[membersWithLiveBranches]
);
// Filter sessions to team-only using sessionHistory + leadSessionId
const teamSessionIds = useMemo(() => {
@ -2168,12 +2173,17 @@ export const TeamDetailView = ({
variant="ghost"
size="sm"
className="h-7 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
disabled={isTeamProvisioning}
onClick={() => setEditDialogOpen(true)}
>
<Pencil size={12} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Edit team</TooltipContent>
<TooltipContent side="bottom">
{isTeamProvisioning
? 'Edit team is unavailable while provisioning is still in progress'
: 'Edit team'}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
@ -2708,7 +2718,10 @@ export const TeamDetailView = ({
currentDescription={data.config.description ?? ''}
currentColor={data.config.color ?? ''}
currentMembers={membersWithLiveBranches.filter((m) => !isLeadMember(m))}
leadMember={membersWithLiveBranches.find((m) => isLeadMember(m)) ?? null}
resolvedMemberColorMap={resolvedMemberColorMap}
isTeamAlive={data.isAlive && !isTeamProvisioning}
isTeamProvisioning={isTeamProvisioning}
projectPath={data.config.projectPath}
onClose={() => setEditDialogOpen(false)}
onSaved={() => void selectTeam(teamName)}

View file

@ -47,6 +47,7 @@ import {
} from '@renderer/utils/teamModelAvailability';
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection';
import { resolveTeamLeadColorName } from '@shared/utils/teamMemberColors';
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
@ -940,7 +941,14 @@ export const CreateTeamDialog = ({
const mentionSuggestions = useMemo(
() =>
soloTeam
? [{ id: 'team-lead', name: 'team-lead', subtitle: 'Team Lead', color: 'blue' }]
? [
{
id: 'team-lead',
name: 'team-lead',
subtitle: 'Team Lead',
color: resolveTeamLeadColorName(),
},
]
: buildMemberDraftSuggestions(members, memberColorMap),
[memberColorMap, members, soloTeam]
);

View file

@ -1,13 +1,15 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api';
import {
buildMembersFromDrafts,
createMemberDraftsFromInputs,
filterEditableMemberInputs,
createMemberDraft,
MembersEditorSection,
validateMemberNameInline,
} from '@renderer/components/team/members/MembersEditorSection';
import { MemberDraftRow } from '@renderer/components/team/members/MemberDraftRow';
import { Button } from '@renderer/components/ui/button';
import {
Dialog,
@ -21,8 +23,17 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
import { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils';
import { buildMemberColorMap, displayMemberName } from '@renderer/utils/memberHelpers';
import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
import { Loader2 } from 'lucide-react';
import {
buildEditTeamSourceSnapshot,
getMemberRuntimeContractKey,
getLiveRosterIdentityChanges,
getMembersRequiringRuntimeRestart,
} from './editTeamRuntimeChanges';
import type { ResolvedTeamMember } from '@shared/types';
const TEAM_COLOR_NAMES = [
@ -43,16 +54,72 @@ interface EditTeamDialogProps {
currentDescription: string;
currentColor: string;
currentMembers: ResolvedTeamMember[];
leadMember?: ResolvedTeamMember | null;
resolvedMemberColorMap?: ReadonlyMap<string, string>;
isTeamAlive?: boolean;
isTeamProvisioning?: boolean;
projectPath?: string | null;
onClose: () => void;
onSaved: () => void;
onSaved: () => Promise<void> | void;
}
function membersToDrafts(members: ResolvedTeamMember[]) {
return createMemberDraftsFromInputs(filterEditableMemberInputs(members));
}
function useEditTeamErrorReset(
setError: (value: string | null) => void,
setSaveOutcomeError: (value: string | null) => void
): () => void {
return () => {
setError(null);
setSaveOutcomeError(null);
};
}
function getInvalidMemberNamesError(
members: readonly {
name: string;
removedAt?: number | string | null;
}[]
): string | null {
for (const member of members) {
if (member.removedAt) {
continue;
}
const name = member.name.trim();
if (!name) {
return 'Member name cannot be empty';
}
if (validateMemberNameInline(name) !== null) {
return 'Member name must start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars';
}
const lower = name.toLowerCase();
if (lower === 'user' || lower === 'team-lead') {
return `Member name "${name}" is reserved`;
}
const suffixInfo = parseNumericSuffixName(name);
if (suffixInfo && suffixInfo.suffix >= 2) {
return `Member name "${name}" is not allowed (reserved for Claude CLI auto-suffix). Use "${suffixInfo.base}" instead.`;
}
}
return null;
}
function applyRemovedMembersToSnapshot(
members: readonly ResolvedTeamMember[],
removedMemberNames: readonly string[]
): ResolvedTeamMember[] {
if (removedMemberNames.length === 0) {
return [...members];
}
const removedKeys = new Set(removedMemberNames.map((name) => name.trim().toLowerCase()));
const removedAt = Date.now();
return members.map((member) =>
removedKeys.has(member.name.trim().toLowerCase()) ? { ...member, removedAt } : member
);
}
export const EditTeamDialog = ({
open,
teamName,
@ -60,7 +127,10 @@ export const EditTeamDialog = ({
currentDescription,
currentColor,
currentMembers,
leadMember = null,
resolvedMemberColorMap,
isTeamAlive = false,
isTeamProvisioning = false,
projectPath,
onClose,
onSaved,
@ -72,39 +142,305 @@ export const EditTeamDialog = ({
const [members, setMembers] = useState(() => membersToDrafts(currentMembers));
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saveOutcomeError, setSaveOutcomeError] = useState<string | null>(null);
const [membersPendingRestartRetry, setMembersPendingRestartRetry] = useState<
Record<string, string>
>({});
const wasOpenRef = useRef(false);
const initializedTeamNameRef = useRef<string | null>(null);
const baselineSourceSnapshotRef = useRef<string | null>(null);
const pendingCommittedSourceSnapshotRef = useRef<string | null>(null);
useFileListCacheWarmer(projectPath ?? null);
const clearTransientErrors = useEditTeamErrorReset(setError, setSaveOutcomeError);
const effectiveResolvedMemberColorMap = useMemo(
() => resolvedMemberColorMap ?? buildMemberColorMap(currentMembers),
[currentMembers, resolvedMemberColorMap]
);
const leadDraft = useMemo(() => {
if (!leadMember) return null;
return createMemberDraft({
id: `lead:${leadMember.name}`,
name: displayMemberName(leadMember.name),
originalName: leadMember.name,
roleSelection: '',
customRole: 'Team Lead',
workflow: leadMember.workflow,
providerId: leadMember.providerId,
model: leadMember.model ?? '',
effort: leadMember.effort,
});
}, [leadMember]);
useEffect(() => {
const wasOpen = wasOpenRef.current;
if (open) {
setName(currentName);
setDescription(currentDescription);
setColor(currentColor);
setMembers(membersToDrafts(currentMembers));
setError(null);
const shouldInitialize = !wasOpen || initializedTeamNameRef.current !== teamName;
if (shouldInitialize) {
setName(currentName);
setDescription(currentDescription);
setColor(currentColor);
setMembers(membersToDrafts(currentMembers));
setError(null);
setSaveOutcomeError(null);
setMembersPendingRestartRetry({});
initializedTeamNameRef.current = teamName;
baselineSourceSnapshotRef.current = buildEditTeamSourceSnapshot({
name: currentName,
description: currentDescription,
color: currentColor,
members: currentMembers,
});
pendingCommittedSourceSnapshotRef.current = null;
} else if (pendingCommittedSourceSnapshotRef.current !== null) {
const latestSourceSnapshot = buildEditTeamSourceSnapshot({
name: currentName,
description: currentDescription,
color: currentColor,
members: currentMembers,
});
if (latestSourceSnapshot === pendingCommittedSourceSnapshotRef.current) {
baselineSourceSnapshotRef.current = latestSourceSnapshot;
pendingCommittedSourceSnapshotRef.current = null;
}
}
} else if (wasOpen) {
initializedTeamNameRef.current = null;
baselineSourceSnapshotRef.current = null;
pendingCommittedSourceSnapshotRef.current = null;
}
}, [open, currentName, currentDescription, currentColor, currentMembers]);
wasOpenRef.current = open;
}, [open, teamName, currentName, currentDescription, currentColor, currentMembers]);
const builtMembers = useMemo(() => buildMembersFromDrafts(members), [members]);
const invalidMemberNamesError = useMemo(() => getInvalidMemberNamesError(members), [members]);
const hasDuplicateMembers = useMemo(() => {
const names = members
.filter((member) => !member.removedAt)
.map((member) => member.name.trim().toLowerCase())
.filter(Boolean);
return new Set(names).size !== names.length;
}, [members]);
const membersToRestart = useMemo(
() =>
isTeamAlive
? getMembersRequiringRuntimeRestart({
previousMembers: currentMembers,
nextMembers: builtMembers,
})
: [],
[builtMembers, currentMembers, isTeamAlive]
);
const builtMembersByName = useMemo(
() =>
new Map(builtMembers.map((member) => [member.name.trim().toLowerCase(), member] as const)),
[builtMembers]
);
const effectiveMembersToRestart = useMemo(() => {
const retryMembers = Object.entries(membersPendingRestartRetry).flatMap(
([normalizedName, expectedRuntimeContractKey]) => {
const nextMember = builtMembersByName.get(normalizedName);
if (!nextMember) {
return [];
}
return getMemberRuntimeContractKey(nextMember) === expectedRuntimeContractKey
? [nextMember.name.trim()]
: [];
}
);
return Array.from(
new Set(
[...membersToRestart, ...retryMembers]
.map((memberName) => memberName.trim())
.filter(Boolean)
)
);
}, [builtMembersByName, membersPendingRestartRetry, membersToRestart]);
const liveIdentityChanges = useMemo(
() =>
isTeamAlive
? getLiveRosterIdentityChanges({
previousMembers: currentMembers,
nextDrafts: members,
})
: { renamed: [], removed: [] },
[currentMembers, isTeamAlive, members]
);
const hasBlockedLiveIdentityChanges = liveIdentityChanges.renamed.length > 0;
const liveRemovedExistingMembers = useMemo(
() => (isTeamAlive ? liveIdentityChanges.removed : []),
[isTeamAlive, liveIdentityChanges.removed]
);
const hasNewLiveTeammates = useMemo(
() =>
isTeamAlive && members.some((member) => !member.removedAt && !member.originalName?.trim()),
[isTeamAlive, members]
);
const memberWarningById = useMemo(() => {
const restartNames = new Set(
effectiveMembersToRestart.map((memberName) => memberName.trim().toLowerCase())
);
if (restartNames.size === 0) {
return undefined;
}
return Object.fromEntries(
members.map((member) => [
member.id,
restartNames.has(member.name.trim().toLowerCase())
? 'Saving will restart this teammate to apply role, workflow, provider, model, or effort changes.'
: null,
])
);
}, [effectiveMembersToRestart, members]);
const handleSave = (): void => {
if (!name.trim()) {
setError('Team name cannot be empty');
return;
}
const builtMembers = buildMembersFromDrafts(members);
if (invalidMemberNamesError) {
setError(invalidMemberNamesError);
return;
}
if (hasDuplicateMembers) {
setError('Member names must be unique before saving');
return;
}
const latestSourceSnapshot = buildEditTeamSourceSnapshot({
name: currentName,
description: currentDescription,
color: currentColor,
members: currentMembers,
});
const allowedSourceSnapshots = new Set(
[baselineSourceSnapshotRef.current, pendingCommittedSourceSnapshotRef.current].filter(
(value): value is string => value !== null
)
);
if (allowedSourceSnapshots.size > 0 && !allowedSourceSnapshots.has(latestSourceSnapshot)) {
setError(
'Team settings changed while this dialog was open. Reopen it and review the latest state before saving.'
);
return;
}
if (hasBlockedLiveIdentityChanges) {
setError(
`Existing teammates cannot be renamed while the team is live. renamed: ${liveIdentityChanges.renamed.join(', ')}`
);
return;
}
if (isTeamProvisioning) {
setError(
'Team settings cannot be edited while provisioning is still in progress. Wait for launch to finish, then try again.'
);
return;
}
if (hasNewLiveTeammates) {
setError(
'Add new teammates from the dedicated Add member dialog while the team is live. Edit Team only supports updating existing teammates.'
);
return;
}
setSaving(true);
setError(null);
setSaveOutcomeError(null);
void (async () => {
let configSaved = false;
let membersSaved = false;
let committedMembersForSnapshot: ResolvedTeamMember[] = currentMembers;
try {
await api.teams.updateConfig(teamName, {
name: name.trim(),
description: description.trim(),
color,
});
configSaved = true;
for (const removedMemberName of liveRemovedExistingMembers) {
await api.teams.removeMember(teamName, removedMemberName);
committedMembersForSnapshot = applyRemovedMembersToSnapshot(committedMembersForSnapshot, [
removedMemberName,
]);
}
await api.teams.replaceMembers(teamName, { members: builtMembers });
onSaved();
onClose();
membersSaved = true;
pendingCommittedSourceSnapshotRef.current = buildEditTeamSourceSnapshot({
name: name.trim(),
description: description.trim(),
color: color.trim(),
members: builtMembers.map((member) => ({
name: member.name,
role: member.role,
workflow: member.workflow,
providerId: member.providerId,
model: member.model,
effort: member.effort,
})) as ResolvedTeamMember[],
});
const restartFailures: string[] = [];
const failedRestartMembers: string[] = [];
for (const memberName of effectiveMembersToRestart) {
try {
await api.teams.restartMember(teamName, memberName);
} catch (restartError) {
const detail =
restartError instanceof Error ? restartError.message : String(restartError);
failedRestartMembers.push(memberName);
restartFailures.push(`${memberName} (${detail})`);
}
}
await Promise.resolve(onSaved());
if (restartFailures.length === 0) {
setMembersPendingRestartRetry({});
onClose();
return;
}
setMembersPendingRestartRetry(
Object.fromEntries(
failedRestartMembers.flatMap((memberName) => {
const nextMember = builtMembersByName.get(memberName.trim().toLowerCase());
if (!nextMember) {
return [];
}
return [
[memberName.trim().toLowerCase(), getMemberRuntimeContractKey(nextMember)] as const,
];
})
)
);
setSaveOutcomeError(
`Team saved, but failed to restart ${restartFailures.length === 1 ? 'this teammate' : 'these teammates'}: ${restartFailures.join(', ')}`
);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to save');
const message = e instanceof Error ? e.message : 'Failed to save';
if (membersSaved) {
setSaveOutcomeError(
`Team changes were saved, but failed to refresh the latest view: ${message}`
);
} else if (configSaved) {
pendingCommittedSourceSnapshotRef.current = buildEditTeamSourceSnapshot({
name: name.trim(),
description: description.trim(),
color: color.trim(),
members: committedMembersForSnapshot,
});
let refreshErrorDetail: string | null = null;
try {
await Promise.resolve(onSaved());
} catch (refreshError) {
refreshErrorDetail =
refreshError instanceof Error ? refreshError.message : String(refreshError);
}
setSaveOutcomeError(
refreshErrorDetail
? `Team settings were saved, but member changes failed: ${message}. Refresh also failed: ${refreshErrorDetail}`
: `Team settings were saved, but member changes failed: ${message}`
);
} else {
setError(message);
}
} finally {
setSaving(false);
}
@ -131,7 +467,10 @@ export const EditTeamDialog = ({
id="edit-team-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
onChange={(e) => {
clearTransientErrors();
setName(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !saving && name.trim()) handleSave();
}}
@ -149,7 +488,10 @@ export const EditTeamDialog = ({
<textarea
id="edit-team-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
onChange={(e) => {
clearTransientErrors();
setDescription(e.target.value);
}}
rows={3}
className="w-full resize-none rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-sm text-[var(--color-text)] outline-none focus:border-[var(--color-border-emphasis)]"
placeholder="Team description (optional)"
@ -158,20 +500,82 @@ export const EditTeamDialog = ({
<div>
<MembersEditorSection
members={members}
onChange={setMembers}
onChange={(nextMembers) => {
clearTransientErrors();
setMembers(nextMembers);
}}
fieldError={invalidMemberNamesError ?? undefined}
validateMemberName={validateMemberNameInline}
showWorkflow
showJsonEditor={!isTeamAlive}
draftKeyPrefix={`editTeam:${teamName}`}
projectPath={projectPath ?? null}
headerExtra={
leadDraft ? (
<div className="space-y-2">
<MemberDraftRow
member={leadDraft}
index={0}
resolvedColor={effectiveResolvedMemberColorMap.get(
leadDraft.originalName ?? leadDraft.name
)}
nameError={null}
onNameChange={() => undefined}
onRoleChange={() => undefined}
onCustomRoleChange={() => undefined}
onRemove={() => undefined}
onProviderChange={() => undefined}
onModelChange={() => undefined}
onEffortChange={() => undefined}
projectPath={projectPath ?? null}
lockProviderModel
lockRole
lockedRoleLabel="Team Lead"
lockIdentity
hideActionButton
modelLockReason="The team lead is shown for context only and cannot be edited from Edit Team."
/>
<p className="text-[11px] text-[var(--color-text-muted)]">
Team lead is shown for context only. Edit Team changes only teammate roster
settings.
</p>
</div>
) : null
}
existingMembers={currentMembers}
lockProviderModel={isTeamAlive}
existingMemberColorMap={effectiveResolvedMemberColorMap}
lockProviderModel={false}
lockExistingMemberIdentity={isTeamAlive}
identityLockReason={undefined}
disableAddMember={isTeamAlive}
addMemberLockReason="Use the dedicated Add member dialog to add new teammates while the team is live."
memberWarningById={memberWarningById}
/>
</div>
{isTeamAlive ? (
{isTeamProvisioning ? (
<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.
Team provisioning is still in progress. Editing is temporarily locked until launch
finishes.
</p>
) : null}
{isTeamAlive && hasNewLiveTeammates ? (
<p className="text-xs text-red-300">
New teammates cannot be added from Edit Team while the team is live. Use the Add
member dialog instead.
</p>
) : null}
{isTeamAlive && hasBlockedLiveIdentityChanges ? (
<p className="text-xs text-red-300">
Live save is blocked because existing teammates were renamed. Revert those identity
changes or stop the team first.
</p>
) : null}
{isTeamAlive && effectiveMembersToRestart.length > 0 ? (
<p className="text-xs text-amber-300">
Saving will restart{' '}
{effectiveMembersToRestart.length === 1 ? 'this teammate' : 'these teammates'} to
apply role, workflow, provider, model, or effort changes:{' '}
{effectiveMembersToRestart.join(', ')}.
</p>
) : null}
<div>
@ -196,7 +600,10 @@ export const EditTeamDialog = ({
borderColor: isSelected ? colorSet.border : 'transparent',
}}
title={colorName}
onClick={() => setColor(isSelected ? '' : colorName)}
onClick={() => {
clearTransientErrors();
setColor(isSelected ? '' : colorName);
}}
>
<span
className="size-3.5 rounded-full"
@ -207,14 +614,26 @@ export const EditTeamDialog = ({
})}
</div>
</div>
{error && <p className="text-xs text-red-400">{error}</p>}
{(error || saveOutcomeError) && (
<p className="text-xs text-red-400">{error ?? saveOutcomeError}</p>
)}
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button size="sm" onClick={handleSave} disabled={saving || !name.trim()}>
<Button
size="sm"
onClick={handleSave}
disabled={
saving ||
isTeamProvisioning ||
!name.trim() ||
hasDuplicateMembers ||
Boolean(invalidMemberNamesError)
}
>
{saving && <Loader2 size={14} className="mr-1.5 animate-spin" />}
Save
</Button>

View file

@ -0,0 +1,165 @@
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
import type { ResolvedTeamMember, TeamProvisioningMemberInput } from '@shared/types';
function normalizeRestartSensitiveMemberContract(member: {
role?: string;
workflow?: string;
providerId?: string;
model?: string;
effort?: string;
}): {
role?: string;
workflow?: string;
providerId?: 'anthropic' | 'codex' | 'gemini';
model?: string;
effort?: 'low' | 'medium' | 'high';
} {
const role = member.role?.trim() || undefined;
const workflow = member.workflow?.trim() || undefined;
const providerId = normalizeOptionalTeamProviderId(member.providerId);
const model = member.model?.trim() || undefined;
const effort =
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
? member.effort
: undefined;
return { role, workflow, providerId, model, effort };
}
export function getMemberRuntimeContractKey(member: {
role?: string;
workflow?: string;
providerId?: string;
model?: string;
effort?: string;
}): string {
return JSON.stringify(normalizeRestartSensitiveMemberContract(member));
}
export function getMembersRequiringRuntimeRestart(params: {
previousMembers: readonly ResolvedTeamMember[];
nextMembers: readonly TeamProvisioningMemberInput[];
}): string[] {
const previousByName = new Map(
params.previousMembers
.filter((member) => !member.removedAt)
.map((member) => [member.name.trim().toLowerCase(), member] as const)
);
const membersToRestart: string[] = [];
for (const nextMember of params.nextMembers) {
const normalizedName = nextMember.name.trim().toLowerCase();
if (!normalizedName) {
continue;
}
const previousMember = previousByName.get(normalizedName);
if (!previousMember) {
continue;
}
const previousRuntime = normalizeRestartSensitiveMemberContract(previousMember);
const nextRuntime = normalizeRestartSensitiveMemberContract(nextMember);
if (
previousRuntime.role !== nextRuntime.role ||
previousRuntime.workflow !== nextRuntime.workflow ||
previousRuntime.providerId !== nextRuntime.providerId ||
previousRuntime.model !== nextRuntime.model ||
previousRuntime.effort !== nextRuntime.effort
) {
membersToRestart.push(previousMember.name);
}
}
return membersToRestart;
}
export function getLiveRosterIdentityChanges(params: {
previousMembers: readonly ResolvedTeamMember[];
nextDrafts: readonly MemberDraft[];
}): {
renamed: string[];
removed: string[];
} {
const previousMembers = params.previousMembers
.filter((member) => !member.removedAt)
.filter((member) => member.name.trim().toLowerCase() !== 'team-lead');
const previousNamesByKey = new Map(
previousMembers.map((member) => [member.name.trim().toLowerCase(), member.name.trim()] as const)
);
const nextExistingOriginalKeys = new Set(
params.nextDrafts
.map((member) => member.originalName?.trim().toLowerCase())
.filter((value): value is string => Boolean(value))
);
const renamed = params.nextDrafts
.flatMap((member) => {
const originalName = member.originalName?.trim();
const nextName = member.name.trim();
if (!originalName || !nextName) {
return [];
}
return originalName.toLowerCase() === nextName.toLowerCase() ? [] : [originalName];
})
.sort((a, b) => a.localeCompare(b));
const removed = Array.from(previousNamesByKey.entries())
.filter(([normalizedName]) => !nextExistingOriginalKeys.has(normalizedName))
.map(([, displayName]) => displayName)
.sort((a, b) => a.localeCompare(b));
return { renamed, removed };
}
function normalizeEditableMemberSnapshot(member: {
name: string;
role?: string;
workflow?: string;
providerId?: string;
model?: string;
effort?: string;
removedAt?: number | string | null;
}): {
name: string;
role?: string;
workflow?: string;
providerId?: 'anthropic' | 'codex' | 'gemini';
model?: string;
effort?: 'low' | 'medium' | 'high';
} | null {
if (member.removedAt) {
return null;
}
const name = member.name.trim();
if (!name || name.toLowerCase() === 'team-lead') {
return null;
}
return {
name,
role: member.role?.trim() || undefined,
workflow: member.workflow?.trim() || undefined,
...normalizeRestartSensitiveMemberContract(member),
};
}
export function buildEditTeamSourceSnapshot(params: {
name: string;
description: string;
color: string;
members: readonly ResolvedTeamMember[];
}): string {
const members = params.members
.map(normalizeEditableMemberSnapshot)
.filter((member): member is NonNullable<typeof member> => member !== null)
.sort((a, b) => a.name.localeCompare(b.name));
return JSON.stringify({
name: params.name.trim(),
description: params.description.trim(),
color: params.color.trim(),
members,
});
}

View file

@ -0,0 +1,132 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { resolveTeamLeadColorName } from '@shared/utils/teamMemberColors';
vi.mock('@renderer/components/common/ProviderBrandLogo', () => ({
ProviderBrandLogo: () => React.createElement('span', { 'data-testid': 'provider-logo' }),
}));
vi.mock('@renderer/components/team/dialogs/EffortLevelSelector', () => ({
EffortLevelSelector: () => React.createElement('div', null, 'effort-selector'),
}));
vi.mock('@renderer/components/team/dialogs/LimitContextCheckbox', () => ({
LimitContextCheckbox: () => React.createElement('div', null, 'limit-context'),
}));
vi.mock('@renderer/components/team/dialogs/TeamModelSelector', () => ({
getProviderScopedTeamModelLabel: (_providerId: string, model: string) => model || 'Default',
getTeamProviderLabel: (providerId: string) => providerId,
TeamModelSelector: () => React.createElement('div', null, 'team-model-selector'),
}));
vi.mock('@renderer/components/ui/checkbox', () => ({
Checkbox: ({
checked,
onCheckedChange,
...props
}: {
checked?: boolean;
onCheckedChange?: (value: boolean) => void;
}) =>
React.createElement('input', {
...props,
checked,
type: 'checkbox',
onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
onCheckedChange?.(event.target.checked),
}),
}));
vi.mock('@renderer/components/ui/label', () => ({
Label: ({
children,
...props
}: React.LabelHTMLAttributes<HTMLLabelElement> & { children: React.ReactNode }) =>
React.createElement('label', props, children),
}));
vi.mock('@renderer/hooks/useTheme', () => ({
useTheme: () => ({ isLight: false }),
}));
vi.mock('@renderer/utils/teamModelCatalog', () => ({
isAnthropicHaikuTeamModel: () => false,
}));
vi.mock('../../ui/button', () => ({
Button: ({
children,
className,
onClick,
disabled,
'aria-label': ariaLabel,
}: {
children: React.ReactNode;
className?: string;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
disabled?: boolean;
'aria-label'?: string;
}) =>
React.createElement(
'button',
{ className, disabled, onClick, type: 'button', 'aria-label': ariaLabel },
children
),
}));
import { LeadModelRow } from './LeadModelRow';
function renderLeadModelRow(): { host: HTMLDivElement; root: ReturnType<typeof createRoot> } {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
act(() => {
root.render(
React.createElement(LeadModelRow, {
providerId: 'anthropic',
model: 'opus',
effort: 'medium',
limitContext: false,
onProviderChange: () => undefined,
onModelChange: () => undefined,
onEffortChange: () => undefined,
onLimitContextChange: () => undefined,
syncModelsWithTeammates: true,
onSyncModelsWithTeammatesChange: () => undefined,
})
);
});
return { host, root };
}
describe('LeadModelRow', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
});
afterEach(() => {
document.body.innerHTML = '';
});
it('uses the canonical team-lead color for the preview stripe', () => {
const { host, root } = renderLeadModelRow();
const stripe = host.querySelector('[aria-hidden="true"]');
const expectedBorder = getTeamColorSet(resolveTeamLeadColorName()).border;
expect(host.textContent).toContain('lead');
expect(host.textContent).toContain('Team Lead');
expect(stripe?.getAttribute('style')).toContain(expectedBorder);
act(() => {
root.unmount();
});
});
});

View file

@ -14,7 +14,7 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils';
import { isAnthropicHaikuTeamModel } from '@renderer/utils/teamModelCatalog';
import { getMemberColorByName } from '@shared/constants/memberColors';
import { resolveTeamLeadColorName } from '@shared/utils/teamMemberColors';
import { AlertTriangle, ChevronDown, ChevronRight, Info } from 'lucide-react';
import { Button } from '../../ui/button';
@ -54,7 +54,7 @@ export const LeadModelRow = ({
}: LeadModelRowProps): React.JSX.Element => {
const { isLight } = useTheme();
const [modelExpanded, setModelExpanded] = useState(false);
const leadColorSet = getTeamColorSet(getMemberColorByName('lead'));
const leadColorSet = getTeamColorSet(resolveTeamLeadColorName());
const modelButtonLabel = model.trim()
? getProviderScopedTeamModelLabel(providerId, model.trim())
: 'Default';

View file

@ -48,6 +48,26 @@ interface MemberCardProps {
onAssignTask?: () => void;
}
function splitRuntimeSummaryMemory(runtimeSummary: string | undefined): {
summary: string | undefined;
memory: string | undefined;
} {
const trimmed = runtimeSummary?.trim();
if (!trimmed) {
return { summary: undefined, memory: undefined };
}
const match = /^(.*?)(?:\s·\s(\d+(?:\.\d+)?\s(?:B|KB|MB|GB|TB)))$/.exec(trimmed);
if (!match) {
return { summary: trimmed, memory: undefined };
}
return {
summary: match[1]?.trim() || undefined,
memory: match[2]?.trim() || undefined,
};
}
export const MemberCard = ({
member,
memberColor,
@ -102,6 +122,8 @@ export const MemberCard = ({
const totalTasks = pending + inProgress + completed;
const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0;
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
const { summary: runtimeSummaryText, memory: memoryLabel } =
splitRuntimeSummaryMemory(runtimeSummary);
const activityTask = currentTask ?? reviewTask ?? null;
const activityTitle = currentTask
? `Current task: #${deriveTaskDisplayId(currentTask.id)}`
@ -215,13 +237,19 @@ export const MemberCard = ({
style={{ backgroundColor: 'var(--skeleton-base)' }}
/>
</div>
) : runtimeSummary || roleLabel ? (
) : runtimeSummaryText || roleLabel || memoryLabel ? (
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[10px] font-medium text-[var(--color-text-muted)]">
{runtimeSummary ? <span className="min-w-0 truncate">{runtimeSummary}</span> : null}
{runtimeSummary && roleLabel ? (
{runtimeSummaryText ? (
<span className="min-w-0 truncate">{runtimeSummaryText}</span>
) : null}
{runtimeSummaryText && roleLabel ? (
<span className="shrink-0 opacity-60"></span>
) : null}
{roleLabel ? <span className="shrink-0">{roleLabel}</span> : null}
{(runtimeSummaryText || roleLabel) && memoryLabel ? (
<span className="shrink-0 opacity-60"></span>
) : null}
{memoryLabel ? <span className="shrink-0">{memoryLabel}</span> : null}
</div>
) : null}
</div>

View file

@ -128,6 +128,9 @@ export const MemberDetailDialog = ({
: undefined,
[launchParams, member, runtimeEntry, spawnEntry]
);
const restartInFlight =
spawnEntry?.launchState === 'starting' ||
spawnEntry?.launchState === 'runtime_pending_bootstrap';
useEffect(() => {
if (!open || !member) {
@ -267,7 +270,7 @@ export const MemberDetailDialog = ({
variant="outline"
size="sm"
className="gap-1.5"
disabled={restarting}
disabled={restarting || restartInFlight}
onClick={async () => {
setRestartError(null);
setRestarting(true);

View file

@ -50,10 +50,15 @@ interface MemberDraftRowProps {
taskSuggestions?: MentionSuggestion[];
teamSuggestions?: MentionSuggestion[];
lockProviderModel?: boolean;
lockRole?: boolean;
lockedRoleLabel?: string;
lockIdentity?: boolean;
identityLockReason?: string;
forceInheritedModelSettings?: boolean;
modelLockReason?: string;
isRemoved?: boolean;
onRestore?: (id: string) => void;
hideActionButton?: boolean;
warningText?: string | null;
disableGeminiOption?: boolean;
modelIssueText?: string | null;
@ -83,10 +88,15 @@ export const MemberDraftRow = ({
taskSuggestions,
teamSuggestions,
lockProviderModel = false,
lockRole = false,
lockedRoleLabel,
lockIdentity = false,
identityLockReason,
forceInheritedModelSettings = false,
modelLockReason,
isRemoved = false,
onRestore,
hideActionButton = false,
warningText,
disableGeminiOption = false,
modelIssueText,
@ -200,29 +210,28 @@ export const MemberDraftRow = ({
className="h-8 text-xs"
value={member.name}
aria-label={`Member ${index + 1} name`}
disabled={isRemoved}
disabled={isRemoved || lockIdentity}
onChange={(event) => onNameChange(member.id, event.target.value)}
placeholder="member-name"
style={
member.name.trim()
? {
color: memberColorSet.text,
}
: undefined
}
/>
{nameError ? <p className="text-[10px] text-red-300">{nameError}</p> : null}
</div>
<div>
<RoleSelect
value={member.roleSelection || '__none__'}
disabled={isRemoved}
onValueChange={(roleSelection) => onRoleChange(member.id, roleSelection)}
customRole={member.customRole}
onCustomRoleChange={(customRole) => onCustomRoleChange(member.id, customRole)}
triggerClassName="h-8 text-xs"
inputClassName="h-8 text-xs"
/>
{lockRole ? (
<div className="flex h-8 items-center rounded-md border border-[var(--color-border)] bg-transparent px-3 text-xs text-[var(--color-text)] opacity-80">
{lockedRoleLabel || member.customRole || member.roleSelection || 'No role'}
</div>
) : (
<RoleSelect
value={member.roleSelection || '__none__'}
disabled={isRemoved}
onValueChange={(roleSelection) => onRoleChange(member.id, roleSelection)}
customRole={member.customRole}
onCustomRoleChange={(customRole) => onCustomRoleChange(member.id, customRole)}
triggerClassName="h-8 text-xs"
inputClassName="h-8 text-xs"
/>
)}
</div>
<div className="space-y-1">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start">
@ -289,7 +298,7 @@ export const MemberDraftRow = ({
) : null}
</Tooltip>
</div>
{isRemoved ? (
{hideActionButton ? null : isRemoved ? (
<Button
variant="outline"
size="sm"

View file

@ -88,10 +88,15 @@ export interface MembersEditorSectionProps {
hideContent?: boolean;
/** Existing team members — used to reserve their colors so drafts get the next available ones */
existingMembers?: readonly { name: string; color?: string; removedAt?: number | string | null }[];
/** Pre-resolved member colors from the live Team view. */
existingMemberColorMap?: ReadonlyMap<string, string>;
/** Default provider to use for newly added member rows. */
defaultProviderId?: TeamProviderId;
/** When true, provider/model controls stay read-only for existing rows. */
lockProviderModel?: boolean;
/** When true, existing teammate names stay read-only while the team is live. */
lockExistingMemberIdentity?: boolean;
identityLockReason?: string;
inheritedProviderId?: TeamProviderId;
inheritedModel?: string;
inheritedEffort?: EffortLevel;
@ -102,6 +107,8 @@ export interface MembersEditorSectionProps {
memberWarningById?: Record<string, string | null | undefined>;
disableGeminiOption?: boolean;
memberModelIssueById?: Record<string, string | null | undefined>;
disableAddMember?: boolean;
addMemberLockReason?: string;
}
export const MembersEditorSection = ({
@ -118,8 +125,11 @@ export const MembersEditorSection = ({
headerExtra,
hideContent = false,
existingMembers,
existingMemberColorMap,
defaultProviderId = 'anthropic',
lockProviderModel = false,
lockExistingMemberIdentity = false,
identityLockReason,
inheritedProviderId,
inheritedModel,
inheritedEffort,
@ -130,6 +140,8 @@ export const MembersEditorSection = ({
memberWarningById,
disableGeminiOption = false,
memberModelIssueById,
disableAddMember = false,
addMemberLockReason,
}: MembersEditorSectionProps): React.JSX.Element => {
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
const [jsonText, setJsonText] = useState('');
@ -257,8 +269,8 @@ export const MembersEditorSection = ({
const names = activeMembers.map((m) => m.name.trim().toLowerCase()).filter(Boolean);
const hasDuplicates = new Set(names).size !== names.length;
const memberColorMap = useMemo(
() => buildMemberDraftColorMap(members, existingMembers),
[members, existingMembers]
() => buildMemberDraftColorMap(members, existingMembers, existingMemberColorMap),
[members, existingMembers, existingMemberColorMap]
);
const mentionSuggestions = useMemo(
@ -272,7 +284,14 @@ export const MembersEditorSection = ({
<Label>Members</Label>
{!hideContent && (
<div className="flex gap-2">
<Button variant="outline" size="sm" className="gap-1.5" onClick={addMember}>
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={addMember}
disabled={disableAddMember}
title={disableAddMember ? addMemberLockReason : undefined}
>
<Plus className="size-3.5" />
Add member
</Button>
@ -287,6 +306,9 @@ export const MembersEditorSection = ({
{headerExtra}
{!hideContent && (
<>
{disableAddMember && addMemberLockReason ? (
<p className="text-[11px] text-[var(--color-text-muted)]">{addMemberLockReason}</p>
) : null}
<div className="space-y-2">
{activeMembers.map((member, index) => (
<MemberDraftRow
@ -315,6 +337,8 @@ export const MembersEditorSection = ({
taskSuggestions={taskSuggestions}
teamSuggestions={teamSuggestions}
lockProviderModel={lockProviderModel}
lockIdentity={lockExistingMemberIdentity && Boolean(member.originalName?.trim())}
identityLockReason={identityLockReason}
modelLockReason={modelLockReason}
warningText={memberWarningById?.[member.id] ?? null}
disableGeminiOption={disableGeminiOption}

View file

@ -4,6 +4,7 @@ import type { EffortLevel, TeamProviderId } from '@shared/types';
export interface MemberDraft {
id: string;
name: string;
originalName?: string;
roleSelection: string;
customRole: string;
workflow?: string;

View file

@ -1,29 +1,20 @@
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
import { serializeChipsWithText } from '@renderer/types/inlineChip';
import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { getMemberColorByName } from '@shared/constants/memberColors';
import { normalizeExplicitTeamModelForUi } from '@renderer/utils/teamModelAvailability';
import { isLeadMember } from '@shared/utils/leadDetection';
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
import { validateTeamMemberNameFormat } from '@shared/utils/teamMemberName';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import type { MemberDraft } from './membersEditorTypes';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { EffortLevel, TeamProviderId, TeamProvisioningMemberInput } from '@shared/types';
function isValidMemberName(name: string): boolean {
if (name.length < 1 || name.length > 128) return false;
if (!/^[a-zA-Z0-9]/.test(name)) return false;
return /^[a-zA-Z0-9._-]+$/.test(name);
}
export function validateMemberNameInline(name: string): string | null {
const trimmed = name.trim();
if (!trimmed) return null;
if (!isValidMemberName(trimmed)) {
return 'Start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars';
}
return null;
return validateTeamMemberNameFormat(trimmed);
}
function newDraftId(): string {
@ -36,6 +27,7 @@ export function createMemberDraft(initial?: Partial<MemberDraft>): MemberDraft {
return {
id: initial?.id ?? newDraftId(),
name: initial?.name ?? '',
originalName: initial?.originalName,
roleSelection: initial?.roleSelection ?? '',
customRole: initial?.customRole ?? '',
workflow: initial?.workflow,
@ -66,6 +58,7 @@ export function createMemberDraftsFromInputs(
const isPreset = presetRoles.includes(role);
return createMemberDraft({
name: member.name,
originalName: member.name,
roleSelection: role ? (isPreset ? role : CUSTOM_ROLE) : '',
customRole: role && !isPreset ? role : '',
workflow: member.workflow,
@ -141,18 +134,29 @@ interface ExistingMemberColorInput {
export function buildMemberDraftColorMap(
members: readonly Pick<MemberDraft, 'name'>[],
existingMembers?: readonly ExistingMemberColorInput[]
existingMembers?: readonly ExistingMemberColorInput[],
existingColorMap?: ReadonlyMap<string, string>
): Map<string, string> {
const draftEntries = members
.map((member) => member.name.trim())
.filter(Boolean)
.map((name) => ({ name }));
const normalizedExistingColorMap = new Map<string, string>(
Array.from(existingColorMap?.entries() ?? []).map(([name, color]) => [
name.trim().toLowerCase(),
color,
])
);
const existingSeedEntries = (existingMembers ?? [])
.map((member) => ({
...member,
name: member.name.trim(),
color: member.color?.trim() || getMemberColorByName(member.name),
color:
normalizedExistingColorMap.get(member.name.trim().toLowerCase()) ??
member.color?.trim() ??
undefined,
}))
.filter((member) => member.name);
const existingNames = new Set(existingSeedEntries.map((member) => member.name.toLowerCase()));
@ -166,17 +170,9 @@ export function buildMemberDraftColorMap(
return true;
});
const predictedDraftSeedEntries = uniqueNewDraftEntries.map((entry) => ({
...entry,
color: getMemberColorByName(entry.name),
}));
// Mirror the team page color inputs:
// 1. existing members keep their persisted/resolved color
// 2. new draft members get the same name-based default color that the resolver
// will assign after create/launch/add
// 3. buildMemberColorMap still resolves rare collisions the same way as the UI
const fullMap = buildMemberColorMap([...existingSeedEntries, ...predictedDraftSeedEntries]);
const fullMap = buildTeamMemberColorMap([...existingSeedEntries, ...uniqueNewDraftEntries], {
preferProvidedColors: true,
});
const fullColorByName = new Map(
Array.from(fullMap.entries()).map(([name, color]) => [name.toLowerCase(), color] as const)
);

View file

@ -30,6 +30,83 @@ const TEAMMATE_COLORS: Record<string, TeamColorSet> = {
text: '#60a5fa',
textLight: '#2563eb',
},
saffron: {
border: '#eab308',
badge: 'rgba(234, 179, 8, 0.15)',
badgeLight: 'rgba(234, 179, 8, 0.12)',
text: '#fde047',
textLight: '#a16207',
},
turquoise: {
border: '#14b8a6',
badge: 'rgba(20, 184, 166, 0.15)',
badgeLight: 'rgba(20, 184, 166, 0.12)',
text: '#5eead4',
textLight: '#0f766e',
},
brick: {
border: '#ef4444',
badge: 'rgba(239, 68, 68, 0.15)',
badgeLight: 'rgba(239, 68, 68, 0.12)',
text: '#f87171',
textLight: '#b91c1c',
},
indigo: {
border: '#8b5cf6',
badge: 'rgba(139, 92, 246, 0.15)',
badgeLight: 'rgba(139, 92, 246, 0.12)',
text: '#c4b5fd',
textLight: '#6d28d9',
},
forest: {
border: '#22c55e',
badge: 'rgba(34, 197, 94, 0.15)',
badgeLight: 'rgba(34, 197, 94, 0.12)',
text: '#86efac',
textLight: '#15803d',
},
apricot: {
border: '#fb923c',
badge: 'rgba(251, 146, 60, 0.15)',
badgeLight: 'rgba(251, 146, 60, 0.12)',
text: '#fdba74',
textLight: '#c2410c',
},
rose: {
border: '#f43f5e',
badge: 'rgba(244, 63, 94, 0.15)',
badgeLight: 'rgba(244, 63, 94, 0.12)',
text: '#fda4af',
textLight: '#be123c',
},
cerulean: {
border: '#38bdf8',
badge: 'rgba(56, 189, 248, 0.15)',
badgeLight: 'rgba(56, 189, 248, 0.12)',
text: '#7dd3fc',
textLight: '#0369a1',
},
olive: {
border: '#84cc16',
badge: 'rgba(132, 204, 22, 0.15)',
badgeLight: 'rgba(132, 204, 22, 0.12)',
text: '#bef264',
textLight: '#4d7c0f',
},
copper: {
border: '#b45309',
badge: 'rgba(180, 83, 9, 0.15)',
badgeLight: 'rgba(180, 83, 9, 0.12)',
text: '#fdba74',
textLight: '#92400e',
},
steel: {
border: '#64748b',
badge: 'rgba(100, 116, 139, 0.15)',
badgeLight: 'rgba(100, 116, 139, 0.12)',
text: '#cbd5e1',
textLight: '#475569',
},
green: {
border: '#22c55e',
badge: 'rgba(34, 197, 94, 0.15)',

View file

@ -1,8 +1,4 @@
import {
getMemberColorByName,
MEMBER_COLOR_PALETTE,
normalizeMemberColorName,
} from '@shared/constants/memberColors';
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
import { isLeadMember } from '@shared/utils/leadDetection';
import type {
@ -579,42 +575,7 @@ interface MemberColorInput {
* Maps "user" to a reserved color.
*/
export function buildMemberColorMap(members: MemberColorInput[]): Map<string, string> {
const map = new Map<string, string>();
const active = members.filter((m) => !m.removedAt);
const removed = members.filter((m) => m.removedAt);
const usedColors = new Set<string>();
let nextPaletteIdx = 0;
for (const member of active) {
let color = member.color ? normalizeMemberColorName(member.color) : undefined;
if (!color || usedColors.has(color)) {
// Assign the next unused color from the pre-ordered palette.
while (
nextPaletteIdx < MEMBER_COLOR_PALETTE.length &&
usedColors.has(MEMBER_COLOR_PALETTE[nextPaletteIdx])
) {
nextPaletteIdx++;
}
color =
nextPaletteIdx < MEMBER_COLOR_PALETTE.length
? MEMBER_COLOR_PALETTE[nextPaletteIdx]
: MEMBER_COLOR_PALETTE[active.indexOf(member) % MEMBER_COLOR_PALETTE.length];
nextPaletteIdx++;
}
map.set(member.name, color);
usedColors.add(color);
}
for (const member of removed) {
const color = member.color
? normalizeMemberColorName(member.color)
: getMemberColorByName(member.name);
map.set(member.name, color);
}
map.set('user', 'user');
return map;
return buildTeamMemberColorMap(members, { preferProvidedColors: true });
}
export const KANBAN_COLUMN_DISPLAY: Record<

View file

@ -6,36 +6,40 @@
* Intentionally excludes purple-family tones.
*/
export const MEMBER_COLOR_PALETTE = [
// ── First 10: maximum contrast (>40° hue gap between any pair) ──
'blue', // 0°
'saffron', // 177°
'turquoise', // 268°
'brick', // 85°
'apricot', // 131°
'indigo', // 314°
'forest', // 223°
'pink', // 39°
'crimson', // 59°
'tangerine', // 105°
// ── First 12: intentionally distinct visual families for roster readability ──
'blue',
'saffron',
'turquoise',
'brick',
'indigo',
'forest',
'apricot',
'rose',
'cerulean',
'olive',
'copper',
'steel',
// ── Next 14: still good separation ──
'gold', // 151°
'emerald', // 203°
'cerulean', // 288°
'denim', // 334°
'cyan', // 20°
'sage', // 242°
'tomato', // 72°
'rust', // 118°
'mustard', // 164°
'canary', // 190°
'teal', // 255°
'arctic', // 301°
'royal', // 347°
'green', // 7°
// ── Next 12: secondary accents after the core distinct set ──
'gold',
'emerald',
'cobalt',
'crimson',
'tangerine',
'denim',
'cyan',
'sage',
'tomato',
'rust',
'mustard',
'canary',
'teal',
'arctic',
'royal',
// ── Remaining: fill the hue gaps progressively ──
'rose', // 46°
'green',
'pink',
'ruby', // 92°
'sienna', // 144°
'mint', // 216°
@ -49,90 +53,45 @@ export const MEMBER_COLOR_PALETTE = [
'salmon', // 79°
'amber', // 98°
'peach', // 111°
'copper', // 124°
'bronze', // 137°
'lemon', // 157°
'honey', // 170°
'marigold', // 183°
'sunflower', // 196°
'lime', // 209°
'olive', // 229°
'jade', // 236°
'chartreuse', // 249°
'aqua', // 262°
'azure', // 281°
'seafoam', // 295°
'cobalt', // 308°
'periwinkle', // 327°
'steel', // 340°
'cornflower', // 353°
] as const;
export type MemberColorName = (typeof MEMBER_COLOR_PALETTE)[number];
/**
* Fixed hue angle (0-359) for each palette color name.
* This is independent of array order colors keep their visual identity
* regardless of how MEMBER_COLOR_PALETTE is sorted.
* Spread evenly across 360° so every name has a unique hue.
* Canonical runtime/member id for the team lead.
* UI surfaces should use this exact key when deriving the lead color so
* previews match the resolved team roster.
*/
export const MEMBER_COLOR_HUE: Record<string, number> = {
blue: 0,
green: 7,
yellow: 13,
cyan: 20,
red: 26,
orange: 33,
pink: 39,
rose: 46,
coral: 52,
crimson: 59,
scarlet: 65,
tomato: 72,
salmon: 79,
brick: 85,
ruby: 92,
amber: 98,
tangerine: 105,
peach: 111,
rust: 118,
copper: 124,
apricot: 131,
bronze: 137,
sienna: 144,
gold: 151,
lemon: 157,
mustard: 164,
honey: 170,
saffron: 177,
marigold: 183,
canary: 190,
sunflower: 196,
emerald: 203,
lime: 209,
mint: 216,
forest: 223,
olive: 229,
jade: 236,
sage: 242,
chartreuse: 249,
teal: 255,
aqua: 262,
turquoise: 268,
sky: 275,
azure: 281,
cerulean: 288,
seafoam: 295,
arctic: 301,
cobalt: 308,
indigo: 314,
sapphire: 321,
periwinkle: 327,
denim: 334,
steel: 340,
royal: 347,
cornflower: 353,
};
export const TEAM_LEAD_MEMBER_COLOR_ID = 'team-lead' as const;
/**
* Fixed hue angle (0-359) for each palette color name.
* The first roster-assigned colors are intentionally spaced far apart so the
* first 10-12 teammates in a team remain visually distinct.
*/
const MEMBER_COLOR_HUES_BY_ORDER = [
240, 60, 180, 0, 120, 300, 30, 210, 330, 90, 150, 270, 15, 195, 105, 285, 45, 225, 135, 315, 75,
255, 165, 345, 7.5, 187.5, 97.5, 277.5, 37.5, 217.5, 127.5, 307.5, 67.5, 247.5, 157.5, 337.5,
22.5, 202.5, 112.5, 292.5, 52.5, 232.5, 142.5, 322.5, 82.5, 262.5, 172.5, 352.5, 11.25, 191.25,
101.25, 281.25, 41.25, 221.25, 131.25,
] as const;
export const MEMBER_COLOR_HUE: Record<string, number> = Object.fromEntries(
MEMBER_COLOR_PALETTE.map((colorName, index) => [colorName, MEMBER_COLOR_HUES_BY_ORDER[index]])
) as Record<string, number>;
const DISALLOWED_MEMBER_COLORS = new Set([
'purple',

View file

@ -0,0 +1,107 @@
import {
getMemberColorByName,
MEMBER_COLOR_PALETTE,
TEAM_LEAD_MEMBER_COLOR_ID,
normalizeMemberColorName,
} from '@shared/constants/memberColors';
import { isLeadMember } from '@shared/utils/leadDetection';
export interface TeamMemberColorInput {
name: string;
color?: string;
removedAt?: number | string | null;
agentType?: string;
}
interface BuildTeamMemberColorMapOptions {
preferProvidedColors?: boolean;
}
/**
* Build a deterministic roster color map that optimizes contrast inside a team.
* Leads reserve their own color but do not consume the teammate palette order.
*/
export function buildTeamMemberColorMap(
members: readonly TeamMemberColorInput[],
options: BuildTeamMemberColorMapOptions = {}
): Map<string, string> {
const preferProvidedColors = options.preferProvidedColors ?? true;
const map = new Map<string, string>();
const active = members.filter((member) => !member.removedAt);
const removed = members.filter((member) => member.removedAt);
const activeLeads = active.filter((member) => isLeadMember(member));
const activeTeammates = active.filter((member) => !isLeadMember(member));
const usedColors = new Set<string>();
let nextPaletteIdx = 0;
for (const member of activeLeads) {
const color =
preferProvidedColors && member.color
? normalizeMemberColorName(member.color)
: getMemberColorByName(member.name);
map.set(member.name, color);
usedColors.add(color);
}
for (const member of activeTeammates) {
let color =
preferProvidedColors && member.color ? normalizeMemberColorName(member.color) : undefined;
if (!color || usedColors.has(color)) {
while (
nextPaletteIdx < MEMBER_COLOR_PALETTE.length &&
usedColors.has(MEMBER_COLOR_PALETTE[nextPaletteIdx])
) {
nextPaletteIdx += 1;
}
color =
nextPaletteIdx < MEMBER_COLOR_PALETTE.length
? MEMBER_COLOR_PALETTE[nextPaletteIdx]
: MEMBER_COLOR_PALETTE[activeTeammates.indexOf(member) % MEMBER_COLOR_PALETTE.length];
nextPaletteIdx += 1;
}
map.set(member.name, color);
usedColors.add(color);
}
for (const member of removed) {
const color =
preferProvidedColors && member.color
? normalizeMemberColorName(member.color)
: getMemberColorByName(member.name);
map.set(member.name, color);
}
map.set('user', 'user');
return map;
}
/**
* Resolve the visual color for a standalone member preview by reusing the same
* roster color pipeline that powers the team screen.
*/
export function resolveTeamMemberColorName(
member: TeamMemberColorInput,
options: BuildTeamMemberColorMapOptions = {}
): string {
const color = buildTeamMemberColorMap([member], options).get(member.name);
if (color) {
return color;
}
if (options.preferProvidedColors !== false && member.color) {
return normalizeMemberColorName(member.color);
}
return getMemberColorByName(member.name);
}
export function resolveTeamLeadColorName(): string {
return resolveTeamMemberColorName(
{
name: TEAM_LEAD_MEMBER_COLOR_ID,
agentType: 'team-lead',
},
{ preferProvidedColors: false }
);
}

View file

@ -8,6 +8,21 @@ export function parseNumericSuffixName(name: string): { base: string; suffix: nu
return { base: match[1], suffix };
}
export function validateTeamMemberNameFormat(name: string): string | null {
const trimmed = name.trim();
if (!trimmed) return null;
if (trimmed.length < 1 || trimmed.length > 128) {
return 'Start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars';
}
if (!/^[a-zA-Z0-9]/.test(trimmed)) {
return 'Start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars';
}
if (!/^[a-zA-Z0-9._-]+$/.test(trimmed)) {
return 'Start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars';
}
return null;
}
/**
* Claude CLI auto-suffixes teammate names when a name already exists in config.json
* (e.g. "alice" "alice-2"). We treat "-2+" as an auto-suffix only when the base

View file

@ -172,6 +172,7 @@ describe('ipc teams handlers', () => {
deleteTeam: vi.fn(async () => undefined),
getLeadMemberName: vi.fn(async () => 'team-lead'),
getTeamDisplayName: vi.fn(async () => 'My Team'),
updateConfig: vi.fn(async () => ({ name: 'My Team' })),
sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'm1' })),
sendDirectToLead: vi.fn(async () => ({ deliveredToInbox: false, messageId: 'direct-1' })),
createTask: vi.fn(async () => ({ id: '1', subject: 'Test', status: 'pending' })),
@ -1732,6 +1733,47 @@ describe('ipc teams handlers', () => {
});
});
describe('updateConfig', () => {
it('notifies a live lead only when the team name actually changes', async () => {
const handler = handlers.get(TEAM_UPDATE_CONFIG)!;
service.getTeamDisplayName.mockResolvedValueOnce('My Team');
provisioningService.isTeamAlive = vi.fn(() => true);
const result = (await handler({} as never, 'my-team', {
name: 'Renamed Team',
})) as { success: boolean };
expect(result.success).toBe(true);
expect(service.updateConfig).toHaveBeenCalledWith('my-team', {
name: 'Renamed Team',
description: undefined,
color: undefined,
});
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
'my-team',
'The team has been renamed to "Renamed Team". Please use this name when referring to the team going forward.'
);
});
it('does not notify the lead when the submitted team name is unchanged', async () => {
const handler = handlers.get(TEAM_UPDATE_CONFIG)!;
service.getTeamDisplayName.mockResolvedValueOnce('My Team');
provisioningService.isTeamAlive = vi.fn(() => true);
const result = (await handler({} as never, 'my-team', {
name: 'My Team',
})) as { success: boolean };
expect(result.success).toBe(true);
expect(service.updateConfig).toHaveBeenCalledWith('my-team', {
name: 'My Team',
description: undefined,
color: undefined,
});
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
});
});
describe('removeMember', () => {
it('calls service on valid input', async () => {
const handler = handlers.get(TEAM_REMOVE_MEMBER)!;

View file

@ -461,6 +461,73 @@ function buildResolvedMember(name: string): ResolvedTeamMember {
}
describe('TeamDataService', () => {
it('rejects duplicate member names in replaceMembers', async () => {
const writeMembers = vi.fn(async () => {});
const membersMetaStore = {
getMembers: vi.fn(async () => []),
writeMembers,
} as never;
const service = new TeamDataService(
{ getConfig: vi.fn(), listTeams: vi.fn() } as never,
{ getTasks: vi.fn(async () => []) } as never,
{ listInboxNames: vi.fn(async () => []), getMessages: vi.fn(async () => []) } as never,
{} as never,
{} as never,
{ resolveMembers: vi.fn(() => []) } as never,
{ getState: vi.fn(async () => ({ teamName: 'dup-team', reviewers: [], tasks: {} })) } as never,
{} as never,
membersMetaStore,
{ readMessages: vi.fn(async () => []) } as never
);
await expect(
service.replaceMembers('dup-team', {
members: [
{ name: 'alice', role: 'Reviewer' },
{ name: 'alice', role: 'Developer' },
],
})
).rejects.toThrow('Member "alice" already exists');
expect(writeMembers).not.toHaveBeenCalled();
});
it('rejects invalid or reserved member names in replaceMembers', async () => {
const writeMembers = vi.fn(async () => {});
const membersMetaStore = {
getMembers: vi.fn(async () => []),
writeMembers,
} as never;
const service = new TeamDataService(
{ getConfig: vi.fn(), listTeams: vi.fn() } as never,
{ getTasks: vi.fn(async () => []) } as never,
{ listInboxNames: vi.fn(async () => []), getMessages: vi.fn(async () => []) } as never,
{} as never,
{} as never,
{ resolveMembers: vi.fn(() => []) } as never,
{ getState: vi.fn(async () => ({ teamName: 'dup-team', reviewers: [], tasks: {} })) } as never,
{} as never,
membersMetaStore,
{ readMessages: vi.fn(async () => []) } as never
);
await expect(
service.replaceMembers('dup-team', {
members: [{ name: 'bad/name', role: 'Reviewer' }],
})
).rejects.toThrow('Member name "bad/name" is invalid');
await expect(
service.replaceMembers('dup-team', {
members: [{ name: 'user', role: 'Reviewer' }],
})
).rejects.toThrow('Member name "user" is reserved');
expect(writeMembers).not.toHaveBeenCalled();
});
it('keeps getTeamData read-only and skips kanban garbage-collect', async () => {
const order: string[] = [];
const tasks: TeamTask[] = [

View file

@ -69,6 +69,7 @@ import {
getAutoResumeService,
initializeAutoResumeService,
} from '@main/services/team/AutoResumeService';
import { getTeamBootstrapStatePath } from '@main/services/team/TeamBootstrapStateReader';
import { createPersistedLaunchSnapshot } from '@main/services/team/TeamLaunchStateEvaluator';
import { getTeamLaunchStatePath } from '@main/services/team/TeamLaunchStateStore';
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
@ -176,6 +177,28 @@ function writeLaunchState(
);
}
function writeBootstrapState(
teamName: string,
members: { name: string; status: string; lastAttemptAt?: number; lastObservedAt?: number }[],
updatedAt = new Date().toISOString()
): void {
fs.writeFileSync(
getTeamBootstrapStatePath(teamName),
`${JSON.stringify(
{
version: 1,
teamName,
updatedAt,
phase: 'completed',
members,
},
null,
2
)}\n`,
'utf8'
);
}
function createMemberSpawnStatusEntry(
overrides: Record<string, unknown> = {}
): Record<string, unknown> {
@ -226,6 +249,7 @@ function createMemberSpawnRun(params?: {
expectedMembers,
memberSpawnStatuses,
memberSpawnToolUseIds: new Map(),
pendingMemberRestarts: new Map(),
memberSpawnLeadInboxCursorByMember:
params?.memberSpawnLeadInboxCursorByMember ?? new Map(),
provisioningOutputParts: [],
@ -484,6 +508,403 @@ describe('TeamProvisioningService', () => {
expect(snapshot.members['team-lead']?.rssBytes).toBe(123_000_000);
expect(snapshot.members.alice?.rssBytes).toBe(456_000_000);
});
it('falls back to direct agent process lookup when tmux pane pid lookup is unavailable', async () => {
const svc = new TeamProvisioningService();
(svc as any).configReader = {
getConfig: vi.fn(async () => ({
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'alice', model: 'gpt-5.2' },
],
})),
};
(svc as any).readPersistedRuntimeMembers = vi.fn(() => [
{
name: 'alice',
agentId: 'alice@nice-team',
tmuxPaneId: '%0',
backendType: 'tmux',
},
]);
(svc as any).aliveRunByTeam.set('nice-team', 'run-1');
(svc as any).runs.set('run-1', {
runId: 'run-1',
child: { pid: 111 },
request: { model: 'gpt-5.4' },
processKilled: false,
cancelRequested: false,
spawnContext: null,
});
(svc as any).readUnixProcessTableRows = vi.fn(() => [
{
pid: 333,
command:
'/Users/belief/.bun/bin/bun cli.js --agent-id alice@nice-team --agent-name alice --team-name nice-team --model gpt-5.2',
},
]);
vi.mocked(listTmuxPanePidsForCurrentPlatform).mockResolvedValueOnce(new Map());
vi.mocked(pidusage).mockResolvedValueOnce({
'111': createPidusageStat(111, 123_000_000),
'333': createPidusageStat(333, 456_000_000),
} as any);
const snapshot = await svc.getTeamAgentRuntimeSnapshot('nice-team');
expect(snapshot.members['team-lead']).toMatchObject({
pid: 111,
rssBytes: 123_000_000,
});
expect(snapshot.members.alice).toMatchObject({
pid: 333,
rssBytes: 456_000_000,
runtimeModel: 'gpt-5.2',
});
});
it('prefers the newest matching agent pid when multiple processes match the same teammate', async () => {
const svc = new TeamProvisioningService();
(svc as any).configReader = {
getConfig: vi.fn(async () => ({
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'alice', model: 'gpt-5.2' },
],
})),
};
(svc as any).readPersistedRuntimeMembers = vi.fn(() => [
{
name: 'alice',
agentId: 'alice@nice-team',
tmuxPaneId: '%0',
backendType: 'tmux',
},
]);
(svc as any).aliveRunByTeam.set('nice-team', 'run-1');
(svc as any).runs.set('run-1', {
runId: 'run-1',
child: { pid: 111 },
request: { model: 'gpt-5.4' },
processKilled: false,
cancelRequested: false,
spawnContext: null,
});
(svc as any).readUnixProcessTableRows = vi.fn(() => [
{
pid: 222,
command:
'/Users/belief/.bun/bin/bun cli.js --agent-id alice@nice-team --agent-name alice --team-name nice-team --model gpt-5.2',
},
{
pid: 333,
command:
'/Users/belief/.bun/bin/bun cli.js --team-name nice-team --agent-id alice@nice-team --agent-name alice --model gpt-5.2',
},
]);
vi.mocked(listTmuxPanePidsForCurrentPlatform).mockResolvedValueOnce(new Map());
vi.mocked(pidusage).mockResolvedValueOnce({
'111': createPidusageStat(111, 123_000_000),
'333': createPidusageStat(333, 456_000_000),
} as any);
const snapshot = await svc.getTeamAgentRuntimeSnapshot('nice-team');
expect(snapshot.members.alice).toMatchObject({
pid: 333,
rssBytes: 456_000_000,
});
});
});
describe('restartMember', () => {
it('uses members meta runtime settings when config members are stale or absent', async () => {
const svc = new TeamProvisioningService();
const run = createMemberSpawnRun({
teamName: 'edited-team',
expectedMembers: ['alice'],
memberSpawnStatuses: new Map(),
});
run.child = { pid: 111 };
run.processKilled = false;
run.cancelRequested = false;
const sendMessageToRun = vi.fn(async () => {});
(svc as any).sendMessageToRun = sendMessageToRun;
(svc as any).configReader = {
getConfig: vi.fn(async () => ({
name: 'Edited Team',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
})),
};
(svc as any).membersMetaStore = {
getMembers: vi.fn(async () => [
{
name: 'alice',
role: 'Reviewer',
workflow: 'Use checklist',
providerId: 'codex',
model: 'gpt-5.4-mini',
effort: 'high',
agentType: 'general-purpose',
},
]),
};
(svc as any).readPersistedRuntimeMembers = vi.fn(() => []);
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map());
(svc as any).aliveRunByTeam.set('edited-team', run.runId);
(svc as any).runs.set(run.runId, run);
await svc.restartMember('edited-team', 'alice');
expect(sendMessageToRun).toHaveBeenCalledTimes(1);
const restartCall = sendMessageToRun.mock.calls[0] as unknown as
| [unknown, string]
| undefined;
const restartMessage = restartCall?.[1] ?? '';
expect(restartMessage).toContain('provider="codex"');
expect(restartMessage).toContain('model="gpt-5.4-mini"');
expect(restartMessage).toContain('effort="high"');
expect(restartMessage).toContain('with role "Reviewer"');
expect(restartMessage).toContain('Their workflow: Use checklist');
});
it('treats duplicate_skipped already_running as a failed codex restart because the old runtime is still active', async () => {
const svc = new TeamProvisioningService();
const run = createMemberSpawnRun({
teamName: 'codex-team',
expectedMembers: ['bob'],
memberSpawnStatuses: new Map(),
});
run.child = { pid: 111 };
run.processKilled = false;
run.cancelRequested = false;
const sendMessageToRun = vi.fn(async () => {});
(svc as any).sendMessageToRun = sendMessageToRun;
(svc as any).configReader = {
getConfig: vi.fn(async () => ({
name: 'Codex Team',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
})),
};
(svc as any).membersMetaStore = {
getMembers: vi.fn(async () => [
{
name: 'bob',
role: 'Developer',
providerId: 'codex',
model: 'gpt-5.2',
effort: 'medium',
agentType: 'general-purpose',
},
]),
};
(svc as any).readPersistedRuntimeMembers = vi.fn(() => []);
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map());
(svc as any).aliveRunByTeam.set('codex-team', run.runId);
(svc as any).runs.set(run.runId, run);
await svc.restartMember('codex-team', 'bob');
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
status: 'spawning',
launchState: 'starting',
});
expect(sendMessageToRun).toHaveBeenCalledWith(
run,
expect.stringContaining('provider="codex", model="gpt-5.2", effort="medium"')
);
run.activeToolCalls.set('tool-agent-1', {
memberName: 'bob',
toolUseId: 'tool-agent-1',
toolName: 'Agent',
preview: 'Spawn teammate bob',
startedAt: new Date().toISOString(),
state: 'running',
source: 'runtime',
});
run.memberSpawnToolUseIds.set('tool-agent-1', 'bob');
(svc as any).finishRuntimeToolActivity(
run,
'tool-agent-1',
[
{
type: 'text',
text: 'status: duplicate_skipped\nreason: already_running\nname: bob\nteam_name: codex-team',
},
],
false
);
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
status: 'error',
launchState: 'failed_to_start',
runtimeAlive: false,
hardFailure: true,
hardFailureReason:
'Restart for teammate "bob" was skipped because the previous runtime still appears to be active. The requested settings may not have been applied.',
});
expect(run.pendingMemberRestarts.has('bob')).toBe(false);
});
it('keeps a codex teammate restart pending instead of failed when lead reports duplicate_skipped bootstrap_pending', async () => {
const svc = new TeamProvisioningService();
const run = createMemberSpawnRun({
teamName: 'codex-team',
expectedMembers: ['bob'],
memberSpawnStatuses: new Map(),
});
run.child = { pid: 111 };
run.processKilled = false;
run.cancelRequested = false;
(svc as any).sendMessageToRun = vi.fn(async () => {});
(svc as any).configReader = {
getConfig: vi.fn(async () => ({
name: 'Codex Team',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
})),
};
(svc as any).membersMetaStore = {
getMembers: vi.fn(async () => [
{
name: 'bob',
role: 'Developer',
providerId: 'codex',
model: 'gpt-5.2',
effort: 'medium',
agentType: 'general-purpose',
},
]),
};
(svc as any).readPersistedRuntimeMembers = vi.fn(() => []);
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map());
(svc as any).aliveRunByTeam.set('codex-team', run.runId);
(svc as any).runs.set(run.runId, run);
await svc.restartMember('codex-team', 'bob');
run.activeToolCalls.set('tool-agent-1', {
memberName: 'bob',
toolUseId: 'tool-agent-1',
toolName: 'Agent',
preview: 'Spawn teammate bob',
startedAt: new Date().toISOString(),
state: 'running',
source: 'runtime',
});
run.memberSpawnToolUseIds.set('tool-agent-1', 'bob');
(svc as any).finishRuntimeToolActivity(
run,
'tool-agent-1',
[
{
type: 'text',
text: 'status: duplicate_skipped\nreason: bootstrap_pending\nname: bob\nteam_name: codex-team',
},
],
false
);
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: false,
agentToolAccepted: true,
hardFailure: false,
hardFailureReason: undefined,
});
expect(run.pendingMemberRestarts.has('bob')).toBe(true);
});
it('rejects a second restart request while the first restart is still in flight', async () => {
const svc = new TeamProvisioningService();
const run = createMemberSpawnRun({
teamName: 'codex-team',
expectedMembers: ['bob'],
memberSpawnStatuses: new Map(),
});
run.child = { pid: 111 };
run.processKilled = false;
run.cancelRequested = false;
run.pendingMemberRestarts.set('bob', {
requestedAt: new Date().toISOString(),
desired: {
name: 'bob',
providerId: 'codex',
model: 'gpt-5.2',
effort: 'medium',
},
});
(svc as any).configReader = {
getConfig: vi.fn(async () => ({
name: 'Codex Team',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
})),
};
(svc as any).membersMetaStore = {
getMembers: vi.fn(async () => [
{
name: 'bob',
role: 'Developer',
providerId: 'codex',
model: 'gpt-5.2',
effort: 'medium',
agentType: 'general-purpose',
},
]),
};
(svc as any).aliveRunByTeam.set('codex-team', run.runId);
(svc as any).runs.set(run.runId, run);
await expect(svc.restartMember('codex-team', 'bob')).rejects.toThrow(
'Restart for teammate "bob" is already in progress'
);
});
it('marks a pending restart as failed when the teammate never rejoins within the restart grace window', async () => {
const svc = new TeamProvisioningService();
const run = createMemberSpawnRun({
teamName: 'codex-team',
expectedMembers: ['bob'],
memberSpawnStatuses: new Map([
[
'bob',
createMemberSpawnStatusEntry({
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
agentToolAccepted: true,
firstSpawnAcceptedAt: new Date(Date.now() - 120_000).toISOString(),
}),
],
]),
});
run.pendingMemberRestarts.set('bob', {
requestedAt: new Date(Date.now() - 120_000).toISOString(),
desired: {
name: 'bob',
providerId: 'codex',
model: 'gpt-5.2',
effort: 'medium',
},
});
(svc as any).refreshMemberSpawnStatusesFromLeadInbox = vi.fn(async () => {});
(svc as any).maybeAuditMemberSpawnStatuses = vi.fn(async () => {});
await (svc as any).reevaluateMemberLaunchStatus(run, 'bob');
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
status: 'error',
launchState: 'failed_to_start',
error: 'Teammate "bob" did not rejoin within the restart grace window.',
hardFailureReason: 'Teammate "bob" did not rejoin within the restart grace window.',
});
expect(run.pendingMemberRestarts.has('bob')).toBe(false);
});
});
it('removes generated MCP config when createTeam spawn fails synchronously', async () => {
@ -1656,4 +2077,225 @@ describe('TeamProvisioningService', () => {
expect(run.memberSpawnStatuses.get('alice')).toBe(existingEntry);
});
it('treats duplicate_skipped already_running as process-confirmed online', () => {
const run = createMemberSpawnRun();
run.activeToolCalls.set('tool-agent-1', {
memberName: 'alice',
toolUseId: 'tool-agent-1',
toolName: 'Agent',
preview: 'Spawn teammate alice',
startedAt: new Date().toISOString(),
state: 'running',
source: 'runtime',
});
run.memberSpawnToolUseIds.set('tool-agent-1', 'alice');
const svc = new TeamProvisioningService();
(svc as any).finishRuntimeToolActivity(
run,
'tool-agent-1',
[
{
type: 'text',
text: 'status: duplicate_skipped\nreason: already_running\nname: alice\nteam_name: nice-team',
},
],
false
);
expect(run.memberSpawnStatuses.get('alice')).toMatchObject({
status: 'online',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: true,
livenessSource: 'process',
hardFailure: false,
});
});
it('clears stale failed_to_start state when live runtime metadata proves the teammate is alive', async () => {
const svc = new TeamProvisioningService();
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () =>
new Map([
[
'bob',
{
alive: true,
model: 'gpt-5.2',
},
],
])
);
const result = await (svc as any).attachLiveRuntimeMetadataToStatuses('beacon-desk-4', {
bob: createMemberSpawnStatusEntry({
status: 'error',
launchState: 'failed_to_start',
error: 'Teammate did not join within the launch grace window.',
hardFailure: true,
hardFailureReason: 'Teammate did not join within the launch grace window.',
}),
});
expect(result.bob).toMatchObject({
status: 'online',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: true,
hardFailure: false,
hardFailureReason: undefined,
error: undefined,
runtimeModel: 'gpt-5.2',
livenessSource: 'process',
});
});
it('does not clear an explicit restart failure just because the old runtime is still alive', async () => {
const svc = new TeamProvisioningService();
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () =>
new Map([
[
'bob',
{
alive: true,
model: 'gpt-5.3-codex',
},
],
])
);
const result = await (svc as any).attachLiveRuntimeMetadataToStatuses('beacon-desk-4', {
bob: createMemberSpawnStatusEntry({
status: 'error',
launchState: 'failed_to_start',
error:
'Restart for teammate "bob" was skipped because the previous runtime still appears to be active. The requested settings may not have been applied.',
hardFailure: true,
hardFailureReason:
'Restart for teammate "bob" was skipped because the previous runtime still appears to be active. The requested settings may not have been applied.',
}),
});
expect(result.bob).toMatchObject({
status: 'error',
launchState: 'failed_to_start',
runtimeAlive: false,
hardFailure: true,
hardFailureReason:
'Restart for teammate "bob" was skipped because the previous runtime still appears to be active. The requested settings may not have been applied.',
error:
'Restart for teammate "bob" was skipped because the previous runtime still appears to be active. The requested settings may not have been applied.',
runtimeModel: 'gpt-5.3-codex',
});
});
it('does not downgrade an already-online teammate when waiting is reported later', () => {
const run = createMemberSpawnRun({
memberSpawnStatuses: new Map([
[
'alice',
createMemberSpawnStatusEntry({
status: 'online',
launchState: 'confirmed_alive',
runtimeAlive: true,
livenessSource: 'heartbeat',
bootstrapConfirmed: true,
lastHeartbeatAt: '2026-04-16T10:00:00.000Z',
}),
],
]),
});
const svc = new TeamProvisioningService();
(svc as any).setMemberSpawnStatus(run, 'alice', 'waiting');
expect(run.memberSpawnStatuses.get('alice')).toMatchObject({
status: 'online',
launchState: 'confirmed_alive',
runtimeAlive: true,
livenessSource: 'heartbeat',
bootstrapConfirmed: true,
lastHeartbeatAt: '2026-04-16T10:00:00.000Z',
});
});
it('clears stale hard failure state when a new spawn attempt starts', () => {
const staleAcceptedAt = '2026-04-16T10:00:00.000Z';
const run = createMemberSpawnRun({
memberSpawnStatuses: new Map([
[
'alice',
createMemberSpawnStatusEntry({
status: 'error',
launchState: 'failed_to_start',
error: 'Teammate was never spawned during launch.',
hardFailure: true,
hardFailureReason: 'Teammate was never spawned during launch.',
runtimeAlive: true,
bootstrapConfirmed: true,
livenessSource: 'heartbeat',
firstSpawnAcceptedAt: staleAcceptedAt,
lastHeartbeatAt: staleAcceptedAt,
}),
],
]),
});
const svc = new TeamProvisioningService();
(svc as any).setMemberSpawnStatus(run, 'alice', 'spawning');
expect(run.memberSpawnStatuses.get('alice')).toMatchObject({
status: 'spawning',
launchState: 'starting',
error: undefined,
hardFailure: false,
hardFailureReason: undefined,
agentToolAccepted: false,
runtimeAlive: false,
bootstrapConfirmed: false,
livenessSource: undefined,
firstSpawnAcceptedAt: undefined,
lastHeartbeatAt: undefined,
});
});
it('reconciles stale never-spawned failures when bootstrap state proves the teammate was registered', async () => {
const teamName = 'registered-bootstrap-team';
const leadSessionId = 'lead-session';
const acceptedAt = new Date(Date.now() - 60_000).toISOString();
writeLaunchConfig(teamName, '/Users/test/proj', leadSessionId, ['alice']);
writeLaunchState(teamName, leadSessionId, {
alice: {
launchState: 'failed_to_start',
agentToolAccepted: false,
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: true,
hardFailureReason: 'Teammate was never spawned during launch.',
},
});
writeBootstrapState(
teamName,
[
{
name: 'alice',
status: 'registered',
lastAttemptAt: Date.parse(acceptedAt),
lastObservedAt: Date.parse(acceptedAt),
},
],
new Date(Date.now() - 30_000).toISOString()
);
const svc = new TeamProvisioningService();
const result = await svc.getMemberSpawnStatuses(teamName);
expect(result.statuses.alice).toMatchObject({
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
hardFailure: false,
hardFailureReason: undefined,
agentToolAccepted: true,
});
});
});

View file

@ -293,6 +293,12 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
expect(message).toContain('team_name="forge-labs", name="alice"');
expect(message).toContain('provider="codex", model="gpt-5.4-mini", effort="medium"');
expect(message).toContain('This is a restart of an existing persistent teammate, not a new teammate.');
expect(message).toContain(
'If the Agent tool returns duplicate_skipped with reason bootstrap_pending, treat that as a pending restart and wait for teammate check-in.'
);
expect(message).toContain(
'If it returns duplicate_skipped with reason already_running, do not report success - it means the previous runtime still appears active and the restart may not have applied.'
);
});
it('createTeam materializes an explicit Codex default model for teammates before bootstrap spawn', async () => {

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,181 @@
import { describe, expect, it } from 'vitest';
import {
buildEditTeamSourceSnapshot,
getLiveRosterIdentityChanges,
getMembersRequiringRuntimeRestart,
} from '@renderer/components/team/dialogs/editTeamRuntimeChanges';
describe('getMembersRequiringRuntimeRestart', () => {
it('returns existing teammates whose role, workflow, provider, model, or effort changed', () => {
const result = getMembersRequiringRuntimeRestart({
previousMembers: [
{
name: 'alice',
role: 'Reviewer',
workflow: 'Review PRs',
providerId: 'codex',
model: 'gpt-5.2',
effort: 'medium',
} as any,
{
name: 'bob',
role: 'Developer',
workflow: 'Ship features',
providerId: 'anthropic',
model: '',
effort: 'low',
} as any,
],
nextMembers: [
{
name: 'alice',
role: 'Reviewer',
workflow: 'Review PRs',
providerId: 'codex',
model: 'gpt-5.4-mini',
effort: 'medium',
},
{
name: 'bob',
role: 'Developer',
workflow: 'Ship safer features',
providerId: 'anthropic',
model: '',
effort: 'high',
},
],
});
expect(result).toEqual(['alice', 'bob']);
});
it('ignores newly added or renamed teammates for restart targeting', () => {
const result = getMembersRequiringRuntimeRestart({
previousMembers: [
{
name: 'alice',
role: 'Reviewer',
workflow: 'Review PRs',
providerId: 'codex',
model: 'gpt-5.2',
effort: 'medium',
} as any,
],
nextMembers: [
{
name: 'alice-2',
role: 'Reviewer',
workflow: 'Review PRs',
providerId: 'codex',
model: 'gpt-5.4',
effort: 'high',
},
{
name: 'bob',
role: 'Developer',
workflow: 'Ship features',
providerId: 'anthropic',
model: '',
effort: undefined,
},
],
});
expect(result).toEqual([]);
});
it('treats empty values as unchanged normalized runtime settings', () => {
const result = getMembersRequiringRuntimeRestart({
previousMembers: [
{
name: 'alice',
role: undefined,
workflow: undefined,
providerId: undefined,
model: undefined,
effort: undefined,
} as any,
],
nextMembers: [
{
name: 'alice',
role: '',
workflow: '',
providerId: undefined,
model: '',
effort: undefined,
},
],
});
expect(result).toEqual([]);
});
it('reports live rename and remove of existing teammates separately from runtime restarts', () => {
const result = getLiveRosterIdentityChanges({
previousMembers: [
{
name: 'alice',
role: 'Reviewer',
} as any,
{
name: 'bob',
role: 'Developer',
} as any,
],
nextDrafts: [
{
id: 'draft-alice',
name: 'alice-renamed',
originalName: 'alice',
roleSelection: '',
customRole: '',
},
] as any,
});
expect(result).toEqual({
renamed: ['alice'],
removed: ['bob'],
});
});
it('ignores live status-only member refreshes in the edit source snapshot', () => {
const base = buildEditTeamSourceSnapshot({
name: 'Team A',
description: 'desc',
color: 'blue',
members: [
{
name: 'alice',
role: 'Reviewer',
providerId: 'codex',
model: 'gpt-5.4-mini',
effort: 'medium',
status: 'online',
branch: 'main',
} as any,
],
});
const refreshed = buildEditTeamSourceSnapshot({
name: 'Team A',
description: 'desc',
color: 'blue',
members: [
{
name: 'alice',
role: 'Reviewer',
providerId: 'codex',
model: 'gpt-5.4-mini',
effort: 'medium',
status: 'offline',
branch: 'feature/x',
} as any,
],
});
expect(refreshed).toBe(base);
});
});

View file

@ -274,4 +274,35 @@ describe('MemberCard starting-state visuals', () => {
await Promise.resolve();
});
});
it('renders memory after the role label in the compact runtime summary row', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(MemberCard, {
member,
memberColor: 'blue',
runtimeSummary: '5.2 · Medium · 238.3 MB',
isTeamAlive: true,
isTeamProvisioning: false,
})
);
await Promise.resolve();
});
const text = host.textContent ?? '';
expect(text).toContain('5.2 · Medium');
expect(text).toContain('Reviewer');
expect(text).toContain('238.3 MB');
expect(text.indexOf('Reviewer')).toBeLessThan(text.indexOf('238.3 MB'));
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -7,7 +7,7 @@ import {
createMemberDraftsFromInputs,
filterEditableMemberInputs,
} from '@renderer/components/team/members/MembersEditorSection';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
import { getMemberColorByName } from '@shared/constants/memberColors';
import type { ResolvedTeamMember } from '@shared/types';
@ -60,6 +60,7 @@ describe('members editor editable input filtering', () => {
expect(drafts).toHaveLength(1);
expect(drafts[0]).toMatchObject({
name: 'alice',
originalName: 'alice',
providerId: 'codex',
model: 'gpt-5.4-mini',
effort: 'medium',
@ -95,12 +96,9 @@ describe('members editor editable input filtering', () => {
const existingMembers = [{ name: 'alice' }, { name: 'tom' }, { name: 'bob' }];
const drafts = existingMembers.map((member) => createMemberDraft({ name: member.name }));
const expectedColors = buildMemberColorMap(
existingMembers.map((member) => ({
...member,
color: getMemberColorByName(member.name),
}))
);
const expectedColors = buildTeamMemberColorMap(existingMembers, {
preferProvidedColors: false,
});
const draftColors = buildMemberDraftColorMap(drafts, existingMembers);
expect(draftColors.get('alice')).toBe(expectedColors.get('alice'));
@ -116,12 +114,9 @@ describe('members editor editable input filtering', () => {
createMemberDraft({ name: 'bob' }),
];
const expectedColors = buildMemberColorMap(
[...existingMembers, { name: 'bob' }].map((member) => ({
...member,
color: getMemberColorByName(member.name),
}))
);
const expectedColors = buildTeamMemberColorMap([...existingMembers, { name: 'bob' }], {
preferProvidedColors: false,
});
const draftColors = buildMemberDraftColorMap(drafts, existingMembers);
expect(draftColors.get('alice')).toBe(expectedColors.get('alice'));
@ -132,11 +127,11 @@ describe('members editor editable input filtering', () => {
it('predicts the same colors as the team page for brand-new draft members', () => {
const drafts = ['alice', 'tom', 'bob'].map((name) => createMemberDraft({ name }));
const expectedColors = buildMemberColorMap(
const expectedColors = buildTeamMemberColorMap(
drafts.map((draft) => ({
name: draft.name,
color: getMemberColorByName(draft.name),
}))
})),
{ preferProvidedColors: false }
);
const draftColors = buildMemberDraftColorMap(drafts);
@ -145,16 +140,32 @@ describe('members editor editable input filtering', () => {
expect(draftColors.get('bob')).toBe(expectedColors.get('bob'));
});
it('preserves explicit existing colors in edit and launch dialogs', () => {
it('preserves the resolved team colors in edit and launch dialogs', () => {
const existingMembers = [
{ name: 'alice', color: 'blue' },
{ name: 'bob', color: 'pink' },
{ name: 'alice', color: getMemberColorByName('alice') },
{ name: 'bob', color: getMemberColorByName('bob') },
{ name: 'tom', color: getMemberColorByName('tom') },
];
const drafts = existingMembers.map((member) => createMemberDraft({ name: member.name }));
const draftColors = buildMemberDraftColorMap(drafts, existingMembers);
expect(draftColors.get('alice')).toBe(existingMembers[0].color);
expect(draftColors.get('bob')).toBe(existingMembers[1].color);
expect(draftColors.get('tom')).toBe(existingMembers[2].color);
});
it('prefers an explicit resolved member color map from the team screen', () => {
const existingMembers = [{ name: 'alice', color: 'brick' }, { name: 'tom', color: 'forest' }];
const drafts = existingMembers.map((member) => createMemberDraft({ name: member.name }));
const resolvedColorMap = new Map<string, string>([
['alice', 'blue'],
['tom', 'saffron'],
]);
const draftColors = buildMemberDraftColorMap(drafts, existingMembers, resolvedColorMap);
expect(draftColors.get('alice')).toBe('blue');
expect(draftColors.get('bob')).toBe('pink');
expect(draftColors.get('tom')).toBe('saffron');
});
});

View file

@ -30,6 +30,20 @@ describe('getTeamColorSet', () => {
expect(getTeamColorSet('purple').border).toBe('#a855f7');
});
it('resolves curated member palette colors for the first roster slots', () => {
expect(getTeamColorSet('saffron').border).toBe('#eab308');
expect(getTeamColorSet('turquoise').border).toBe('#14b8a6');
expect(getTeamColorSet('brick').border).toBe('#ef4444');
expect(getTeamColorSet('indigo').border).toBe('#8b5cf6');
expect(getTeamColorSet('forest').border).toBe('#22c55e');
expect(getTeamColorSet('apricot').border).toBe('#fb923c');
expect(getTeamColorSet('rose').border).toBe('#f43f5e');
expect(getTeamColorSet('cerulean').border).toBe('#38bdf8');
expect(getTeamColorSet('olive').border).toBe('#84cc16');
expect(getTeamColorSet('copper').border).toBe('#b45309');
expect(getTeamColorSet('steel').border).toBe('#64748b');
});
it('is case-insensitive for named colors', () => {
expect(getTeamColorSet('Green')).toEqual(getTeamColorSet('green'));
expect(getTeamColorSet('BLUE')).toEqual(getTeamColorSet('blue'));

View file

@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { getMemberColorByName, TEAM_LEAD_MEMBER_COLOR_ID } from '@shared/constants/memberColors';
import {
buildTeamMemberColorMap,
resolveTeamLeadColorName,
resolveTeamMemberColorName,
} from '@shared/utils/teamMemberColors';
describe('buildTeamMemberColorMap', () => {
it('assigns the high-contrast palette order to active teammates', () => {
const members = [{ name: 'alice' }, { name: 'tom' }, { name: 'bob' }, { name: 'atlas' }];
const colorMap = buildTeamMemberColorMap(members, { preferProvidedColors: false });
expect(colorMap.get('alice')).toBe('blue');
expect(colorMap.get('tom')).toBe('saffron');
expect(colorMap.get('bob')).toBe('turquoise');
expect(colorMap.get('atlas')).toBe('brick');
});
it('does not let the lead consume the teammate palette order', () => {
const members = [
{ name: 'team-lead', agentType: 'team-lead' as const },
{ name: 'alice' },
{ name: 'tom' },
];
const colorMap = buildTeamMemberColorMap(members, { preferProvidedColors: false });
expect(colorMap.get('team-lead')).toBeDefined();
expect(colorMap.get('alice')).toBe('blue');
expect(colorMap.get('tom')).toBe('saffron');
});
it('resolves standalone lead previews through the same shared roster pipeline', () => {
expect(resolveTeamLeadColorName()).toBe(
resolveTeamMemberColorName(
{ name: TEAM_LEAD_MEMBER_COLOR_ID, agentType: 'team-lead' },
{ preferProvidedColors: false }
)
);
expect(resolveTeamLeadColorName()).not.toBe(getMemberColorByName('lead'));
});
});