From fbf299f276bbf4df0326121044d4d05fbb177b71 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 19 Apr 2026 11:56:53 +0300 Subject: [PATCH 01/36] fix(team): update package manager and enhance member color handling - Bumped pnpm version to 10.33.0 in package.json. - Added existing members to EditTeamDialog for better context. - Improved buildMemberDraftColorMap to reserve colors for existing members and predict colors for new drafts. - Added tests to ensure color assignment logic works correctly for existing and new members. --- package.json | 2 +- .../team/dialogs/EditTeamDialog.tsx | 1 + .../team/members/membersEditorUtils.ts | 40 ++++++++--- .../team/members/membersEditorUtils.test.ts | 71 +++++++++++++++++++ 4 files changed, 105 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 9809f68c..f1a8bb6c 100644 --- a/package.json +++ b/package.json @@ -301,7 +301,7 @@ } ] }, - "packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501", + "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319", "pnpm": { "onlyBuiltDependencies": [ "electron", diff --git a/src/renderer/components/team/dialogs/EditTeamDialog.tsx b/src/renderer/components/team/dialogs/EditTeamDialog.tsx index 8cf506be..a19e19b4 100644 --- a/src/renderer/components/team/dialogs/EditTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/EditTeamDialog.tsx @@ -164,6 +164,7 @@ export const EditTeamDialog = ({ showJsonEditor={!isTeamAlive} draftKeyPrefix={`editTeam:${teamName}`} projectPath={projectPath ?? null} + existingMembers={currentMembers} lockProviderModel={isTeamAlive} /> diff --git a/src/renderer/components/team/members/membersEditorUtils.ts b/src/renderer/components/team/members/membersEditorUtils.ts index b69843ad..e55e9351 100644 --- a/src/renderer/components/team/members/membersEditorUtils.ts +++ b/src/renderer/components/team/members/membersEditorUtils.ts @@ -2,6 +2,7 @@ import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRole 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 { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; @@ -147,19 +148,42 @@ export function buildMemberDraftColorMap( .filter(Boolean) .map((name) => ({ name })); - // When existing members are provided, include them first so their colors - // are reserved and new drafts receive the next available palette entries. - const allEntries = existingMembers ? [...existingMembers, ...draftEntries] : draftEntries; + const existingSeedEntries = (existingMembers ?? []) + .map((member) => ({ + ...member, + name: member.name.trim(), + color: member.color?.trim() || getMemberColorByName(member.name), + })) + .filter((member) => member.name); + const existingNames = new Set(existingSeedEntries.map((member) => member.name.toLowerCase())); + const unseenNewDraftNames = new Set(); + const uniqueNewDraftEntries = draftEntries.filter((entry) => { + const normalizedName = entry.name.toLowerCase(); + if (existingNames.has(normalizedName) || unseenNewDraftNames.has(normalizedName)) { + return false; + } + unseenNewDraftNames.add(normalizedName); + return true; + }); - const fullMap = buildMemberColorMap(allEntries); + const predictedDraftSeedEntries = uniqueNewDraftEntries.map((entry) => ({ + ...entry, + color: getMemberColorByName(entry.name), + })); - // Return only draft entries so callers don't see existing-member keys - // they didn't ask for (keeps the API surface unchanged). - if (!existingMembers) return fullMap; + // 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 fullColorByName = new Map( + Array.from(fullMap.entries()).map(([name, color]) => [name.toLowerCase(), color] as const) + ); const draftMap = new Map(); for (const entry of draftEntries) { - const color = fullMap.get(entry.name); + const color = fullColorByName.get(entry.name.toLowerCase()); if (color) draftMap.set(entry.name, color); } return draftMap; diff --git a/test/renderer/components/team/members/membersEditorUtils.test.ts b/test/renderer/components/team/members/membersEditorUtils.test.ts index defea87b..54b5929b 100644 --- a/test/renderer/components/team/members/membersEditorUtils.test.ts +++ b/test/renderer/components/team/members/membersEditorUtils.test.ts @@ -1,10 +1,14 @@ import { describe, expect, it } from 'vitest'; import { + buildMemberDraftColorMap, buildMembersFromDrafts, + createMemberDraft, createMemberDraftsFromInputs, filterEditableMemberInputs, } from '@renderer/components/team/members/MembersEditorSection'; +import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; +import { getMemberColorByName } from '@shared/constants/memberColors'; import type { ResolvedTeamMember } from '@shared/types'; describe('members editor editable input filtering', () => { @@ -86,4 +90,71 @@ describe('members editor editable input filtering', () => { }), ]); }); + + it('reuses existing member colors for matching draft names', () => { + 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 draftColors = buildMemberDraftColorMap(drafts, existingMembers); + + expect(draftColors.get('alice')).toBe(expectedColors.get('alice')); + expect(draftColors.get('tom')).toBe(expectedColors.get('tom')); + expect(draftColors.get('bob')).toBe(expectedColors.get('bob')); + }); + + it('assigns new draft members after reserving existing team colors', () => { + const existingMembers = [{ name: 'alice' }, { name: 'tom' }]; + const drafts = [ + createMemberDraft({ name: 'alice' }), + createMemberDraft({ name: 'tom' }), + createMemberDraft({ name: 'bob' }), + ]; + + const expectedColors = buildMemberColorMap( + [...existingMembers, { name: 'bob' }].map((member) => ({ + ...member, + color: getMemberColorByName(member.name), + })) + ); + const draftColors = buildMemberDraftColorMap(drafts, existingMembers); + + expect(draftColors.get('alice')).toBe(expectedColors.get('alice')); + expect(draftColors.get('tom')).toBe(expectedColors.get('tom')); + expect(draftColors.get('bob')).toBe(expectedColors.get('bob')); + }); + + 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( + drafts.map((draft) => ({ + name: draft.name, + color: getMemberColorByName(draft.name), + })) + ); + const draftColors = buildMemberDraftColorMap(drafts); + + expect(draftColors.get('alice')).toBe(expectedColors.get('alice')); + expect(draftColors.get('tom')).toBe(expectedColors.get('tom')); + expect(draftColors.get('bob')).toBe(expectedColors.get('bob')); + }); + + it('preserves explicit existing colors in edit and launch dialogs', () => { + const existingMembers = [ + { name: 'alice', color: 'blue' }, + { name: 'bob', color: 'pink' }, + ]; + const drafts = existingMembers.map((member) => createMemberDraft({ name: member.name })); + + const draftColors = buildMemberDraftColorMap(drafts, existingMembers); + + expect(draftColors.get('alice')).toBe('blue'); + expect(draftColors.get('bob')).toBe('pink'); + }); }); From 1e2241aeadc396bceb96fb470e285e33ad1af4db Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 19 Apr 2026 16:08:38 +0300 Subject: [PATCH 02/36] chore: checkpoint workspace before relaunch flow --- ...dex-native-runtime-integration-decision.md | 312 +++++ src/main/ipc/teams.ts | 7 +- src/main/services/team/TeamDataService.ts | 119 +- src/main/services/team/TeamMemberResolver.ts | 8 +- .../services/team/TeamProvisioningService.ts | 386 +++++- src/main/workers/team-fs-worker.ts | 8 +- .../components/team/TeamDetailView.tsx | 15 +- .../team/dialogs/CreateTeamDialog.tsx | 10 +- .../team/dialogs/EditTeamDialog.tsx | 463 ++++++- .../team/dialogs/editTeamRuntimeChanges.ts | 165 +++ .../team/members/LeadModelRow.test.tsx | 132 ++ .../components/team/members/LeadModelRow.tsx | 4 +- .../components/team/members/MemberCard.tsx | 34 +- .../team/members/MemberDetailDialog.tsx | 5 +- .../team/members/MemberDraftRow.tsx | 45 +- .../team/members/MembersEditorSection.tsx | 30 +- .../team/members/membersEditorTypes.ts | 1 + .../team/members/membersEditorUtils.ts | 46 +- src/renderer/constants/teamColors.ts | 77 ++ src/renderer/utils/memberHelpers.ts | 43 +- src/shared/constants/memberColors.ts | 143 +-- src/shared/utils/teamMemberColors.ts | 107 ++ src/shared/utils/teamMemberName.ts | 15 + test/main/ipc/teams.test.ts | 42 + .../services/team/TeamDataService.test.ts | 67 + .../team/TeamProvisioningService.test.ts | 642 ++++++++++ .../TeamProvisioningServicePrompts.test.ts | 6 + .../team/dialogs/EditTeamDialog.test.ts | 1123 +++++++++++++++++ .../dialogs/editTeamRuntimeChanges.test.ts | 181 +++ .../team/members/MemberCard.test.ts | 31 + .../team/members/membersEditorUtils.test.ts | 51 +- test/renderer/constants/teamColors.test.ts | 14 + test/shared/utils/teamMemberColors.test.ts | 45 + 33 files changed, 4078 insertions(+), 299 deletions(-) create mode 100644 docs/research/codex-native-runtime-integration-decision.md create mode 100644 src/renderer/components/team/dialogs/editTeamRuntimeChanges.ts create mode 100644 src/renderer/components/team/members/LeadModelRow.test.tsx create mode 100644 src/shared/utils/teamMemberColors.ts create mode 100644 test/renderer/components/team/dialogs/EditTeamDialog.test.ts create mode 100644 test/renderer/components/team/dialogs/editTeamRuntimeChanges.test.ts create mode 100644 test/shared/utils/teamMemberColors.test.ts diff --git a/docs/research/codex-native-runtime-integration-decision.md b/docs/research/codex-native-runtime-integration-decision.md new file mode 100644 index 00000000..a51ee887 --- /dev/null +++ b/docs/research/codex-native-runtime-integration-decision.md @@ -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. diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index dcfdc648..337c3da1 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -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 { diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 5d29e6db..0fc2be98 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -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( + 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(); - 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( diff --git a/src/main/services/team/TeamMemberResolver.ts b/src/main/services/team/TeamMemberResolver.ts index ec65957e..62bc8f06 100644 --- a/src/main/services/team/TeamMemberResolver.ts +++ b/src/main/services/team/TeamMemberResolver.ts @@ -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), + })); } } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 5bf9f284..25654973 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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; + /** Explicit restart requests awaiting teammate rejoin or failure. */ + pendingMemberRestarts: Map; /** Per-member latest processed lead-inbox bootstrap signal cursor for the current live run. */ memberSpawnLeadInboxCursorByMember: Map; /** 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> = []; + 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>, + 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> + ): 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(); + 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 { + 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(); + 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> { 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: ${ diff --git a/src/main/workers/team-fs-worker.ts b/src/main/workers/team-fs-worker.ts index 89c83b83..6ad828ef 100644 --- a/src/main/workers/team-fs-worker.ts +++ b/src/main/workers/team-fs-worker.ts @@ -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 } : {}), diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 68d73349..8142a16e 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -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)} > - Edit team + + {isTeamProvisioning + ? 'Edit team is unavailable while provisioning is still in progress' + : 'Edit team'} + @@ -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)} diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index f6216327..9b230ef8 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -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] ); diff --git a/src/renderer/components/team/dialogs/EditTeamDialog.tsx b/src/renderer/components/team/dialogs/EditTeamDialog.tsx index a19e19b4..bae1f8b2 100644 --- a/src/renderer/components/team/dialogs/EditTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/EditTeamDialog.tsx @@ -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; isTeamAlive?: boolean; + isTeamProvisioning?: boolean; projectPath?: string | null; onClose: () => void; - onSaved: () => void; + onSaved: () => Promise | 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(null); + const [saveOutcomeError, setSaveOutcomeError] = useState(null); + const [membersPendingRestartRetry, setMembersPendingRestartRetry] = useState< + Record + >({}); + const wasOpenRef = useRef(false); + const initializedTeamNameRef = useRef(null); + const baselineSourceSnapshotRef = useRef(null); + const pendingCommittedSourceSnapshotRef = useRef(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 = ({