diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts
index 21a1d228..9a31800a 100644
--- a/packages/agent-graph/src/canvas/draw-agents.ts
+++ b/packages/agent-graph/src/canvas/draw-agents.ts
@@ -71,7 +71,7 @@ export function drawAgents(
drawAvatar(ctx, x, y, r, node.label, color, node.kind === 'lead', node.avatarUrl);
// Breathing animation + launch-stage effects
- drawBreathing(ctx, x, y, r, node.state, time, node.spawnStatus, node.runtimeLabel);
+ drawBreathing(ctx, x, y, r, node.state, time, node.spawnStatus);
drawLaunchStage(ctx, x, y, r, node.launchVisualState, time);
}
@@ -122,7 +122,17 @@ export function drawAgents(
if (!simplify) {
// Name + role label (single line: "jack · developer")
const labelText = node.role ? `${node.label} · ${node.role}` : node.label;
- drawLabel(ctx, x, y, r, labelText, color, node.runtimeLabel);
+ drawLabel(
+ ctx,
+ x,
+ y,
+ r,
+ labelText,
+ color,
+ node.runtimeLabel,
+ node.launchStatusLabel,
+ node.launchVisualState
+ );
}
// TODO: Context ring disabled — LeadContextUsage.percent is unreliable
@@ -257,11 +267,11 @@ function drawLaunchStage(
switch (visualState) {
case 'waiting': {
const ringR = r + 7 + Math.sin(time * 3.2) * 1.2;
- const pulseAlpha = 0.16 + 0.12 * (0.5 + 0.5 * Math.sin(time * 3.2));
+ const pulseAlpha = 0.2 + 0.14 * (0.5 + 0.5 * Math.sin(time * 3.2));
ctx.beginPath();
ctx.arc(x, y, ringR, 0, Math.PI * 2);
ctx.strokeStyle = hexWithAlpha('#d4d4d8', pulseAlpha);
- ctx.lineWidth = 2;
+ ctx.lineWidth = 2.2;
ctx.stroke();
break;
}
@@ -270,8 +280,8 @@ function drawLaunchStage(
const rotation = time * 2.4;
ctx.beginPath();
ctx.arc(x, y, ringR, rotation, rotation + Math.PI * 1.15);
- ctx.strokeStyle = hexWithAlpha('#f59e0b', 0.72);
- ctx.lineWidth = 2;
+ ctx.strokeStyle = hexWithAlpha('#f59e0b', 0.8);
+ ctx.lineWidth = 2.2;
ctx.lineCap = 'round';
ctx.stroke();
break;
@@ -280,8 +290,8 @@ function drawLaunchStage(
const ringR = r + 8;
ctx.beginPath();
ctx.arc(x, y, ringR, 0, Math.PI * 2);
- ctx.strokeStyle = hexWithAlpha('#38bdf8', 0.4);
- ctx.lineWidth = 1.5;
+ ctx.strokeStyle = hexWithAlpha('#38bdf8', 0.48);
+ ctx.lineWidth = 1.75;
ctx.stroke();
const orbit = time * 1.6;
@@ -300,8 +310,8 @@ function drawLaunchStage(
const rotation = time * 1.25;
ctx.beginPath();
ctx.arc(x, y, ringR, rotation, rotation + Math.PI * arc);
- ctx.strokeStyle = hexWithAlpha('#22c55e', 0.55);
- ctx.lineWidth = 1.75;
+ ctx.strokeStyle = hexWithAlpha('#22c55e', 0.62);
+ ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.stroke();
break;
@@ -310,8 +320,8 @@ function drawLaunchStage(
const ringR = r + 7 + Math.sin(time * 4) * 0.8;
ctx.beginPath();
ctx.arc(x, y, ringR, Math.PI * 0.2, Math.PI * 1.15);
- ctx.strokeStyle = hexWithAlpha('#ef4444', 0.6);
- ctx.lineWidth = 2;
+ ctx.strokeStyle = hexWithAlpha('#ef4444', 0.72);
+ ctx.lineWidth = 2.2;
ctx.lineCap = 'round';
ctx.stroke();
break;
@@ -467,12 +477,8 @@ function drawBreathing(
r: number,
state: string,
time: number,
- spawnStatus?: GraphNode['spawnStatus'],
- runtimeLabel?: string
+ spawnStatus?: GraphNode['spawnStatus']
): void {
- const hasRuntimeLabel = Boolean(runtimeLabel?.trim());
- const serviceLabelY = y + r + AGENT_DRAW.labelYOffset + (hasRuntimeLabel ? 24 : 14);
-
// Spawning: bright animated double ring + radial glow
if (spawnStatus === 'spawning') {
const ringR = r + AGENT_DRAW.orbitParticleOffset;
@@ -505,12 +511,6 @@ function drawBreathing(
ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
-
- // "connecting" label below name
- ctx.font = '7px monospace';
- ctx.textAlign = 'center';
- ctx.fillStyle = hexWithAlpha(COLORS.holoBase, 0.5 + 0.3 * Math.sin(time * 2));
- ctx.fillText('connecting...', x, serviceLabelY);
return;
}
@@ -532,12 +532,6 @@ function drawBreathing(
ctx.strokeStyle = hexWithAlpha(COLORS.waiting, pulse);
ctx.lineWidth = 1.5;
ctx.stroke();
-
- // "waiting" label
- ctx.font = '7px monospace';
- ctx.textAlign = 'center';
- ctx.fillStyle = hexWithAlpha(COLORS.waiting, 0.4 + 0.2 * Math.sin(time * 1.5));
- ctx.fillText('waiting...', x, serviceLabelY);
return;
}
@@ -649,7 +643,9 @@ function drawLabel(
r: number,
label: string,
color: string,
- runtimeLabel?: string
+ runtimeLabel?: string,
+ launchStatusLabel?: string,
+ launchVisualState?: GraphNode['launchVisualState']
): void {
const labelY = y + r + AGENT_DRAW.labelYOffset;
ctx.font = '9px monospace';
@@ -659,16 +655,27 @@ function drawLabel(
ctx.fillText(label, x, labelY);
const trimmedRuntimeLabel = runtimeLabel?.trim();
- if (!trimmedRuntimeLabel) {
+ const trimmedLaunchStatusLabel = launchStatusLabel?.trim();
+ if (!trimmedRuntimeLabel && !trimmedLaunchStatusLabel) {
return;
}
- ctx.font = '8px monospace';
- ctx.fillStyle = hexWithAlpha(ensureHex(color), 0.72);
- ctx.fillText(truncateRuntimeLabel(ctx, trimmedRuntimeLabel, r), x, labelY + 10);
+ let nextLineY = labelY + 10;
+ if (trimmedRuntimeLabel) {
+ ctx.font = '8px monospace';
+ ctx.fillStyle = hexWithAlpha(ensureHex(color), 0.72);
+ ctx.fillText(truncateSubLabel(ctx, trimmedRuntimeLabel, r), x, nextLineY);
+ nextLineY += 10;
+ }
+
+ if (trimmedLaunchStatusLabel) {
+ ctx.font = '7px monospace';
+ ctx.fillStyle = getLaunchStatusColor(launchVisualState);
+ ctx.fillText(truncateSubLabel(ctx, trimmedLaunchStatusLabel, r), x, nextLineY);
+ }
}
-function truncateRuntimeLabel(ctx: CanvasRenderingContext2D, label: string, r: number): string {
+function truncateSubLabel(ctx: CanvasRenderingContext2D, label: string, r: number): string {
const maxWidth = Math.max(132, r * AGENT_DRAW.labelWidthMultiplier * 2);
if (ctx.measureText(label).width <= maxWidth) return label;
@@ -679,6 +686,23 @@ function truncateRuntimeLabel(ctx: CanvasRenderingContext2D, label: string, r: n
return `${out}…`;
}
+function getLaunchStatusColor(visualState: GraphNode['launchVisualState']): string {
+ switch (visualState) {
+ case 'waiting':
+ return hexWithAlpha('#d4d4d8', 0.8);
+ case 'spawning':
+ return hexWithAlpha('#f59e0b', 0.9);
+ case 'runtime_pending':
+ return hexWithAlpha('#67e8f9', 0.9);
+ case 'settling':
+ return hexWithAlpha('#22c55e', 0.9);
+ case 'error':
+ return hexWithAlpha('#ef4444', 0.92);
+ default:
+ return hexWithAlpha(COLORS.holoBright, 0.75);
+ }
+}
+
/**
* Draw context usage ring around lead node.
*/
diff --git a/packages/agent-graph/src/layout/stableSlotGeometry.ts b/packages/agent-graph/src/layout/stableSlotGeometry.ts
index a9e58b13..d1e5265f 100644
--- a/packages/agent-graph/src/layout/stableSlotGeometry.ts
+++ b/packages/agent-graph/src/layout/stableSlotGeometry.ts
@@ -9,7 +9,7 @@ export const STABLE_SLOT_GEOMETRY = {
unassignedGap: 72,
maxGeneratedRings: 12,
ownerCollisionPadding: 28,
- ownerBandHeight: 72,
+ ownerBandHeight: 96,
ownerMinWidth: 200,
processBandHeight: 32,
processRailWidth: 220,
diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts
index df378f75..af439ae4 100644
--- a/packages/agent-graph/src/ports/types.ts
+++ b/packages/agent-graph/src/ports/types.ts
@@ -84,6 +84,8 @@ export interface GraphNode {
spawnStatus?: 'offline' | 'waiting' | 'spawning' | 'online' | 'error';
/** Shared launch-stage visual derived by the host app */
launchVisualState?: GraphLaunchVisualState;
+ /** Shared launch-stage text shown beside the node during launch only */
+ launchStatusLabel?: string;
/** Context window usage ratio (0..1), available for lead only */
contextUsage?: number;
/** Current task ID this member is working on */
diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx
index a842bbf9..bb3a43af 100644
--- a/packages/agent-graph/src/ui/GraphView.tsx
+++ b/packages/agent-graph/src/ui/GraphView.tsx
@@ -135,6 +135,7 @@ export function GraphView({
y: number;
color?: string | null;
} | null>(null);
+ const selectionLockRef = useRef<{ userSelect: string; webkitUserSelect: string } | null>(null);
// ─── Hooks ──────────────────────────────────────────────────────────────
const simulation = useGraphSimulation();
@@ -255,6 +256,30 @@ export function GraphView({
return { x: node.x, y: node.y };
}, []);
+ const setInteractionSelectionDisabled = useCallback((disabled: boolean) => {
+ if (typeof document === 'undefined') {
+ return;
+ }
+ const bodyStyle = document.body.style;
+ if (disabled) {
+ if (!selectionLockRef.current) {
+ selectionLockRef.current = {
+ userSelect: bodyStyle.userSelect,
+ webkitUserSelect: bodyStyle.webkitUserSelect,
+ };
+ }
+ bodyStyle.userSelect = 'none';
+ bodyStyle.webkitUserSelect = 'none';
+ return;
+ }
+ if (!selectionLockRef.current) {
+ return;
+ }
+ bodyStyle.userSelect = selectionLockRef.current.userSelect;
+ bodyStyle.webkitUserSelect = selectionLockRef.current.webkitUserSelect;
+ selectionLockRef.current = null;
+ }, []);
+
const animate = useCallback(() => {
if (!runningRef.current) return;
@@ -405,10 +430,15 @@ export function GraphView({
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (e.button !== 0) return; // only left click
+ e.preventDefault();
dragPreviewRef.current = null;
+ setInteractionSelectionDisabled(true);
const canvas = canvasHandle.current?.getCanvas();
- if (!canvas) return;
+ if (!canvas) {
+ setInteractionSelectionDisabled(false);
+ return;
+ }
const rect = canvas.getBoundingClientRect();
const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
const nodes = getVisibleNodes(simulation.stateRef.current.nodes);
@@ -464,6 +494,9 @@ export function GraphView({
}
if (isPanningRef.current) {
+ if (typeof document !== 'undefined') {
+ document.getSelection()?.removeAllRanges();
+ }
camera.handlePanMove(clientX, clientY);
return true;
}
@@ -480,6 +513,9 @@ export function GraphView({
const draggedNodeId = interaction.dragNodeId.current;
if (interaction.isDragging.current && draggedNodeId) {
+ if (typeof document !== 'undefined') {
+ document.getSelection()?.removeAllRanges();
+ }
const draggedNode = simulation.stateRef.current.nodes.find((node) => node.id === draggedNodeId);
if (draggedNode?.kind === 'member') {
const nearest = simulation.resolveNearestOwnerSlot(draggedNodeId, world.x, world.y);
@@ -509,6 +545,7 @@ export function GraphView({
if (isPanningRef.current) {
camera.handlePanEnd();
isPanningRef.current = false;
+ setInteractionSelectionDisabled(false);
dragPreviewRef.current = null;
setSelectedNodeId(null);
setSelectedEdgeId(null);
@@ -519,6 +556,7 @@ export function GraphView({
const clickedId = interaction.handleMouseUp();
if (wasDragging && draggedNodeId) {
+ setInteractionSelectionDisabled(false);
const draggedNode = simulation.stateRef.current.nodes.find((node) => node.id === draggedNodeId);
if (draggedNode?.kind === 'member' && draggedNode.x != null && draggedNode.y != null) {
const nearest = simulation.resolveNearestOwnerSlot(
@@ -547,6 +585,7 @@ export function GraphView({
return;
}
+ setInteractionSelectionDisabled(false);
if (clickedId) {
setSelectedNodeId(clickedId);
setSelectedEdgeId(null);
@@ -585,7 +624,7 @@ export function GraphView({
}
dragPreviewRef.current = null;
},
- [camera, events, interaction, onOwnerSlotDrop, simulation]
+ [camera, events, interaction, onOwnerSlotDrop, setInteractionSelectionDisabled, simulation]
);
const handleMouseMove = useCallback(
@@ -640,6 +679,7 @@ export function GraphView({
useEffect(() => {
const handleWindowMouseMove = (event: MouseEvent): void => {
if ((event.buttons & 1) === 0) {
+ setInteractionSelectionDisabled(false);
return;
}
if (
@@ -650,6 +690,7 @@ export function GraphView({
) {
return;
}
+ event.preventDefault();
processActivePointerMove(event.clientX, event.clientY, event.buttons);
};
@@ -660,6 +701,7 @@ export function GraphView({
!interaction.isDragging.current &&
!edgeMouseDownRef.current
) {
+ setInteractionSelectionDisabled(false);
return;
}
completePointerInteraction(event.clientX, event.clientY);
@@ -670,8 +712,9 @@ export function GraphView({
return () => {
window.removeEventListener('mousemove', handleWindowMouseMove);
window.removeEventListener('mouseup', handleWindowMouseUp);
+ setInteractionSelectionDisabled(false);
};
- }, [completePointerInteraction, interaction, processActivePointerMove]);
+ }, [completePointerInteraction, interaction, processActivePointerMove, setInteractionSelectionDisabled]);
const handleDoubleClick = useCallback(
(e: React.MouseEvent) => {
@@ -819,7 +862,10 @@ export function GraphView({
// ─── Render ─────────────────────────────────────────────────────────────
return (
-
+
{visibleLanes.map((lane) => (
@@ -431,12 +431,15 @@ export const GraphActivityHud = ({
ref={(element) => {
shellRefs.current.set(lane.node.id, element);
}}
- className="pointer-events-auto absolute z-10 origin-top-left opacity-0"
+ className="pointer-events-auto absolute z-10 origin-top-left select-none opacity-0"
style={{
width: `${laneWidth}px`,
maxWidth: `${laneWidth}px`,
height: `${laneHeight}px`,
}}
+ onDragStart={(event) => {
+ event.preventDefault();
+ }}
>
diff --git a/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx b/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx
index f8cf303f..265876e2 100644
--- a/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx
+++ b/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx
@@ -327,11 +327,17 @@ const MemberPopoverContent = ({
: null;
const fallbackSpawnStatusLabel =
node.spawnStatus && node.spawnStatus !== 'online'
- ? node.spawnStatus === 'waiting' || node.spawnStatus === 'spawning'
- ? 'starting'
- : node.spawnStatus
+ ? node.spawnStatus === 'waiting'
+ ? 'waiting to start'
+ : node.spawnStatus === 'spawning'
+ ? 'starting'
+ : node.spawnStatus === 'error'
+ ? 'failed'
+ : node.spawnStatus
: null;
const statusLabel =
+ launchPresentation?.launchStatusLabel ??
+ node.launchStatusLabel ??
launchPresentation?.presenceLabel ??
fallbackSpawnStatusLabel ??
(node.state === 'active'
diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts
index 18b4de9f..9ace3693 100644
--- a/src/main/services/team/TeamProvisioningService.ts
+++ b/src/main/services/team/TeamProvisioningService.ts
@@ -783,6 +783,36 @@ function createInitialMemberSpawnStatusEntry(): MemberSpawnStatusEntry {
};
}
+interface LiveTeamAgentRuntimeMetadata {
+ model?: string;
+}
+
+function stripWrappedCliFlagValue(raw: string | undefined): string | undefined {
+ const trimmed = raw?.trim();
+ if (!trimmed) {
+ return undefined;
+ }
+ if (
+ (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))
+ ) {
+ const unwrapped = trimmed.slice(1, -1).trim();
+ return unwrapped.length > 0 ? unwrapped : undefined;
+ }
+ return trimmed;
+}
+
+function extractCliFlagValue(command: string, flagName: string): string | undefined {
+ const escapedFlag = flagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const match = new RegExp(`(?:^|\\s)${escapedFlag}\\s+("([^"]*)"|'([^']*)'|([^\\s]+))`).exec(
+ command
+ );
+ if (!match) {
+ return undefined;
+ }
+ return stripWrappedCliFlagValue(match[2] ?? match[3] ?? match[4] ?? match[1]);
+}
+
export function shouldAcceptDeterministicBootstrapEvent(params: {
runId: string;
teamName: string;
@@ -911,11 +941,17 @@ function buildEffectiveTeamMemberSpec(
): TeamMemberInput {
const memberProviderId = normalizeTeamMemberProviderId(member.providerId);
const defaultProviderId = normalizeTeamMemberProviderId(defaults.providerId);
- const model = member.model?.trim() || defaults.model?.trim() || undefined;
+ const effectiveProviderId = memberProviderId ?? defaultProviderId ?? 'anthropic';
+ const model =
+ member.model?.trim() ||
+ (memberProviderId == null || memberProviderId === defaultProviderId
+ ? defaults.model?.trim()
+ : undefined) ||
+ undefined;
return {
...member,
- providerId: memberProviderId ?? defaultProviderId ?? 'anthropic',
+ providerId: effectiveProviderId,
model,
effort: member.effort ?? defaults.effort,
};
@@ -3396,16 +3432,19 @@ export class TeamProvisioningService {
}> {
const runId = this.getTrackedRunId(teamName);
if (!runId) {
- return this.reconcilePersistedLaunchState(teamName).then(({ snapshot, statuses }) => ({
- statuses,
- runId: null,
- teamLaunchState: snapshot?.teamLaunchState,
- launchPhase: snapshot?.launchPhase,
- expectedMembers: snapshot?.expectedMembers,
- updatedAt: snapshot?.updatedAt,
- summary: snapshot?.summary,
- source: snapshot ? 'persisted' : 'persisted',
- }));
+ return this.reconcilePersistedLaunchState(teamName).then(({ snapshot, statuses }) => {
+ this.attachLiveRuntimeMetadataToStatuses(teamName, statuses);
+ return {
+ statuses,
+ runId: null,
+ teamLaunchState: snapshot?.teamLaunchState,
+ launchPhase: snapshot?.launchPhase,
+ expectedMembers: snapshot?.expectedMembers,
+ updatedAt: snapshot?.updatedAt,
+ summary: snapshot?.summary,
+ source: snapshot ? 'persisted' : 'persisted',
+ };
+ });
}
const run = this.runs.get(runId);
if (!run) {
@@ -3426,6 +3465,7 @@ export class TeamProvisioningService {
});
const snapshot = persisted ?? liveSnapshot;
const statuses = snapshotToMemberSpawnStatuses(snapshot);
+ this.attachLiveRuntimeMetadataToStatuses(teamName, statuses);
return {
statuses,
runId,
@@ -3552,7 +3592,6 @@ export class TeamProvisioningService {
!current ||
current.launchState === 'failed_to_start' ||
current.launchState === 'confirmed_alive' ||
- current.runtimeAlive === true ||
current.hardFailure === true ||
current.agentToolAccepted !== true
) {
@@ -3902,6 +3941,89 @@ export class TeamProvisioningService {
: null;
}
+ private async materializeEffectiveTeamMemberSpecs(params: {
+ claudePath: string;
+ cwd: string;
+ members: TeamCreateRequest['members'];
+ defaults: {
+ providerId?: TeamProviderId;
+ model?: string;
+ effort?: TeamCreateRequest['effort'];
+ };
+ primaryProviderId?: TeamProviderId;
+ primaryEnv?: ProvisioningEnvResolution;
+ limitContext?: boolean;
+ }): Promise
{
+ const envByProvider = new Map>();
+ const defaultModelByProvider = new Map>();
+ const normalizedPrimaryProviderId = resolveTeamProviderId(params.primaryProviderId);
+
+ const getProvisioningEnv = (providerId: TeamProviderId): Promise => {
+ if (normalizedPrimaryProviderId === providerId && params.primaryEnv != null) {
+ return Promise.resolve(params.primaryEnv);
+ }
+
+ const cached = envByProvider.get(providerId);
+ if (cached) {
+ return cached;
+ }
+
+ const created = this.buildProvisioningEnv(providerId);
+ envByProvider.set(providerId, created);
+ return created;
+ };
+
+ const getResolvedDefaultModel = (providerId: TeamProviderId): Promise => {
+ const cached = defaultModelByProvider.get(providerId);
+ if (cached) {
+ return cached;
+ }
+
+ const providerLabel = getTeamProviderLabel(providerId);
+ const created = (async () => {
+ const envResolution = await getProvisioningEnv(providerId);
+ if (envResolution.warning) {
+ throw new Error(envResolution.warning);
+ }
+
+ const resolvedDefaultModel = await this.resolveProviderDefaultModel(
+ params.claudePath,
+ params.cwd,
+ providerId,
+ envResolution.env,
+ params.limitContext === true
+ );
+ const normalized = resolvedDefaultModel?.trim();
+ if (!normalized) {
+ throw new Error(
+ `Could not resolve the runtime default model for ${providerLabel} teammates. Select an explicit model and retry.`
+ );
+ }
+ return normalized;
+ })();
+
+ defaultModelByProvider.set(providerId, created);
+ return created;
+ };
+
+ const effectiveMembers: TeamCreateRequest['members'] = [];
+ for (const member of params.members) {
+ const effectiveMember = buildEffectiveTeamMemberSpec(member, params.defaults);
+ const providerId = normalizeTeamMemberProviderId(effectiveMember.providerId) ?? 'anthropic';
+ if (providerId === 'anthropic' || effectiveMember.model?.trim()) {
+ effectiveMembers.push(effectiveMember);
+ continue;
+ }
+
+ effectiveMembers.push({
+ ...effectiveMember,
+ model: await getResolvedDefaultModel(providerId),
+ });
+ }
+
+ return effectiveMembers;
+ }
+
private getFreshCachedProbeResult(
cwd: string,
providerId: TeamProviderId | undefined
@@ -4756,10 +4878,23 @@ export class TeamProvisioningService {
throw new Error('Claude CLI not found; install it or provide a valid path');
}
- const effectiveMemberSpecs = buildEffectiveTeamMemberSpecs(request.members, {
- providerId: request.providerId,
- model: request.model,
- effort: request.effort,
+ const provisioningEnv = await this.buildProvisioningEnv(request.providerId);
+ const { env: shellEnv, geminiRuntimeAuth, warning: envWarning } = provisioningEnv;
+ if (envWarning) {
+ throw new Error(envWarning);
+ }
+ const effectiveMemberSpecs = await this.materializeEffectiveTeamMemberSpecs({
+ claudePath,
+ cwd: request.cwd,
+ members: request.members,
+ defaults: {
+ providerId: request.providerId,
+ model: request.model,
+ effort: request.effort,
+ },
+ primaryProviderId: request.providerId,
+ primaryEnv: provisioningEnv,
+ limitContext: request.limitContext,
});
const runId = randomUUID();
const startedAt = nowIso();
@@ -4864,14 +4999,6 @@ export class TeamProvisioningService {
const initialUserPrompt = request.prompt?.trim() ?? '';
const promptSize = getPromptSizeSummary(initialUserPrompt);
let child: ReturnType;
- const {
- env: shellEnv,
- geminiRuntimeAuth,
- warning: envWarning,
- } = await this.buildProvisioningEnv(request.providerId);
- if (envWarning) {
- throw new Error(envWarning);
- }
shellEnv.CLAUDE_ENABLE_DETERMINISTIC_TEAM_BOOTSTRAP = '1';
const teammateModeDecision = await resolveDesktopTeammateModeDecision(request.extraCliArgs);
if (teammateModeDecision.forceProcessTeammates) {
@@ -4963,10 +5090,16 @@ export class TeamProvisioningService {
});
await this.membersMetaStore.writeMembers(
request.teamName,
- request.members.map((m) => ({
+ effectiveMemberSpecs.map((m) => ({
name: m.name.trim(),
role: m.role?.trim() || undefined,
workflow: m.workflow?.trim() || undefined,
+ providerId: normalizeOptionalTeamProviderId(m.providerId),
+ model: m.model?.trim() || undefined,
+ effort:
+ m.effort === 'low' || m.effort === 'medium' || m.effort === 'high'
+ ? m.effort
+ : undefined,
agentType: 'general-purpose' as const,
color: getMemberColorByName(m.name.trim()),
joinedAt: Date.now(),
@@ -5300,16 +5433,30 @@ export class TeamProvisioningService {
const runId = randomUUID();
const startedAt = nowIso();
- const effectiveMemberSpecs = buildEffectiveTeamMemberSpecs(expectedMemberSpecs, {
- providerId: request.providerId,
- model: request.model,
- effort: request.effort,
+ const provisioningEnv = await this.buildProvisioningEnv(request.providerId);
+ const { env: shellEnv, geminiRuntimeAuth, warning: envWarning } = provisioningEnv;
+ if (envWarning) {
+ throw new Error(envWarning);
+ }
+
+ const effectiveMemberSpecs = await this.materializeEffectiveTeamMemberSpecs({
+ claudePath,
+ cwd: request.cwd,
+ members: expectedMemberSpecs,
+ defaults: {
+ providerId: request.providerId,
+ model: request.model,
+ effort: request.effort,
+ },
+ primaryProviderId: request.providerId,
+ primaryEnv: provisioningEnv,
+ limitContext: request.limitContext,
});
// Build a synthetic TeamCreateRequest for reuse by shared infrastructure
const syntheticRequest: TeamCreateRequest = {
teamName: request.teamName,
- members: expectedMemberSpecs,
+ members: effectiveMemberSpecs,
cwd: request.cwd,
providerId: request.providerId,
model: request.model,
@@ -5448,14 +5595,6 @@ export class TeamProvisioningService {
);
const promptSize = getPromptSizeSummary(prompt);
let child: ReturnType;
- const {
- env: shellEnv,
- geminiRuntimeAuth,
- warning: envWarning,
- } = await this.buildProvisioningEnv(request.providerId);
- if (envWarning) {
- throw new Error(envWarning);
- }
shellEnv.CLAUDE_ENABLE_DETERMINISTIC_TEAM_BOOTSTRAP = '1';
const teammateModeDecision = await resolveDesktopTeammateModeDecision(request.extraCliArgs);
if (teammateModeDecision.forceProcessTeammates) {
@@ -6842,12 +6981,34 @@ export class TeamProvisioningService {
}
private hasLiveTeamAgentProcess(teamName: string, memberName: string): boolean {
- return this.getLiveTeamAgentNames(teamName).has(memberName);
+ return this.getLiveTeamAgentRuntimeMetadata(teamName).has(memberName);
+ }
+
+ private attachLiveRuntimeMetadataToStatuses(
+ teamName: string,
+ statuses: Record
+ ): void {
+ for (const [memberName, metadata] of this.getLiveTeamAgentRuntimeMetadata(teamName).entries()) {
+ const current = statuses[memberName];
+ if (!current || !metadata.model) {
+ continue;
+ }
+ statuses[memberName] = {
+ ...current,
+ runtimeModel: metadata.model,
+ };
+ }
}
private getLiveTeamAgentNames(teamName: string): Set {
+ return new Set(this.getLiveTeamAgentRuntimeMetadata(teamName).keys());
+ }
+
+ private getLiveTeamAgentRuntimeMetadata(
+ teamName: string
+ ): Map {
if (process.platform === 'win32') {
- return new Set();
+ return new Map();
}
let output = '';
@@ -6857,11 +7018,11 @@ export class TeamProvisioningService {
stdio: ['ignore', 'pipe', 'ignore'],
});
} catch {
- return new Set();
+ return new Map();
}
const teamMarker = `--team-name ${teamName}`;
- const names = new Set();
+ const metadataByAgent = new Map();
for (const line of output.split('\n')) {
const trimmed = line.trim();
if (!trimmed.includes(teamMarker)) continue;
@@ -6869,10 +7030,13 @@ export class TeamProvisioningService {
if (!match) continue;
const agentName = match[1]?.trim();
if (agentName) {
- names.add(agentName);
+ const model = extractCliFlagValue(trimmed, '--model');
+ metadataByAgent.set(agentName, {
+ ...(model ? { model } : {}),
+ });
}
}
- return names;
+ return metadataByAgent;
}
private async clearPersistedLaunchState(teamName: string): Promise {
@@ -7107,7 +7271,7 @@ export class TeamProvisioningService {
current.hardFailure = false;
current.hardFailureReason = undefined;
}
- if (!current.bootstrapConfirmed && !runtimeAlive && !current.hardFailure) {
+ if (!current.bootstrapConfirmed && !current.hardFailure) {
const transcriptFailureReason = await this.findBootstrapTranscriptFailureReason(
teamName,
expected,
diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx
index 0dd81283..1a3037f6 100644
--- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx
+++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx
@@ -593,7 +593,7 @@ export const CreateTeamDialog = ({
selectedMemberProviders
);
setPrepareState('loading');
- setPrepareMessage('Checking selected providers...');
+ setPrepareMessage('Checking selected providers in parallel...');
setPrepareWarnings([]);
setPrepareChecks(initialChecks);
@@ -601,118 +601,128 @@ export const CreateTeamDialog = ({
const timer = setTimeout(() => {
void (async () => {
let checks = initialChecks;
- let anyFailure = false;
- let anyNotes = false;
- const collectedWarnings: string[] = [];
-
- try {
- for (const providerId of selectedMemberProviders) {
- const selectedModelChecks = (() => {
- const next = new Set();
- let hasDefaultSelection = false;
- const supportsProviderDefaultCheck =
- providerId === 'codex' ||
- providerId === 'gemini' ||
- (providerId === 'anthropic' && selectedProviderId === 'anthropic');
- const leadModel = computeEffectiveTeamModel(
- selectedModel,
- limitContext,
- selectedProviderId
- );
- if (selectedProviderId === providerId && selectedModel.trim()) {
- if (leadModel?.trim()) {
- next.add(leadModel.trim());
- }
- } else if (selectedProviderId === providerId && supportsProviderDefaultCheck) {
+ const providerPlans = selectedMemberProviders.map((providerId) => {
+ const selectedModelChecks = (() => {
+ const next = new Set();
+ let hasDefaultSelection = false;
+ const supportsProviderDefaultCheck =
+ providerId === 'codex' ||
+ providerId === 'gemini' ||
+ (providerId === 'anthropic' && selectedProviderId === 'anthropic');
+ const leadModel = computeEffectiveTeamModel(
+ selectedModel,
+ limitContext,
+ selectedProviderId
+ );
+ if (selectedProviderId === providerId && selectedModel.trim()) {
+ if (leadModel?.trim()) {
+ next.add(leadModel.trim());
+ }
+ } else if (selectedProviderId === providerId && supportsProviderDefaultCheck) {
+ hasDefaultSelection = true;
+ }
+ for (const member of effectiveMemberDrafts) {
+ if (member.removedAt) {
+ continue;
+ }
+ const memberProviderId =
+ normalizeOptionalTeamProviderId(member.providerId) ?? selectedProviderId;
+ if (memberProviderId !== providerId) {
+ continue;
+ }
+ const memberModel = member.model?.trim();
+ if (memberModel) {
+ next.add(memberModel);
+ } else if (supportsProviderDefaultCheck) {
hasDefaultSelection = true;
}
- for (const member of effectiveMemberDrafts) {
- if (member.removedAt) {
- continue;
- }
- const memberProviderId =
- normalizeOptionalTeamProviderId(member.providerId) ?? selectedProviderId;
- if (memberProviderId !== providerId) {
- continue;
- }
- const memberModel = member.model?.trim();
- if (memberModel) {
- next.add(memberModel);
- } else if (supportsProviderDefaultCheck) {
- hasDefaultSelection = true;
- }
- }
- if (supportsProviderDefaultCheck && hasDefaultSelection) {
- next.add(DEFAULT_PROVIDER_MODEL_SELECTION);
- }
- return Array.from(next);
- })();
- const backendSummary =
- runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null;
- const cacheKey = buildPrepareModelCacheKey(effectiveCwd, providerId, backendSummary);
- const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {};
- const cachedSnapshot = getProviderPrepareCachedSnapshot({
- providerId,
- selectedModelIds: selectedModelChecks,
- cachedModelResultsById,
- });
- checks = updateProviderCheck(checks, providerId, {
- status: selectedModelChecks.length > 0 ? cachedSnapshot.status : 'checking',
- backendSummary,
- details: cachedSnapshot.details,
- });
- if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
- setPrepareChecks(checks);
- setPrepareMessage(
- selectedModelChecks.length > 0
- ? `Checking ${getProviderLabel(providerId)} runtime and selected model checks ${cachedSnapshot.completedCount}/${cachedSnapshot.totalCount}...`
- : `Checking ${getProviderLabel(providerId)} runtime...`
- );
}
+ if (supportsProviderDefaultCheck && hasDefaultSelection) {
+ next.add(DEFAULT_PROVIDER_MODEL_SELECTION);
+ }
+ return Array.from(next);
+ })();
+ const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null;
+ const cacheKey = buildPrepareModelCacheKey(effectiveCwd, providerId, backendSummary);
+ const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {};
+ const cachedSnapshot = getProviderPrepareCachedSnapshot({
+ providerId,
+ selectedModelIds: selectedModelChecks,
+ cachedModelResultsById,
+ });
+ return {
+ providerId,
+ selectedModelChecks,
+ backendSummary,
+ cacheKey,
+ cachedModelResultsById,
+ cachedSnapshot,
+ };
+ });
- const prepResult = await runProviderPrepareDiagnostics({
- cwd: effectiveCwd,
- providerId,
- selectedModelIds: selectedModelChecks,
- prepareProvisioning: api.teams.prepareProvisioning,
- limitContext,
- cachedModelResultsById,
- onModelProgress: ({ details, completedCount, totalCount }) => {
- checks = updateProviderCheck(checks, providerId, {
- status: 'checking',
- backendSummary,
- details,
- });
- if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
- setPrepareChecks(checks);
- setPrepareMessage(
- `Checking ${getProviderLabel(providerId)} runtime and selected model checks ${completedCount}/${totalCount}...`
- );
- }
- },
+ try {
+ for (const plan of providerPlans) {
+ checks = updateProviderCheck(checks, plan.providerId, {
+ status: plan.selectedModelChecks.length > 0 ? plan.cachedSnapshot.status : 'checking',
+ backendSummary: plan.backendSummary,
+ details: plan.cachedSnapshot.details,
});
- if (prepResult.warnings.length > 0) {
+ }
+ if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
+ setPrepareChecks(checks);
+ }
+ const providerResults = await Promise.all(
+ providerPlans.map(async (plan) => {
+ const prepResult = await runProviderPrepareDiagnostics({
+ cwd: effectiveCwd,
+ providerId: plan.providerId,
+ selectedModelIds: plan.selectedModelChecks,
+ prepareProvisioning: api.teams.prepareProvisioning,
+ limitContext,
+ cachedModelResultsById: plan.cachedModelResultsById,
+ onModelProgress: ({ details }) => {
+ checks = updateProviderCheck(checks, plan.providerId, {
+ status: 'checking',
+ backendSummary: plan.backendSummary,
+ details,
+ });
+ if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
+ setPrepareChecks(checks);
+ }
+ },
+ });
+ return { ...plan, prepResult };
+ })
+ );
+ let anyFailure = false;
+ let anyNotes = false;
+ const collectedWarnings: string[] = [];
+ for (const plan of providerResults) {
+ if (plan.prepResult.warnings.length > 0) {
anyNotes = true;
collectedWarnings.push(
- ...prepResult.warnings.map(
- (warning) => `${getProviderLabel(providerId)}: ${warning}`
+ ...plan.prepResult.warnings.map(
+ (warning) => `${getProviderLabel(plan.providerId)}: ${warning}`
)
);
}
- if (prepResult.status === 'failed') {
+ if (plan.prepResult.status === 'failed') {
anyFailure = true;
- } else if (prepResult.status === 'notes') {
+ } else if (plan.prepResult.status === 'notes') {
anyNotes = true;
}
- prepareModelResultsCacheRef.current.set(cacheKey, prepResult.modelResultsById);
- checks = updateProviderCheck(checks, providerId, {
- status: prepResult.status,
- backendSummary,
- details: prepResult.details,
+ prepareModelResultsCacheRef.current.set(
+ plan.cacheKey,
+ plan.prepResult.modelResultsById
+ );
+ checks = updateProviderCheck(checks, plan.providerId, {
+ status: plan.prepResult.status,
+ backendSummary: plan.backendSummary,
+ details: plan.prepResult.details,
});
- if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
- setPrepareChecks(checks);
- }
+ }
+ if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
+ setPrepareChecks(checks);
}
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
const failureMessage =
diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx
index fcc80825..2b3a6f4e 100644
--- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx
+++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx
@@ -923,82 +923,92 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
selectedMemberProviders
);
setPrepareState('loading');
- setPrepareMessage('Checking selected providers...');
+ setPrepareMessage('Checking selected providers in parallel...');
setPrepareWarnings([]);
setPrepareChecks(initialChecks);
void (async () => {
let checks = initialChecks;
- let anyFailure = false;
- let anyNotes = false;
- const collectedWarnings: string[] = [];
+ const providerPlans = selectedMemberProviders.map((providerId) => {
+ const selectedModelChecks = selectedModelChecksByProvider.get(providerId) ?? [];
+ const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null;
+ const cacheKey = buildPrepareModelCacheKey(effectiveCwd, providerId, backendSummary);
+ const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {};
+ const cachedSnapshot = getProviderPrepareCachedSnapshot({
+ providerId,
+ selectedModelIds: selectedModelChecks,
+ cachedModelResultsById,
+ });
+ return {
+ providerId,
+ selectedModelChecks,
+ backendSummary,
+ cacheKey,
+ cachedModelResultsById,
+ cachedSnapshot,
+ };
+ });
try {
- for (const providerId of selectedMemberProviders) {
- const selectedModelChecks = selectedModelChecksByProvider.get(providerId) ?? [];
- const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null;
- const cacheKey = buildPrepareModelCacheKey(effectiveCwd, providerId, backendSummary);
- const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {};
- const cachedSnapshot = getProviderPrepareCachedSnapshot({
- providerId,
- selectedModelIds: selectedModelChecks,
- cachedModelResultsById,
+ for (const plan of providerPlans) {
+ checks = updateProviderCheck(checks, plan.providerId, {
+ status: plan.selectedModelChecks.length > 0 ? plan.cachedSnapshot.status : 'checking',
+ backendSummary: plan.backendSummary,
+ details: plan.cachedSnapshot.details,
});
- checks = updateProviderCheck(checks, providerId, {
- status: selectedModelChecks.length > 0 ? cachedSnapshot.status : 'checking',
- backendSummary,
- details: cachedSnapshot.details,
- });
- if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
- setPrepareChecks(checks);
- setPrepareMessage(
- selectedModelChecks.length > 0
- ? `Checking ${getProviderLabel(providerId)} runtime and selected model checks ${cachedSnapshot.completedCount}/${cachedSnapshot.totalCount}...`
- : `Checking ${getProviderLabel(providerId)} runtime...`
- );
- }
-
- const prepResult = await runProviderPrepareDiagnostics({
- cwd: effectiveCwd,
- providerId,
- selectedModelIds: selectedModelChecks,
- prepareProvisioning: api.teams.prepareProvisioning,
- limitContext,
- cachedModelResultsById,
- onModelProgress: ({ details, completedCount, totalCount }) => {
- checks = updateProviderCheck(checks, providerId, {
- status: 'checking',
- backendSummary,
- details,
- });
- if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
- setPrepareChecks(checks);
- setPrepareMessage(
- `Checking ${getProviderLabel(providerId)} runtime and selected model checks ${completedCount}/${totalCount}...`
- );
- }
- },
- });
- if (prepResult.warnings.length > 0) {
+ }
+ if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
+ setPrepareChecks(checks);
+ }
+ const providerResults = await Promise.all(
+ providerPlans.map(async (plan) => {
+ const prepResult = await runProviderPrepareDiagnostics({
+ cwd: effectiveCwd,
+ providerId: plan.providerId,
+ selectedModelIds: plan.selectedModelChecks,
+ prepareProvisioning: api.teams.prepareProvisioning,
+ limitContext,
+ cachedModelResultsById: plan.cachedModelResultsById,
+ onModelProgress: ({ details }) => {
+ checks = updateProviderCheck(checks, plan.providerId, {
+ status: 'checking',
+ backendSummary: plan.backendSummary,
+ details,
+ });
+ if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
+ setPrepareChecks(checks);
+ }
+ },
+ });
+ return { ...plan, prepResult };
+ })
+ );
+ let anyFailure = false;
+ let anyNotes = false;
+ const collectedWarnings: string[] = [];
+ for (const plan of providerResults) {
+ if (plan.prepResult.warnings.length > 0) {
anyNotes = true;
collectedWarnings.push(
- ...prepResult.warnings.map((warning) => `${getProviderLabel(providerId)}: ${warning}`)
+ ...plan.prepResult.warnings.map(
+ (warning) => `${getProviderLabel(plan.providerId)}: ${warning}`
+ )
);
}
- if (prepResult.status === 'failed') {
+ if (plan.prepResult.status === 'failed') {
anyFailure = true;
- } else if (prepResult.status === 'notes') {
+ } else if (plan.prepResult.status === 'notes') {
anyNotes = true;
}
- prepareModelResultsCacheRef.current.set(cacheKey, prepResult.modelResultsById);
- checks = updateProviderCheck(checks, providerId, {
- status: prepResult.status,
- backendSummary,
- details: prepResult.details,
+ prepareModelResultsCacheRef.current.set(plan.cacheKey, plan.prepResult.modelResultsById);
+ checks = updateProviderCheck(checks, plan.providerId, {
+ status: plan.prepResult.status,
+ backendSummary: plan.backendSummary,
+ details: plan.prepResult.details,
});
- if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
- setPrepareChecks(checks);
- }
+ }
+ if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
+ setPrepareChecks(checks);
}
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
const failureMessage =
diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx
index 3937e09f..bcf7ebc2 100644
--- a/src/renderer/components/team/members/MemberCard.tsx
+++ b/src/renderer/components/team/members/MemberCard.tsx
@@ -111,7 +111,8 @@ export const MemberCard = ({
!isRemoved &&
presenceLabel === 'starting' &&
spawnLaunchState !== 'failed_to_start' &&
- !activityTask;
+ !activityTask &&
+ !runtimeSummary;
const showStartingBadge = !isRemoved && presenceLabel === 'starting' && !activityTask;
const showRuntimeAdvisoryBadge =
!isRemoved &&
diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx
index c19f4b10..58db6a84 100644
--- a/src/renderer/components/team/members/MemberList.tsx
+++ b/src/renderer/components/team/members/MemberList.tsx
@@ -1,13 +1,8 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import {
- formatTeamModelSummary,
- getTeamEffortLabel,
- getTeamModelLabel,
- getTeamProviderLabel,
-} from '@renderer/components/team/dialogs/TeamModelSelector';
+import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
-import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
+import { isLeadMember } from '@shared/utils/leadDetection';
import { MemberCard } from './MemberCard';
@@ -152,6 +147,7 @@ function areMemberSpawnStatusesEquivalent(
leftEntry.launchState !== rightEntry.launchState ||
leftEntry.error !== rightEntry.error ||
leftEntry.livenessSource !== rightEntry.livenessSource ||
+ leftEntry.runtimeModel !== rightEntry.runtimeModel ||
leftEntry.runtimeAlive !== rightEntry.runtimeAlive
) {
return false;
@@ -242,12 +238,11 @@ export const MemberList = memo(function MemberList({
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const buildRuntimeSummary = useCallback(
- (member: ResolvedTeamMember): string | undefined => {
- const effectiveProvider = member.providerId ?? launchParams?.providerId ?? 'anthropic';
- const effectiveModel = member.model?.trim() || launchParams?.model?.trim() || '';
- const effectiveEffort = member.effort ?? launchParams?.effort;
-
- return formatTeamModelSummary(effectiveProvider, effectiveModel, effectiveEffort);
+ (
+ member: ResolvedTeamMember,
+ spawnEntry: MemberSpawnStatusEntry | undefined
+ ): string | undefined => {
+ return resolveMemberRuntimeSummary(member, launchParams, spawnEntry);
},
[launchParams]
);
@@ -293,7 +288,7 @@ export const MemberList = memo(function MemberList({
reviewTask={isRemoved ? null : reviewTask}
isAwaitingReply={isRemoved ? false : awaitingReply}
isRemoved={isRemoved}
- runtimeSummary={isRemoved ? buildRuntimeSummary(member) : buildRuntimeSummary(member)}
+ runtimeSummary={buildRuntimeSummary(member, isRemoved ? undefined : spawnEntry)}
spawnStatus={isRemoved ? undefined : spawnEntry?.status}
spawnError={isRemoved ? undefined : spawnEntry?.error}
spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource}
diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts
index b473e828..3d2e02ba 100644
--- a/src/renderer/store/slices/teamSlice.ts
+++ b/src/renderer/store/slices/teamSlice.ts
@@ -456,6 +456,7 @@ function areMemberSpawnStatusEntriesEqual(
left.error === right.error &&
left.livenessSource === right.livenessSource &&
left.runtimeAlive === right.runtimeAlive &&
+ left.runtimeModel === right.runtimeModel &&
left.bootstrapConfirmed === right.bootstrapConfirmed &&
left.hardFailure === right.hardFailure
);
diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts
index da447536..ce19b342 100644
--- a/src/renderer/utils/memberHelpers.ts
+++ b/src/renderer/utils/memberHelpers.ts
@@ -423,9 +423,27 @@ export interface MemberLaunchPresentation {
runtimeAdvisoryLabel: string | null;
runtimeAdvisoryTitle?: string;
launchVisualState: MemberLaunchVisualState;
+ launchStatusLabel: string | null;
spawnBadgeLabel: string | null;
}
+export function getMemberLaunchStatusLabel(visualState: MemberLaunchVisualState): string | null {
+ switch (visualState) {
+ case 'waiting':
+ return 'waiting to start';
+ case 'spawning':
+ return 'starting';
+ case 'runtime_pending':
+ return 'connecting';
+ case 'settling':
+ return 'joining team';
+ case 'error':
+ return 'failed';
+ default:
+ return null;
+ }
+}
+
export function buildMemberLaunchPresentation({
member,
spawnStatus,
@@ -511,6 +529,7 @@ export function buildMemberLaunchPresentation({
}
}
+ const launchStatusLabel = getMemberLaunchStatusLabel(launchVisualState);
const spawnBadgeLabel =
spawnStatus && spawnStatus !== 'online'
? spawnStatus === 'waiting' || spawnStatus === 'spawning'
@@ -525,6 +544,7 @@ export function buildMemberLaunchPresentation({
runtimeAdvisoryLabel,
runtimeAdvisoryTitle,
launchVisualState,
+ launchStatusLabel,
spawnBadgeLabel,
};
}
diff --git a/src/renderer/utils/memberRuntimeSummary.ts b/src/renderer/utils/memberRuntimeSummary.ts
new file mode 100644
index 00000000..937a4f0f
--- /dev/null
+++ b/src/renderer/utils/memberRuntimeSummary.ts
@@ -0,0 +1,41 @@
+import { formatTeamModelSummary } from '@renderer/components/team/dialogs/TeamModelSelector';
+
+import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice';
+import type { MemberSpawnStatusEntry, ResolvedTeamMember, TeamProviderId } from '@shared/types';
+import { inferTeamProviderIdFromModel } from '@shared/utils/teamProvider';
+
+function isMemberLaunchPending(spawnEntry: MemberSpawnStatusEntry | undefined): boolean {
+ if (!spawnEntry) {
+ return false;
+ }
+
+ return (
+ spawnEntry.launchState === 'starting' ||
+ spawnEntry.launchState === 'runtime_pending_bootstrap' ||
+ spawnEntry.status === 'waiting' ||
+ spawnEntry.status === 'spawning'
+ );
+}
+
+export function resolveMemberRuntimeSummary(
+ member: ResolvedTeamMember,
+ launchParams: TeamLaunchParams | undefined,
+ spawnEntry: MemberSpawnStatusEntry | undefined
+): string | undefined {
+ const configuredProvider: TeamProviderId =
+ member.providerId ?? launchParams?.providerId ?? 'anthropic';
+ const configuredModel = member.model?.trim() || launchParams?.model?.trim() || '';
+ const configuredEffort = member.effort ?? launchParams?.effort;
+ const runtimeModel = spawnEntry?.runtimeModel?.trim();
+
+ if (runtimeModel && (isMemberLaunchPending(spawnEntry) || configuredModel.length === 0)) {
+ const runtimeProvider = inferTeamProviderIdFromModel(runtimeModel) ?? configuredProvider;
+ return formatTeamModelSummary(runtimeProvider, runtimeModel, configuredEffort);
+ }
+
+ if (isMemberLaunchPending(spawnEntry)) {
+ return undefined;
+ }
+
+ return formatTeamModelSummary(configuredProvider, configuredModel, configuredEffort);
+}
diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts
index 69e5e5ab..22421731 100644
--- a/src/shared/types/team.ts
+++ b/src/shared/types/team.ts
@@ -945,6 +945,8 @@ export interface MemberSpawnStatusEntry {
firstSpawnAcceptedAt?: string;
/** ISO timestamp of the latest confirmed heartbeat/bootstrap message. */
lastHeartbeatAt?: string;
+ /** Live runtime model observed from the teammate process, when available. */
+ runtimeModel?: string;
/** ISO timestamp of the last status change. */
updatedAt: string;
}
diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts
index 659d5d68..889c74c8 100644
--- a/test/main/services/team/TeamProvisioningService.test.ts
+++ b/test/main/services/team/TeamProvisioningService.test.ts
@@ -752,7 +752,7 @@ describe('TeamProvisioningService', () => {
expect(result.teamLaunchState).toBe('partial_failure');
});
- it('marks a live teammate bootstrap as failed when transcript shows model unavailability', async () => {
+ it('marks an online teammate bootstrap as failed when transcript shows model unavailability', async () => {
allowConsoleLogs();
const teamName = 'zz-live-bootstrap-model-unavailable';
const leadSessionId = 'lead-session';
@@ -816,8 +816,8 @@ describe('TeamProvisioningService', () => {
launchState: 'runtime_pending_bootstrap',
error: undefined,
updatedAt: acceptedAt,
- runtimeAlive: false,
- livenessSource: undefined,
+ runtimeAlive: true,
+ livenessSource: 'process',
bootstrapConfirmed: false,
hardFailure: false,
agentToolAccepted: true,
@@ -846,4 +846,77 @@ describe('TeamProvisioningService', () => {
);
expect(run.provisioningOutputParts.join('\n')).toContain('requested model is not available');
});
+
+ it('marks a persisted online teammate bootstrap as failed when transcript shows model unavailability', async () => {
+ allowConsoleLogs();
+ const teamName = 'zz-persisted-live-bootstrap-model-unavailable';
+ const leadSessionId = 'lead-session';
+ const memberSessionId = 'jack-session';
+ const projectPath = '/Users/test/proj';
+ const projectId = '-Users-test-proj';
+ const acceptedAt = new Date(Date.now() - 5_000).toISOString();
+ const errorAt = new Date(Date.now() - 4_000).toISOString();
+
+ writeLaunchConfig(teamName, projectPath, leadSessionId, ['jack']);
+ writeLaunchState(teamName, leadSessionId, {
+ jack: {
+ launchState: 'runtime_pending_bootstrap',
+ agentToolAccepted: true,
+ runtimeAlive: false,
+ bootstrapConfirmed: false,
+ hardFailure: false,
+ hardFailureReason: undefined,
+ firstSpawnAcceptedAt: acceptedAt,
+ },
+ });
+
+ const projectRoot = path.join(tempProjectsBase, projectId);
+ fs.mkdirSync(projectRoot, { recursive: true });
+ fs.writeFileSync(
+ path.join(projectRoot, `${memberSessionId}.jsonl`),
+ [
+ JSON.stringify({
+ timestamp: acceptedAt,
+ teamName,
+ agentName: 'jack',
+ type: 'user',
+ message: {
+ role: 'user',
+ content: `You are bootstrapping into team "${teamName}" as member "jack".`,
+ },
+ }),
+ JSON.stringify({
+ timestamp: errorAt,
+ teamName,
+ agentName: 'jack',
+ type: 'assistant',
+ isApiErrorMessage: true,
+ message: {
+ role: 'assistant',
+ content: [
+ {
+ type: 'text',
+ text: 'API Error: 400 {"detail":"The requested model is not available for your account."}',
+ },
+ ],
+ },
+ }),
+ ].join('\n') + '\n',
+ 'utf8'
+ );
+
+ const svc = new TeamProvisioningService();
+ (svc as any).getLiveTeamAgentNames = vi.fn(() => new Set(['jack']));
+
+ const result = await svc.getMemberSpawnStatuses(teamName);
+
+ expect(result.statuses.jack).toMatchObject({
+ status: 'error',
+ launchState: 'failed_to_start',
+ runtimeAlive: true,
+ });
+ expect(result.statuses.jack?.error).toContain('requested model is not available');
+ expect(result.statuses.jack?.hardFailureReason).toContain('requested model is not available');
+ expect(result.teamLaunchState).toBe('partial_failure');
+ });
});
diff --git a/test/main/services/team/TeamProvisioningServicePrompts.test.ts b/test/main/services/team/TeamProvisioningServicePrompts.test.ts
index 8ceb6d05..7eef52f4 100644
--- a/test/main/services/team/TeamProvisioningServicePrompts.test.ts
+++ b/test/main/services/team/TeamProvisioningServicePrompts.test.ts
@@ -276,6 +276,76 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
await svc.cancelProvisioning(runId);
});
+ it('createTeam materializes an explicit Codex default model for teammates before bootstrap spawn', async () => {
+ vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude');
+ const { child } = createFakeChild();
+ vi.mocked(spawnCli).mockReturnValue(child as any);
+
+ const svc = new TeamProvisioningService();
+ (svc as any).buildProvisioningEnv = vi.fn(async () => ({
+ env: { PATH: '/usr/bin' },
+ authSource: 'codex_runtime',
+ geminiRuntimeAuth: null,
+ }));
+ (svc as any).resolveProviderDefaultModel = vi.fn(async () => 'gpt-5.4');
+ (svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
+ (svc as any).startFilesystemMonitor = vi.fn();
+ (svc as any).pathExists = vi.fn(async () => false);
+
+ const { runId } = await svc.createTeam(
+ {
+ teamName: 'codex-default-team',
+ cwd: process.cwd(),
+ providerId: 'codex',
+ members: [{ name: 'alice', role: 'developer', providerId: 'codex' }],
+ },
+ () => {}
+ );
+
+ const bootstrapSpec = extractBootstrapSpec();
+ expect(bootstrapSpec.members).toEqual([
+ expect.objectContaining({
+ name: 'alice',
+ provider: 'codex',
+ model: 'gpt-5.4',
+ }),
+ ]);
+
+ await svc.cancelProvisioning(runId);
+ });
+
+ it('createTeam fails fast when a Codex teammate default model cannot be resolved', async () => {
+ vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude');
+ vi.mocked(spawnCli).mockReset();
+
+ const svc = new TeamProvisioningService();
+ (svc as any).buildProvisioningEnv = vi.fn(async () => ({
+ env: { PATH: '/usr/bin' },
+ authSource: 'codex_runtime',
+ geminiRuntimeAuth: null,
+ }));
+ (svc as any).resolveProviderDefaultModel = vi.fn(async () => null);
+ (svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
+ (svc as any).startFilesystemMonitor = vi.fn();
+ (svc as any).pathExists = vi.fn(async () => false);
+
+ await expect(
+ svc.createTeam(
+ {
+ teamName: 'codex-default-missing',
+ cwd: process.cwd(),
+ providerId: 'codex',
+ members: [{ name: 'alice', providerId: 'codex' }],
+ },
+ () => {}
+ )
+ ).rejects.toThrow(
+ 'Could not resolve the runtime default model for Codex teammates. Select an explicit model and retry.'
+ );
+
+ expect(spawnCli).not.toHaveBeenCalled();
+ });
+
it('add-member spawn prompt tells teammates to keep review on the same task', () => {
const prompt = buildAddMemberSpawnMessage('my-team', 'My Team', 'team-lead', {
name: 'alice',
@@ -429,4 +499,67 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
await svc.cancelProvisioning(runId);
});
+
+ it('launchTeam materializes an explicit Codex default model for launch teammates before bootstrap spawn', async () => {
+ const teamName = 'codex-default-launch';
+ const teamDir = path.join(tempTeamsBase, teamName);
+ fs.mkdirSync(teamDir, { recursive: true });
+ fs.writeFileSync(
+ path.join(teamDir, 'config.json'),
+ JSON.stringify({
+ name: teamName,
+ members: [
+ { name: 'team-lead', agentType: 'team-lead', providerId: 'codex' },
+ { name: 'alice', agentType: 'teammate', role: 'developer', providerId: 'codex' },
+ ],
+ }),
+ 'utf8'
+ );
+
+ vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude');
+ const { child } = createFakeChild();
+ vi.mocked(spawnCli).mockReturnValue(child as any);
+
+ const svc = new TeamProvisioningService();
+ (svc as any).buildProvisioningEnv = vi.fn(async () => ({
+ env: { PATH: '/usr/bin' },
+ authSource: 'codex_runtime',
+ geminiRuntimeAuth: null,
+ }));
+ (svc as any).resolveProviderDefaultModel = vi.fn(async () => 'gpt-5.4');
+ (svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {});
+ (svc as any).updateConfigProjectPath = vi.fn(async () => {});
+ (svc as any).restorePrelaunchConfig = vi.fn(async () => {});
+ (svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {});
+ (svc as any).persistLaunchStateSnapshot = vi.fn(async () => {});
+ (svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({
+ members: [{ name: 'alice', role: 'developer', providerId: 'codex' }],
+ source: 'config-fallback',
+ warning: undefined,
+ }));
+ (svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
+ (svc as any).pathExists = vi.fn(async () => false);
+ (svc as any).startFilesystemMonitor = vi.fn();
+
+ const { runId } = await svc.launchTeam(
+ {
+ teamName,
+ cwd: process.cwd(),
+ providerId: 'codex',
+ clearContext: true,
+ } as any,
+ () => {}
+ );
+
+ const bootstrapSpec = extractBootstrapSpec();
+ expect(bootstrapSpec.members).toEqual([
+ expect.objectContaining({
+ name: 'alice',
+ provider: 'codex',
+ model: 'gpt-5.4',
+ }),
+ ]);
+
+ await svc.cancelProvisioning(runId);
+ });
});
diff --git a/test/renderer/features/agent-graph/GraphNodePopover.test.ts b/test/renderer/features/agent-graph/GraphNodePopover.test.ts
index 7eaa65b5..e5d8ce6b 100644
--- a/test/renderer/features/agent-graph/GraphNodePopover.test.ts
+++ b/test/renderer/features/agent-graph/GraphNodePopover.test.ts
@@ -70,7 +70,7 @@ describe('GraphNodePopover spawn badge labels', () => {
vi.unstubAllGlobals();
});
- it('shows human-facing starting for raw waiting/spawning spawn statuses', async () => {
+ it('shows human-readable launch-status labels for waiting and spawning spawn states', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
@@ -96,9 +96,9 @@ describe('GraphNodePopover spawn badge labels', () => {
await Promise.resolve();
});
+ expect(host.textContent).toContain('waiting to start');
expect(host.textContent).toContain('starting');
expect(host.textContent).toContain('Codex · GPT-5.4 Mini · Medium');
- expect(host.textContent).not.toContain('waiting');
expect(host.textContent).not.toContain('spawning');
await act(async () => {
@@ -193,7 +193,7 @@ describe('GraphNodePopover spawn badge labels', () => {
await Promise.resolve();
});
- expect(host.textContent).toContain('online');
+ expect(host.textContent).toContain('connecting');
expect(host.textContent).not.toContain('Idle');
await act(async () => {
diff --git a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts
index 86f3c872..55294abe 100644
--- a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts
+++ b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts
@@ -1078,7 +1078,10 @@ describe('TeamGraphAdapter particles', () => {
} as never
);
- expect(findNode(graph, 'member:my-team:alice')?.launchVisualState).toBe('runtime_pending');
+ expect(findNode(graph, 'member:my-team:alice')).toMatchObject({
+ launchVisualState: 'runtime_pending',
+ launchStatusLabel: 'connecting',
+ });
});
it('keeps confirmed teammates in settling visuals while launch is still joining', () => {
@@ -1125,7 +1128,10 @@ describe('TeamGraphAdapter particles', () => {
} as never
);
- expect(findNode(graph, 'member:my-team:alice')?.launchVisualState).toBe('settling');
+ expect(findNode(graph, 'member:my-team:alice')).toMatchObject({
+ launchVisualState: 'settling',
+ launchStatusLabel: 'joining team',
+ });
});
it('scopes inbox particle ids by team name to avoid cross-team collisions', () => {
diff --git a/test/renderer/features/agent-graph/drawAgents.test.ts b/test/renderer/features/agent-graph/drawAgents.test.ts
index 4d5bcbb7..1208a2c6 100644
--- a/test/renderer/features/agent-graph/drawAgents.test.ts
+++ b/test/renderer/features/agent-graph/drawAgents.test.ts
@@ -108,4 +108,36 @@ describe('drawAgents', () => {
expect(runtimeCall!.y).toBeGreaterThan(labelCall!.y);
expect(toolCall!.y).toBeLessThan(node.y!);
});
+
+ it('renders launch text as a third label line and removes old ad-hoc waiting text', () => {
+ const { ctx, fillTextCalls } = createMockContext();
+ const node: GraphNode = {
+ id: 'member:demo:alice',
+ kind: 'member',
+ label: 'alice',
+ state: 'idle',
+ color: '#60a5fa',
+ runtimeLabel: 'Codex · GPT-5.4 Mini · Medium',
+ launchVisualState: 'runtime_pending',
+ launchStatusLabel: 'connecting',
+ spawnStatus: 'online',
+ domainRef: { kind: 'member', teamName: 'demo', memberName: 'alice' },
+ x: 320,
+ y: 240,
+ };
+
+ drawAgents(ctx, [node], 0, null, null, null, 1);
+
+ const labelCall = fillTextCalls.find((call) => call.text === 'alice');
+ const runtimeCall = fillTextCalls.find((call) => call.text.includes('Codex'));
+ const launchCall = fillTextCalls.find((call) => call.text === 'connecting');
+
+ expect(labelCall).toBeDefined();
+ expect(runtimeCall).toBeDefined();
+ expect(launchCall).toBeDefined();
+ expect(runtimeCall!.y).toBeGreaterThan(labelCall!.y);
+ expect(launchCall!.y).toBeGreaterThan(runtimeCall!.y);
+ expect(fillTextCalls.some((call) => call.text === 'waiting...')).toBe(false);
+ expect(fillTextCalls.some((call) => call.text === 'connecting...')).toBe(false);
+ });
});
diff --git a/test/renderer/utils/memberHelpers.test.ts b/test/renderer/utils/memberHelpers.test.ts
index 56a8dfe9..b626c0fe 100644
--- a/test/renderer/utils/memberHelpers.test.ts
+++ b/test/renderer/utils/memberHelpers.test.ts
@@ -177,33 +177,90 @@ describe('memberHelpers spawn-aware presence', () => {
});
it('derives runtime-pending and settling visual states from the same launch inputs', () => {
+ const runtimePending = buildMemberLaunchPresentation({
+ member,
+ spawnStatus: 'online',
+ spawnLaunchState: 'runtime_pending_bootstrap',
+ spawnLivenessSource: 'process',
+ spawnRuntimeAlive: true,
+ runtimeAdvisory: undefined,
+ isLaunchSettling: false,
+ isTeamAlive: true,
+ isTeamProvisioning: false,
+ });
+
+ const settling = buildMemberLaunchPresentation({
+ member,
+ spawnStatus: 'online',
+ spawnLaunchState: 'confirmed_alive',
+ spawnLivenessSource: 'heartbeat',
+ spawnRuntimeAlive: true,
+ runtimeAdvisory: undefined,
+ isLaunchSettling: true,
+ isTeamAlive: true,
+ isTeamProvisioning: false,
+ });
+
+ expect(runtimePending.launchVisualState).toBe('runtime_pending');
+ expect(runtimePending.launchStatusLabel).toBe('connecting');
+ expect(settling.launchVisualState).toBe('settling');
+ expect(settling.launchStatusLabel).toBe('joining team');
+ });
+
+ it('returns shared launch status labels without changing generic presence labels', () => {
expect(
buildMemberLaunchPresentation({
member,
- spawnStatus: 'online',
- spawnLaunchState: 'runtime_pending_bootstrap',
- spawnLivenessSource: 'process',
- spawnRuntimeAlive: true,
+ spawnStatus: 'waiting',
+ spawnLaunchState: 'starting',
+ spawnLivenessSource: undefined,
+ spawnRuntimeAlive: false,
runtimeAdvisory: undefined,
isLaunchSettling: false,
isTeamAlive: true,
isTeamProvisioning: false,
- }).launchVisualState
- ).toBe('runtime_pending');
+ })
+ ).toMatchObject({
+ presenceLabel: 'starting',
+ launchVisualState: 'waiting',
+ launchStatusLabel: 'waiting to start',
+ });
expect(
buildMemberLaunchPresentation({
member,
- spawnStatus: 'online',
- spawnLaunchState: 'confirmed_alive',
- spawnLivenessSource: 'heartbeat',
- spawnRuntimeAlive: true,
+ spawnStatus: 'spawning',
+ spawnLaunchState: 'starting',
+ spawnLivenessSource: undefined,
+ spawnRuntimeAlive: false,
runtimeAdvisory: undefined,
- isLaunchSettling: true,
+ isLaunchSettling: false,
isTeamAlive: true,
isTeamProvisioning: false,
- }).launchVisualState
- ).toBe('settling');
+ })
+ ).toMatchObject({
+ presenceLabel: 'starting',
+ launchVisualState: 'spawning',
+ launchStatusLabel: 'starting',
+ });
+
+ expect(
+ buildMemberLaunchPresentation({
+ member,
+ spawnStatus: 'error',
+ spawnLaunchState: 'failed_to_start',
+ spawnLivenessSource: undefined,
+ spawnRuntimeAlive: false,
+ runtimeAdvisory: undefined,
+ isLaunchSettling: false,
+ isTeamAlive: true,
+ isTeamProvisioning: false,
+ })
+ ).toMatchObject({
+ presenceLabel: 'spawn failed',
+ launchVisualState: 'error',
+ launchStatusLabel: 'failed',
+ });
});
it('renders unified retry advisory labels for provider retries', () => {
diff --git a/test/renderer/utils/memberRuntimeSummary.test.ts b/test/renderer/utils/memberRuntimeSummary.test.ts
new file mode 100644
index 00000000..dd8ab9b5
--- /dev/null
+++ b/test/renderer/utils/memberRuntimeSummary.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from 'vitest';
+
+import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary';
+
+import type { MemberSpawnStatusEntry, ResolvedTeamMember } from '@shared/types';
+
+function createMember(overrides: Partial = {}): ResolvedTeamMember {
+ return {
+ name: 'alice',
+ agentId: 'alice@test-team',
+ agentType: 'general-purpose',
+ role: 'developer',
+ providerId: 'codex',
+ effort: 'medium',
+ status: 'idle',
+ currentTaskId: null,
+ taskCount: 0,
+ lastActiveAt: null,
+ messageCount: 0,
+ color: 'blue',
+ ...overrides,
+ };
+}
+
+function createSpawnEntry(overrides: Partial = {}): MemberSpawnStatusEntry {
+ return {
+ status: 'waiting',
+ launchState: 'starting',
+ runtimeAlive: false,
+ bootstrapConfirmed: false,
+ hardFailure: false,
+ agentToolAccepted: true,
+ updatedAt: '2026-04-16T17:10:48.646Z',
+ ...overrides,
+ };
+}
+
+describe('resolveMemberRuntimeSummary', () => {
+ it('shows the live runtime model for loading members when available', () => {
+ const member = createMember();
+ const spawnEntry = createSpawnEntry({ runtimeModel: 'claude-opus-4-6', runtimeAlive: true });
+
+ expect(resolveMemberRuntimeSummary(member, undefined, spawnEntry)).toBe(
+ 'Anthropic · Opus 4.6 · Medium'
+ );
+ });
+
+ it('keeps the loading skeleton when a pending member has no live runtime model yet', () => {
+ const member = createMember();
+ const spawnEntry = createSpawnEntry();
+
+ expect(resolveMemberRuntimeSummary(member, undefined, spawnEntry)).toBeUndefined();
+ });
+
+ it('uses the live runtime model as a fallback when config has no explicit model', () => {
+ const member = createMember({ providerId: 'codex', model: undefined });
+ const spawnEntry = createSpawnEntry({
+ status: 'online',
+ launchState: 'confirmed_alive',
+ runtimeAlive: true,
+ runtimeModel: 'gpt-5.4-mini',
+ });
+
+ expect(resolveMemberRuntimeSummary(member, undefined, spawnEntry)).toBe('5.4 Mini · Medium');
+ });
+});