From d5894c029d5b1839df3773514231f873011f5646 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 19 May 2026 22:39:13 +0300 Subject: [PATCH] feat(team): improve runtime bootstrap controls --- AGENTS.md | 1 - docs/team-management/debugging-agent-teams.md | 23 + .../member-mcp-bootstrap-spike.md | 35 ++ landing/AGENTS.md | 13 + runtime.lock.json | 12 +- src/main/index.ts | 17 +- src/main/ipc/editor.ts | 24 +- src/main/ipc/teams.ts | 16 +- .../runtime/McpConfigStateReader.ts | 72 ++- .../OpenCodeRuntimeInstallerService.ts | 2 + src/main/services/team/TeamConfigReader.ts | 2 + src/main/services/team/TeamDataService.ts | 5 + .../services/team/TeamMcpConfigBuilder.ts | 85 ++- src/main/services/team/TeamMemberResolver.ts | 6 + .../services/team/TeamMembersMetaStore.ts | 2 + .../services/team/TeamProvisioningService.ts | 311 ++++++++-- .../OpenCodeRuntimeDeliveryDiagnostics.ts | 2 +- .../components/dashboard/CliStatusBanner.tsx | 91 +-- .../runtime/providerTerminalCommands.ts | 58 ++ .../settings/sections/CliStatusSection.tsx | 60 +- .../components/team/TeamDetailView.tsx | 1 + src/renderer/components/team/TeamListView.tsx | 2 +- .../team/dialogs/AddMemberDialog.tsx | 4 +- .../team/dialogs/CreateTeamDialog.tsx | 464 ++++++++------ .../team/dialogs/EditTeamDialog.tsx | 1 + .../team/dialogs/LaunchTeamDialog.tsx | 386 +++++++----- ...visioningProviderRuntimeSettingsDialog.tsx | 198 ++++++ .../ProvisioningProviderStatusList.tsx | 71 ++- .../team/dialogs/editTeamRuntimeChanges.ts | 12 +- .../team/dialogs/providerPreparePlans.ts | 111 ++++ .../providerPrepareRequestSignature.ts | 11 - .../components/team/members/LeadModelRow.tsx | 13 - .../team/members/MemberDraftRow.test.tsx | 182 +++++- .../team/members/MemberDraftRow.tsx | 305 +++++++-- .../members/MembersEditorSection.test.tsx | 510 +++++++++++++++ .../team/members/MembersEditorSection.tsx | 178 ++++-- .../team/members/membersEditorTypes.ts | 2 + .../team/members/membersEditorUtils.ts | 8 + src/renderer/components/ui/checkbox.tsx | 8 +- src/renderer/components/ui/hover-tooltip.tsx | 21 +- .../hooks/useCreateTeamDraft.test.tsx | 80 +++ src/renderer/hooks/useCreateTeamDraft.ts | 16 +- .../services/createTeamDraftStorage.ts | 13 +- src/renderer/store/slices/teamSlice.ts | 12 +- src/shared/types/team.ts | 16 + .../__tests__/ephemeralProjectPath.test.ts | 11 + src/shared/utils/ephemeralProjectPath.ts | 13 +- src/shared/utils/teamMemberMcpPolicy.ts | 141 +++++ test/main/ipc/editor.test.ts | 39 ++ .../OpenCodeRuntimeInstallerService.test.ts | 72 ++- .../AnthropicLaunchSelection.live.test.ts | 111 +++- .../team/AnthropicRuntimeMemory.live.test.ts | 3 +- .../MemberWorkSyncClaudeStopHook.live.test.ts | 6 +- .../team/MemberWorkSyncCodex.live.test.ts | 6 +- .../team/MixedProviderTeamLaunch.live.test.ts | 6 +- ...penCodeAcceptFastDelivery.live-e2e.test.ts | 7 +- .../team/OpenCodeMixedRecovery.live.test.ts | 36 +- .../OpenCodeTeamProvisioning.live.test.ts | 14 +- .../ProviderLaunchStress.live-e2e.test.ts | 3 +- .../team/TeamMcpConfigBuilder.test.ts | 130 +++- .../team/TeamProvisioningService.test.ts | 582 +++++++++++++++++- .../TeamTaskActivityIntervalService.test.ts | 47 +- .../services/team/openCodeLiveTestHarness.ts | 10 +- .../cli/CliStatusVisibility.test.ts | 61 ++ .../team/dialogs/LaunchTeamDialog.test.ts | 23 +- .../ProvisioningProviderStatusList.test.ts | 68 ++ .../team/dialogs/providerPreparePlans.test.ts | 119 ++++ .../providerPrepareRequestSignature.test.ts | 163 ++++- test/shared/utils/inboxNoise.test.ts | 5 + test/shared/utils/teamMemberMcpPolicy.test.ts | 48 ++ 70 files changed, 4387 insertions(+), 799 deletions(-) create mode 100644 docs/team-management/member-mcp-bootstrap-spike.md create mode 100644 landing/AGENTS.md create mode 100644 src/renderer/components/runtime/providerTerminalCommands.ts create mode 100644 src/renderer/components/team/dialogs/ProvisioningProviderRuntimeSettingsDialog.tsx create mode 100644 src/renderer/components/team/dialogs/providerPreparePlans.ts create mode 100644 src/renderer/components/team/members/MembersEditorSection.test.tsx create mode 100644 src/shared/utils/teamMemberMcpPolicy.ts create mode 100644 test/renderer/components/team/dialogs/providerPreparePlans.test.ts create mode 100644 test/shared/utils/teamMemberMcpPolicy.test.ts diff --git a/AGENTS.md b/AGENTS.md index 769ded1a..e6c9f728 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,6 @@ Live team smoke runtime: - Source-mode teammate startup can be slower than bundled startup. Live smoke harnesses may raise `CLAUDE_TEAM_PROCESS_RUNTIME_READY_TIMEOUT_MS` and `CLAUDE_TEAM_PROCESS_INBOX_POLLER_READY_TIMEOUT_MS` when the test is validating source behavior instead of watchdog latency. - Use the built wrapper only for release or production-like smoke checks. Build first in `/Users/belief/dev/projects/claude/agent_teams_orchestrator` with `bun run build`, then set `CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH=/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli`. - Do not use `cli-dev` or `bun run build:dev` as proof for the production wrapper. `cli` reads `dist/local-cli/cli.js`; `cli-dev` reads `dist/local-cli-dev/cli.js`. - Fast local lint: - Use `pnpm lint:fast:files -- ` for quick preflight on files you touched. diff --git a/docs/team-management/debugging-agent-teams.md b/docs/team-management/debugging-agent-teams.md index 6741f8c6..b8dc5f78 100644 --- a/docs/team-management/debugging-agent-teams.md +++ b/docs/team-management/debugging-agent-teams.md @@ -59,6 +59,29 @@ Desktop launches use the app-managed process backend by default. That is the sup normal app launches because the app owns the process lifecycle, runtime logs, cleanup, and bootstrap evidence. +## Live Smoke Runtime Launcher + +Live/dev smoke tests should use the orchestrator source launcher by default: + +```bash +CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH=/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source \ + pnpm vitest run --maxWorkers 1 --minWorkers 1 test/main/services/team/AnthropicLaunchSelection.live.test.ts +``` + +`cli-source` runs `src/entrypoints/cli.tsx` directly through Bun. Use it while developing launch/runtime code so the smoke test cannot accidentally pass or fail against a stale `dist/local-cli/cli.js` bundle. + +For release or production-like smoke checks, test the built wrapper explicitly: + +```bash +cd /Users/belief/dev/projects/claude/agent_teams_orchestrator +bun run build +cd /Users/belief/dev/projects/claude/claude_team +CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH=/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli \ + pnpm vitest run --maxWorkers 1 --minWorkers 1 test/main/services/team/AnthropicLaunchSelection.live.test.ts +``` + +The built wrapper `cli` reads `dist/local-cli/cli.js`. `cli-dev` reads `dist/local-cli-dev/cli.js`; it is useful for dev-bundle checks, but it is not the production wrapper. + For local debugging, force pane-backed teammates through `tmux`: ```bash diff --git a/docs/team-management/member-mcp-bootstrap-spike.md b/docs/team-management/member-mcp-bootstrap-spike.md new file mode 100644 index 00000000..c1ef5afe --- /dev/null +++ b/docs/team-management/member-mcp-bootstrap-spike.md @@ -0,0 +1,35 @@ +# Per-Member MCP Bootstrap Spike + +Date: 2026-05-19 + +## Question + +Can the app pass per-member MCP inheritance or allowlist settings through Claude Code native agent-team bootstrap? + +## Findings + +- The app currently starts native Claude-led teams with one lead CLI process and one generated `--mcp-config`. That generated config only contains the app-owned `agent-teams` server. User, project, and local MCP servers are inherited through `--setting-sources user,project,local`. +- Claude Code docs for agent teams state that teammates load MCP servers from project and user settings like regular sessions. They also state that subagent-definition `mcpServers` frontmatter is not applied when the definition runs as an agent-team teammate: https://code.claude.com/docs/en/agent-teams +- Claude Code docs for subagents support `mcpServers` for normal subagents and main sessions, but agent teams explicitly exclude that field on the teammate path: https://code.claude.com/docs/en/sub-agents +- Local Claude Code `2.1.119` supports `--mcp-config`, `--setting-sources`, and `--strict-mcp-config`. The public CLI reference documents those flags: https://code.claude.com/docs/en/cli-usage +- A local probe with `claude --bare --team-bootstrap-spec ` on `2.1.119` exits with `error: unknown option '--team-bootstrap-spec'`, so the hidden app bootstrap path cannot be validated as a public CLI contract from the installed binary. + +## Decision + +Per-member MCP settings should be treated as a gated app feature until the native bootstrap contract is proven to apply them on initial spawn. + +The safe implementation path is: + +1. Persist `mcpPolicy` on members. +2. Surface the policy in the roster editor. +3. Apply the policy only on app-controlled teammate launches/restarts where the app owns the CLI args. +4. Keep initial native bootstrap behavior unchanged until the app either moves that path to app-managed teammate launch or detects a Claude Code capability that supports per-member MCP in bootstrap specs. + +## Current Runtime Semantics + +- `inheritLead`: keep existing behavior. +- `inheritScopes`: app-controlled teammate launches can narrow `--setting-sources`. +- `strictAllowlist`: app-controlled teammate launches generate a strict MCP config containing `agent-teams` plus selected server definitions. +- `appOnly`: app-controlled teammate launches generate a strict MCP config containing only `agent-teams`. + +`agent-teams` must remain non-removable because it carries team messaging and task tooling. diff --git a/landing/AGENTS.md b/landing/AGENTS.md new file mode 100644 index 00000000..5c0451cf --- /dev/null +++ b/landing/AGENTS.md @@ -0,0 +1,13 @@ +# Landing Visual QA + +- For cyberpunk landing work, verify layout with browser/runtime measurements, not by eye only. +- Use Chrome DevTools MCP for landing visual QA. Do not use Brave real browser as a fallback for this landing unless the user explicitly asks for Brave. +- First try the direct `mcp__chrome_devtools__*` namespace for viewport screenshots, DOM rectangles, computed styles, console errors, network state, and performance checks. +- If the direct `mcp__chrome_devtools__*` namespace is exposed but the transport is closed, diagnose/fix that first. The stable global config should point to a fixed local install, not `npx @latest`: `/usr/local/bin/node /Users/belief/.codex/mcp/chrome-devtools/node_modules/chrome-devtools-mcp/build/src/bin/chrome-devtools-mcp.js --isolated --viewport=1440x900 --logFile /Users/belief/.codex/log/chrome-devtools-mcp.log --no-usage-statistics --no-performance-crux`. +- If the current Codex thread cannot recover the direct transport after the config is fixed, use only the official Chrome DevTools MCP isolated stdio client as the temporary fallback. The current server uses newline-delimited JSON-RPC over stdio. Kill the spawned MCP process after screenshots/metrics finish. +- If `chrome-devtools` is enabled in `~/.codex/config.toml` but no `mcp__chrome_devtools__*` tools are exposed in the current thread, treat it as a Codex Desktop tool-schema/session exposure issue. Start a fresh thread/session when possible; otherwise use the official isolated Chrome DevTools MCP stdio client above, not Brave. +- Required visual viewports for this landing: `2048x1152`, `1680x941`, `1366x768`, and `390x844`. +- For the HUD header, measure the brand panel, nav rail, action panel, nav item centers, and action button centers with `getBoundingClientRect()`. Check that hover/focus glow is clipped to the angular panel shape. +- For the hero video scene, measure robot foot baselines against the video frame top/bottom/side edges. Top-row robots should stand on the top edge; bottom-row robots should stand on the bottom edge; side robots should not cover the video content randomly. +- Keep the header as live DOM plus SVG. Do not ship a PNG header asset except for reference images stored under `assets/images/references/`. +- Do not run `pnpm generate` while the landing dev server is running. Stop dev first, generate, then run cleanup before starting dev again. diff --git a/runtime.lock.json b/runtime.lock.json index b0d99c8d..1db56931 100644 --- a/runtime.lock.json +++ b/runtime.lock.json @@ -1,27 +1,27 @@ { - "version": "0.0.41", - "sourceRef": "v0.0.41", + "version": "0.0.42", + "sourceRef": "v0.0.42", "sourceRepository": "777genius/agent_teams_orchestrator", "releaseRepository": "777genius/agent-teams-ai", "releaseTag": "v2.0.0", "assets": { "darwin-arm64": { - "file": "agent-teams-runtime-darwin-arm64-v0.0.41.tar.gz", + "file": "agent-teams-runtime-darwin-arm64-v0.0.42.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "darwin-x64": { - "file": "agent-teams-runtime-darwin-x64-v0.0.41.tar.gz", + "file": "agent-teams-runtime-darwin-x64-v0.0.42.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "linux-x64": { - "file": "agent-teams-runtime-linux-x64-v0.0.41.tar.gz", + "file": "agent-teams-runtime-linux-x64-v0.0.42.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "win32-x64": { - "file": "agent-teams-runtime-win32-x64-v0.0.41.zip", + "file": "agent-teams-runtime-win32-x64-v0.0.42.zip", "archiveKind": "zip", "binaryName": "claude-multimodel.exe" } diff --git a/src/main/index.ts b/src/main/index.ts index e04a503a..ccc6a263 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -70,6 +70,7 @@ import { ChangeExtractorService } from '@main/services/team/ChangeExtractorServi import { CrossTeamService } from '@main/services/team/CrossTeamService'; import { FileContentResolver } from '@main/services/team/FileContentResolver'; import { GitDiffFallback } from '@main/services/team/GitDiffFallback'; +import { isInformationalOpenCodeRuntimeDeliveryDiagnostic } from '@main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics'; import { copyOpenCodeLocalMcpLaunchEnv, hasOpenCodeLocalMcpLaunchEnv, @@ -237,6 +238,13 @@ const logger = createLogger('App'); const appStartedAtMs = Date.now(); const openCodeManagedHostInstanceId = `${process.pid}-${appStartedAtMs}`; let openCodeLifecycleBridge: OpenCodeReadinessBridge | null = null; + +function hasWarningRelayDiagnostics(diagnostics: readonly string[]): boolean { + return diagnostics.some( + (diagnostic) => !isInformationalOpenCodeRuntimeDeliveryDiagnostic(diagnostic) + ); +} + if ( earlyElectronUserDataMigrationResult.migrated && earlyElectronUserDataMigrationResult.legacyPath && @@ -1268,9 +1276,12 @@ function wireFileWatcherEvents(context: ServiceContext): void { .relayInboxFileToLiveRecipient(teamName, inboxName) .then((relay) => { if (relay.diagnostics?.length) { - logger.warn( - `[FileWatcher] relay diagnostics for ${teamName}/${inboxName}: ${relay.diagnostics.join('; ')}` - ); + const message = `[FileWatcher] relay diagnostics for ${teamName}/${inboxName}: ${relay.diagnostics.join('; ')}`; + if (hasWarningRelayDiagnostics(relay.diagnostics)) { + logger.warn(message); + } else { + logger.info(message); + } } }) .catch((e: unknown) => diff --git a/src/main/ipc/editor.ts b/src/main/ipc/editor.ts index cd85dc94..bd411d78 100644 --- a/src/main/ipc/editor.ts +++ b/src/main/ipc/editor.ts @@ -79,6 +79,20 @@ const editorFileWatcher = new EditorFileWatcher(); const wrapHandler = createIpcWrapper('IPC:editor'); const log = createLogger('IPC:editor'); +const MISSING_PROJECT_PATH_ERROR_CODES = new Set(['ENOENT', 'ENOTDIR']); + +function getFileSystemErrorCode(error: unknown): string | null { + if (typeof error !== 'object' || error === null || !('code' in error)) { + return null; + } + const code = (error as { code?: unknown }).code; + return typeof code === 'string' ? code : null; +} + +function isMissingProjectPathError(error: unknown): boolean { + return MISSING_PROJECT_PATH_ERROR_CODES.has(getFileSystemErrorCode(error) ?? ''); +} + // ============================================================================= // Handlers // ============================================================================= @@ -316,7 +330,15 @@ async function handleProjectListFiles( throw new Error('projectPath is required'); } const normalized = path.resolve(projectPath); - await fs.access(normalized); + const stat = await fs.stat(normalized).catch((error: unknown) => { + if (isMissingProjectPathError(error)) { + return null; + } + throw error; + }); + if (!stat?.isDirectory()) { + return []; + } return fileSearchService.listFiles(normalized); }); } diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 26c80cfd..ff9a6abc 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -115,6 +115,7 @@ import { buildStandaloneSlashCommandMeta, parseStandaloneSlashCommand, } from '@shared/utils/slashCommands'; +import { normalizeTeamMemberMcpPolicy } from '@shared/utils/teamMemberMcpPolicy'; import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import crypto from 'crypto'; import { app, BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron'; @@ -1564,6 +1565,7 @@ interface RuntimeRosterMutationMember { model?: string; effort?: EffortLevel; fastMode?: TeamFastMode; + mcpPolicy?: ReturnType; removedAt?: number | string | null; } @@ -1622,7 +1624,9 @@ function didOpenCodeRosterMemberChange( ) || (previous.model?.trim() || undefined) !== (next.model?.trim() || undefined) || previous.effort !== next.effort || - previous.fastMode !== next.fastMode + previous.fastMode !== next.fastMode || + JSON.stringify(normalizeTeamMemberMcpPolicy(previous.mcpPolicy)) !== + JSON.stringify(normalizeTeamMemberMcpPolicy(next.mcpPolicy)) ); } @@ -1661,6 +1665,7 @@ function toRollbackReplaceMembersRequest(members: RuntimeRosterMutationMember[]) model?: string; effort?: EffortLevel; fastMode?: TeamFastMode; + mcpPolicy?: ReturnType; }[]; } { return { @@ -1676,6 +1681,7 @@ function toRollbackReplaceMembersRequest(members: RuntimeRosterMutationMember[]) model: member.model?.trim() || undefined, effort: member.effort, fastMode: member.fastMode, + mcpPolicy: normalizeTeamMemberMcpPolicy(member.mcpPolicy), })), }; } @@ -1885,6 +1891,7 @@ async function validateProvisioningRequest( model: typeof model === 'string' ? model.trim() || undefined : undefined, effort: effortValidation.value, fastMode: fastModeValidation.value, + mcpPolicy: normalizeTeamMemberMcpPolicy((member as { mcpPolicy?: unknown }).mcpPolicy), }); } @@ -4295,7 +4302,7 @@ async function handleAddMember( if (!payload || typeof payload !== 'object') { return { success: false, error: 'Invalid payload' }; } - const { name, role, workflow, isolation, providerId, model } = payload as { + const { name, role, workflow, isolation, providerId, model, mcpPolicy } = payload as { name?: unknown; role?: unknown; workflow?: unknown; @@ -4303,6 +4310,7 @@ async function handleAddMember( providerId?: unknown; model?: unknown; effort?: unknown; + mcpPolicy?: unknown; }; const vName = validateTeammateName(name); if (!vName.valid) return { success: false, error: vName.error ?? 'Invalid member name' }; @@ -4351,6 +4359,7 @@ async function handleAddMember( providerId: providerValidation.value, model: typeof model === 'string' ? model.trim() || undefined : undefined, effort: effortValidation.value, + mcpPolicy: normalizeTeamMemberMcpPolicy(mcpPolicy), }); invalidateTeamRosterSnapshotCaches(tn); @@ -4431,6 +4440,7 @@ async function handleReplaceMembers( model?: string; effort?: EffortLevel; fastMode?: TeamFastMode; + mcpPolicy?: ReturnType; }[] = []; for (const item of payload.members) { if (!item || typeof item !== 'object') { @@ -4446,6 +4456,7 @@ async function handleReplaceMembers( model?: unknown; effort?: unknown; fastMode?: unknown; + mcpPolicy?: unknown; }; const vName = validateTeammateName(m.name); if (!vName.valid) return { success: false, error: vName.error ?? 'Invalid member name' }; @@ -4498,6 +4509,7 @@ async function handleReplaceMembers( model: typeof m.model === 'string' ? m.model.trim() || undefined : undefined, effort: effortValidation.value, fastMode: fastModeValidation.value, + mcpPolicy: normalizeTeamMemberMcpPolicy(m.mcpPolicy), }); } diff --git a/src/main/services/extensions/runtime/McpConfigStateReader.ts b/src/main/services/extensions/runtime/McpConfigStateReader.ts index a9302277..15d0d66c 100644 --- a/src/main/services/extensions/runtime/McpConfigStateReader.ts +++ b/src/main/services/extensions/runtime/McpConfigStateReader.ts @@ -8,6 +8,11 @@ import type { InstalledMcpEntry } from '@shared/types/extensions'; const logger = createLogger('Extensions:McpConfigStateReader'); +export interface ConfiguredMcpEntry extends InstalledMcpEntry { + scope: 'local' | 'user' | 'project'; + config: Record; +} + export class McpConfigStateReader { async readInstalled(projectPath?: string): Promise { const entries: InstalledMcpEntry[] = []; @@ -23,6 +28,20 @@ export class McpConfigStateReader { return entries; } + async readConfigured(projectPath?: string): Promise { + const entries: ConfiguredMcpEntry[] = []; + const claudeConfig = await this.readClaudeConfig(); + + entries.push(...this.readConfiguredMcpServersFromConfig(claudeConfig?.mcpServers, 'user')); + + if (projectPath) { + entries.push(...this.readLocalConfiguredMcpServers(claudeConfig, projectPath)); + entries.push(...(await this.readProjectConfiguredMcpServers(projectPath))); + } + + return entries; + } + private async readClaudeConfig(): Promise | null> { const configPath = path.join(getHomeDir(), '.claude.json'); try { @@ -45,6 +64,15 @@ export class McpConfigStateReader { config: Record | null, projectPath: string ): InstalledMcpEntry[] { + return this.readLocalConfiguredMcpServers(config, projectPath).map( + ({ config: _config, ...entry }) => entry + ); + } + + private readLocalConfiguredMcpServers( + config: Record | null, + projectPath: string + ): ConfiguredMcpEntry[] { const projects = config && typeof config.projects === 'object' && config.projects ? (config.projects as Record) @@ -53,7 +81,7 @@ export class McpConfigStateReader { projects && typeof projects[projectPath] === 'object' && projects[projectPath] ? (projects[projectPath] as Record) : null; - return this.readMcpServersFromConfig(projectConfig?.mcpServers, 'local'); + return this.readConfiguredMcpServersFromConfig(projectConfig?.mcpServers, 'local'); } private async readProjectMcpServers(projectPath: string): Promise { @@ -61,6 +89,13 @@ export class McpConfigStateReader { return this.readMcpServersFromFile(configPath, 'project'); } + private async readProjectConfiguredMcpServers( + projectPath: string + ): Promise { + const configPath = path.join(projectPath, '.mcp.json'); + return this.readConfiguredMcpServersFromFile(configPath, 'project'); + } + private readMcpServersFromConfig( value: unknown, scope: 'user' | 'project' | 'local' @@ -82,14 +117,47 @@ export class McpConfigStateReader { }); } + private readConfiguredMcpServersFromConfig( + value: unknown, + scope: 'user' | 'project' | 'local' + ): ConfiguredMcpEntry[] { + const mcpServers = + value && typeof value === 'object' ? (value as Record) : null; + if (!mcpServers) { + return []; + } + + return Object.entries(mcpServers) + .filter((entry): entry is [string, Record] => { + const [, config] = entry; + return Boolean(config && typeof config === 'object' && !Array.isArray(config)); + }) + .map(([name, config]): ConfiguredMcpEntry => { + let transport: string | undefined; + if (typeof config.command === 'string') transport = 'stdio'; + else if (typeof config.url === 'string') transport = 'http'; + + return { name, scope, transport, config: { ...config } }; + }); + } + private async readMcpServersFromFile( filePath: string, scope: 'user' | 'project' ): Promise { + return (await this.readConfiguredMcpServersFromFile(filePath, scope)).map( + ({ config: _config, ...entry }) => entry + ); + } + + private async readConfiguredMcpServersFromFile( + filePath: string, + scope: 'user' | 'project' + ): Promise { try { const raw = await fs.readFile(filePath, 'utf-8'); const json = JSON.parse(raw) as Record; - return this.readMcpServersFromConfig(json.mcpServers, scope); + return this.readConfiguredMcpServersFromConfig(json.mcpServers, scope); } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { return []; diff --git a/src/main/services/infrastructure/OpenCodeRuntimeInstallerService.ts b/src/main/services/infrastructure/OpenCodeRuntimeInstallerService.ts index cabcc480..2d9ba36c 100644 --- a/src/main/services/infrastructure/OpenCodeRuntimeInstallerService.ts +++ b/src/main/services/infrastructure/OpenCodeRuntimeInstallerService.ts @@ -1,4 +1,5 @@ import { execCli } from '@main/utils/childProcess'; +import { buildMergedCliPath } from '@main/utils/cliPathMerge'; import { getAppDataPath } from '@main/utils/pathDecoder'; import { safeSendToRenderer } from '@main/utils/safeWebContentsSend'; import { getCachedShellEnv, resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv'; @@ -142,6 +143,7 @@ function resolvePathOpenCodeBinary( const pathEntries = [ ...additionalEnvSources.flatMap((env) => splitPathEnv(env?.PATH)), ...splitPathEnv(shellEnv.PATH), + ...splitPathEnv(buildMergedCliPath()), ...splitPathEnv(process.env.PATH), ]; const seen = new Set(); diff --git a/src/main/services/team/TeamConfigReader.ts b/src/main/services/team/TeamConfigReader.ts index 4d217aef..158224cf 100644 --- a/src/main/services/team/TeamConfigReader.ts +++ b/src/main/services/team/TeamConfigReader.ts @@ -2,6 +2,7 @@ import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRea import { getTeamsBasePath } from '@main/utils/pathDecoder'; import { isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; +import { normalizeTeamMemberMcpPolicy } from '@shared/utils/teamMemberMcpPolicy'; import { createCliAutoSuffixNameGuard, createCliProvisionerNameGuard, @@ -559,6 +560,7 @@ export class TeamConfigReader { name: existing?.name ?? name, role: m.role?.trim() || existing?.role, color: m.color?.trim() || existing?.color, + mcpPolicy: normalizeTeamMemberMcpPolicy(m.mcpPolicy) ?? existing?.mcpPolicy, }); }; diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 80f470f8..5076572e 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -18,6 +18,7 @@ import { getReviewStateFromTask } from '@shared/utils/reviewState'; import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors'; +import { normalizeTeamMemberMcpPolicy } from '@shared/utils/teamMemberMcpPolicy'; import { parseNumericSuffixName, validateTeamMemberNameFormat } from '@shared/utils/teamMemberName'; import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary'; @@ -998,6 +999,7 @@ export class TeamDataService { model: member.model, effort: member.effort, fastMode: member.fastMode, + mcpPolicy: normalizeTeamMemberMcpPolicy(member.mcpPolicy), })), }; } @@ -1812,6 +1814,7 @@ export class TeamDataService { providerId: normalizeOptionalTeamProviderId(request.providerId), model: request.model?.trim() || undefined, effort: isTeamEffortLevel(request.effort) ? request.effort : undefined, + mcpPolicy: normalizeTeamMemberMcpPolicy(request.mcpPolicy), agentType: 'general-purpose', joinedAt: Date.now(), }; @@ -1888,6 +1891,7 @@ export class TeamDataService { member.fastMode === 'inherit' || member.fastMode === 'on' || member.fastMode === 'off' ? member.fastMode : undefined, + mcpPolicy: normalizeTeamMemberMcpPolicy(member.mcpPolicy), agentType: prev?.agentType ?? 'general-purpose', agentId: isSameActiveMember ? prev?.agentId : undefined, color: prev?.color, @@ -3011,6 +3015,7 @@ export class TeamDataService { model: member.model?.trim() || undefined, effort: isTeamEffortLevel(member.effort) ? member.effort : undefined, fastMode: member.fastMode, + mcpPolicy: normalizeTeamMemberMcpPolicy(member.mcpPolicy), agentType: 'general-purpose' as const, joinedAt, })) diff --git a/src/main/services/team/TeamMcpConfigBuilder.ts b/src/main/services/team/TeamMcpConfigBuilder.ts index 2fc93d56..1005b769 100644 --- a/src/main/services/team/TeamMcpConfigBuilder.ts +++ b/src/main/services/team/TeamMcpConfigBuilder.ts @@ -7,12 +7,17 @@ import { } from '@main/utils/pathDecoder'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { createLogger } from '@shared/utils/logger'; +import { resolveTeamMemberMcpScopes } from '@shared/utils/teamMemberMcpPolicy'; import { randomUUID } from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; +import { McpConfigStateReader } from '../extensions/runtime/McpConfigStateReader'; + import { atomicWriteAsync } from './atomicWrite'; +import type { TeamMemberMcpPolicy, TeamMemberMcpScope } from '@shared/types'; + export interface McpLaunchSpec { command: string; args: string[]; @@ -27,6 +32,10 @@ export interface McpLaunchSpecResolveOptions { onProgress?: (progress: McpLaunchSpecResolveProgress) => void; } +interface WriteMcpConfigOptions { + mcpPolicy?: TeamMemberMcpPolicy; +} + const MCP_SERVER_NAME = 'agent-teams'; const MCP_CLAUDE_DIR_ENV = 'AGENT_TEAMS_MCP_CLAUDE_DIR'; const logger = createLogger('Service:TeamMcpConfigBuilder'); @@ -43,6 +52,8 @@ const MCP_CONFIG_STALE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; type McpServerConfig = Record; +const MCP_CONFIG_SCOPE_PRECEDENCE: readonly TeamMemberMcpScope[] = ['user', 'project', 'local']; + function isPackagedApp(): boolean { try { const { app } = require('electron') as typeof import('electron'); @@ -417,27 +428,42 @@ export async function resolveAgentTeamsMcpLaunchSpec( } export class TeamMcpConfigBuilder { - async writeConfigFile(_projectPath?: string): Promise { + async writeConfigFile(projectPath?: string, options?: WriteMcpConfigOptions): Promise; + async writeConfigFile(projectPath?: string, mcpPolicy?: TeamMemberMcpPolicy): Promise; + async writeConfigFile( + projectPath?: string, + optionsOrPolicy?: WriteMcpConfigOptions | TeamMemberMcpPolicy + ): Promise { const launchSpec = await resolveAgentTeamsMcpLaunchSpec(); const configDir = getMcpConfigsBasePath(); const configPath = path.join( configDir, `${MCP_CONFIG_PREFIX}${process.pid}-${Date.now()}-${randomUUID()}.json` ); + const mcpPolicy = + optionsOrPolicy && 'mcpPolicy' in optionsOrPolicy + ? optionsOrPolicy.mcpPolicy + : (optionsOrPolicy as TeamMemberMcpPolicy | undefined); // Keep the team bootstrap config minimal: recent Claude sidechain runs can // lose the agent-teams tool surface when we inline large user MCP bundles // into the generated --mcp-config. User/project/local MCP remain loaded // through Claude's native settings sources. - const generatedServers: Record = { - [MCP_SERVER_NAME]: { - command: launchSpec.command, - args: launchSpec.args, - enabled: true, - env: { - [MCP_CLAUDE_DIR_ENV]: getClaudeBasePath(), - }, + const generatedServers: Record = Object.create(null); + generatedServers[MCP_SERVER_NAME] = { + command: launchSpec.command, + args: launchSpec.args, + enabled: true, + env: { + [MCP_CLAUDE_DIR_ENV]: getClaudeBasePath(), }, }; + if (mcpPolicy?.mode === 'strictAllowlist') { + for (const [name, config] of Object.entries( + await this.readAllowlistedServers(projectPath, mcpPolicy) + )) { + generatedServers[name] = config; + } + } await fs.promises.mkdir(configDir, { recursive: true }); await atomicWriteAsync( @@ -454,6 +480,47 @@ export class TeamMcpConfigBuilder { return configPath; } + private async readAllowlistedServers( + projectPath: string | undefined, + policy: TeamMemberMcpPolicy + ): Promise> { + const allowlist = new Set( + (policy.serverNames ?? []) + .map((name) => name.trim()) + .filter(Boolean) + .map((name) => name.toLowerCase()) + ); + if (allowlist.size === 0) { + return {}; + } + + const scopes = resolveTeamMemberMcpScopes(policy); + const entries = await new McpConfigStateReader().readConfigured(projectPath); + const byScope = new Map(); + for (const scope of MCP_CONFIG_SCOPE_PRECEDENCE) { + byScope.set(scope, []); + } + for (const entry of entries) { + if (!scopes[entry.scope]) { + continue; + } + byScope.get(entry.scope)?.push(entry); + } + + const selected: Record = Object.create(null); + for (const scope of MCP_CONFIG_SCOPE_PRECEDENCE) { + for (const entry of byScope.get(scope) ?? []) { + if (entry.name.toLowerCase() === MCP_SERVER_NAME) { + continue; + } + if (allowlist.has(entry.name.toLowerCase())) { + selected[entry.name] = entry.config; + } + } + } + return selected; + } + /** Delete a single MCP config file (best-effort). */ async removeConfigFile(configPath: string): Promise { for (let attempt = 0; attempt <= MCP_CONFIG_REMOVE_RETRY_DELAYS_MS.length; attempt += 1) { diff --git a/src/main/services/team/TeamMemberResolver.ts b/src/main/services/team/TeamMemberResolver.ts index a2b36bfb..de9c8394 100644 --- a/src/main/services/team/TeamMemberResolver.ts +++ b/src/main/services/team/TeamMemberResolver.ts @@ -2,6 +2,7 @@ import { buildPlannedMemberLaneIdentity } from '@features/team-runtime-lanes'; import { getMemberColorByName } from '@shared/constants/memberColors'; import { migrateProviderBackendId } from '@shared/utils/providerBackend'; import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors'; +import { normalizeTeamMemberMcpPolicy } from '@shared/utils/teamMemberMcpPolicy'; import { createCliAutoSuffixNameGuard, createCliProvisionerNameGuard, @@ -163,6 +164,7 @@ export class TeamMemberResolver { model?: string; effort?: TeamMember['effort']; fastMode?: TeamMember['fastMode']; + mcpPolicy?: TeamMember['mcpPolicy']; color?: string; cwd?: string; } @@ -190,6 +192,7 @@ export class TeamMemberResolver { configMember.fastMode === 'off' ? configMember.fastMode : undefined, + mcpPolicy: normalizeTeamMemberMcpPolicy(configMember.mcpPolicy), color: configMember.color, cwd: configMember.cwd, }); @@ -210,6 +213,7 @@ export class TeamMemberResolver { model?: string; effort?: TeamMember['effort']; fastMode?: TeamMember['fastMode']; + mcpPolicy?: TeamMember['mcpPolicy']; color?: string; cwd?: string; removedAt?: number; @@ -235,6 +239,7 @@ export class TeamMemberResolver { member.fastMode === 'inherit' || member.fastMode === 'on' || member.fastMode === 'off' ? member.fastMode : undefined, + mcpPolicy: normalizeTeamMemberMcpPolicy(member.mcpPolicy), color: member.color, cwd: member.cwd, removedAt: member.removedAt, @@ -324,6 +329,7 @@ export class TeamMemberResolver { providerBackendId, model: launchMember?.model ?? configMember?.model ?? metaMember?.model, effort: launchMember?.effort ?? configMember?.effort ?? metaMember?.effort, + mcpPolicy: configMember?.mcpPolicy ?? metaMember?.mcpPolicy, selectedFastMode: launchMember?.selectedFastMode ?? configMember?.fastMode ?? diff --git a/src/main/services/team/TeamMembersMetaStore.ts b/src/main/services/team/TeamMembersMetaStore.ts index 63d201c5..d1d6b3be 100644 --- a/src/main/services/team/TeamMembersMetaStore.ts +++ b/src/main/services/team/TeamMembersMetaStore.ts @@ -2,6 +2,7 @@ import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRea import { getTeamsBasePath } from '@main/utils/pathDecoder'; import { isTeamEffortLevel } from '@shared/utils/effortLevels'; import { migrateProviderBackendId } from '@shared/utils/providerBackend'; +import { normalizeTeamMemberMcpPolicy } from '@shared/utils/teamMemberMcpPolicy'; import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName'; import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import * as fs from 'fs'; @@ -50,6 +51,7 @@ function normalizeMember(member: TeamMember): TeamMember | null { model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined, effort: isTeamEffortLevel(member.effort) ? member.effort : undefined, fastMode: normalizeFastMode(member.fastMode), + mcpPolicy: normalizeTeamMemberMcpPolicy(member.mcpPolicy), agentType: typeof member.agentType === 'string' ? member.agentType.trim() || undefined : undefined, color: typeof member.color === 'string' ? member.color.trim() || undefined : undefined, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 9d169465..4add31bf 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -128,6 +128,11 @@ import { type ParsedTeammateContent, } from '@shared/utils/teammateMessageParser'; import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors'; +import { + buildTeamMemberMcpSettingSources, + normalizeTeamMemberMcpPolicy, + requiresStrictTeamMemberMcpConfig, +} from '@shared/utils/teamMemberMcpPolicy'; import { createCliAutoSuffixNameGuard, parseNumericSuffixName } from '@shared/utils/teamMemberName'; import { inferTeamProviderIdFromModel, @@ -2132,6 +2137,8 @@ interface ProvisioningRun { firstRealTurnSucceeded: boolean; /** Path to the generated MCP config file for later cleanup. */ mcpConfigPath: string | null; + /** Paths to per-member generated MCP config files consumed by deterministic bootstrap. */ + memberMcpConfigPaths: string[]; /** Path to the deterministic bootstrap spec file for later cleanup. */ bootstrapSpecPath: string | null; /** Path to the deferred first-user-task file consumed by runtime after bootstrap. */ @@ -2228,8 +2235,8 @@ interface ProvisioningRun { * Post-compact context reinjection lifecycle. * - pendingPostCompactReminder: compact_boundary was received; waiting for idle to inject. * - postCompactReminderInFlight: the reminder turn has been injected via stdin, waiting for result. - * - suppressPostCompactReminderOutput: true while processing a reminder turn — suppress - * low-value acknowledgement text so the user doesn't see "OK, I'll remember that." + * - suppressPostCompactReminderOutput: true while processing a reminder turn - suppress + * low-value context-refresh acknowledgement text. */ pendingPostCompactReminder: boolean; postCompactReminderInFlight: boolean; @@ -4518,9 +4525,18 @@ interface RuntimeBootstrapMemberSpec { description?: string; useSplitPane?: boolean; planModeRequired?: boolean; + mcpConfigPath?: string; + mcpSettingSources?: string; + strictMcpConfig?: boolean; nativeAppManagedBootstrap?: NativeAppManagedBootstrapSpec; } +interface RuntimeBootstrapMemberMcpLaunchConfig { + mcpConfigPath: string; + mcpSettingSources: string; + strictMcpConfig: boolean; +} + interface RuntimeBootstrapSpec { version: 1; runId: string; @@ -4581,7 +4597,8 @@ function buildDeterministicCreateBootstrapSpec( runId: string, request: TeamCreateRequest, effectiveMembers: TeamCreateRequest['members'], - nativeAppManagedBootstrapByMember: ReadonlyMap = new Map() + nativeAppManagedBootstrapByMember: ReadonlyMap = new Map(), + mcpLaunchConfigByMember: ReadonlyMap = new Map() ): RuntimeBootstrapSpec { return { version: 1, @@ -4611,20 +4628,24 @@ function buildDeterministicCreateBootstrapSpec( } : {}), }, - members: effectiveMembers.map((member) => ({ - name: member.name, - ...(member.role?.trim() ? { role: member.role.trim() } : {}), - ...(member.workflow?.trim() ? { workflow: member.workflow.trim() } : {}), - ...(request.cwd ? { cwd: request.cwd } : {}), - ...(member.model?.trim() ? { model: member.model.trim() } : {}), - ...(member.providerId ? { provider: member.providerId } : {}), - ...(member.effort ? { effort: member.effort } : {}), - ...(member.isolation === 'worktree' ? { isolation: 'worktree' as const } : {}), - ...(member.role?.trim() ? { description: member.role.trim() } : {}), - ...(nativeAppManagedBootstrapByMember.get(member.name) - ? { nativeAppManagedBootstrap: nativeAppManagedBootstrapByMember.get(member.name)! } - : {}), - })), + members: effectiveMembers.map((member) => { + const mcpLaunchConfig = mcpLaunchConfigByMember.get(member.name); + return { + name: member.name, + ...(member.role?.trim() ? { role: member.role.trim() } : {}), + ...(member.workflow?.trim() ? { workflow: member.workflow.trim() } : {}), + ...(request.cwd ? { cwd: request.cwd } : {}), + ...(member.model?.trim() ? { model: member.model.trim() } : {}), + ...(member.providerId ? { provider: member.providerId } : {}), + ...(member.effort ? { effort: member.effort } : {}), + ...(member.isolation === 'worktree' ? { isolation: 'worktree' as const } : {}), + ...(member.role?.trim() ? { description: member.role.trim() } : {}), + ...(mcpLaunchConfig ? mcpLaunchConfig : {}), + ...(nativeAppManagedBootstrapByMember.get(member.name) + ? { nativeAppManagedBootstrap: nativeAppManagedBootstrapByMember.get(member.name)! } + : {}), + }; + }), launch: { bootstrapTimeoutMs: getDeterministicBootstrapTimeoutMs(effectiveMembers.length), continueOnPartialFailure: true, @@ -4639,7 +4660,8 @@ function buildDeterministicLaunchBootstrapSpec( runId: string, request: TeamLaunchRequest, effectiveMembers: TeamCreateRequest['members'], - nativeAppManagedBootstrapByMember: ReadonlyMap = new Map() + nativeAppManagedBootstrapByMember: ReadonlyMap = new Map(), + mcpLaunchConfigByMember: ReadonlyMap = new Map() ): RuntimeBootstrapSpec { return { version: 1, @@ -4666,20 +4688,24 @@ function buildDeterministicLaunchBootstrapSpec( } : {}), }, - members: effectiveMembers.map((member) => ({ - name: member.name, - ...(request.cwd ? { cwd: request.cwd } : {}), - ...(member.model?.trim() ? { model: member.model.trim() } : {}), - ...(member.providerId ? { provider: member.providerId } : {}), - ...(member.effort ? { effort: member.effort } : {}), - ...(member.isolation === 'worktree' ? { isolation: 'worktree' as const } : {}), - ...(member.role?.trim() ? { role: member.role.trim() } : {}), - ...(member.workflow?.trim() ? { workflow: member.workflow.trim() } : {}), - ...(member.role?.trim() ? { description: member.role.trim() } : {}), - ...(nativeAppManagedBootstrapByMember.get(member.name) - ? { nativeAppManagedBootstrap: nativeAppManagedBootstrapByMember.get(member.name)! } - : {}), - })), + members: effectiveMembers.map((member) => { + const mcpLaunchConfig = mcpLaunchConfigByMember.get(member.name); + return { + name: member.name, + ...(request.cwd ? { cwd: request.cwd } : {}), + ...(member.model?.trim() ? { model: member.model.trim() } : {}), + ...(member.providerId ? { provider: member.providerId } : {}), + ...(member.effort ? { effort: member.effort } : {}), + ...(member.isolation === 'worktree' ? { isolation: 'worktree' as const } : {}), + ...(member.role?.trim() ? { role: member.role.trim() } : {}), + ...(member.workflow?.trim() ? { workflow: member.workflow.trim() } : {}), + ...(member.role?.trim() ? { description: member.role.trim() } : {}), + ...(mcpLaunchConfig ? mcpLaunchConfig : {}), + ...(nativeAppManagedBootstrapByMember.get(member.name) + ? { nativeAppManagedBootstrap: nativeAppManagedBootstrapByMember.get(member.name)! } + : {}), + }; + }), launch: { bootstrapTimeoutMs: getDeterministicBootstrapTimeoutMs(effectiveMembers.length), continueOnPartialFailure: true, @@ -5086,7 +5112,7 @@ function buildDeterministicLaunchHydrationPrompt( Do NOT call TeamCreate. Do NOT use Agent to spawn or restore teammates. Do NOT start implementation in this turn. -Use this turn only to refresh context, review the current board snapshot, and confirm you are ready. +Use this turn only to review the current board snapshot and confirm operational readiness. ${ hasOriginalUserPrompt ? 'Do NOT create or update any new task in this turn - wait for the next normal operating turn before translating those instructions into board work.' @@ -5096,7 +5122,7 @@ ${ Do NOT call TeamCreate. Do NOT use Agent to spawn or restore teammates. Do NOT repeat the launch summary. -Use this turn only to refresh context and review the current board snapshot. +Use this turn only to review the current board snapshot and teammate readiness. ${ hasOriginalUserPrompt ? 'Do NOT create or assign any new task in this turn - wait for the next normal operating turn before translating those instructions into board work.' @@ -5115,7 +5141,7 @@ ${nextSteps} ${taskBoardSnapshot} ${persistentContext} -If there is nothing else to say after refreshing context, reply with exactly one word: "OK".`; +Reply with one concise user-facing team status line. Mention whether there is actionable board work and whether any teammate is still bootstrap-pending. Only report board readiness and teammate availability. Do not start work, create tasks, or delegate in this turn.`; } function buildGeminiPostLaunchHydrationPrompt( @@ -5162,13 +5188,13 @@ function buildGeminiPostLaunchHydrationPrompt( }); const nextStepInstruction = isSolo ? hasOriginalUserPrompt - ? 'From this point on, use the full operating rules below for all future turns. Do NOT create or update any new task in this context-refresh turn - wait for the next normal operating turn before translating those instructions into board work.' - : 'From this point on, use the full operating rules below for all future turns. Do NOT create, assign, or delegate any new task in this context-refresh turn. If the board is empty, stay silent and wait for a fresh user instruction.' + ? 'From this point on, use the full operating rules below for all future turns. Do NOT create or update any new task in this turn - wait for the next normal operating turn before translating those instructions into board work.' + : 'From this point on, use the full operating rules below for all future turns. Do NOT create, assign, or delegate any new task in this turn. If the board is empty, stay silent and wait for a fresh user instruction.' : hasOriginalUserPrompt - ? 'From this point on, use the full team operating rules below for all future turns. Do NOT create or assign any new task in this context-refresh turn - wait for the next normal operating turn before translating those instructions into board work. Do NOT assume bootstrap-pending or failed teammates are ready; only treat teammates with confirmed bootstrap as immediately available for blocking assignments.' - : 'From this point on, use the full team operating rules below for all future turns. Do NOT create, assign, or delegate any new task in this context-refresh turn. If the board is empty, stay silent and wait for a fresh user instruction. Do NOT assume bootstrap-pending or failed teammates are ready; only treat teammates with confirmed bootstrap as immediately available for blocking assignments.'; + ? 'From this point on, use the full team operating rules below for all future turns. Do NOT create or assign any new task in this turn - wait for the next normal operating turn before translating those instructions into board work. Do NOT assume bootstrap-pending or failed teammates are ready; only treat teammates with confirmed bootstrap as immediately available for blocking assignments.' + : 'From this point on, use the full team operating rules below for all future turns. Do NOT create, assign, or delegate any new task in this turn. If the board is empty, stay silent and wait for a fresh user instruction. Do NOT assume bootstrap-pending or failed teammates are ready; only treat teammates with confirmed bootstrap as immediately available for blocking assignments.'; - return `Gemini launch phase 2 — operating context for team "${run.teamName}". + return `Gemini launch phase 2 - team readiness check for team "${run.teamName}". The first launch/reconnect turn has already completed. Do NOT call TeamCreate again. @@ -5182,7 +5208,7 @@ ${nextStepInstruction} ${teammateBootstrapSnapshot}${taskBoardSnapshot} ${persistentContext} -This is a context-refresh turn only. Do not re-run launch. If no task planning or delegation is needed right now, reply with exactly one word: "OK".`; +This is a readiness-check turn only. Do not re-run launch. Reply with one concise user-facing team status line about board readiness and teammate availability. Only report board readiness and teammate availability. Do not start work, create tasks, or delegate in this turn.`; } /** @@ -6293,6 +6319,7 @@ export class TeamProvisioningService { private readonly memberLogsFinder: TeamMemberLogsFinder; private readonly transcriptProjectResolver: TeamTranscriptProjectResolver; private readonly taskActivityIntervalService = new TeamTaskActivityIntervalService(); + private readonly leadTaskActivitySyncedRunKeys = new Set(); private readonly crashRepairedActivityIntervalsByTeam = new Set(); private readonly pendingCrashRepairSnapshotByTeam = new Map< string, @@ -12090,9 +12117,49 @@ export class TeamProvisioningService { ...(configuredMember.model ? { model: configuredMember.model } : {}), ...(configuredMember.effort ? { effort: configuredMember.effort } : {}), ...(configuredMember.fastMode ? { fastMode: configuredMember.fastMode } : {}), + ...(configuredMember.mcpPolicy + ? { mcpPolicy: normalizeTeamMemberMcpPolicy(configuredMember.mcpPolicy) } + : {}), }; } + private async buildRuntimeBootstrapMemberMcpLaunchConfigs(input: { + cwd: string; + members: TeamCreateRequest['members']; + run: ProvisioningRun; + }): Promise> { + const configs = new Map(); + for (const member of input.members) { + const mcpPolicy = normalizeTeamMemberMcpPolicy(member.mcpPolicy); + if (!mcpPolicy) { + continue; + } + + const memberCwd = member.cwd?.trim() || input.cwd; + const mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(memberCwd, mcpPolicy); + input.run.memberMcpConfigPaths.push(mcpConfigPath); + configs.set(member.name, { + mcpConfigPath, + mcpSettingSources: buildTeamMemberMcpSettingSources(mcpPolicy), + strictMcpConfig: requiresStrictTeamMemberMcpConfig(mcpPolicy), + }); + } + return configs; + } + + private async removeRunMemberMcpConfigFiles(run: ProvisioningRun): Promise { + const paths = run.memberMcpConfigPaths?.splice(0) ?? []; + await Promise.all( + paths.map((configPath) => this.mcpConfigBuilder.removeConfigFile(configPath)) + ); + } + + private removeRunMemberMcpConfigFilesLater(run: ProvisioningRun): void { + for (const configPath of run.memberMcpConfigPaths?.splice(0) ?? []) { + void this.mcpConfigBuilder.removeConfigFile(configPath); + } + } + private buildMembersMetaWritePayload(members: TeamCreateRequest['members']): TeamMember[] { return applyDistinctProvisioningMemberColors( members.map((member) => ({ @@ -12109,6 +12176,7 @@ export class TeamProvisioningService { member.fastMode === 'inherit' || member.fastMode === 'on' || member.fastMode === 'off' ? member.fastMode : undefined, + mcpPolicy: normalizeTeamMemberMcpPolicy(member.mcpPolicy), agentType: 'general-purpose' as const, color: getMemberColorByName(member.name.trim()), joinedAt: @@ -14986,6 +15054,9 @@ export class TeamProvisioningService { if (!runId) return { state: 'offline', runId: null }; const run = this.runs.get(runId); if (!run || run.processKilled || run.cancelRequested) return { state: 'offline', runId: null }; + // Read-repair active lead task intervals for runs that were already active + // before interval tracking was introduced or before the renderer polled state. + this.syncLeadTaskActivityForState(run, run.leadActivityState, run.leadActivityState); return { state: run.leadActivityState, runId }; } @@ -15122,10 +15193,54 @@ export class TeamProvisioningService { return parts.join('\n').trim(); } + private getLeadTaskActivityRunKey(run: ProvisioningRun): string { + return `${run.teamName}\u0000${run.runId}`; + } + + private syncLeadTaskActivityForState( + run: ProvisioningRun, + state: 'active' | 'idle' | 'offline', + previousState: 'active' | 'idle' | 'offline', + at = nowIso() + ): void { + const key = this.getLeadTaskActivityRunKey(run); + if (state === 'active') { + if (this.leadTaskActivitySyncedRunKeys.has(key)) return; + const result = this.taskActivityIntervalService.resumeActiveIntervalsForMember( + run.teamName, + this.getRunLeadName(run), + at + ); + if (result.failed) return; + this.leadTaskActivitySyncedRunKeys.add(key); + return; + } + + const wasSynced = this.leadTaskActivitySyncedRunKeys.has(key); + if (previousState !== 'active' && !wasSynced) return; + const result = this.taskActivityIntervalService.pauseActiveIntervalsForMember( + run.teamName, + this.getRunLeadName(run), + at + ); + if (result.failed) { + this.leadTaskActivitySyncedRunKeys.add(key); + return; + } + this.leadTaskActivitySyncedRunKeys.delete(key); + } + private setLeadActivity(run: ProvisioningRun, state: 'active' | 'idle' | 'offline'): void { - if (run.leadActivityState === state) return; + const previousState = run.leadActivityState; + const isCurrentRun = this.isCurrentTrackedRun(run); + if (isCurrentRun) { + this.syncLeadTaskActivityForState(run, state, previousState); + } else { + this.leadTaskActivitySyncedRunKeys.delete(this.getLeadTaskActivityRunKey(run)); + } + if (previousState === state) return; run.leadActivityState = state; - if (!this.isCurrentTrackedRun(run)) return; + if (!isCurrentRun) return; this.teamChangeEmitter?.({ type: 'lead-activity', teamName: run.teamName, @@ -16671,7 +16786,10 @@ export class TeamProvisioningService { throw new Error(provisioningEnv.warning); } - const mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(cwd); + const memberMcpPolicy = normalizeTeamMemberMcpPolicy(input.configuredMember.mcpPolicy); + const mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(cwd, memberMcpPolicy); + const memberMcpSettingSources = buildTeamMemberMcpSettingSources(memberMcpPolicy); + const strictMemberMcpConfig = requiresStrictTeamMemberMcpConfig(memberMcpPolicy); const agentId = `${input.configuredMember.name}@${input.teamName}`; const color = input.config.members @@ -16721,9 +16839,10 @@ export class TeamProvisioningService { ? ['--agent-type', input.configuredMember.agentType] : []), '--setting-sources', - 'user,project,local', + memberMcpSettingSources, '--mcp-config', mcpConfigPath, + ...(strictMemberMcpConfig ? ['--strict-mcp-config'] : []), '--disallowedTools', APP_TEAM_RUNTIME_DISALLOWED_TOOLS, ...(input.run.request.skipPermissions !== false @@ -16814,7 +16933,10 @@ export class TeamProvisioningService { throw new Error(provisioningEnv.warning); } - const mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(cwd); + const memberMcpPolicy = normalizeTeamMemberMcpPolicy(input.configuredMember.mcpPolicy); + const mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(cwd, memberMcpPolicy); + const memberMcpSettingSources = buildTeamMemberMcpSettingSources(memberMcpPolicy); + const strictMemberMcpConfig = requiresStrictTeamMemberMcpConfig(memberMcpPolicy); const agentId = `${input.configuredMember.name}@${input.teamName}`; const color = input.config.members @@ -16835,6 +16957,7 @@ export class TeamProvisioningService { ...(input.configuredMember.model ? { model: input.configuredMember.model } : {}), ...(input.configuredMember.effort ? { effort: input.configuredMember.effort } : {}), ...(input.configuredMember.agentType ? { agentType: input.configuredMember.agentType } : {}), + ...(memberMcpPolicy ? { mcpPolicy: memberMcpPolicy } : {}), ...(input.configuredMember.isolation === 'worktree' ? { isolation: 'worktree' as const } : {}), @@ -16903,9 +17026,10 @@ export class TeamProvisioningService { ? ['--agent-type', input.configuredMember.agentType] : []), '--setting-sources', - 'user,project,local', + memberMcpSettingSources, '--mcp-config', mcpConfigPath, + ...(strictMemberMcpConfig ? ['--strict-mcp-config'] : []), '--disallowedTools', APP_TEAM_RUNTIME_DISALLOWED_TOOLS, ...(input.run.request.skipPermissions !== false @@ -21671,6 +21795,7 @@ export class TeamProvisioningService { requiresFirstRealTurnSuccess: false, firstRealTurnSucceeded: false, mcpConfigPath: null, + memberMcpConfigPaths: [], bootstrapSpecPath: null, bootstrapUserPromptPath: null, isLaunch: false, @@ -21802,6 +21927,11 @@ export class TeamProvisioningService { cwd: request.cwd, members: effectiveMemberSpecs, }); + const memberMcpLaunchConfigs = await this.buildRuntimeBootstrapMemberMcpLaunchConfigs({ + cwd: request.cwd, + members: effectiveMemberSpecs, + run, + }); if (nativeBootstrapBuild.diagnostics.warning) { run.progress = { ...run.progress, @@ -21820,7 +21950,8 @@ export class TeamProvisioningService { runId, request, effectiveMemberSpecs, - nativeBootstrapBuild.specs + nativeBootstrapBuild.specs, + memberMcpLaunchConfigs ); emitProvisioningCheckpoint(run, 'Writing deterministic bootstrap spec file'); bootstrapSpecPath = await writeDeterministicBootstrapSpecFile(bootstrapSpec); @@ -21865,6 +21996,11 @@ export class TeamProvisioningService { () => {} ); run.bootstrapUserPromptPath = null; + if (run.mcpConfigPath) { + await this.mcpConfigBuilder.removeConfigFile(run.mcpConfigPath).catch(() => {}); + run.mcpConfigPath = null; + } + await this.removeRunMemberMcpConfigFiles(run).catch(() => {}); throw error; } const launchModelArg = getLaunchModelArg( @@ -21966,6 +22102,7 @@ export class TeamProvisioningService { await this.mcpConfigBuilder.removeConfigFile(run.mcpConfigPath).catch(() => {}); run.mcpConfigPath = null; } + await this.removeRunMemberMcpConfigFiles(run).catch(() => {}); if (provisioningEnv.anthropicApiKeyHelper) { await cleanupAnthropicTeamApiKeyHelperMaterial({ directory: provisioningEnv.anthropicApiKeyHelper.directory, @@ -22400,6 +22537,7 @@ export class TeamProvisioningService { providerId: normalizeOptionalTeamProviderId(member.providerId), model: member.model, effort: member.effort, + mcpPolicy: normalizeTeamMemberMcpPolicy(member.mcpPolicy), cwd: member.cwd?.trim() || undefined, })), ], @@ -22952,6 +23090,7 @@ export class TeamProvisioningService { requiresFirstRealTurnSuccess: false, firstRealTurnSucceeded: false, mcpConfigPath: null, + memberMcpConfigPaths: [], bootstrapSpecPath: null, bootstrapUserPromptPath: null, isLaunch: true, @@ -23079,6 +23218,11 @@ export class TeamProvisioningService { cwd: request.cwd, members: effectiveMemberSpecs, }); + const memberMcpLaunchConfigs = await this.buildRuntimeBootstrapMemberMcpLaunchConfigs({ + cwd: request.cwd, + members: effectiveMemberSpecs, + run, + }); if (nativeBootstrapBuild.diagnostics.warning) { run.progress = { ...run.progress, @@ -23097,7 +23241,8 @@ export class TeamProvisioningService { runId, request, effectiveMemberSpecs, - nativeBootstrapBuild.specs + nativeBootstrapBuild.specs, + memberMcpLaunchConfigs ); emitProvisioningCheckpoint(run, 'Writing deterministic bootstrap spec file'); bootstrapSpecPath = await writeDeterministicBootstrapSpecFile(bootstrapSpec); @@ -23134,6 +23279,11 @@ export class TeamProvisioningService { () => {} ); run.bootstrapUserPromptPath = null; + if (run.mcpConfigPath) { + await this.mcpConfigBuilder.removeConfigFile(run.mcpConfigPath).catch(() => {}); + run.mcpConfigPath = null; + } + await this.removeRunMemberMcpConfigFiles(run).catch(() => {}); await this.restorePrelaunchConfig(request.teamName); throw error; } @@ -23271,6 +23421,7 @@ export class TeamProvisioningService { () => {} ); run.bootstrapUserPromptPath = null; + await this.removeRunMemberMcpConfigFiles(run).catch(() => {}); if (provisioningEnv.anthropicApiKeyHelper) { await cleanupAnthropicTeamApiKeyHelperMaterial({ directory: provisioningEnv.anthropicApiKeyHelper.directory, @@ -26174,6 +26325,7 @@ export class TeamProvisioningService { model?: string; effort?: EffortLevel; fastMode?: TeamFastMode; + mcpPolicy?: ReturnType; cwd?: string; agentType?: string; removedAt?: number | string; @@ -26224,6 +26376,9 @@ export class TeamProvisioningService { : undefined; const agentType = metaMember?.agentType?.trim() || configuredMember?.agentType?.trim() || undefined; + const mcpPolicy = + normalizeTeamMemberMcpPolicy(metaMember?.mcpPolicy) ?? + normalizeTeamMemberMcpPolicy(configuredMember?.mcpPolicy); const cwd = metaMember?.cwd?.trim() || configuredMember?.cwd?.trim() || undefined; const removedAt = metaMember?.removedAt ?? configuredMember?.removedAt; @@ -26237,6 +26392,7 @@ export class TeamProvisioningService { ...(model ? { model } : {}), ...(effort ? { effort } : {}), ...(fastMode ? { fastMode } : {}), + ...(mcpPolicy ? { mcpPolicy } : {}), ...(cwd ? { cwd } : {}), ...(agentType ? { agentType } : {}), ...(removedAt != null ? { removedAt } : {}), @@ -28794,9 +28950,39 @@ export class TeamProvisioningService { return false; } + const secondaryLanes = run.mixedSecondaryLanes ?? []; + const confirmedSecondaryMembers = new Set(); + for (const lane of secondaryLanes) { + const memberName = lane.member.name.trim(); + if (!memberName) { + return false; + } + if (lane.state !== 'finished' || !lane.result) { + return false; + } + if (lane.runId && lane.result.runId !== lane.runId) { + return false; + } + const evidence = resolveOpenCodeSecondaryLaneMemberEvidence(lane, memberName); + if ( + evidence?.launchState !== 'confirmed_alive' || + evidence.bootstrapConfirmed !== true || + evidence.hardFailure === true + ) { + return false; + } + confirmedSecondaryMembers.add(memberName); + } + return expectedMembers.every((memberName) => { const member = run.memberSpawnStatuses.get(memberName); - return member?.launchState === 'confirmed_alive'; + if (member?.launchState !== 'confirmed_alive' || member.bootstrapConfirmed !== true) { + return false; + } + const isSecondaryMember = secondaryLanes.some((lane) => + matchesTeamMemberIdentity(lane.member.name, memberName) + ); + return !isSecondaryMember || confirmedSecondaryMembers.has(memberName); }); } @@ -33149,7 +33335,7 @@ export class TeamProvisioningService { } const message = [ - `Context reminder (post-compaction) — your context was compacted. Here are your standing rules and current state:`, + `Apply these standing rules and current team state before responding:`, ``, `You are "${leadName}", the team lead of team "${run.teamName}".`, `You are running in a non-interactive CLI session. Do not ask questions.`, @@ -33158,7 +33344,7 @@ export class TeamProvisioningService { persistentContext, taskBoardBlock.trim() ? `\n${taskBoardBlock}` : '', ``, - `This is a context-only reminder. Do NOT start new work or execute tasks in this turn. Reply with a single word: "OK".`, + `Do NOT start new work or execute tasks in this turn. Reply with one concise user-facing team status line about board readiness and teammate availability. Only report board readiness and teammate availability.`, ] .filter(Boolean) .join('\n'); @@ -34669,7 +34855,6 @@ export class TeamProvisioningService { if (run.teamLaunchedNotificationFired) { return; } - run.teamLaunchedNotificationFired = true; try { const config = ConfigManager.getInstance().getConfig(); @@ -34680,6 +34865,7 @@ export class TeamProvisioningService { if (run.isLaunch && joinedCount > 0 && !allJoined) { return; } + run.teamLaunchedNotificationFired = true; const body = run.isLaunch ? allJoined ? `Team "${displayName}" has been launched - all ${joinedCount} teammates joined and are ready for tasks.` @@ -35329,6 +35515,7 @@ export class TeamProvisioningService { void this.mcpConfigBuilder.removeConfigFile(run.mcpConfigPath); run.mcpConfigPath = null; } + this.removeRunMemberMcpConfigFilesLater(run); if (run.bootstrapSpecPath) { void removeDeterministicBootstrapSpecFile(run.bootstrapSpecPath); run.bootstrapSpecPath = null; @@ -36273,6 +36460,7 @@ export class TeamProvisioningService { providerId: 'opencode', model: member.model?.trim() || undefined, effort: isTeamEffortLevel(member.effort) ? member.effort : undefined, + mcpPolicy: normalizeTeamMemberMcpPolicy(member.mcpPolicy), cwd: member.cwd?.trim() || undefined, joinedAt: Date.now(), }; @@ -37021,6 +37209,7 @@ export class TeamProvisioningService { providerId: configMember?.providerId, model: configMember?.model, effort: configMember?.effort, + mcpPolicy: configMember?.mcpPolicy, }; }); const memberOverridesUsed = members.some( @@ -37164,9 +37353,20 @@ export class TeamProvisioningService { const model = typeof member.model === 'string' ? member.model.trim() || undefined : undefined; const effort = isTeamEffortLevel(member.effort) ? member.effort : undefined; const cwd = typeof member.cwd === 'string' ? member.cwd.trim() || undefined : undefined; + const mcpPolicy = normalizeTeamMemberMcpPolicy(member.mcpPolicy); const prev = byName.get(name); if (!prev) { - byName.set(name, { name, role, workflow, isolation, cwd, providerId, model, effort }); + byName.set(name, { + name, + role, + workflow, + isolation, + cwd, + providerId, + model, + effort, + mcpPolicy, + }); } else { byName.set(name, { ...prev, @@ -37177,6 +37377,7 @@ export class TeamProvisioningService { providerId: prev.providerId || providerId, model: prev.model || model, effort: prev.effort || effort, + mcpPolicy: prev.mcpPolicy || mcpPolicy, }); } } @@ -37269,6 +37470,7 @@ export class TeamProvisioningService { provider?: string; model?: string; effort?: string; + mcpPolicy?: unknown; cwd?: string; removedAt?: unknown; }[]; @@ -37294,6 +37496,7 @@ export class TeamProvisioningService { providerId: normalizeTeamMemberProviderId(member.providerId ?? member.provider), model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined, effort: isTeamEffortLevel(member.effort) ? member.effort : undefined, + mcpPolicy: normalizeTeamMemberMcpPolicy(member.mcpPolicy), }); } // Defense: ignore CLI auto-suffixed duplicates (alice-2) when base name exists. diff --git a/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics.ts b/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics.ts index fb27767e..6458b8ac 100644 --- a/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics.ts +++ b/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics.ts @@ -77,7 +77,7 @@ function isOpenCodeRuntimeDeliveryCleanSessionRefreshDiagnostic(message: string) ); } -function isInformationalOpenCodeRuntimeDeliveryDiagnostic( +export function isInformationalOpenCodeRuntimeDeliveryDiagnostic( message: string | null | undefined ): boolean { const normalized = message?.trim().toLowerCase(); diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index b7cf7dc8..b7904727 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -34,6 +34,10 @@ import { import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges'; import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector'; import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog'; +import { + getProviderTerminalCommand, + getProviderTerminalLogoutCommand, +} from '@renderer/components/runtime/providerTerminalCommands'; import { TerminalLogPanel } from '@renderer/components/terminal/TerminalLogPanel'; import { TerminalModal } from '@renderer/components/terminal/TerminalModal'; import { useCliInstaller } from '@renderer/hooks/useCliInstaller'; @@ -393,62 +397,6 @@ function getProviderLabel(providerId: CliProviderId): string { } } -function getProviderTerminalCommand(provider: CliProviderStatus): { - args: string[]; - env?: Record; -} { - if (provider.providerId === 'gemini') { - return { - args: ['login'], - env: { - CLAUDE_CODE_ENTRY_PROVIDER: 'gemini', - CLAUDE_CODE_GEMINI_BACKEND: provider.selectedBackendId ?? 'auto', - }, - }; - } - - if (provider.providerId === 'codex') { - return { - args: ['auth', 'login', '--provider', provider.providerId], - env: { - CLAUDE_CODE_CODEX_BACKEND: provider.selectedBackendId ?? 'codex-native', - }, - }; - } - - return { - args: ['auth', 'login', '--provider', provider.providerId], - }; -} - -function getProviderTerminalLogoutCommand(provider: CliProviderStatus): { - args: string[]; - env?: Record; -} { - if (provider.providerId === 'gemini') { - return { - args: ['logout'], - env: { - CLAUDE_CODE_ENTRY_PROVIDER: 'gemini', - CLAUDE_CODE_GEMINI_BACKEND: provider.selectedBackendId ?? 'auto', - }, - }; - } - - if (provider.providerId === 'codex') { - return { - args: ['auth', 'logout', '--provider', provider.providerId], - env: { - CLAUDE_CODE_CODEX_BACKEND: provider.selectedBackendId ?? 'codex-native', - }, - }; - } - - return { - args: ['auth', 'logout', '--provider', provider.providerId], - }; -} - const ProviderDetailSkeleton = (): React.JSX.Element => { return (
@@ -584,18 +532,35 @@ function hasVisibleAuthenticatedMultimodelProvider( return visibleProviders.some((provider) => provider.authenticated); } +function isOpenCodeProviderEffectivelyReady(provider: CliProviderStatus): boolean { + return ( + provider.providerId === 'opencode' && + provider.supported === true && + provider.authenticated === true && + provider.verificationState === 'verified' && + provider.capabilities.teamLaunch === true + ); +} + +function isOpenCodeRuntimeReady(openCodeRuntimeStatus: OpenCodeRuntimeStatus | null): boolean { + return ( + openCodeRuntimeStatus?.installed === true && + (openCodeRuntimeStatus.source === 'path' || + (openCodeRuntimeStatus.source === 'app-managed' && openCodeRuntimeStatus.state !== 'failed')) + ); +} + function shouldShowOpenCodeInstallAction( provider: CliProviderStatus, showSkeleton: boolean, openCodeRuntimeStatus: OpenCodeRuntimeStatus | null ): boolean { - const runtimeReady = - openCodeRuntimeStatus?.installed === true && - (openCodeRuntimeStatus.source === 'path' || - (openCodeRuntimeStatus.source === 'app-managed' && openCodeRuntimeStatus.state !== 'failed')); - const runtimeNeedsInstall = !runtimeReady; - - return provider.providerId === 'opencode' && !showSkeleton && runtimeNeedsInstall; + return ( + provider.providerId === 'opencode' && + !showSkeleton && + !isOpenCodeProviderEffectivelyReady(provider) && + !isOpenCodeRuntimeReady(openCodeRuntimeStatus) + ); } function shouldShowCodexInstallAction( diff --git a/src/renderer/components/runtime/providerTerminalCommands.ts b/src/renderer/components/runtime/providerTerminalCommands.ts new file mode 100644 index 00000000..bc9593ae --- /dev/null +++ b/src/renderer/components/runtime/providerTerminalCommands.ts @@ -0,0 +1,58 @@ +import type { CliProviderStatus } from '@shared/types'; + +export interface ProviderTerminalCommand { + args: string[]; + env?: Record; +} + +export function getProviderTerminalCommand(provider: CliProviderStatus): ProviderTerminalCommand { + if (provider.providerId === 'gemini') { + return { + args: ['login'], + env: { + CLAUDE_CODE_ENTRY_PROVIDER: 'gemini', + CLAUDE_CODE_GEMINI_BACKEND: provider.selectedBackendId ?? 'auto', + }, + }; + } + + if (provider.providerId === 'codex') { + return { + args: ['auth', 'login', '--provider', provider.providerId], + env: { + CLAUDE_CODE_CODEX_BACKEND: provider.selectedBackendId ?? 'codex-native', + }, + }; + } + + return { + args: ['auth', 'login', '--provider', provider.providerId], + }; +} + +export function getProviderTerminalLogoutCommand( + provider: CliProviderStatus +): ProviderTerminalCommand { + if (provider.providerId === 'gemini') { + return { + args: ['logout'], + env: { + CLAUDE_CODE_ENTRY_PROVIDER: 'gemini', + CLAUDE_CODE_GEMINI_BACKEND: provider.selectedBackendId ?? 'auto', + }, + }; + } + + if (provider.providerId === 'codex') { + return { + args: ['auth', 'logout', '--provider', provider.providerId], + env: { + CLAUDE_CODE_CODEX_BACKEND: provider.selectedBackendId ?? 'codex-native', + }, + }; + } + + return { + args: ['auth', 'logout', '--provider', provider.providerId], + }; +} diff --git a/src/renderer/components/settings/sections/CliStatusSection.tsx b/src/renderer/components/settings/sections/CliStatusSection.tsx index 0b6ee6dc..77953757 100644 --- a/src/renderer/components/settings/sections/CliStatusSection.tsx +++ b/src/renderer/components/settings/sections/CliStatusSection.tsx @@ -28,6 +28,10 @@ import { import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges'; import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector'; import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog'; +import { + getProviderTerminalCommand, + getProviderTerminalLogoutCommand, +} from '@renderer/components/runtime/providerTerminalCommands'; import { TerminalModal } from '@renderer/components/terminal/TerminalModal'; import { useCliInstaller } from '@renderer/hooks/useCliInstaller'; import { useStore } from '@renderer/store'; @@ -129,62 +133,6 @@ function getProviderLabel(providerId: CliProviderId): string { } } -function getProviderTerminalCommand(provider: CliProviderStatus): { - args: string[]; - env?: Record; -} { - if (provider.providerId === 'gemini') { - return { - args: ['login'], - env: { - CLAUDE_CODE_ENTRY_PROVIDER: 'gemini', - CLAUDE_CODE_GEMINI_BACKEND: provider.selectedBackendId ?? 'auto', - }, - }; - } - - if (provider.providerId === 'codex') { - return { - args: ['auth', 'login', '--provider', provider.providerId], - env: { - CLAUDE_CODE_CODEX_BACKEND: provider.selectedBackendId ?? 'codex-native', - }, - }; - } - - return { - args: ['auth', 'login', '--provider', provider.providerId], - }; -} - -function getProviderTerminalLogoutCommand(provider: CliProviderStatus): { - args: string[]; - env?: Record; -} { - if (provider.providerId === 'gemini') { - return { - args: ['logout'], - env: { - CLAUDE_CODE_ENTRY_PROVIDER: 'gemini', - CLAUDE_CODE_GEMINI_BACKEND: provider.selectedBackendId ?? 'auto', - }, - }; - } - - if (provider.providerId === 'codex') { - return { - args: ['auth', 'logout', '--provider', provider.providerId], - env: { - CLAUDE_CODE_CODEX_BACKEND: provider.selectedBackendId ?? 'codex-native', - }, - }; - } - - return { - args: ['auth', 'logout', '--provider', provider.providerId], - }; -} - export const CliStatusSection = (): React.JSX.Element | null => { const isElectron = useMemo(() => isElectronMode(), []); const appConfig = useStore((s) => s.appConfig); diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index fc6f8284..ae75fac9 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -3306,6 +3306,7 @@ export const TeamDetailView = memo(function TeamDetailView({ providerId: entry.providerId, model: entry.model, effort: entry.effort, + mcpPolicy: entry.mcpPolicy, }); } setAddMemberDialogOpen(false); diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index f2e896e7..eeb43ace 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -844,7 +844,7 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element { if (!role && m.agentType && m.agentType !== 'general-purpose') { role = m.agentType; } - return { name: m.name, role }; + return { name: m.name, role, mcpPolicy: m.mcpPolicy }; }); setCopyData({ teamName: uniqueName, diff --git a/src/renderer/components/team/dialogs/AddMemberDialog.tsx b/src/renderer/components/team/dialogs/AddMemberDialog.tsx index 9d4ef77e..09f608fd 100644 --- a/src/renderer/components/team/dialogs/AddMemberDialog.tsx +++ b/src/renderer/components/team/dialogs/AddMemberDialog.tsx @@ -20,7 +20,7 @@ import { isGeminiUiFrozen } from '@renderer/utils/geminiUiFreeze'; import { Loader2 } from 'lucide-react'; import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes'; -import type { EffortLevel, TeamProviderId } from '@shared/types'; +import type { EffortLevel, TeamMemberMcpPolicy, TeamProviderId } from '@shared/types'; export interface AddMemberEntry { name: string; @@ -30,6 +30,7 @@ export interface AddMemberEntry { providerId?: TeamProviderId; model?: string; effort?: EffortLevel; + mcpPolicy?: TeamMemberMcpPolicy; } interface AddMemberDialogProps { @@ -153,6 +154,7 @@ export const AddMemberDialog = ({ providerId: m.providerId, model: m.model, effort: m.effort, + mcpPolicy: m.mcpPolicy, })) ); }; diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index f54c88b9..cc7e689c 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -103,29 +103,27 @@ import { loadProjectPathProjects, type ProjectPathProject } from './projectPathP import { ProjectPathSelector } from './ProjectPathSelector'; import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey'; import { - getProviderPrepareCachedSnapshot, mergeReusableProviderPrepareModelResults, type ProviderPrepareDiagnosticsModelResult, runProviderPrepareDiagnostics, } from './providerPrepareDiagnostics'; +import { buildProviderPreparePlans, type ProviderPreparePlan } from './providerPreparePlans'; import { - buildProviderPrepareMembersSignature, buildProviderPrepareModelChecksSignature, - buildProviderPrepareRequestSignature, buildProviderPrepareRuntimeStatusSignature, } from './providerPrepareRequestSignature'; import { getShortLivedProviderPrepareModelIssueReasons, - getShortLivedProviderPrepareModelResults, storeShortLivedProviderPrepareModelResults, } from './providerPrepareShortLivedCache'; import { getProvisioningModelIssue } from './provisioningModelIssues'; +import { ProvisioningProviderRuntimeSettingsDialog } from './ProvisioningProviderRuntimeSettingsDialog'; import { deriveEffectiveProvisioningPrepareState, - failIncompleteProviderChecks, getPrimaryProvisioningFailureDetail, getProvisioningFailureHint, getProvisioningProviderBackendSummary, + getProvisioningProviderProgressMessage, type ProvisioningProviderCheck, ProvisioningProviderStatusList, shouldHideProvisioningProviderStatusList, @@ -148,6 +146,7 @@ import { import type { MemberDraft } from '@renderer/components/team/members/MembersEditorSection'; import type { + CliProviderId, EffortLevel, TeamCreateRequest, TeamFastMode, @@ -366,6 +365,13 @@ function cancelScheduledIdle(handle: ScheduledIdleHandle | null): void { window.clearTimeout(handle.id); } +function cancelScheduledIdleSet(handles: Set): void { + for (const handle of handles) { + cancelScheduledIdle(handle); + } + handles.clear(); +} + function isCurrentPrepareGeneration(ref: { current: number }, generation: number): boolean { return ref.current === generation; } @@ -452,8 +458,13 @@ export const CreateTeamDialog = ({ const [prepareMessage, setPrepareMessage] = useState(null); const [prepareWarnings, setPrepareWarnings] = useState([]); const [prepareChecks, setPrepareChecks] = useState([]); + const [prepareProviderInvalidationEpochById, setPrepareProviderInvalidationEpochById] = useState< + Partial> + >({}); + const [providerSettingsProviderId, setProviderSettingsProviderId] = + useState(null); const prepareRequestSeqRef = useRef(0); - const prepareIdleHandleRef = useRef(null); + const prepareIdleHandlesRef = useRef(new Set()); const prepareUnmountGenerationRef = useRef(0); const appliedDefaultProjectPathRef = useRef(null); const lastAutoDescriptionRef = useRef(null); @@ -486,6 +497,12 @@ export const CreateTeamDialog = ({ migrateLegacyCreateTeamPreferences(); }, []); + useEffect(() => { + if (!open) { + setProviderSettingsProviderId(null); + } + }, [open]); + // Re-read localStorage when advancedKey changes useEffect(() => { const storedEnabled = @@ -679,10 +696,14 @@ export const CreateTeamDialog = ({ ); const runtimeBackendSummaryByProviderRef = useRef(runtimeBackendSummaryByProvider); const prepareChecksRef = useRef([]); + const prepareMessageRef = useRef(null); const prepareModelResultsCacheRef = useRef( new Map>() ); - const lastPrepareRequestSignatureRef = useRef(null); + const lastPrepareProviderSignatureByIdRef = useRef(new Map()); + const pendingPrepareProviderSignatureByIdRef = useRef(new Map()); + const prepareProviderRequestSeqByIdRef = useRef(new Map()); + const prepareWarningsByProviderIdRef = useRef(new Map()); useEffect(() => { runtimeBackendSummaryByProviderRef.current = runtimeBackendSummaryByProvider; @@ -703,14 +724,44 @@ export const CreateTeamDialog = ({ prepareChecksRef.current = prepareChecks; }, [prepareChecks]); + useEffect(() => { + prepareMessageRef.current = prepareMessage; + }, [prepareMessage]); + + const invalidatePrepareProvider = useCallback((providerId: CliProviderId): void => { + if (!isTeamProviderId(providerId)) { + return; + } + + lastPrepareProviderSignatureByIdRef.current.delete(providerId); + pendingPrepareProviderSignatureByIdRef.current.delete(providerId); + prepareProviderRequestSeqByIdRef.current.set( + providerId, + (prepareProviderRequestSeqByIdRef.current.get(providerId) ?? 0) + 1 + ); + prepareWarningsByProviderIdRef.current.delete(providerId); + setPrepareProviderInvalidationEpochById((current) => ({ + ...current, + [providerId]: (current[providerId] ?? 0) + 1, + })); + }, []); + useEffect(() => { if (!open) { - lastPrepareRequestSignatureRef.current = null; + lastPrepareProviderSignatureByIdRef.current.clear(); + pendingPrepareProviderSignatureByIdRef.current.clear(); + prepareProviderRequestSeqByIdRef.current.clear(); + prepareWarningsByProviderIdRef.current.clear(); } }, [open]); useEffect(() => { const generation = ++prepareUnmountGenerationRef.current; + const idleHandles = prepareIdleHandlesRef.current; + const lastProviderSignatures = lastPrepareProviderSignatureByIdRef.current; + const pendingProviderSignatures = pendingPrepareProviderSignatureByIdRef.current; + const providerRequestSeqs = prepareProviderRequestSeqByIdRef.current; + const warningsByProviderId = prepareWarningsByProviderIdRef.current; return () => { // React StrictMode replays effect cleanup/setup in development; defer // invalidation so the replay does not cancel the live prepare request. @@ -718,26 +769,16 @@ export const CreateTeamDialog = ({ if (!isCurrentPrepareGeneration(prepareUnmountGenerationRef, generation)) { return; } - cancelScheduledIdle(prepareIdleHandleRef.current); - prepareIdleHandleRef.current = null; + cancelScheduledIdleSet(idleHandles); prepareRequestSeqRef.current += 1; - lastPrepareRequestSignatureRef.current = null; + lastProviderSignatures.clear(); + pendingProviderSignatures.clear(); + providerRequestSeqs.clear(); + warningsByProviderId.clear(); }); }; }, []); - const prepareRuntimeStatusSignature = useMemo( - () => - buildProviderPrepareRuntimeStatusSignature( - selectedMemberProviders, - runtimeProviderStatusById - ), - [runtimeProviderStatusById, selectedMemberProviders] - ); - const prepareMembersSignature = useMemo( - () => buildProviderPrepareMembersSignature(effectiveMemberDrafts), - [effectiveMemberDrafts] - ); const selectedModelChecksByProvider = useMemo(() => { const modelsByProvider = new Map(); const leadEffort = (selectedEffort as EffortLevel | '') || undefined; @@ -817,31 +858,9 @@ export const CreateTeamDialog = ({ () => buildProviderPrepareModelChecksSignature(selectedModelChecksByProvider), [selectedModelChecksByProvider] ); - const prepareRequestSignature = useMemo( - () => - buildProviderPrepareRequestSignature({ - cwd: effectiveCwd, - selectedProviderId, - selectedModel, - selectedMemberProviders, - limitContext: effectiveAnthropicRuntimeLimitContext, - runtimeStatusSignature: prepareRuntimeStatusSignature, - membersSignature: prepareMembersSignature, - modelChecksSignature: selectedModelChecksByProviderSignature, - }), - [ - effectiveCwd, - effectiveAnthropicRuntimeLimitContext, - prepareMembersSignature, - prepareRuntimeStatusSignature, - selectedMemberProviders, - selectedModel, - selectedModelChecksByProviderSignature, - selectedProviderId, - ] - ); const shortLivedModelIssueReasons = useMemo(() => { void prepareChecks; + void selectedModelChecksByProviderSignature; const modelAdvisoryReasonByProvider: Partial>> = {}; const modelIssueReasonByProvider: Partial>> = {}; @@ -851,13 +870,20 @@ export const CreateTeamDialog = ({ for (const providerId of selectedMemberProviders) { const backendSummary = runtimeBackendSummaryByProvider.get(providerId) ?? null; + const providerRuntimeStatusSignature = buildProviderPrepareRuntimeStatusSignature( + [providerId], + runtimeProviderStatusById + ); + const providerModelChecksSignature = buildProviderPrepareModelChecksSignature( + new Map([[providerId, selectedModelChecksByProvider.get(providerId) ?? []]]) + ); const cacheKey = buildProviderPrepareModelCacheKey({ cwd: effectiveCwd, providerId, backendSummary, limitContext: effectiveAnthropicRuntimeLimitContext, - runtimeStatusSignature: prepareRuntimeStatusSignature, - modelChecksSignature: selectedModelChecksByProviderSignature, + runtimeStatusSignature: providerRuntimeStatusSignature, + modelChecksSignature: providerModelChecksSignature, }); const issueReasons = getShortLivedProviderPrepareModelIssueReasons({ providerId, @@ -883,8 +909,9 @@ export const CreateTeamDialog = ({ effectiveAnthropicRuntimeLimitContext, effectiveCwd, prepareChecks, - prepareRuntimeStatusSignature, runtimeBackendSummaryByProvider, + runtimeProviderStatusById, + selectedModelChecksByProvider, selectedModelChecksByProviderSignature, selectedMemberProviders, ]); @@ -926,18 +953,22 @@ export const CreateTeamDialog = ({ useEffect(() => { if (!open || !canCreate || !launchTeam) { - cancelScheduledIdle(prepareIdleHandleRef.current); - prepareIdleHandleRef.current = null; + cancelScheduledIdleSet(prepareIdleHandlesRef.current); prepareRequestSeqRef.current += 1; - lastPrepareRequestSignatureRef.current = null; + lastPrepareProviderSignatureByIdRef.current.clear(); + pendingPrepareProviderSignatureByIdRef.current.clear(); + prepareProviderRequestSeqByIdRef.current.clear(); + prepareWarningsByProviderIdRef.current.clear(); return; } if (typeof api.teams.prepareProvisioning !== 'function') { - cancelScheduledIdle(prepareIdleHandleRef.current); - prepareIdleHandleRef.current = null; + cancelScheduledIdleSet(prepareIdleHandlesRef.current); prepareRequestSeqRef.current += 1; - lastPrepareRequestSignatureRef.current = null; + lastPrepareProviderSignatureByIdRef.current.clear(); + pendingPrepareProviderSignatureByIdRef.current.clear(); + prepareProviderRequestSeqByIdRef.current.clear(); + prepareWarningsByProviderIdRef.current.clear(); setPrepareState('failed'); setPrepareWarnings([]); setPrepareChecks([]); @@ -948,10 +979,12 @@ export const CreateTeamDialog = ({ } if (!effectiveCwd) { - cancelScheduledIdle(prepareIdleHandleRef.current); - prepareIdleHandleRef.current = null; + cancelScheduledIdleSet(prepareIdleHandlesRef.current); prepareRequestSeqRef.current += 1; - lastPrepareRequestSignatureRef.current = null; + lastPrepareProviderSignatureByIdRef.current.clear(); + pendingPrepareProviderSignatureByIdRef.current.clear(); + prepareProviderRequestSeqByIdRef.current.clear(); + prepareWarningsByProviderIdRef.current.clear(); setPrepareState('idle'); setPrepareWarnings([]); setPrepareChecks([]); @@ -959,81 +992,126 @@ export const CreateTeamDialog = ({ return; } - if (lastPrepareRequestSignatureRef.current === prepareRequestSignature) { + const selectedProviderIdSet = new Set(selectedMemberProviders); + for (const providerId of Array.from(lastPrepareProviderSignatureByIdRef.current.keys())) { + if (!selectedProviderIdSet.has(providerId)) { + lastPrepareProviderSignatureByIdRef.current.delete(providerId); + pendingPrepareProviderSignatureByIdRef.current.delete(providerId); + prepareProviderRequestSeqByIdRef.current.delete(providerId); + prepareWarningsByProviderIdRef.current.delete(providerId); + } + } + + const providerPlans = buildProviderPreparePlans({ + cwd: effectiveCwd, + providerIds: selectedMemberProviders, + selectedModelChecksByProvider, + backendSummaryByProvider: runtimeBackendSummaryByProviderRef.current, + limitContext: effectiveAnthropicRuntimeLimitContext, + runtimeProviderStatusById, + cachedModelResultsByCacheKey: prepareModelResultsCacheRef.current, + }); + const changedPlans = providerPlans.filter((plan) => { + const lastSignature = lastPrepareProviderSignatureByIdRef.current.get(plan.providerId); + const pendingSignature = pendingPrepareProviderSignatureByIdRef.current.get(plan.providerId); + return lastSignature !== plan.requestSignature && pendingSignature !== plan.requestSignature; + }); + const loadingMessage = getProvisioningProviderProgressMessage( + changedPlans.map((plan) => plan.providerId), + selectedMemberProviders.length + ); + const getSelectedWarnings = (): string[] => + selectedMemberProviders.flatMap( + (providerId) => prepareWarningsByProviderIdRef.current.get(providerId) ?? [] + ); + const commitChecks = (nextChecks: ProvisioningProviderCheck[]): void => { + prepareChecksRef.current = nextChecks; + setPrepareChecks(nextChecks); + }; + const applyPrepareOutcome = ( + nextChecks: ProvisioningProviderCheck[], + pendingMessage: string | null + ): void => { + const selectedWarnings = getSelectedWarnings(); + setPrepareWarnings(selectedWarnings); + + if (nextChecks.some((check) => check.status === 'pending' || check.status === 'checking')) { + setPrepareState('loading'); + setPrepareMessage(pendingMessage); + return; + } + + const anyFailure = nextChecks.some((check) => check.status === 'failed'); + const anyNotes = + selectedWarnings.length > 0 || nextChecks.some((check) => check.status === 'notes'); + const failureMessage = + getPrimaryProvisioningFailureDetail(nextChecks) ?? + 'Some selected providers need attention.'; + setPrepareState(anyFailure ? 'failed' : 'ready'); + setPrepareMessage( + anyFailure + ? failureMessage + : anyNotes + ? 'All selected providers are ready, with notes.' + : 'All selected providers are ready.' + ); + }; + + let checks = alignProvisioningChecks(prepareChecksRef.current, selectedMemberProviders); + for (const plan of changedPlans) { + checks = updateProviderCheck(checks, plan.providerId, { + status: plan.selectedModelIds.length > 0 ? plan.cachedSnapshot.status : 'checking', + backendSummary: plan.backendSummary, + details: plan.cachedSnapshot.details, + }); + prepareWarningsByProviderIdRef.current.delete(plan.providerId); + } + commitChecks(checks); + applyPrepareOutcome( + checks, + changedPlans.length > 0 + ? loadingMessage + : (prepareMessageRef.current ?? + getProvisioningProviderProgressMessage([], selectedMemberProviders.length)) + ); + + if (changedPlans.length === 0) { return; } - lastPrepareRequestSignatureRef.current = prepareRequestSignature; - const requestSeq = ++prepareRequestSeqRef.current; - const initialChecks = alignProvisioningChecks( - prepareChecksRef.current, - selectedMemberProviders - ); - setPrepareState('loading'); - setPrepareMessage('Checking selected providers in parallel...'); - setPrepareWarnings([]); - setPrepareChecks(initialChecks); + for (const plan of changedPlans) { + pendingPrepareProviderSignatureByIdRef.current.set(plan.providerId, plan.requestSignature); + } - // Defer the heavy IPC orchestration until the renderer is idle so the - // synchronous state updates above (setPrepareState etc.) can paint first. - // Cancel any pending idle work from a superseded run so a stale callback - // can't start expensive diagnostics for an obsolete request. - cancelScheduledIdle(prepareIdleHandleRef.current); - prepareIdleHandleRef.current = null; - - prepareIdleHandleRef.current = scheduleIdle(() => { - prepareIdleHandleRef.current = null; - if (prepareRequestSeqRef.current !== requestSeq) return; + const idleHandle = scheduleIdle(() => { + prepareIdleHandlesRef.current.delete(idleHandle); + const generation = prepareRequestSeqRef.current; + const runningPlans = changedPlans.flatMap((plan) => { + if ( + pendingPrepareProviderSignatureByIdRef.current.get(plan.providerId) !== + plan.requestSignature + ) { + return []; + } + pendingPrepareProviderSignatureByIdRef.current.delete(plan.providerId); + const requestSeq = (prepareProviderRequestSeqByIdRef.current.get(plan.providerId) ?? 0) + 1; + prepareProviderRequestSeqByIdRef.current.set(plan.providerId, requestSeq); + lastPrepareProviderSignatureByIdRef.current.set(plan.providerId, plan.requestSignature); + return [{ ...plan, requestSeq }]; + }); + if (runningPlans.length === 0) { + return; + } + const isPlanCurrent = (plan: ProviderPreparePlan & { requestSeq: number }): boolean => + prepareRequestSeqRef.current === generation && + lastPrepareProviderSignatureByIdRef.current.get(plan.providerId) === + plan.requestSignature && + prepareProviderRequestSeqByIdRef.current.get(plan.providerId) === plan.requestSeq && + !pendingPrepareProviderSignatureByIdRef.current.has(plan.providerId); void (async () => { - let checks = initialChecks; - const providerPlans = selectedMemberProviders.map((providerId) => { - const selectedModelChecks = selectedModelChecksByProvider.get(providerId) ?? []; - const selectedModelIds = selectedModelChecks.map((check) => check.model); - const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null; - const cacheKey = buildProviderPrepareModelCacheKey({ - cwd: effectiveCwd, - providerId, - backendSummary, - limitContext: effectiveAnthropicRuntimeLimitContext, - runtimeStatusSignature: prepareRuntimeStatusSignature, - modelChecksSignature: selectedModelChecksByProviderSignature, - }); - const cachedModelResultsById = { - ...getShortLivedProviderPrepareModelResults({ - providerId, - cacheKey, - }), - ...(prepareModelResultsCacheRef.current.get(cacheKey) ?? {}), - }; - const cachedSnapshot = getProviderPrepareCachedSnapshot({ - providerId, - selectedModelIds, - cachedModelResultsById, - }); - return { - providerId, - selectedModelChecks, - selectedModelIds, - backendSummary, - cacheKey, - cachedModelResultsById, - cachedSnapshot, - }; - }); - - try { - for (const plan of providerPlans) { - checks = updateProviderCheck(checks, plan.providerId, { - status: plan.selectedModelIds.length > 0 ? plan.cachedSnapshot.status : 'checking', - backendSummary: plan.backendSummary, - details: plan.cachedSnapshot.details, - }); - } - if (prepareRequestSeqRef.current === requestSeq) { - setPrepareChecks(checks); - } - const providerResults = await Promise.all( - providerPlans.map(async (plan) => { + await Promise.all( + runningPlans.map(async (plan) => { + try { const prepResult = await runProviderPrepareDiagnostics({ cwd: effectiveCwd, providerId: plan.providerId, @@ -1043,83 +1121,70 @@ export const CreateTeamDialog = ({ limitContext: effectiveAnthropicRuntimeLimitContext, cachedModelResultsById: plan.cachedModelResultsById, onModelProgress: ({ status, details }) => { - checks = updateProviderCheck(checks, plan.providerId, { - status, - backendSummary: plan.backendSummary, - details, - }); - if (prepareRequestSeqRef.current === requestSeq) { - setPrepareChecks(checks); + if (!isPlanCurrent(plan)) { + return; } + const nextChecks = updateProviderCheck( + prepareChecksRef.current, + plan.providerId, + { + status, + backendSummary: plan.backendSummary, + details, + } + ); + commitChecks(nextChecks); + applyPrepareOutcome(nextChecks, loadingMessage); }, }); - 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( - ...plan.prepResult.warnings.map( + if (!isPlanCurrent(plan)) { + return; + } + prepareWarningsByProviderIdRef.current.set( + plan.providerId, + prepResult.warnings.map( (warning) => `${getProviderLabel(plan.providerId)}: ${warning}` ) ); - } - if (plan.prepResult.status === 'failed') { - anyFailure = true; - } else if (plan.prepResult.status === 'notes') { - anyNotes = true; - } - if (prepareRequestSeqRef.current === requestSeq) { prepareModelResultsCacheRef.current.set( plan.cacheKey, mergeReusableProviderPrepareModelResults( prepareModelResultsCacheRef.current.get(plan.cacheKey), - plan.prepResult.modelResultsById + prepResult.modelResultsById ) ); storeShortLivedProviderPrepareModelResults({ providerId: plan.providerId, cacheKey: plan.cacheKey, - modelResultsById: plan.prepResult.modelResultsById, + modelResultsById: prepResult.modelResultsById, }); + const nextChecks = updateProviderCheck(prepareChecksRef.current, plan.providerId, { + status: prepResult.status, + backendSummary: plan.backendSummary, + details: prepResult.details, + }); + commitChecks(nextChecks); + applyPrepareOutcome(nextChecks, loadingMessage); + } catch (error) { + if (!isPlanCurrent(plan)) { + return; + } + const failureMessage = + error instanceof Error ? error.message : 'Failed to prepare selected providers'; + const nextChecks = updateProviderCheck(prepareChecksRef.current, plan.providerId, { + status: 'failed', + backendSummary: plan.backendSummary, + details: [failureMessage], + }); + prepareWarningsByProviderIdRef.current.delete(plan.providerId); + commitChecks(nextChecks); + applyPrepareOutcome(nextChecks, failureMessage); } - checks = updateProviderCheck(checks, plan.providerId, { - status: plan.prepResult.status, - backendSummary: plan.backendSummary, - details: plan.prepResult.details, - }); - } - if (prepareRequestSeqRef.current === requestSeq) { - setPrepareChecks(checks); - } - if (prepareRequestSeqRef.current !== requestSeq) return; - const failureMessage = - getPrimaryProvisioningFailureDetail(checks) ?? - 'Some selected providers need attention.'; - setPrepareState(anyFailure ? 'failed' : 'ready'); - setPrepareMessage( - anyFailure - ? failureMessage - : anyNotes - ? 'All selected providers are ready, with notes.' - : 'All selected providers are ready.' - ); - setPrepareWarnings(collectedWarnings); - } catch (error) { - if (prepareRequestSeqRef.current !== requestSeq) return; - const failureMessage = - error instanceof Error ? error.message : 'Failed to prepare selected providers'; - setPrepareState('failed'); - setPrepareWarnings([]); - setPrepareChecks(failIncompleteProviderChecks(checks, failureMessage)); - setPrepareMessage(failureMessage); - } + }) + ); })(); }); + prepareIdleHandlesRef.current.add(idleHandle); }, [ open, canCreate, @@ -1127,8 +1192,7 @@ export const CreateTeamDialog = ({ effectiveCwd, effectiveMemberDrafts, effectiveAnthropicRuntimeLimitContext, - prepareRequestSignature, - prepareRuntimeStatusSignature, + prepareProviderInvalidationEpochById, runtimeProviderStatusById, selectedModel, selectedModelChecksByProvider, @@ -1199,6 +1263,7 @@ export const CreateTeamDialog = ({ providerId: normalizeOptionalTeamProviderId(m.providerId), model: m.model ?? '', effort: m.effort, + mcpPolicy: m.mcpPolicy, }), multimodelEnabled ); @@ -2382,7 +2447,11 @@ export const CreateTeamDialog = ({

- + setProviderSettingsProviderId(providerId)} + /> ) : null} @@ -2402,7 +2471,11 @@ export const CreateTeamDialog = ({ {effectivePrepare.message}

) : null} - + setProviderSettingsProviderId(providerId)} + /> {prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
{prepareWarnings.map((warning, index) => ( @@ -2436,6 +2509,9 @@ export const CreateTeamDialog = ({ checks={prepareChecks} className="mt-2" suppressDetailsMatching={prepareMessage} + onOpenProviderSettings={(providerId) => + setProviderSettingsProviderId(providerId) + } /> ) : null} {prepareWarnings.length > 0 && prepareChecks.length === 0 ? ( @@ -2503,6 +2579,14 @@ export const CreateTeamDialog = ({
+ setProviderSettingsProviderId(providerId)} + providers={effectiveCliStatus?.providers ?? []} + projectPath={effectiveCwd || null} + disabled={isSubmitting} + onProviderRuntimeChanged={invalidatePrepareProvider} + /> ); }; diff --git a/src/renderer/components/team/dialogs/EditTeamDialog.tsx b/src/renderer/components/team/dialogs/EditTeamDialog.tsx index 8c42d319..38252cc9 100644 --- a/src/renderer/components/team/dialogs/EditTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/EditTeamDialog.tsx @@ -478,6 +478,7 @@ export const EditTeamDialog = ({ model: member.model, effort: member.effort, isolation: member.isolation, + mcpPolicy: member.mcpPolicy, })) as ResolvedTeamMember[], }); diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 62db4765..859e5380 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { reconcileAnthropicRuntimeSelections, @@ -107,28 +107,27 @@ import { loadProjectPathProjects, type ProjectPathProject } from './projectPathP import { ProjectPathSelector } from './ProjectPathSelector'; import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey'; import { - getProviderPrepareCachedSnapshot, mergeReusableProviderPrepareModelResults, type ProviderPrepareDiagnosticsModelResult, runProviderPrepareDiagnostics, } from './providerPrepareDiagnostics'; +import { buildProviderPreparePlans, type ProviderPreparePlan } from './providerPreparePlans'; import { buildProviderPrepareModelChecksSignature, - buildProviderPrepareRequestSignature, buildProviderPrepareRuntimeStatusSignature, } from './providerPrepareRequestSignature'; import { getShortLivedProviderPrepareModelIssueReasons, - getShortLivedProviderPrepareModelResults, storeShortLivedProviderPrepareModelResults, } from './providerPrepareShortLivedCache'; import { getProvisioningModelIssue } from './provisioningModelIssues'; +import { ProvisioningProviderRuntimeSettingsDialog } from './ProvisioningProviderRuntimeSettingsDialog'; import { deriveEffectiveProvisioningPrepareState, - failIncompleteProviderChecks, getPrimaryProvisioningFailureDetail, getProvisioningFailureHint, getProvisioningProviderBackendSummary, + getProvisioningProviderProgressMessage, type ProvisioningProviderCheck, ProvisioningProviderStatusList, shouldHideProvisioningProviderStatusList, @@ -157,6 +156,7 @@ import type { ActiveTeamRef } from './CreateTeamDialog'; import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { + CliProviderId, CreateScheduleInput, EffortLevel, ResolvedTeamMember, @@ -463,6 +463,11 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const [prepareMessage, setPrepareMessage] = useState(null); const [prepareWarnings, setPrepareWarnings] = useState([]); const [prepareChecks, setPrepareChecks] = useState([]); + const [prepareProviderInvalidationEpochById, setPrepareProviderInvalidationEpochById] = useState< + Partial> + >({}); + const [providerSettingsProviderId, setProviderSettingsProviderId] = + useState(null); const prepareRequestSeqRef = useRef(0); const appliedDefaultProjectPathRef = useRef(null); const storeMembers = useStore((s) => selectResolvedMembersForTeamName(s, s.selectedTeamName)); @@ -475,6 +480,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen null ); + useEffect(() => { + if (!open) { + setProviderSettingsProviderId(null); + } + }, [open]); + // Advanced CLI section state (with localStorage persistence) const [worktreeEnabled, setWorktreeEnabledRaw] = useState( () => @@ -540,10 +551,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen }, [effectiveCliStatus?.providers]); const runtimeBackendSummaryByProviderRef = useRef(runtimeBackendSummaryByProvider); const prepareChecksRef = useRef([]); + const prepareMessageRef = useRef(null); const prepareModelResultsCacheRef = useRef( new Map>() ); - const lastPrepareRequestSignatureRef = useRef(null); + const lastPrepareProviderSignatureByIdRef = useRef(new Map()); + const prepareProviderRequestSeqByIdRef = useRef(new Map()); + const prepareWarningsByProviderIdRef = useRef(new Map()); useEffect(() => { runtimeBackendSummaryByProviderRef.current = runtimeBackendSummaryByProvider; @@ -551,9 +565,32 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen useEffect(() => { prepareChecksRef.current = prepareChecks; }, [prepareChecks]); + useEffect(() => { + prepareMessageRef.current = prepareMessage; + }, [prepareMessage]); + + const invalidatePrepareProvider = useCallback((providerId: CliProviderId): void => { + if (!isTeamProviderId(providerId)) { + return; + } + + lastPrepareProviderSignatureByIdRef.current.delete(providerId); + prepareProviderRequestSeqByIdRef.current.set( + providerId, + (prepareProviderRequestSeqByIdRef.current.get(providerId) ?? 0) + 1 + ); + prepareWarningsByProviderIdRef.current.delete(providerId); + setPrepareProviderInvalidationEpochById((current) => ({ + ...current, + [providerId]: (current[providerId] ?? 0) + 1, + })); + }, []); + useEffect(() => { if (!open) { - lastPrepareRequestSignatureRef.current = null; + lastPrepareProviderSignatureByIdRef.current.clear(); + prepareProviderRequestSeqByIdRef.current.clear(); + prepareWarningsByProviderIdRef.current.clear(); } }, [open]); const runtimeProviderStatusById = useMemo( @@ -1429,40 +1466,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen worktreeGitReadiness, hasSelectedWorktreeIsolation ); - const prepareRuntimeStatusSignature = useMemo( - () => - buildProviderPrepareRuntimeStatusSignature( - selectedMemberProviders, - runtimeProviderStatusById - ), - [runtimeProviderStatusById, selectedMemberProviders] - ); const selectedModelChecksByProviderSignature = useMemo( () => buildProviderPrepareModelChecksSignature(selectedModelChecksByProvider), [selectedModelChecksByProvider] ); - const prepareRequestSignature = useMemo( - () => - buildProviderPrepareRequestSignature({ - cwd: effectiveCwd, - selectedProviderId, - selectedModel, - selectedMemberProviders, - limitContext: effectiveAnthropicRuntimeLimitContext, - runtimeStatusSignature: prepareRuntimeStatusSignature, - modelChecksSignature: selectedModelChecksByProviderSignature, - }), - [ - effectiveCwd, - effectiveAnthropicRuntimeLimitContext, - prepareRuntimeStatusSignature, - selectedMemberProviders, - selectedModel, - selectedModelChecksByProviderSignature, - selectedProviderId, - ] - ); const shortLivedModelIssueReasons = useMemo(() => { + void prepareChecks; + void selectedModelChecksByProviderSignature; const modelAdvisoryReasonByProvider: Partial>> = {}; const modelIssueReasonByProvider: Partial>> = {}; @@ -1480,13 +1490,20 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen for (const providerId of selectedMemberProviders) { const backendSummary = runtimeBackendSummaryByProvider.get(providerId) ?? null; + const providerRuntimeStatusSignature = buildProviderPrepareRuntimeStatusSignature( + [providerId], + runtimeProviderStatusById + ); + const providerModelChecksSignature = buildProviderPrepareModelChecksSignature( + new Map([[providerId, selectedModelChecksByProvider.get(providerId) ?? []]]) + ); const cacheKey = buildProviderPrepareModelCacheKey({ cwd: effectiveCwd, providerId, backendSummary, limitContext: effectiveAnthropicRuntimeLimitContext, - runtimeStatusSignature: prepareRuntimeStatusSignature, - modelChecksSignature: selectedModelChecksByProviderSignature, + runtimeStatusSignature: providerRuntimeStatusSignature, + modelChecksSignature: providerModelChecksSignature, }); const issueReasons = getShortLivedProviderPrepareModelIssueReasons({ providerId, @@ -1513,8 +1530,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen effectiveCwd, isLaunchMode, prepareChecks, - prepareRuntimeStatusSignature, runtimeBackendSummaryByProvider, + runtimeProviderStatusById, + selectedModelChecksByProvider, selectedModelChecksByProviderSignature, selectedMemberProviders, ]); @@ -1530,13 +1548,17 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen useEffect(() => { if (!open || !isLaunchMode) { prepareRequestSeqRef.current += 1; - lastPrepareRequestSignatureRef.current = null; + lastPrepareProviderSignatureByIdRef.current.clear(); + prepareProviderRequestSeqByIdRef.current.clear(); + prepareWarningsByProviderIdRef.current.clear(); return; } if (typeof api.teams.prepareProvisioning !== 'function') { prepareRequestSeqRef.current += 1; - lastPrepareRequestSignatureRef.current = null; + lastPrepareProviderSignatureByIdRef.current.clear(); + prepareProviderRequestSeqByIdRef.current.clear(); + prepareWarningsByProviderIdRef.current.clear(); setPrepareState('failed'); setPrepareWarnings([]); setPrepareChecks([]); @@ -1548,7 +1570,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen if (!effectiveCwd) { prepareRequestSeqRef.current += 1; - lastPrepareRequestSignatureRef.current = null; + lastPrepareProviderSignatureByIdRef.current.clear(); + prepareProviderRequestSeqByIdRef.current.clear(); + prepareWarningsByProviderIdRef.current.clear(); setPrepareState('idle'); setPrepareWarnings([]); setPrepareChecks([]); @@ -1556,71 +1580,107 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen return; } - if (lastPrepareRequestSignatureRef.current === prepareRequestSignature) { + const selectedProviderIdSet = new Set(selectedMemberProviders); + for (const providerId of Array.from(lastPrepareProviderSignatureByIdRef.current.keys())) { + if (!selectedProviderIdSet.has(providerId)) { + lastPrepareProviderSignatureByIdRef.current.delete(providerId); + prepareProviderRequestSeqByIdRef.current.delete(providerId); + prepareWarningsByProviderIdRef.current.delete(providerId); + } + } + + const providerPlans = buildProviderPreparePlans({ + cwd: effectiveCwd, + providerIds: selectedMemberProviders, + selectedModelChecksByProvider, + backendSummaryByProvider: runtimeBackendSummaryByProviderRef.current, + limitContext: effectiveAnthropicRuntimeLimitContext, + runtimeProviderStatusById, + cachedModelResultsByCacheKey: prepareModelResultsCacheRef.current, + }); + const changedPlans = providerPlans.filter( + (plan) => + lastPrepareProviderSignatureByIdRef.current.get(plan.providerId) !== plan.requestSignature + ); + const loadingMessage = getProvisioningProviderProgressMessage( + changedPlans.map((plan) => plan.providerId), + selectedMemberProviders.length + ); + const getSelectedWarnings = (): string[] => + selectedMemberProviders.flatMap( + (providerId) => prepareWarningsByProviderIdRef.current.get(providerId) ?? [] + ); + const commitChecks = (nextChecks: ProvisioningProviderCheck[]): void => { + prepareChecksRef.current = nextChecks; + setPrepareChecks(nextChecks); + }; + const applyPrepareOutcome = ( + nextChecks: ProvisioningProviderCheck[], + pendingMessage: string | null + ): void => { + const selectedWarnings = getSelectedWarnings(); + setPrepareWarnings(selectedWarnings); + + if (nextChecks.some((check) => check.status === 'pending' || check.status === 'checking')) { + setPrepareState('loading'); + setPrepareMessage(pendingMessage); + return; + } + + const anyFailure = nextChecks.some((check) => check.status === 'failed'); + const anyNotes = + selectedWarnings.length > 0 || nextChecks.some((check) => check.status === 'notes'); + const failureMessage = + getPrimaryProvisioningFailureDetail(nextChecks) ?? + 'Some selected providers need attention.'; + setPrepareState(anyFailure ? 'failed' : 'ready'); + setPrepareMessage( + anyFailure + ? failureMessage + : anyNotes + ? 'All selected providers are ready, with notes.' + : 'All selected providers are ready.' + ); + }; + + let checks = alignProvisioningChecks(prepareChecksRef.current, selectedMemberProviders); + for (const plan of changedPlans) { + checks = updateProviderCheck(checks, plan.providerId, { + status: plan.selectedModelIds.length > 0 ? plan.cachedSnapshot.status : 'checking', + backendSummary: plan.backendSummary, + details: plan.cachedSnapshot.details, + }); + prepareWarningsByProviderIdRef.current.delete(plan.providerId); + } + commitChecks(checks); + applyPrepareOutcome( + checks, + changedPlans.length > 0 + ? loadingMessage + : (prepareMessageRef.current ?? + getProvisioningProviderProgressMessage([], selectedMemberProviders.length)) + ); + + if (changedPlans.length === 0) { return; } - lastPrepareRequestSignatureRef.current = prepareRequestSignature; - const requestSeq = ++prepareRequestSeqRef.current; - const initialChecks = alignProvisioningChecks( - prepareChecksRef.current, - selectedMemberProviders - ); - setPrepareState('loading'); - setPrepareMessage('Checking selected providers in parallel...'); - setPrepareWarnings([]); - setPrepareChecks(initialChecks); + const generation = prepareRequestSeqRef.current; + const runningPlans = changedPlans.map((plan) => { + const requestSeq = (prepareProviderRequestSeqByIdRef.current.get(plan.providerId) ?? 0) + 1; + prepareProviderRequestSeqByIdRef.current.set(plan.providerId, requestSeq); + lastPrepareProviderSignatureByIdRef.current.set(plan.providerId, plan.requestSignature); + return { ...plan, requestSeq }; + }); + const isPlanCurrent = (plan: ProviderPreparePlan & { requestSeq: number }): boolean => + prepareRequestSeqRef.current === generation && + lastPrepareProviderSignatureByIdRef.current.get(plan.providerId) === plan.requestSignature && + prepareProviderRequestSeqByIdRef.current.get(plan.providerId) === plan.requestSeq; void (async () => { - let checks = initialChecks; - const providerPlans = selectedMemberProviders.map((providerId) => { - const selectedModelChecks = selectedModelChecksByProvider.get(providerId) ?? []; - const selectedModelIds = selectedModelChecks.map((check) => check.model); - const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null; - const cacheKey = buildProviderPrepareModelCacheKey({ - cwd: effectiveCwd, - providerId, - backendSummary, - limitContext: effectiveAnthropicRuntimeLimitContext, - runtimeStatusSignature: prepareRuntimeStatusSignature, - modelChecksSignature: selectedModelChecksByProviderSignature, - }); - const cachedModelResultsById = { - ...getShortLivedProviderPrepareModelResults({ - providerId, - cacheKey, - }), - ...(prepareModelResultsCacheRef.current.get(cacheKey) ?? {}), - }; - const cachedSnapshot = getProviderPrepareCachedSnapshot({ - providerId, - selectedModelIds, - cachedModelResultsById, - }); - return { - providerId, - selectedModelChecks, - selectedModelIds, - backendSummary, - cacheKey, - cachedModelResultsById, - cachedSnapshot, - }; - }); - - try { - for (const plan of providerPlans) { - checks = updateProviderCheck(checks, plan.providerId, { - status: plan.selectedModelIds.length > 0 ? plan.cachedSnapshot.status : 'checking', - backendSummary: plan.backendSummary, - details: plan.cachedSnapshot.details, - }); - } - if (prepareRequestSeqRef.current === requestSeq) { - setPrepareChecks(checks); - } - const providerResults = await Promise.all( - providerPlans.map(async (plan) => { + await Promise.all( + runningPlans.map(async (plan) => { + try { const prepResult = await runProviderPrepareDiagnostics({ cwd: effectiveCwd, providerId: plan.providerId, @@ -1630,88 +1690,71 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen limitContext: effectiveAnthropicRuntimeLimitContext, cachedModelResultsById: plan.cachedModelResultsById, onModelProgress: ({ status, details }) => { - checks = updateProviderCheck(checks, plan.providerId, { + if (!isPlanCurrent(plan)) { + return; + } + const nextChecks = updateProviderCheck(prepareChecksRef.current, plan.providerId, { status, backendSummary: plan.backendSummary, details, }); - if (prepareRequestSeqRef.current === requestSeq) { - setPrepareChecks(checks); - } + commitChecks(nextChecks); + applyPrepareOutcome(nextChecks, loadingMessage); }, }); - 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( - ...plan.prepResult.warnings.map( + if (!isPlanCurrent(plan)) { + return; + } + prepareWarningsByProviderIdRef.current.set( + plan.providerId, + prepResult.warnings.map( (warning) => `${getProviderLabel(plan.providerId)}: ${warning}` ) ); - } - if (plan.prepResult.status === 'failed') { - anyFailure = true; - } else if (plan.prepResult.status === 'notes') { - anyNotes = true; - } - if (prepareRequestSeqRef.current === requestSeq) { prepareModelResultsCacheRef.current.set( plan.cacheKey, mergeReusableProviderPrepareModelResults( prepareModelResultsCacheRef.current.get(plan.cacheKey), - plan.prepResult.modelResultsById + prepResult.modelResultsById ) ); storeShortLivedProviderPrepareModelResults({ providerId: plan.providerId, cacheKey: plan.cacheKey, - modelResultsById: plan.prepResult.modelResultsById, + modelResultsById: prepResult.modelResultsById, }); + const nextChecks = updateProviderCheck(prepareChecksRef.current, plan.providerId, { + status: prepResult.status, + backendSummary: plan.backendSummary, + details: prepResult.details, + }); + commitChecks(nextChecks); + applyPrepareOutcome(nextChecks, loadingMessage); + } catch (error) { + if (!isPlanCurrent(plan)) { + return; + } + const failureMessage = + error instanceof Error ? error.message : 'Failed to prepare selected providers'; + const nextChecks = updateProviderCheck(prepareChecksRef.current, plan.providerId, { + status: 'failed', + backendSummary: plan.backendSummary, + details: [failureMessage], + }); + prepareWarningsByProviderIdRef.current.delete(plan.providerId); + commitChecks(nextChecks); + applyPrepareOutcome(nextChecks, failureMessage); } - checks = updateProviderCheck(checks, plan.providerId, { - status: plan.prepResult.status, - backendSummary: plan.backendSummary, - details: plan.prepResult.details, - }); - } - if (prepareRequestSeqRef.current === requestSeq) { - setPrepareChecks(checks); - } - if (prepareRequestSeqRef.current !== requestSeq) return; - const failureMessage = - getPrimaryProvisioningFailureDetail(checks) ?? 'Some selected providers need attention.'; - setPrepareState(anyFailure ? 'failed' : 'ready'); - setPrepareMessage( - anyFailure - ? failureMessage - : anyNotes - ? 'All selected providers are ready, with notes.' - : 'All selected providers are ready.' - ); - setPrepareWarnings(collectedWarnings); - } catch (error) { - if (prepareRequestSeqRef.current !== requestSeq) return; - const failureMessage = - error instanceof Error ? error.message : 'Failed to prepare selected providers'; - setPrepareState('failed'); - setPrepareWarnings([]); - setPrepareChecks(failIncompleteProviderChecks(checks, failureMessage)); - setPrepareMessage(failureMessage); - } + }) + ); })(); }, [ open, isLaunchMode, effectiveCwd, effectiveAnthropicRuntimeLimitContext, - prepareRequestSignature, - selectedProviderId, + prepareProviderInvalidationEpochById, + runtimeProviderStatusById, selectedMemberProviders, selectedModelChecksByProvider, selectedModelChecksByProviderSignature, @@ -2985,7 +3028,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen

- + + setProviderSettingsProviderId(providerId) + } + /> ) : null} @@ -3005,7 +3054,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen {effectivePrepare.message}

) : null} - + + setProviderSettingsProviderId(providerId) + } + /> {prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
{prepareWarnings.map((warning, index) => ( @@ -3043,6 +3098,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen checks={prepareChecks} className="mt-2" suppressDetailsMatching={effectivePrepare.message} + onOpenProviderSettings={(providerId) => + setProviderSettingsProviderId(providerId) + } /> ) : null} {prepareWarnings.length > 0 && prepareChecks.length === 0 ? ( @@ -3113,6 +3171,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
+ setProviderSettingsProviderId(providerId)} + providers={effectiveCliStatus?.providers ?? []} + projectPath={effectiveCwd || null} + disabled={isSubmitting || launchInFlight} + onProviderRuntimeChanged={invalidatePrepareProvider} + /> ); }; diff --git a/src/renderer/components/team/dialogs/ProvisioningProviderRuntimeSettingsDialog.tsx b/src/renderer/components/team/dialogs/ProvisioningProviderRuntimeSettingsDialog.tsx new file mode 100644 index 00000000..ca040f48 --- /dev/null +++ b/src/renderer/components/team/dialogs/ProvisioningProviderRuntimeSettingsDialog.tsx @@ -0,0 +1,198 @@ +import React, { useCallback, useMemo, useState } from 'react'; + +import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog'; +import { + getProviderTerminalCommand, + getProviderTerminalLogoutCommand, +} from '@renderer/components/runtime/providerTerminalCommands'; +import { TerminalModal } from '@renderer/components/terminal/TerminalModal'; +import { useStore } from '@renderer/store'; +import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus'; +import { getRuntimeDisplayName } from '@renderer/utils/runtimeDisplayName'; +import { useShallow } from 'zustand/react/shallow'; + +import { getProvisioningProviderLabel } from './ProvisioningProviderStatusList'; + +import type { CliProviderId, CliProviderStatus } from '@shared/types'; + +interface ProvisioningProviderRuntimeSettingsDialogProps { + readonly openProviderId: CliProviderId | null; + readonly onOpenProviderIdChange: (providerId: CliProviderId | null) => void; + readonly providers: CliProviderStatus[]; + readonly projectPath?: string | null; + readonly disabled?: boolean; + readonly onProviderRuntimeChanged?: (providerId: CliProviderId) => void; +} + +type ProviderTerminalState = { + providerId: CliProviderId; + action: 'login' | 'logout'; +}; + +export const ProvisioningProviderRuntimeSettingsDialog = ({ + openProviderId, + onOpenProviderIdChange, + providers, + projectPath = null, + disabled = false, + onProviderRuntimeChanged, +}: ProvisioningProviderRuntimeSettingsDialogProps): React.JSX.Element | null => { + const [providerTerminal, setProviderTerminal] = useState(null); + const { + appConfig, + bootstrapCliStatus, + cliProviderStatusLoading, + cliStatus, + cliStatusLoading, + codexRuntimeStatus, + codexRuntimeStatusLoading, + fetchCliProviderStatus, + fetchCliStatus, + installCodexRuntime, + invalidateCliStatus, + multimodelEnabled, + updateConfig, + } = useStore( + useShallow((s) => ({ + appConfig: s.appConfig, + bootstrapCliStatus: s.bootstrapCliStatus, + cliProviderStatusLoading: s.cliProviderStatusLoading, + cliStatus: s.cliStatus, + cliStatusLoading: s.cliStatusLoading, + codexRuntimeStatus: s.codexRuntimeStatus, + codexRuntimeStatusLoading: s.codexRuntimeStatusLoading, + fetchCliProviderStatus: s.fetchCliProviderStatus, + fetchCliStatus: s.fetchCliStatus, + installCodexRuntime: s.installCodexRuntime, + invalidateCliStatus: s.invalidateCliStatus, + multimodelEnabled: s.appConfig?.general?.multimodelEnabled ?? true, + updateConfig: s.updateConfig, + })) + ); + + const selectedProviderId = useMemo(() => { + if (!openProviderId || providers.length === 0) { + return null; + } + + return providers.some((provider) => provider.providerId === openProviderId) + ? openProviderId + : (providers[0]?.providerId ?? null); + }, [openProviderId, providers]); + + const handleProviderBackendChange = useCallback( + async (providerId: CliProviderId, backendId: string) => { + if (providerId !== 'gemini' && providerId !== 'codex') { + return; + } + + const currentBackends = appConfig?.runtime?.providerBackends ?? { + gemini: 'auto' as const, + codex: 'codex-native' as const, + }; + + await updateConfig('runtime', { + providerBackends: { + ...currentBackends, + [providerId]: backendId, + }, + }); + + try { + await fetchCliProviderStatus(providerId, { silent: false }); + onProviderRuntimeChanged?.(providerId); + } catch { + throw new Error('Runtime updated, but failed to refresh provider status.'); + } + }, + [ + appConfig?.runtime?.providerBackends, + fetchCliProviderStatus, + onProviderRuntimeChanged, + updateConfig, + ] + ); + + const handleProviderRefresh = useCallback( + async (providerId: CliProviderId) => { + await fetchCliProviderStatus(providerId, { silent: false }); + onProviderRuntimeChanged?.(providerId); + }, + [fetchCliProviderStatus, onProviderRuntimeChanged] + ); + + const refreshRuntimeAfterTerminal = useCallback(() => { + void (async () => { + await invalidateCliStatus(); + await refreshCliStatusForCurrentMode({ + multimodelEnabled, + bootstrapCliStatus, + fetchCliStatus, + }); + })(); + }, [bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, multimodelEnabled]); + + const activeTerminalProvider = providerTerminal + ? (providers.find((provider) => provider.providerId === providerTerminal.providerId) ?? null) + : null; + const providerTerminalCommand = + providerTerminal && activeTerminalProvider + ? providerTerminal.action === 'login' + ? getProviderTerminalCommand(activeTerminalProvider) + : getProviderTerminalLogoutCommand(activeTerminalProvider) + : null; + + if (!selectedProviderId) { + return null; + } + + return ( + <> + { + if (!open) { + onOpenProviderIdChange(null); + } + }} + providers={providers} + projectPath={projectPath} + initialProviderId={selectedProviderId} + providerStatusLoading={cliProviderStatusLoading} + disabled={disabled || cliStatusLoading || !cliStatus?.binaryPath} + codexRuntimeStatus={codexRuntimeStatus} + codexRuntimeStatusLoading={codexRuntimeStatusLoading} + onInstallCodexRuntime={() => installCodexRuntime()} + onSelectBackend={handleProviderBackendChange} + onRefreshProvider={handleProviderRefresh} + onRequestLogin={(providerId) => setProviderTerminal({ providerId, action: 'login' })} + /> + {providerTerminal && cliStatus?.binaryPath && ( + { + setProviderTerminal(null); + onProviderRuntimeChanged?.(providerTerminal.providerId); + refreshRuntimeAfterTerminal(); + }} + onExit={() => { + onProviderRuntimeChanged?.(providerTerminal.providerId); + refreshRuntimeAfterTerminal(); + }} + autoCloseOnSuccessMs={3000} + successMessage={ + providerTerminal.action === 'login' ? 'Authentication updated' : 'Provider logged out' + } + failureMessage={ + providerTerminal.action === 'login' ? 'Authentication failed' : 'Logout failed' + } + /> + )} + + ); +}; diff --git a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx index f583cd1c..84eb0958 100644 --- a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx +++ b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { formatProviderBackendLabel } from '@renderer/utils/providerBackendIdentity'; import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog'; -import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react'; +import { AlertTriangle, CheckCircle2, Loader2, SlidersHorizontal } from 'lucide-react'; import type { CliProviderStatus, TeamProviderId } from '@shared/types'; @@ -133,6 +133,21 @@ export function failIncompleteProviderChecks( ); } +export function getProvisioningProviderProgressMessage( + providerIds: readonly TeamProviderId[], + totalProviderCount: number +): string { + if (providerIds.length === 0 || providerIds.length === totalProviderCount) { + return 'Checking selected providers in parallel...'; + } + + if (providerIds.length === 1) { + return `Checking ${getProvisioningProviderLabel(providerIds[0])} provider...`; + } + + return `Checking ${providerIds.map(getProvisioningProviderLabel).join(', ')} providers...`; +} + type ProvisioningDetailSummary = | 'CLI binary missing' | 'OpenCode runtime missing' @@ -667,14 +682,49 @@ const StatusIcon = ({ status }: { status: ProvisioningProviderCheckStatus }): Re return ; }; +function getProvisioningProviderSettingsActionLabel( + check: ProvisioningProviderCheck +): string | null { + if (check.status !== 'notes' && check.status !== 'failed') { + return null; + } + + const details = getPublicProvisioningDetails(check.details); + const combined = [check.backendSummary ?? '', ...details].join('\n').toLowerCase(); + if (!combined.trim()) { + return null; + } + + const hasActionableProviderSetupDetail = + combined.includes('auth required') || + combined.includes('authentication required') || + combined.includes('not authenticated') || + combined.includes('not logged in') || + combined.includes('provider is not configured for runtime use') || + combined.includes('connect a chatgpt account') || + combined.includes('connected chatgpt account') || + combined.includes('reconnect chatgpt') || + combined.includes('openai_api_key') || + combined.includes('codex_api_key') || + combined.includes('anthropic_api_key') || + combined.includes('gemini_api_key') || + combined.includes('api key mode is selected'); + + return hasActionableProviderSetupDetail + ? `Open ${getProvisioningProviderLabel(check.providerId)} settings` + : null; +} + export const ProvisioningProviderStatusList = ({ checks, className = '', suppressDetailsMatching, + onOpenProviderSettings, }: { checks: ProvisioningProviderCheck[]; className?: string; suppressDetailsMatching?: string | null; + onOpenProviderSettings?: (providerId: TeamProviderId) => void; }): React.JSX.Element | null => { if (checks.length === 0) { return null; @@ -687,6 +737,9 @@ export const ProvisioningProviderStatusList = ({ const visibleDetails = getPublicProvisioningDetails(check.details).filter( (detail) => detail.trim() !== suppressDetailsMatchingTrimmed ); + const settingsActionLabel = onOpenProviderSettings + ? getProvisioningProviderSettingsActionLabel(check) + : null; return (
@@ -712,6 +765,22 @@ export const ProvisioningProviderStatusList = ({ ))}
) : null} + {settingsActionLabel ? ( +
+ +
+ ) : null} ); })} diff --git a/src/renderer/components/team/dialogs/editTeamRuntimeChanges.ts b/src/renderer/components/team/dialogs/editTeamRuntimeChanges.ts index eb300d43..58df9dca 100644 --- a/src/renderer/components/team/dialogs/editTeamRuntimeChanges.ts +++ b/src/renderer/components/team/dialogs/editTeamRuntimeChanges.ts @@ -1,5 +1,6 @@ import { isTeamEffortLevel } from '@shared/utils/effortLevels'; import { migrateProviderBackendId } from '@shared/utils/providerBackend'; +import { normalizeTeamMemberMcpPolicy } from '@shared/utils/teamMemberMcpPolicy'; import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes'; @@ -19,6 +20,7 @@ function normalizeRestartSensitiveMemberContract(member: { providerId?: string; model?: string; effort?: string; + mcpPolicy?: unknown; }): { role?: string; workflow?: string; @@ -26,6 +28,7 @@ function normalizeRestartSensitiveMemberContract(member: { model?: string; effort?: EffortLevel; isolation?: 'worktree'; + mcpPolicy?: ReturnType; } { const role = member.role?.trim() || undefined; const workflow = member.workflow?.trim() || undefined; @@ -33,7 +36,8 @@ function normalizeRestartSensitiveMemberContract(member: { const model = member.model?.trim() || undefined; const effort = isTeamEffortLevel(member.effort) ? member.effort : undefined; const isolation = member.isolation === 'worktree' ? 'worktree' : undefined; - return { role, workflow, providerId, model, effort, isolation }; + const mcpPolicy = normalizeTeamMemberMcpPolicy(member.mcpPolicy); + return { role, workflow, providerId, model, effort, isolation, mcpPolicy }; } export function getMemberRuntimeContractKey(member: { @@ -43,6 +47,7 @@ export function getMemberRuntimeContractKey(member: { model?: string; effort?: string; isolation?: string; + mcpPolicy?: unknown; }): string { return JSON.stringify(normalizeRestartSensitiveMemberContract(member)); } @@ -76,7 +81,8 @@ export function getMembersRequiringRuntimeRestart(params: { previousRuntime.providerId !== nextRuntime.providerId || previousRuntime.model !== nextRuntime.model || previousRuntime.effort !== nextRuntime.effort || - previousRuntime.isolation !== nextRuntime.isolation + previousRuntime.isolation !== nextRuntime.isolation || + JSON.stringify(previousRuntime.mcpPolicy) !== JSON.stringify(nextRuntime.mcpPolicy) ) { membersToRestart.push(previousMember.name); } @@ -135,6 +141,7 @@ function normalizeEditableMemberSnapshot(member: { effort?: string; isolation?: string; fastMode?: string; + mcpPolicy?: unknown; removedAt?: number | string | null; }): { name: string; @@ -146,6 +153,7 @@ function normalizeEditableMemberSnapshot(member: { effort?: EffortLevel; isolation?: 'worktree'; fastMode?: TeamFastMode; + mcpPolicy?: ReturnType; } | null { if (member.removedAt) { return null; diff --git a/src/renderer/components/team/dialogs/providerPreparePlans.ts b/src/renderer/components/team/dialogs/providerPreparePlans.ts new file mode 100644 index 00000000..3d987b55 --- /dev/null +++ b/src/renderer/components/team/dialogs/providerPreparePlans.ts @@ -0,0 +1,111 @@ +import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey'; +import { + getProviderPrepareCachedSnapshot, + type ProviderPrepareDiagnosticsCachedSnapshot, + type ProviderPrepareDiagnosticsModelResult, +} from './providerPrepareDiagnostics'; +import { + buildProviderPrepareModelChecksSignature, + buildProviderPrepareRequestSignature, + buildProviderPrepareRuntimeStatusSignature, +} from './providerPrepareRequestSignature'; +import { getShortLivedProviderPrepareModelResults } from './providerPrepareShortLivedCache'; + +import type { + CliProviderStatus, + TeamProviderId, + TeamProvisioningModelCheckRequest, +} from '@shared/types'; + +type RuntimeProviderStatusById = ReadonlyMap; + +export interface ProviderPreparePlan { + providerId: TeamProviderId; + selectedModelChecks: TeamProvisioningModelCheckRequest[]; + selectedModelIds: string[]; + backendSummary: string | null; + runtimeStatusSignature: string; + modelChecksSignature: string; + requestSignature: string; + cacheKey: string; + cachedModelResultsById: Record; + cachedSnapshot: ProviderPrepareDiagnosticsCachedSnapshot; +} + +export function buildProviderPreparePlans({ + cwd, + providerIds, + selectedModelChecksByProvider, + backendSummaryByProvider, + limitContext, + runtimeProviderStatusById, + cachedModelResultsByCacheKey, +}: { + cwd: string; + providerIds: readonly TeamProviderId[]; + selectedModelChecksByProvider: ReadonlyMap< + TeamProviderId, + readonly TeamProvisioningModelCheckRequest[] + >; + backendSummaryByProvider: ReadonlyMap; + limitContext: boolean; + runtimeProviderStatusById: RuntimeProviderStatusById; + cachedModelResultsByCacheKey: ReadonlyMap< + string, + Record + >; +}): ProviderPreparePlan[] { + return providerIds.map((providerId) => { + const selectedModelChecks = [...(selectedModelChecksByProvider.get(providerId) ?? [])]; + const selectedModelIds = selectedModelChecks.map((check) => check.model); + const backendSummary = backendSummaryByProvider.get(providerId) ?? null; + const runtimeStatusSignature = buildProviderPrepareRuntimeStatusSignature( + [providerId], + runtimeProviderStatusById + ); + const modelChecksSignature = buildProviderPrepareModelChecksSignature( + new Map([[providerId, selectedModelChecks]]) + ); + const requestSignature = buildProviderPrepareRequestSignature({ + cwd, + selectedProviderId: providerId, + selectedModel: '', + selectedMemberProviders: [providerId], + limitContext, + runtimeStatusSignature, + modelChecksSignature, + }); + const cacheKey = buildProviderPrepareModelCacheKey({ + cwd, + providerId, + backendSummary, + limitContext, + runtimeStatusSignature, + modelChecksSignature, + }); + const cachedModelResultsById = { + ...getShortLivedProviderPrepareModelResults({ + providerId, + cacheKey, + }), + ...(cachedModelResultsByCacheKey.get(cacheKey) ?? {}), + }; + + return { + providerId, + selectedModelChecks, + selectedModelIds, + backendSummary, + runtimeStatusSignature, + modelChecksSignature, + requestSignature, + cacheKey, + cachedModelResultsById, + cachedSnapshot: getProviderPrepareCachedSnapshot({ + providerId, + selectedModelIds, + cachedModelResultsById, + }), + }; + }); +} diff --git a/src/renderer/components/team/dialogs/providerPrepareRequestSignature.ts b/src/renderer/components/team/dialogs/providerPrepareRequestSignature.ts index 39a6bd64..473126c5 100644 --- a/src/renderer/components/team/dialogs/providerPrepareRequestSignature.ts +++ b/src/renderer/components/team/dialogs/providerPrepareRequestSignature.ts @@ -47,7 +47,6 @@ function normalizeModelChecks( export function buildProviderPrepareMembersSignature(members: readonly MemberDraft[]): string { return JSON.stringify( members.map((member) => ({ - id: member.id, providerId: member.providerId ?? null, model: member.model?.trim() || null, effort: member.effort ?? null, @@ -120,16 +119,6 @@ export function buildProviderPrepareRuntimeStatusSignature( : null, } : null, - availableBackends: (provider?.availableBackends ?? []) - .map((backend) => ({ - id: backend.id, - available: backend.available, - selectable: backend.selectable, - state: backend.state ?? null, - recommended: backend.recommended, - audience: backend.audience ?? null, - })) - .sort((left, right) => left.id.localeCompare(right.id)), }; }) ); diff --git a/src/renderer/components/team/members/LeadModelRow.tsx b/src/renderer/components/team/members/LeadModelRow.tsx index 5f644418..634c984c 100644 --- a/src/renderer/components/team/members/LeadModelRow.tsx +++ b/src/renderer/components/team/members/LeadModelRow.tsx @@ -9,9 +9,7 @@ import { import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector'; import { LimitContextCheckbox } from '@renderer/components/team/dialogs/LimitContextCheckbox'; import { - formatTeamModelSummary, getProviderScopedTeamModelLabel, - getTeamEffortLabel, getTeamProviderLabel, TeamModelSelector, } from '@renderer/components/team/dialogs/TeamModelSelector'; @@ -113,11 +111,6 @@ export const LeadModelRow = ({ const contextLimitDisabled = disableAnthropicContextLimit ?? (providerId === 'anthropic' && isAnthropicHaikuTeamModel(model)); - const runtimeSummary = formatTeamModelSummary(providerId, model, effort); - const runtimeMeta = [ - effort || providerId === 'anthropic' ? `Effort ${getTeamEffortLabel(effort ?? '')}` : null, - providerId === 'anthropic' ? (limitContext ? '200K context' : '1M-capable context') : null, - ].filter((item): item is string => Boolean(item)); useEffect(() => { if (hasActiveProviderNotice && !modelExpanded) { @@ -196,12 +189,6 @@ export const LeadModelRow = ({ {hasModelIssue ? : null} {hasModelAdvisory ? : null} - {runtimeMeta.length > 0 ? ( -

- {runtimeMeta.join(' · ')} - . {runtimeSummary} -

- ) : null} {hasWarnings ? ( diff --git a/src/renderer/components/team/members/MemberDraftRow.test.tsx b/src/renderer/components/team/members/MemberDraftRow.test.tsx index 874ae59d..b0a0db0f 100644 --- a/src/renderer/components/team/members/MemberDraftRow.test.tsx +++ b/src/renderer/components/team/members/MemberDraftRow.test.tsx @@ -178,27 +178,205 @@ describe('MemberDraftRow', () => { }); }); - it('renders the workflow control as an icon-only button with tooltip text', () => { + it('renders workflow and MCP row controls as icon-only buttons with tooltip text', () => { const { host, root } = renderMemberDraftRow({ showWorkflow: true, onWorkflowChange: () => undefined, + onMcpPolicyChange: () => undefined, }); const workflowButton = host.querySelector( 'button[aria-label="Add teammate workflow"]' )!; + const mcpButton = Array.from(host.querySelectorAll('button')).find( + (button) => + button.getAttribute('aria-label') === + "MCP inherit: Control this member's MCP inheritance policy" + )!; + const removeButton = host.querySelector( + 'button[aria-label="Remove alice"]' + )!; expect(workflowButton).toBeTruthy(); expect(workflowButton.textContent).not.toContain('Workflow'); expect(workflowButton.closest('[title]')?.getAttribute('title')).toBe('Add teammate workflow'); - expect(host.textContent).not.toContain('Workflow (optional)'); + expect(workflowButton.getAttribute('aria-expanded')).toBe('false'); + + expect(mcpButton).toBeTruthy(); + expect(mcpButton.textContent).not.toContain('MCP'); + expect(mcpButton.textContent).not.toContain('inherit'); + const mcpTooltipWrapper = mcpButton.closest('[title]'); + const mcpTooltipContent = Array.from(mcpTooltipWrapper?.children ?? []).find( + (element) => element.getAttribute('aria-hidden') === 'true' + ); + expect(mcpTooltipWrapper?.getAttribute('title')).toBe( + "MCP inherit: Control this member's MCP inheritance policy" + ); + expect(mcpTooltipContent?.getAttribute('class')).toContain( + 'group-hover/hover-tooltip:opacity-100' + ); + expect(mcpButton.getAttribute('aria-expanded')).toBe('false'); + expect( + workflowButton.compareDocumentPosition(mcpButton) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + expect( + mcpButton.compareDocumentPosition(removeButton) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); act(() => { workflowButton.click(); + mcpButton.click(); }); expect(workflowButton.getAttribute('aria-expanded')).toBe('true'); + expect(mcpButton.getAttribute('aria-expanded')).toBe('true'); + expect(mcpButton.className).toContain('border-sky-400/45'); + expect(mcpTooltipWrapper?.getAttribute('title')).toBeNull(); + expect(mcpTooltipContent?.getAttribute('class')).not.toContain( + 'group-hover/hover-tooltip:opacity-100' + ); expect(host.textContent).toContain('Workflow (optional)'); + expect(host.textContent).toContain('MCP mode'); + + act(() => { + root.unmount(); + }); + }); + + it.each([ + { + label: 'inherit lead', + mcpPolicy: undefined, + ariaLabel: "MCP inherit: Control this member's MCP inheritance policy", + highlightedBeforeClick: false, + }, + { + label: 'agent teams mcp', + mcpPolicy: { mode: 'appOnly' as const }, + ariaLabel: "Agent Teams MCP: Control this member's MCP inheritance policy", + highlightedBeforeClick: true, + }, + { + label: 'scope inheritance', + mcpPolicy: { + mode: 'inheritScopes' as const, + scopes: { user: true, project: false, local: true }, + }, + ariaLabel: "MCP scopes: Control this member's MCP inheritance policy", + highlightedBeforeClick: true, + }, + { + label: 'strict allowlist', + mcpPolicy: { + mode: 'strictAllowlist' as const, + scopes: { user: true, project: true, local: false }, + serverNames: ['github', 'linear'], + }, + ariaLabel: "MCP 2: Control this member's MCP inheritance policy", + highlightedBeforeClick: true, + }, + ])( + 'keeps MCP control state correct across $label settings in the row fixture e2e', + ({ mcpPolicy, ariaLabel, highlightedBeforeClick }) => { + const { host, root } = renderMemberDraftRow({ + member: createMemberDraft({ + id: 'member-1', + name: 'alice', + roleSelection: 'developer', + providerId: 'anthropic', + model: 'opus', + mcpPolicy, + }), + onMcpPolicyChange: () => undefined, + }); + + const mcpButton = Array.from(host.querySelectorAll('button')).find( + (button) => button.getAttribute('aria-label') === ariaLabel + )!; + const tooltipWrapper = mcpButton.closest('[title]'); + const tooltipContent = Array.from(tooltipWrapper?.children ?? []).find( + (element) => element.getAttribute('aria-hidden') === 'true' + ); + + expect(mcpButton).toBeTruthy(); + expect(mcpButton.textContent).not.toContain('MCP'); + expect(mcpButton.className.includes('border-sky-400/45')).toBe(highlightedBeforeClick); + expect(tooltipWrapper?.getAttribute('title')).toBe(ariaLabel); + expect(tooltipContent?.getAttribute('class')).toContain( + 'group-hover/hover-tooltip:opacity-100' + ); + + act(() => { + mcpButton.click(); + }); + + expect(mcpButton.getAttribute('aria-expanded')).toBe('true'); + expect(mcpButton.className).toContain('border-sky-400/45'); + expect(tooltipWrapper?.getAttribute('title')).toBeNull(); + expect(tooltipContent?.getAttribute('class')).not.toContain( + 'group-hover/hover-tooltip:opacity-100' + ); + expect(host.textContent).toContain('MCP mode'); + + act(() => { + root.unmount(); + }); + } + ); + + it('locks MCP controls when Agent Teams MCP master mode is enabled', () => { + const onMcpPolicyChange = vi.fn(); + const { host, root } = renderMemberDraftRow({ + member: createMemberDraft({ + id: 'member-1', + name: 'alice', + roleSelection: 'developer', + providerId: 'anthropic', + model: 'opus', + mcpPolicy: { + mode: 'strictAllowlist', + scopes: { user: true, project: true, local: true }, + serverNames: ['github'], + }, + }), + onMcpPolicyChange, + agentTeamsMcpLocked: true, + }); + + const mcpButton = Array.from(host.querySelectorAll('button')).find( + (button) => + button.getAttribute('aria-label') === + "Agent Teams MCP: Control this member's MCP inheritance policy" + )!; + + expect(mcpButton).toBeTruthy(); + expect(mcpButton.className).toContain('border-amber-300/50'); + expect(mcpButton.querySelector('.bg-amber-300')).toBeTruthy(); + + act(() => { + mcpButton.click(); + }); + + expect(host.textContent).toContain('MCP mode'); + expect(host.textContent).toContain('Agent Teams MCP'); + expect(host.textContent).toContain( + 'Agent Teams MCP only is enabled for all teammates. This teammate will launch with only the Agent Teams server.' + ); + + const mcpModeTrigger = host.querySelector('#member-member-1-mcp-mode')!; + const scopeCheckboxes = Array.from( + host.querySelectorAll('input[type="checkbox"]') + ); + + expect(mcpModeTrigger.disabled).toBe(true); + expect(scopeCheckboxes).toHaveLength(3); + expect(scopeCheckboxes.every((checkbox) => checkbox.disabled)).toBe(true); + + act(() => { + scopeCheckboxes[0]?.click(); + }); + + expect(onMcpPolicyChange).not.toHaveBeenCalled(); act(() => { root.unmount(); diff --git a/src/renderer/components/team/members/MemberDraftRow.tsx b/src/renderer/components/team/members/MemberDraftRow.tsx index 6b75502a..9ab071f4 100644 --- a/src/renderer/components/team/members/MemberDraftRow.tsx +++ b/src/renderer/components/team/members/MemberDraftRow.tsx @@ -6,7 +6,6 @@ import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLev import { formatTeamModelSummary, getProviderScopedTeamModelLabel, - getTeamEffortLabel, getTeamProviderLabel, TeamModelSelector, } from '@renderer/components/team/dialogs/TeamModelSelector'; @@ -17,6 +16,13 @@ import { HoverTooltip } from '@renderer/components/ui/hover-tooltip'; import { Input } from '@renderer/components/ui/input'; import { Label } from '@renderer/components/ui/label'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@renderer/components/ui/select'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer'; @@ -25,12 +31,17 @@ import { cn } from '@renderer/lib/utils'; import { reconcileChips, removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { isAnthropicSonnetOneMillionContextTeamModel } from '@renderer/utils/teamModelCatalog'; import { getMemberColorByName } from '@shared/constants/memberColors'; +import { + normalizeTeamMemberMcpPolicy, + resolveTeamMemberMcpScopes, +} from '@shared/utils/teamMemberMcpPolicy'; import { AlertTriangle, ChevronDown, ChevronRight, GitBranch, Info, + Plug, RotateCcw, Trash2, Workflow as WorkflowIcon, @@ -39,7 +50,12 @@ import { import type { MemberDraft } from './membersEditorTypes'; import type { InlineChip } from '@renderer/types/inlineChip'; import type { MentionSuggestion } from '@renderer/types/mention'; -import type { EffortLevel, TeamProviderId } from '@shared/types'; +import type { + EffortLevel, + TeamMemberMcpMode, + TeamMemberMcpPolicy, + TeamProviderId, +} from '@shared/types'; interface MemberDraftRowProps { member: MemberDraft; @@ -92,6 +108,8 @@ interface MemberDraftRowProps { showWorktreeIsolationControls?: boolean; worktreeIsolationDisabledReason?: string | null; onWorktreeIsolationChange?: (id: string, enabled: boolean) => void; + onMcpPolicyChange?: (id: string, policy: TeamMemberMcpPolicy | undefined) => void; + agentTeamsMcpLocked?: boolean; lockedModelAction?: { label: string; description?: string; @@ -145,6 +163,8 @@ export const MemberDraftRow = ({ showWorktreeIsolationControls = false, worktreeIsolationDisabledReason, onWorktreeIsolationChange, + onMcpPolicyChange, + agentTeamsMcpLocked = false, lockedModelAction, }: MemberDraftRowProps): React.JSX.Element => { const { isLight } = useTheme(); @@ -154,6 +174,7 @@ export const MemberDraftRow = ({ ); const [workflowExpanded, setWorkflowExpanded] = useState(false); const [modelExpanded, setModelExpanded] = useState(false); + const [mcpExpanded, setMcpExpanded] = useState(false); // Pre-warm file list cache when workflow section is expanded useFileListCacheWarmer(workflowExpanded && projectPath ? projectPath : null); @@ -203,6 +224,88 @@ export const MemberDraftRow = ({ [chips, member.id, onWorkflowChange, onWorkflowChipsChange, workflowDraft] ); + const effectiveMcpPolicy = useMemo( + () => (agentTeamsMcpLocked ? { mode: 'appOnly' } : member.mcpPolicy), + [agentTeamsMcpLocked, member.mcpPolicy] + ); + const mcpMode: TeamMemberMcpMode = effectiveMcpPolicy?.mode ?? 'inheritLead'; + const mcpScopes = useMemo( + () => resolveTeamMemberMcpScopes(effectiveMcpPolicy), + [effectiveMcpPolicy] + ); + const mcpServerNames = useMemo( + () => effectiveMcpPolicy?.serverNames ?? [], + [effectiveMcpPolicy?.serverNames] + ); + const mcpButtonLabel = + mcpMode === 'appOnly' + ? 'Agent Teams MCP' + : mcpMode === 'strictAllowlist' + ? `MCP ${mcpServerNames.length || 'strict'}` + : mcpMode === 'inheritScopes' + ? 'MCP scopes' + : 'MCP inherit'; + const updateMcpPolicy = useCallback( + (policy: TeamMemberMcpPolicy | undefined) => { + if (agentTeamsMcpLocked) { + return; + } + onMcpPolicyChange?.(member.id, normalizeTeamMemberMcpPolicy(policy)); + }, + [agentTeamsMcpLocked, member.id, onMcpPolicyChange] + ); + const handleMcpModeChange = useCallback( + (mode: string) => { + if (mode === 'inheritLead') { + updateMcpPolicy(undefined); + return; + } + if (mode === 'appOnly') { + updateMcpPolicy({ mode: 'appOnly' }); + return; + } + if (mode === 'inheritScopes' || mode === 'strictAllowlist') { + updateMcpPolicy({ + mode, + scopes: mcpScopes, + ...(mode === 'strictAllowlist' && mcpServerNames.length > 0 + ? { serverNames: mcpServerNames } + : {}), + }); + } + }, + [mcpScopes, mcpServerNames, updateMcpPolicy] + ); + const updateMcpScope = useCallback( + (scope: 'user' | 'project' | 'local', enabled: boolean) => { + if (mcpMode !== 'inheritScopes' && mcpMode !== 'strictAllowlist') { + return; + } + updateMcpPolicy({ + mode: mcpMode, + scopes: { ...mcpScopes, [scope]: enabled }, + ...(mcpMode === 'strictAllowlist' && mcpServerNames.length > 0 + ? { serverNames: mcpServerNames } + : {}), + }); + }, + [mcpMode, mcpScopes, mcpServerNames, updateMcpPolicy] + ); + const updateMcpServerNames = useCallback( + (value: string) => { + const serverNames = value + .split(',') + .map((name) => name.trim()) + .filter(Boolean); + updateMcpPolicy({ + mode: 'strictAllowlist', + scopes: mcpScopes, + serverNames, + }); + }, + [mcpScopes, updateMcpPolicy] + ); + useEffect(() => { if ( onWorkflowChange && @@ -309,27 +412,21 @@ export const MemberDraftRow = ({ Boolean(message) ); const hasWarnings = warningMessages.length > 0 || showSonnetExtraUsageWarning; - const anthropicContextModeLabel = limitContext - ? '200K limit enabled' - : '1M-capable context allowed'; + const anthropicContextModeLabel = limitContext ? '200K limit enabled' : 'default context setting'; const workflowTooltipText = workflowDraft.value.trim() ? 'Edit teammate workflow' : 'Add teammate workflow'; + const mcpTooltipText = `${mcpButtonLabel}: Control this member's MCP inheritance policy`; + const mcpLockedInfoText = + 'Agent Teams MCP only is enabled for all teammates. This teammate will launch with only the Agent Teams server.'; + const mcpSettingInfoText = agentTeamsMcpLocked + ? mcpLockedInfoText + : 'Agent Teams MCP launches this teammate with only the Agent Teams server. Scope and allowlist modes apply only to this teammate launch.'; const runtimeSummary = formatTeamModelSummary( effectiveProviderId, effectiveModel?.trim() ?? '', effectiveEffort ); - const runtimeMeta = [ - effectiveEffort || effectiveProviderId === 'anthropic' - ? `Effort ${getTeamEffortLabel(effectiveEffort ?? '')}` - : null, - effectiveProviderId === 'anthropic' - ? limitContext - ? '200K context' - : '1M-capable context' - : null, - ].filter((item): item is string => Boolean(item)); return (
- {showWorkflow && onWorkflowChange ? ( - - - - ) : null}
: null} - {runtimeMeta.length > 0 ? ( -

- {runtimeMeta.join(' · ')} - . {runtimeSummary} -

- ) : null} {modelTooltipText ? ( {modelTooltipText} @@ -520,6 +584,70 @@ export const MemberDraftRow = ({
) : null} + {showWorkflow && onWorkflowChange ? ( + + + + ) : null} + {onMcpPolicyChange ? ( + + + + ) : null} {hideActionButton ? null : isRemoved ? (
) : null} + {!isRemoved && onMcpPolicyChange && mcpExpanded ? ( +
+
+
+
+ + +
+
+
+ {(['user', 'project', 'local'] as const).map((scope) => ( + + ))} +
+ {mcpMode === 'strictAllowlist' ? ( +
+ + updateMcpServerNames(event.target.value)} + placeholder="github, sentry" + /> +
+ ) : null} + {mcpMode !== 'inheritLead' ? ( +

{mcpSettingInfoText}

+ ) : null} +
+
+
+
+ ) : null} {showWorkflow && onWorkflowChange && workflowExpanded ? (