feat(team): improve runtime bootstrap controls

This commit is contained in:
777genius 2026-05-19 22:39:13 +03:00
parent 98d11b260c
commit d5894c029d
70 changed files with 4387 additions and 799 deletions

View file

@ -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 -- <changed files>` for quick preflight on files you touched.

View file

@ -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

View file

@ -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 <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.

13
landing/AGENTS.md Normal file
View file

@ -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.

View file

@ -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"
}

View file

@ -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) =>

View file

@ -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);
});
}

View file

@ -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<typeof normalizeTeamMemberMcpPolicy>;
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<typeof normalizeTeamMemberMcpPolicy>;
}[];
} {
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<typeof normalizeTeamMemberMcpPolicy>;
}[] = [];
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),
});
}

View file

@ -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<string, unknown>;
}
export class McpConfigStateReader {
async readInstalled(projectPath?: string): Promise<InstalledMcpEntry[]> {
const entries: InstalledMcpEntry[] = [];
@ -23,6 +28,20 @@ export class McpConfigStateReader {
return entries;
}
async readConfigured(projectPath?: string): Promise<ConfiguredMcpEntry[]> {
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<Record<string, unknown> | null> {
const configPath = path.join(getHomeDir(), '.claude.json');
try {
@ -45,6 +64,15 @@ export class McpConfigStateReader {
config: Record<string, unknown> | null,
projectPath: string
): InstalledMcpEntry[] {
return this.readLocalConfiguredMcpServers(config, projectPath).map(
({ config: _config, ...entry }) => entry
);
}
private readLocalConfiguredMcpServers(
config: Record<string, unknown> | null,
projectPath: string
): ConfiguredMcpEntry[] {
const projects =
config && typeof config.projects === 'object' && config.projects
? (config.projects as Record<string, unknown>)
@ -53,7 +81,7 @@ export class McpConfigStateReader {
projects && typeof projects[projectPath] === 'object' && projects[projectPath]
? (projects[projectPath] as Record<string, unknown>)
: null;
return this.readMcpServersFromConfig(projectConfig?.mcpServers, 'local');
return this.readConfiguredMcpServersFromConfig(projectConfig?.mcpServers, 'local');
}
private async readProjectMcpServers(projectPath: string): Promise<InstalledMcpEntry[]> {
@ -61,6 +89,13 @@ export class McpConfigStateReader {
return this.readMcpServersFromFile(configPath, 'project');
}
private async readProjectConfiguredMcpServers(
projectPath: string
): Promise<ConfiguredMcpEntry[]> {
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<string, unknown>) : null;
if (!mcpServers) {
return [];
}
return Object.entries(mcpServers)
.filter((entry): entry is [string, Record<string, unknown>] => {
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<InstalledMcpEntry[]> {
return (await this.readConfiguredMcpServersFromFile(filePath, scope)).map(
({ config: _config, ...entry }) => entry
);
}
private async readConfiguredMcpServersFromFile(
filePath: string,
scope: 'user' | 'project'
): Promise<ConfiguredMcpEntry[]> {
try {
const raw = await fs.readFile(filePath, 'utf-8');
const json = JSON.parse(raw) as Record<string, unknown>;
return this.readMcpServersFromConfig(json.mcpServers, scope);
return this.readConfiguredMcpServersFromConfig(json.mcpServers, scope);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return [];

View file

@ -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<string>();

View file

@ -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,
});
};

View file

@ -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,
}))

View file

@ -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<string, unknown>;
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<string> {
async writeConfigFile(projectPath?: string, options?: WriteMcpConfigOptions): Promise<string>;
async writeConfigFile(projectPath?: string, mcpPolicy?: TeamMemberMcpPolicy): Promise<string>;
async writeConfigFile(
projectPath?: string,
optionsOrPolicy?: WriteMcpConfigOptions | TeamMemberMcpPolicy
): Promise<string> {
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<string, McpServerConfig> = {
[MCP_SERVER_NAME]: {
const generatedServers: Record<string, McpServerConfig> = 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<Record<string, McpServerConfig>> {
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<TeamMemberMcpScope, typeof entries>();
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<string, McpServerConfig> = 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<void> {
for (let attempt = 0; attempt <= MCP_CONFIG_REMOVE_RETRY_DELAYS_MS.length; attempt += 1) {

View file

@ -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 ??

View file

@ -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,

View file

@ -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<string, NativeAppManagedBootstrapSpec> = new Map()
nativeAppManagedBootstrapByMember: ReadonlyMap<string, NativeAppManagedBootstrapSpec> = new Map(),
mcpLaunchConfigByMember: ReadonlyMap<string, RuntimeBootstrapMemberMcpLaunchConfig> = new Map()
): RuntimeBootstrapSpec {
return {
version: 1,
@ -4611,7 +4628,9 @@ function buildDeterministicCreateBootstrapSpec(
}
: {}),
},
members: effectiveMembers.map((member) => ({
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() } : {}),
@ -4621,10 +4640,12 @@ function buildDeterministicCreateBootstrapSpec(
...(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<string, NativeAppManagedBootstrapSpec> = new Map()
nativeAppManagedBootstrapByMember: ReadonlyMap<string, NativeAppManagedBootstrapSpec> = new Map(),
mcpLaunchConfigByMember: ReadonlyMap<string, RuntimeBootstrapMemberMcpLaunchConfig> = new Map()
): RuntimeBootstrapSpec {
return {
version: 1,
@ -4666,7 +4688,9 @@ function buildDeterministicLaunchBootstrapSpec(
}
: {}),
},
members: effectiveMembers.map((member) => ({
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() } : {}),
@ -4676,10 +4700,12 @@ function buildDeterministicLaunchBootstrapSpec(
...(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<string>();
private readonly crashRepairedActivityIntervalsByTeam = new Set<string>();
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<Map<string, RuntimeBootstrapMemberMcpLaunchConfig>> {
const configs = new Map<string, RuntimeBootstrapMemberMcpLaunchConfig>();
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<void> {
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<typeof normalizeTeamMemberMcpPolicy>;
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<string>();
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.

View file

@ -77,7 +77,7 @@ function isOpenCodeRuntimeDeliveryCleanSessionRefreshDiagnostic(message: string)
);
}
function isInformationalOpenCodeRuntimeDeliveryDiagnostic(
export function isInformationalOpenCodeRuntimeDeliveryDiagnostic(
message: string | null | undefined
): boolean {
const normalized = message?.trim().toLowerCase();

View file

@ -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<string, string>;
} {
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<string, string>;
} {
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 (
<div className="mt-1 space-y-2">
@ -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(

View file

@ -0,0 +1,58 @@
import type { CliProviderStatus } from '@shared/types';
export interface ProviderTerminalCommand {
args: string[];
env?: Record<string, string>;
}
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],
};
}

View file

@ -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<string, string>;
} {
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<string, string>;
} {
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);

View file

@ -3306,6 +3306,7 @@ export const TeamDetailView = memo(function TeamDetailView({
providerId: entry.providerId,
model: entry.model,
effort: entry.effort,
mcpPolicy: entry.mcpPolicy,
});
}
setAddMemberDialogOpen(false);

View file

@ -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,

View file

@ -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,
}))
);
};

View file

@ -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<ScheduledIdleHandle>): 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<string | null>(null);
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
const [prepareChecks, setPrepareChecks] = useState<ProvisioningProviderCheck[]>([]);
const [prepareProviderInvalidationEpochById, setPrepareProviderInvalidationEpochById] = useState<
Partial<Record<TeamProviderId, number>>
>({});
const [providerSettingsProviderId, setProviderSettingsProviderId] =
useState<TeamProviderId | null>(null);
const prepareRequestSeqRef = useRef(0);
const prepareIdleHandleRef = useRef<ScheduledIdleHandle | null>(null);
const prepareIdleHandlesRef = useRef(new Set<ScheduledIdleHandle>());
const prepareUnmountGenerationRef = useRef(0);
const appliedDefaultProjectPathRef = useRef<string | null>(null);
const lastAutoDescriptionRef = useRef<string | null>(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<ProvisioningProviderCheck[]>([]);
const prepareMessageRef = useRef<string | null>(null);
const prepareModelResultsCacheRef = useRef(
new Map<string, Record<string, ProviderPrepareDiagnosticsModelResult>>()
);
const lastPrepareRequestSignatureRef = useRef<string | null>(null);
const lastPrepareProviderSignatureByIdRef = useRef(new Map<TeamProviderId, string>());
const pendingPrepareProviderSignatureByIdRef = useRef(new Map<TeamProviderId, string>());
const prepareProviderRequestSeqByIdRef = useRef(new Map<TeamProviderId, number>());
const prepareWarningsByProviderIdRef = useRef(new Map<TeamProviderId, string[]>());
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<TeamProviderId, TeamProvisioningModelCheckRequest[]>();
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<Record<TeamProviderId, Record<string, string>>> =
{};
const modelIssueReasonByProvider: Partial<Record<TeamProviderId, Record<string, string>>> = {};
@ -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,146 +992,60 @@ 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;
}
lastPrepareRequestSignatureRef.current = prepareRequestSignature;
const requestSeq = ++prepareRequestSeqRef.current;
const initialChecks = alignProvisioningChecks(
prepareChecksRef.current,
selectedMemberProviders
);
setPrepareState('loading');
setPrepareMessage('Checking selected providers in parallel...');
setPrepareWarnings([]);
setPrepareChecks(initialChecks);
// 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;
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) => {
const prepResult = await runProviderPrepareDiagnostics({
cwd: effectiveCwd,
providerId: plan.providerId,
selectedModelIds: plan.selectedModelIds,
selectedModelChecks: plan.selectedModelChecks,
prepareProvisioning: api.teams.prepareProvisioning,
limitContext: effectiveAnthropicRuntimeLimitContext,
cachedModelResultsById: plan.cachedModelResultsById,
onModelProgress: ({ status, details }) => {
checks = updateProviderCheck(checks, plan.providerId, {
status,
backendSummary: plan.backendSummary,
details,
});
if (prepareRequestSeqRef.current === requestSeq) {
setPrepareChecks(checks);
}
},
});
return { ...plan, prepResult };
})
);
let anyFailure = false;
let anyNotes = false;
const collectedWarnings: string[] = [];
for (const plan of providerResults) {
if (plan.prepResult.warnings.length > 0) {
anyNotes = true;
collectedWarnings.push(
...plan.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
)
);
storeShortLivedProviderPrepareModelResults({
providerId: plan.providerId,
cacheKey: plan.cacheKey,
modelResultsById: plan.prepResult.modelResultsById,
});
}
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 anyFailure = nextChecks.some((check) => check.status === 'failed');
const anyNotes =
selectedWarnings.length > 0 || nextChecks.some((check) => check.status === 'notes');
const failureMessage =
getPrimaryProvisioningFailureDetail(checks) ??
getPrimaryProvisioningFailureDetail(nextChecks) ??
'Some selected providers need attention.';
setPrepareState(anyFailure ? 'failed' : 'ready');
setPrepareMessage(
@ -1108,18 +1055,136 @@ export const CreateTeamDialog = ({
? 'All selected providers are ready, with notes.'
: 'All selected providers are ready.'
);
setPrepareWarnings(collectedWarnings);
};
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;
}
for (const plan of changedPlans) {
pendingPrepareProviderSignatureByIdRef.current.set(plan.providerId, plan.requestSignature);
}
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 () => {
await Promise.all(
runningPlans.map(async (plan) => {
try {
const prepResult = await runProviderPrepareDiagnostics({
cwd: effectiveCwd,
providerId: plan.providerId,
selectedModelIds: plan.selectedModelIds,
selectedModelChecks: plan.selectedModelChecks,
prepareProvisioning: api.teams.prepareProvisioning,
limitContext: effectiveAnthropicRuntimeLimitContext,
cachedModelResultsById: plan.cachedModelResultsById,
onModelProgress: ({ status, details }) => {
if (!isPlanCurrent(plan)) {
return;
}
const nextChecks = updateProviderCheck(
prepareChecksRef.current,
plan.providerId,
{
status,
backendSummary: plan.backendSummary,
details,
}
);
commitChecks(nextChecks);
applyPrepareOutcome(nextChecks, loadingMessage);
},
});
if (!isPlanCurrent(plan)) {
return;
}
prepareWarningsByProviderIdRef.current.set(
plan.providerId,
prepResult.warnings.map(
(warning) => `${getProviderLabel(plan.providerId)}: ${warning}`
)
);
prepareModelResultsCacheRef.current.set(
plan.cacheKey,
mergeReusableProviderPrepareModelResults(
prepareModelResultsCacheRef.current.get(plan.cacheKey),
prepResult.modelResultsById
)
);
storeShortLivedProviderPrepareModelResults({
providerId: plan.providerId,
cacheKey: plan.cacheKey,
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 (prepareRequestSeqRef.current !== requestSeq) return;
if (!isPlanCurrent(plan)) {
return;
}
const failureMessage =
error instanceof Error ? error.message : 'Failed to prepare selected providers';
setPrepareState('failed');
setPrepareWarnings([]);
setPrepareChecks(failIncompleteProviderChecks(checks, failureMessage));
setPrepareMessage(failureMessage);
const nextChecks = updateProviderCheck(prepareChecksRef.current, plan.providerId, {
status: 'failed',
backendSummary: plan.backendSummary,
details: [failureMessage],
});
prepareWarningsByProviderIdRef.current.delete(plan.providerId);
commitChecks(nextChecks);
applyPrepareOutcome(nextChecks, 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 = ({
</p>
</div>
</div>
<ProvisioningProviderStatusList checks={prepareChecks} className="mt-2" />
<ProvisioningProviderStatusList
checks={prepareChecks}
className="mt-2"
onOpenProviderSettings={(providerId) => setProviderSettingsProviderId(providerId)}
/>
</>
) : null}
@ -2402,7 +2471,11 @@ export const CreateTeamDialog = ({
{effectivePrepare.message}
</p>
) : null}
<ProvisioningProviderStatusList checks={prepareChecks} className="mt-1" />
<ProvisioningProviderStatusList
checks={prepareChecks}
className="mt-1"
onOpenProviderSettings={(providerId) => setProviderSettingsProviderId(providerId)}
/>
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
<div className="mt-0.5 space-y-0.5 pl-5">
{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 = ({
</div>
</DialogFooter>
</DialogContent>
<ProvisioningProviderRuntimeSettingsDialog
openProviderId={providerSettingsProviderId}
onOpenProviderIdChange={(providerId) => setProviderSettingsProviderId(providerId)}
providers={effectiveCliStatus?.providers ?? []}
projectPath={effectiveCwd || null}
disabled={isSubmitting}
onProviderRuntimeChanged={invalidatePrepareProvider}
/>
</Dialog>
);
};

View file

@ -478,6 +478,7 @@ export const EditTeamDialog = ({
model: member.model,
effort: member.effort,
isolation: member.isolation,
mcpPolicy: member.mcpPolicy,
})) as ResolvedTeamMember[],
});

View file

@ -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<string | null>(null);
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
const [prepareChecks, setPrepareChecks] = useState<ProvisioningProviderCheck[]>([]);
const [prepareProviderInvalidationEpochById, setPrepareProviderInvalidationEpochById] = useState<
Partial<Record<TeamProviderId, number>>
>({});
const [providerSettingsProviderId, setProviderSettingsProviderId] =
useState<TeamProviderId | null>(null);
const prepareRequestSeqRef = useRef(0);
const appliedDefaultProjectPathRef = useRef<string | null>(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<ProvisioningProviderCheck[]>([]);
const prepareMessageRef = useRef<string | null>(null);
const prepareModelResultsCacheRef = useRef(
new Map<string, Record<string, ProviderPrepareDiagnosticsModelResult>>()
);
const lastPrepareRequestSignatureRef = useRef<string | null>(null);
const lastPrepareProviderSignatureByIdRef = useRef(new Map<TeamProviderId, string>());
const prepareProviderRequestSeqByIdRef = useRef(new Map<TeamProviderId, number>());
const prepareWarningsByProviderIdRef = useRef(new Map<TeamProviderId, string[]>());
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<Record<TeamProviderId, Record<string, string>>> =
{};
const modelIssueReasonByProvider: Partial<Record<TeamProviderId, Record<string, string>>> = {};
@ -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;
}
lastPrepareRequestSignatureRef.current = prepareRequestSignature;
const requestSeq = ++prepareRequestSeqRef.current;
const initialChecks = alignProvisioningChecks(
prepareChecksRef.current,
selectedMemberProviders
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.'
);
setPrepareState('loading');
setPrepareMessage('Checking selected providers in parallel...');
setPrepareWarnings([]);
setPrepareChecks(initialChecks);
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) {
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);
}
if (prepareRequestSeqRef.current === requestSeq) {
setPrepareChecks(checks);
commitChecks(checks);
applyPrepareOutcome(
checks,
changedPlans.length > 0
? loadingMessage
: (prepareMessageRef.current ??
getProvisioningProviderProgressMessage([], selectedMemberProviders.length))
);
if (changedPlans.length === 0) {
return;
}
const providerResults = await Promise.all(
providerPlans.map(async (plan) => {
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 () => {
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,
});
}
checks = updateProviderCheck(checks, plan.providerId, {
status: plan.prepResult.status,
const nextChecks = updateProviderCheck(prepareChecksRef.current, plan.providerId, {
status: prepResult.status,
backendSummary: plan.backendSummary,
details: plan.prepResult.details,
details: 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);
commitChecks(nextChecks);
applyPrepareOutcome(nextChecks, loadingMessage);
} catch (error) {
if (prepareRequestSeqRef.current !== requestSeq) return;
if (!isPlanCurrent(plan)) {
return;
}
const failureMessage =
error instanceof Error ? error.message : 'Failed to prepare selected providers';
setPrepareState('failed');
setPrepareWarnings([]);
setPrepareChecks(failIncompleteProviderChecks(checks, failureMessage));
setPrepareMessage(failureMessage);
const nextChecks = updateProviderCheck(prepareChecksRef.current, plan.providerId, {
status: 'failed',
backendSummary: plan.backendSummary,
details: [failureMessage],
});
prepareWarningsByProviderIdRef.current.delete(plan.providerId);
commitChecks(nextChecks);
applyPrepareOutcome(nextChecks, failureMessage);
}
})
);
})();
}, [
open,
isLaunchMode,
effectiveCwd,
effectiveAnthropicRuntimeLimitContext,
prepareRequestSignature,
selectedProviderId,
prepareProviderInvalidationEpochById,
runtimeProviderStatusById,
selectedMemberProviders,
selectedModelChecksByProvider,
selectedModelChecksByProviderSignature,
@ -2985,7 +3028,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
</p>
</div>
</div>
<ProvisioningProviderStatusList checks={prepareChecks} className="mt-2" />
<ProvisioningProviderStatusList
checks={prepareChecks}
className="mt-2"
onOpenProviderSettings={(providerId) =>
setProviderSettingsProviderId(providerId)
}
/>
</>
) : null}
@ -3005,7 +3054,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
{effectivePrepare.message}
</p>
) : null}
<ProvisioningProviderStatusList checks={prepareChecks} className="mt-1" />
<ProvisioningProviderStatusList
checks={prepareChecks}
className="mt-1"
onOpenProviderSettings={(providerId) =>
setProviderSettingsProviderId(providerId)
}
/>
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
<div className="mt-0.5 space-y-0.5 pl-5">
{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
</div>
</DialogFooter>
</DialogContent>
<ProvisioningProviderRuntimeSettingsDialog
openProviderId={providerSettingsProviderId}
onOpenProviderIdChange={(providerId) => setProviderSettingsProviderId(providerId)}
providers={effectiveCliStatus?.providers ?? []}
projectPath={effectiveCwd || null}
disabled={isSubmitting || launchInFlight}
onProviderRuntimeChanged={invalidatePrepareProvider}
/>
</Dialog>
);
};

View file

@ -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<ProviderTerminalState | null>(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<CliProviderId | null>(() => {
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 (
<>
<ProviderRuntimeSettingsDialog
open={Boolean(openProviderId)}
onOpenChange={(open) => {
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 && (
<TerminalModal
title={`${getRuntimeDisplayName(cliStatus, multimodelEnabled)} ${
providerTerminal.action === 'login' ? 'Login' : 'Logout'
}: ${getProvisioningProviderLabel(providerTerminal.providerId)}`}
command={cliStatus.binaryPath}
args={providerTerminalCommand?.args}
env={providerTerminalCommand?.env}
onClose={() => {
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'
}
/>
)}
</>
);
};

View file

@ -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 <span className="inline-block size-1.5 rounded-full bg-current opacity-60" />;
};
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 (
<div key={check.providerId}>
@ -712,6 +765,22 @@ export const ProvisioningProviderStatusList = ({
))}
</div>
) : null}
{settingsActionLabel ? (
<div className="mt-1 pl-4">
<button
type="button"
className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[10px] font-medium transition-colors hover:bg-white/5"
style={{
borderColor: 'var(--color-border-subtle)',
color: 'var(--color-text-secondary)',
}}
onClick={() => onOpenProviderSettings?.(check.providerId)}
>
<SlidersHorizontal className="size-3" />
{settingsActionLabel}
</button>
</div>
) : null}
</div>
);
})}

View file

@ -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<typeof normalizeTeamMemberMcpPolicy>;
} {
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<typeof normalizeTeamMemberMcpPolicy>;
} | null {
if (member.removedAt) {
return null;

View file

@ -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<TeamProviderId, CliProviderStatus | null | undefined>;
export interface ProviderPreparePlan {
providerId: TeamProviderId;
selectedModelChecks: TeamProvisioningModelCheckRequest[];
selectedModelIds: string[];
backendSummary: string | null;
runtimeStatusSignature: string;
modelChecksSignature: string;
requestSignature: string;
cacheKey: string;
cachedModelResultsById: Record<string, ProviderPrepareDiagnosticsModelResult>;
cachedSnapshot: ProviderPrepareDiagnosticsCachedSnapshot;
}
export function buildProviderPreparePlans({
cwd,
providerIds,
selectedModelChecksByProvider,
backendSummaryByProvider,
limitContext,
runtimeProviderStatusById,
cachedModelResultsByCacheKey,
}: {
cwd: string;
providerIds: readonly TeamProviderId[];
selectedModelChecksByProvider: ReadonlyMap<
TeamProviderId,
readonly TeamProvisioningModelCheckRequest[]
>;
backendSummaryByProvider: ReadonlyMap<TeamProviderId, string | null>;
limitContext: boolean;
runtimeProviderStatusById: RuntimeProviderStatusById;
cachedModelResultsByCacheKey: ReadonlyMap<
string,
Record<string, ProviderPrepareDiagnosticsModelResult>
>;
}): 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,
}),
};
});
}

View file

@ -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)),
};
})
);

View file

@ -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 ? <AlertTriangle className="size-3.5 shrink-0 text-red-300" /> : null}
{hasModelAdvisory ? <Info className="size-3.5 shrink-0 text-amber-300" /> : null}
</Button>
{runtimeMeta.length > 0 ? (
<p className="truncate px-1 text-[10px] leading-tight text-[var(--color-text-muted)]">
{runtimeMeta.join(' · ')}
<span className="sr-only">. {runtimeSummary}</span>
</p>
) : null}
</div>
</div>
{hasWarnings ? (

View file

@ -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<HTMLButtonElement>(
'button[aria-label="Add teammate workflow"]'
)!;
const mcpButton = Array.from(host.querySelectorAll<HTMLButtonElement>('button')).find(
(button) =>
button.getAttribute('aria-label') ===
"MCP inherit: Control this member's MCP inheritance policy"
)!;
const removeButton = host.querySelector<HTMLButtonElement>(
'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<HTMLButtonElement>('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<HTMLButtonElement>('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<HTMLButtonElement>('#member-member-1-mcp-mode')!;
const scopeCheckboxes = Array.from(
host.querySelectorAll<HTMLInputElement>('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();

View file

@ -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<TeamMemberMcpPolicy | undefined>(
() => (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 (
<div
@ -387,33 +484,6 @@ export const MemberDraftRow = ({
</div>
<div className="space-y-1">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start">
{showWorkflow && onWorkflowChange ? (
<HoverTooltip
content={workflowTooltipText}
title={workflowTooltipText}
className="shrink-0"
contentClassName="max-w-64"
>
<Button
variant="outline"
size="sm"
className={cn(
'relative size-8 shrink-0 px-0',
workflowExpanded &&
'border-blue-400/50 bg-blue-500/10 text-blue-100 hover:bg-blue-500/15'
)}
aria-label={workflowTooltipText}
aria-expanded={workflowExpanded}
disabled={isRemoved}
onClick={() => setWorkflowExpanded((prev) => !prev)}
>
<WorkflowIcon className="size-3.5" />
{!workflowExpanded && workflowDraft.value.trim() ? (
<span className="absolute -right-1 -top-1 size-2 rounded-full bg-blue-500" />
) : null}
</Button>
</HoverTooltip>
) : null}
<div className="w-full min-w-0 space-y-1 sm:w-[150px] sm:min-w-[150px]">
<HoverTooltip
content={modelButtonTooltipContent}
@ -450,12 +520,6 @@ export const MemberDraftRow = ({
{hasModelAdvisory ? <Info className="size-3.5 shrink-0 text-amber-300" /> : null}
</Button>
</HoverTooltip>
{runtimeMeta.length > 0 ? (
<p className="truncate px-1 text-[10px] leading-tight text-[var(--color-text-muted)]">
{runtimeMeta.join(' · ')}
<span className="sr-only">. {runtimeSummary}</span>
</p>
) : null}
{modelTooltipText ? (
<span id={modelHelpDescriptionId} className="sr-only">
{modelTooltipText}
@ -520,6 +584,70 @@ export const MemberDraftRow = ({
</span>
</div>
) : null}
{showWorkflow && onWorkflowChange ? (
<HoverTooltip
content={workflowTooltipText}
title={workflowTooltipText}
dismissOnClick
className="shrink-0"
contentClassName="max-w-64"
>
<Button
variant="outline"
size="sm"
className={cn(
'relative size-8 shrink-0 px-0',
workflowExpanded &&
'border-blue-400/50 bg-blue-500/10 text-blue-100 hover:bg-blue-500/15'
)}
aria-label={workflowTooltipText}
aria-expanded={workflowExpanded}
disabled={isRemoved}
onClick={() => setWorkflowExpanded((prev) => !prev)}
>
<WorkflowIcon className="size-3.5" />
{!workflowExpanded && workflowDraft.value.trim() ? (
<span className="absolute -right-1 -top-1 size-2 rounded-full bg-blue-500" />
) : null}
</Button>
</HoverTooltip>
) : null}
{onMcpPolicyChange ? (
<HoverTooltip
content={mcpTooltipText}
title={mcpTooltipText}
dismissOnClick
className="shrink-0"
contentClassName="max-w-64"
>
<Button
variant="outline"
size="sm"
className={cn(
'relative size-8 shrink-0 px-0',
agentTeamsMcpLocked &&
'border-amber-300/50 bg-amber-400/10 text-amber-100 hover:bg-amber-400/15',
!agentTeamsMcpLocked &&
(mcpExpanded || mcpMode !== 'inheritLead') &&
'border-sky-400/45 bg-sky-500/10 text-sky-100 hover:bg-sky-500/15'
)}
aria-label={mcpTooltipText}
aria-expanded={mcpExpanded}
disabled={isRemoved}
onClick={() => setMcpExpanded((prev) => !prev)}
>
<Plug className="size-3.5" />
{agentTeamsMcpLocked || mcpMode !== 'inheritLead' ? (
<span
className={cn(
'absolute -right-1 -top-1 size-2 rounded-full',
agentTeamsMcpLocked ? 'bg-amber-300' : 'bg-sky-400'
)}
/>
) : null}
</Button>
</HoverTooltip>
) : null}
{hideActionButton ? null : isRemoved ? (
<Button
variant="outline"
@ -569,6 +697,87 @@ export const MemberDraftRow = ({
</div>
</div>
) : null}
{!isRemoved && onMcpPolicyChange && mcpExpanded ? (
<div className="space-y-3 pl-3 md:col-span-3">
<div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
<div className="grid gap-3 sm:grid-cols-[minmax(160px,220px)_1fr]">
<div className="space-y-1">
<Label
htmlFor={`member-${member.id}-mcp-mode`}
className="text-[10px] text-[var(--color-text-muted)]"
>
MCP mode
</Label>
<Select
value={mcpMode}
onValueChange={handleMcpModeChange}
disabled={agentTeamsMcpLocked}
>
<SelectTrigger
id={`member-${member.id}-mcp-mode`}
className="h-8 text-xs"
disabled={agentTeamsMcpLocked}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="inheritLead">Inherit lead</SelectItem>
<SelectItem value="inheritScopes">Choose scopes</SelectItem>
<SelectItem value="strictAllowlist">Strict allowlist</SelectItem>
<SelectItem value="appOnly">Agent Teams MCP</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="grid gap-2 sm:grid-cols-3">
{(['user', 'project', 'local'] as const).map((scope) => (
<label
key={scope}
className={cn(
'flex h-8 items-center gap-2 rounded-md border border-[var(--color-border)] px-2 text-xs text-[var(--color-text-secondary)]',
(agentTeamsMcpLocked ||
mcpMode === 'inheritLead' ||
mcpMode === 'appOnly') &&
'opacity-50'
)}
>
<Checkbox
checked={mcpMode === 'appOnly' ? false : mcpScopes[scope]}
disabled={
agentTeamsMcpLocked || mcpMode === 'inheritLead' || mcpMode === 'appOnly'
}
onCheckedChange={(checked) => updateMcpScope(scope, checked === true)}
/>
<span className="capitalize">{scope}</span>
</label>
))}
</div>
{mcpMode === 'strictAllowlist' ? (
<div className="space-y-1">
<Label
htmlFor={`member-${member.id}-mcp-servers`}
className="text-[10px] text-[var(--color-text-muted)]"
>
Server names
</Label>
<Input
id={`member-${member.id}-mcp-servers`}
className="h-8 text-xs"
value={mcpServerNames.join(', ')}
disabled={agentTeamsMcpLocked}
onChange={(event) => updateMcpServerNames(event.target.value)}
placeholder="github, sentry"
/>
</div>
) : null}
{mcpMode !== 'inheritLead' ? (
<p className="text-[10px] leading-snug text-amber-200">{mcpSettingInfoText}</p>
) : null}
</div>
</div>
</div>
</div>
) : null}
{showWorkflow && onWorkflowChange && workflowExpanded ? (
<div className="space-y-0.5 pl-3 md:col-span-3">
<label

View file

@ -0,0 +1,510 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@renderer/components/ui/button', () => ({
Button: ({
children,
onClick,
disabled,
title,
className,
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { children: React.ReactNode }) =>
React.createElement(
'button',
{
type: 'button',
className,
disabled,
title,
onClick,
},
children
),
}));
vi.mock('@renderer/components/ui/checkbox', () => ({
Checkbox: ({
checked,
onCheckedChange,
...props
}: Omit<React.InputHTMLAttributes<HTMLInputElement>, 'checked' | 'onChange'> & {
checked?: boolean | 'indeterminate';
onCheckedChange?: (value: boolean | 'indeterminate') => void;
}) =>
React.createElement('input', {
...props,
checked: checked === true,
'data-state':
checked === 'indeterminate' ? 'indeterminate' : checked ? 'checked' : 'unchecked',
type: 'checkbox',
onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
onCheckedChange?.(event.target.checked),
}),
}));
vi.mock('@renderer/components/ui/label', () => ({
Label: ({
children,
...props
}: React.LabelHTMLAttributes<HTMLLabelElement> & { children: React.ReactNode }) =>
React.createElement('label', props, children),
}));
vi.mock('../dialogs/MembersJsonEditor', () => ({
MembersJsonEditor: ({ value, onChange }: { value: string; onChange: (value: string) => void }) =>
React.createElement('textarea', {
'data-testid': 'members-json-editor',
value,
onChange: (event: React.ChangeEvent<HTMLTextAreaElement>) => onChange(event.target.value),
}),
}));
vi.mock('./MemberDraftRow', () => ({
MemberDraftRow: ({
member,
onWorktreeIsolationChange,
onRemove,
onRestore,
agentTeamsMcpLocked,
}: {
member: {
id: string;
name: string;
isolation?: 'worktree';
mcpPolicy?: { mode?: string };
removedAt?: number | string | null;
};
onWorktreeIsolationChange?: (id: string, enabled: boolean) => void;
onRemove?: (id: string) => void;
onRestore?: (id: string) => void;
agentTeamsMcpLocked?: boolean;
}) =>
React.createElement(
'div',
null,
React.createElement(
'button',
{
type: 'button',
'data-testid': `member-${member.name}`,
'data-isolation': member.isolation ?? '',
'data-mcp-policy': member.mcpPolicy?.mode ?? '',
'data-agent-teams-mcp-locked': agentTeamsMcpLocked ? 'true' : 'false',
'data-removed': member.removedAt ? 'true' : 'false',
onClick: () => onWorktreeIsolationChange?.(member.id, member.isolation !== 'worktree'),
},
member.name
),
React.createElement(
'button',
{
type: 'button',
'data-testid': `remove-${member.name}`,
onClick: () => onRemove?.(member.id),
},
'remove'
),
React.createElement(
'button',
{
type: 'button',
'data-testid': `restore-${member.name}`,
onClick: () => onRestore?.(member.id),
},
'restore'
)
),
}));
import { MembersEditorSection } from './MembersEditorSection';
import { createMemberDraft } from './membersEditorUtils';
import type { MemberDraft } from './membersEditorTypes';
const mountedRoots: ReturnType<typeof createRoot>[] = [];
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
});
function renderMembersEditor(props: {
members: MemberDraft[];
teammateWorktreeDefault?: boolean;
onChange?: (members: MemberDraft[]) => void;
softDeleteMembers?: boolean;
}): {
host: HTMLDivElement;
onChange: ReturnType<typeof vi.fn>;
rerender: (members: MemberDraft[]) => void;
} {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
mountedRoots.push(root);
const onChange = props.onChange ?? vi.fn();
const render = (members: MemberDraft[]): void => {
root.render(
<MembersEditorSection
members={members}
onChange={onChange}
showWorktreeIsolationControls
teammateWorktreeDefault={props.teammateWorktreeDefault}
softDeleteMembers={props.softDeleteMembers}
draftKeyPrefix="worktree-test"
/>
);
};
act(() => {
render(props.members);
});
return {
host,
onChange: onChange as ReturnType<typeof vi.fn>,
rerender: (members: MemberDraft[]) => {
act(() => render(members));
},
};
}
function masterWorktreeCheckbox(host: HTMLElement): HTMLInputElement {
const checkbox = host.querySelector<HTMLInputElement>('#teammate-worktree-default-worktree-test');
if (!checkbox) {
throw new Error('Master worktree checkbox not found');
}
return checkbox;
}
function masterAgentTeamsMcpCheckbox(host: HTMLElement): HTMLInputElement {
const checkbox = host.querySelector<HTMLInputElement>(
'#teammate-agent-teams-mcp-default-worktree-test'
);
if (!checkbox) {
throw new Error('Master Agent Teams MCP checkbox not found');
}
return checkbox;
}
function addMemberButton(host: HTMLElement): HTMLButtonElement {
const button = Array.from(host.querySelectorAll<HTMLButtonElement>('button')).find((element) =>
element.textContent?.includes('Add member')
);
if (!button) {
throw new Error('Add member button not found');
}
return button;
}
function editJsonButton(host: HTMLElement): HTMLButtonElement {
const button = Array.from(host.querySelectorAll<HTMLButtonElement>('button')).find((element) =>
element.textContent?.includes('Edit as JSON')
);
if (!button) {
throw new Error('Edit as JSON button not found');
}
return button;
}
afterEach(() => {
for (const root of mountedRoots.splice(0)) {
act(() => root.unmount());
}
document.body.innerHTML = '';
});
describe('MembersEditorSection worktree master checkbox', () => {
it('renders indeterminate when only some active members use worktrees', () => {
const { host } = renderMembersEditor({
members: [
createMemberDraft({ id: 'alice', name: 'alice' }),
createMemberDraft({ id: 'bob', name: 'bob', isolation: 'worktree' }),
],
teammateWorktreeDefault: true,
});
const checkbox = masterWorktreeCheckbox(host);
expect(checkbox.checked).toBe(false);
expect(checkbox.dataset.state).toBe('indeterminate');
});
it('renders checked only when all active members use worktrees', () => {
const { host } = renderMembersEditor({
members: [
createMemberDraft({ id: 'alice', name: 'alice', isolation: 'worktree' }),
createMemberDraft({ id: 'bob', name: 'bob', isolation: 'worktree' }),
],
});
const checkbox = masterWorktreeCheckbox(host);
expect(checkbox.checked).toBe(true);
expect(checkbox.dataset.state).toBe('checked');
});
it('turns all active members on when clicked from mixed state', () => {
const { host, onChange } = renderMembersEditor({
members: [
createMemberDraft({ id: 'alice', name: 'alice' }),
createMemberDraft({ id: 'bob', name: 'bob', isolation: 'worktree' }),
],
});
act(() => {
masterWorktreeCheckbox(host).click();
});
const nextMembers = onChange.mock.calls[0]?.[0] as MemberDraft[];
expect(nextMembers.map((member) => member.isolation)).toEqual(['worktree', 'worktree']);
});
});
describe('MembersEditorSection Agent Teams MCP master checkbox', () => {
it('renders to the right of the worktree master control', () => {
const { host } = renderMembersEditor({
members: [createMemberDraft({ id: 'alice', name: 'alice' })],
});
const worktreeCheckbox = masterWorktreeCheckbox(host);
const mcpCheckbox = masterAgentTeamsMcpCheckbox(host);
expect(host.textContent).toContain('Agent Teams MCP only');
expect(mcpCheckbox.checked).toBe(false);
expect(
worktreeCheckbox.compareDocumentPosition(mcpCheckbox) & Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
});
it('forces all active members to Agent Teams MCP when enabled', () => {
const removed = createMemberDraft({
id: 'removed',
name: 'removed',
mcpPolicy: { mode: 'strictAllowlist', serverNames: ['github'] },
removedAt: Date.now(),
});
const { host, onChange } = renderMembersEditor({
members: [
createMemberDraft({ id: 'alice', name: 'alice' }),
createMemberDraft({
id: 'bob',
name: 'bob',
mcpPolicy: { mode: 'inheritScopes', scopes: { user: true, project: false } },
}),
removed,
],
softDeleteMembers: true,
});
act(() => {
masterAgentTeamsMcpCheckbox(host).click();
});
const nextMembers = onChange.mock.calls[0]?.[0] as MemberDraft[];
expect(nextMembers.find((member) => member.id === 'alice')?.mcpPolicy).toEqual({
mode: 'appOnly',
});
expect(nextMembers.find((member) => member.id === 'bob')?.mcpPolicy).toEqual({
mode: 'appOnly',
});
expect(nextMembers.find((member) => member.id === 'removed')?.mcpPolicy).toEqual(
removed.mcpPolicy
);
});
it('restores previous policies after rerender when disabled', () => {
const originalMembers = [
createMemberDraft({ id: 'alice', name: 'alice' }),
createMemberDraft({
id: 'bob',
name: 'bob',
mcpPolicy: {
mode: 'strictAllowlist',
scopes: { user: true, project: false, local: true },
serverNames: ['github', 'linear'],
},
}),
];
const { host, onChange, rerender } = renderMembersEditor({ members: originalMembers });
act(() => {
masterAgentTeamsMcpCheckbox(host).click();
});
const lockedMembers = onChange.mock.calls[0]?.[0] as MemberDraft[];
rerender(lockedMembers);
act(() => {
masterAgentTeamsMcpCheckbox(host).click();
});
const restoredMembers = onChange.mock.calls[1]?.[0] as MemberDraft[];
expect(restoredMembers.find((member) => member.id === 'alice')?.mcpPolicy).toBeUndefined();
expect(restoredMembers.find((member) => member.id === 'bob')?.mcpPolicy).toEqual(
originalMembers[1].mcpPolicy
);
});
it('gives new members Agent Teams MCP while lock is enabled, then restores them to inherited MCP', () => {
const { host, onChange, rerender } = renderMembersEditor({
members: [createMemberDraft({ id: 'alice', name: 'alice' })],
});
act(() => {
masterAgentTeamsMcpCheckbox(host).click();
});
const lockedMembers = onChange.mock.calls[0]?.[0] as MemberDraft[];
rerender(lockedMembers);
act(() => {
addMemberButton(host).click();
});
const withNewMember = onChange.mock.calls[1]?.[0] as MemberDraft[];
const addedMember = withNewMember.at(-1);
expect(addedMember?.mcpPolicy).toEqual({ mode: 'appOnly' });
rerender(withNewMember);
act(() => {
masterAgentTeamsMcpCheckbox(host).click();
});
const restoredMembers = onChange.mock.calls[2]?.[0] as MemberDraft[];
expect(
restoredMembers.find((member) => member.id === addedMember?.id)?.mcpPolicy
).toBeUndefined();
});
it('keeps the restore map stable when a member is removed while locked', () => {
const originalMembers = [
createMemberDraft({
id: 'alice',
name: 'alice',
mcpPolicy: { mode: 'inheritScopes', scopes: { user: false, project: true } },
}),
createMemberDraft({
id: 'bob',
name: 'bob',
mcpPolicy: { mode: 'strictAllowlist', serverNames: ['github'] },
}),
];
const { host, onChange, rerender } = renderMembersEditor({ members: originalMembers });
act(() => {
masterAgentTeamsMcpCheckbox(host).click();
});
const lockedMembers = onChange.mock.calls[0]?.[0] as MemberDraft[];
rerender(lockedMembers);
act(() => {
host.querySelector<HTMLButtonElement>('[data-testid="remove-bob"]')?.click();
});
const withoutBob = onChange.mock.calls[1]?.[0] as MemberDraft[];
rerender(withoutBob);
act(() => {
masterAgentTeamsMcpCheckbox(host).click();
});
const restoredMembers = onChange.mock.calls[2]?.[0] as MemberDraft[];
expect(restoredMembers.map((member) => member.id)).toEqual(['alice']);
expect(restoredMembers[0]?.mcpPolicy).toEqual(originalMembers[0].mcpPolicy);
});
it('forces JSON editor changes through Agent Teams MCP while lock is enabled', async () => {
const { host, onChange, rerender } = renderMembersEditor({
members: [createMemberDraft({ id: 'alice', name: 'alice' })],
});
act(() => {
masterAgentTeamsMcpCheckbox(host).click();
});
const lockedMembers = onChange.mock.calls[0]?.[0] as MemberDraft[];
rerender(lockedMembers);
await act(async () => {
editJsonButton(host).click();
await Promise.resolve();
});
const editor = host.querySelector<HTMLTextAreaElement>('[data-testid="members-json-editor"]')!;
const textareaValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
'value'
)?.set;
await act(async () => {
textareaValueSetter?.call(
editor,
JSON.stringify([
{
name: 'alice',
role: 'developer',
mcpPolicy: {
mode: 'strictAllowlist',
scopes: { user: true, project: true, local: true },
serverNames: ['github'],
},
},
])
);
editor.dispatchEvent(new Event('input', { bubbles: true }));
await Promise.resolve();
});
const nextMembers = onChange.mock.calls[1]?.[0] as MemberDraft[];
expect(nextMembers).toHaveLength(1);
expect(nextMembers[0]?.mcpPolicy).toEqual({ mode: 'appOnly' });
});
it('restores a soft-deleted member policy when that member is restored while locked', () => {
const originalMembers = [
createMemberDraft({
id: 'alice',
name: 'alice',
mcpPolicy: { mode: 'inheritScopes', scopes: { user: true, project: false } },
}),
createMemberDraft({
id: 'bob',
name: 'bob',
mcpPolicy: { mode: 'strictAllowlist', serverNames: ['github'] },
removedAt: Date.now(),
}),
];
const { host, onChange, rerender } = renderMembersEditor({
members: originalMembers,
softDeleteMembers: true,
});
act(() => {
masterAgentTeamsMcpCheckbox(host).click();
});
const lockedMembers = onChange.mock.calls[0]?.[0] as MemberDraft[];
rerender(lockedMembers);
act(() => {
host.querySelector<HTMLButtonElement>('[data-testid="restore-bob"]')?.click();
});
const restoredDuringLock = onChange.mock.calls[1]?.[0] as MemberDraft[];
expect(restoredDuringLock.find((member) => member.id === 'bob')?.mcpPolicy).toEqual({
mode: 'appOnly',
});
rerender(restoredDuringLock);
act(() => {
masterAgentTeamsMcpCheckbox(host).click();
});
const restoredAfterUnlock = onChange.mock.calls[2]?.[0] as MemberDraft[];
expect(restoredAfterUnlock.find((member) => member.id === 'alice')?.mcpPolicy).toEqual(
originalMembers[0].mcpPolicy
);
expect(restoredAfterUnlock.find((member) => member.id === 'bob')?.mcpPolicy).toEqual(
originalMembers[1].mcpPolicy
);
});
});

View file

@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
@ -8,8 +8,9 @@ import { cn } from '@renderer/lib/utils';
import { getParticipantAvatarUrlByIndex } from '@renderer/utils/memberAvatarCatalog';
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 { GitBranch, Plus } from 'lucide-react';
import { GitBranch, Plug, Plus } from 'lucide-react';
import { MembersJsonEditor } from '../dialogs/MembersJsonEditor';
@ -33,7 +34,7 @@ function membersToJsonText(drafts: MemberDraft[]): string {
.filter((d) => d.name.trim())
.map((d) => {
const role = getMemberDraftRole(d);
const obj: Record<string, string> = { name: d.name.trim() };
const obj: Record<string, unknown> = { name: d.name.trim() };
if (role) obj.role = role;
const workflow = getWorkflowForExport(d);
if (workflow) obj.workflow = workflow;
@ -41,6 +42,7 @@ function membersToJsonText(drafts: MemberDraft[]): string {
if (d.providerId) obj.providerId = d.providerId;
if (d.model?.trim()) obj.model = d.model.trim();
if (d.effort) obj.effort = d.effort;
if (d.mcpPolicy) obj.mcpPolicy = d.mcpPolicy;
return obj;
});
return JSON.stringify(arr, null, 2);
@ -59,6 +61,7 @@ function parseJsonToDrafts(text: string): MemberDraft[] {
const effort: EffortLevel | undefined = isTeamEffortLevel(item.effort)
? item.effort
: undefined;
const mcpPolicy = normalizeTeamMemberMcpPolicy(item.mcpPolicy);
const presetRoles: readonly string[] = PRESET_ROLES;
const isPreset = presetRoles.includes(role);
return createMemberDraft({
@ -70,10 +73,29 @@ function parseJsonToDrafts(text: string): MemberDraft[] {
providerId,
model,
effort,
mcpPolicy,
});
});
}
function cloneMcpPolicy(policy: MemberDraft['mcpPolicy']): MemberDraft['mcpPolicy'] {
const normalized = normalizeTeamMemberMcpPolicy(policy);
if (!normalized) {
return undefined;
}
return {
mode: normalized.mode,
...(normalized.scopes ? { scopes: { ...normalized.scopes } } : {}),
...(normalized.serverNames ? { serverNames: [...normalized.serverNames] } : {}),
};
}
function forceActiveMembersToAgentTeamsMcp(drafts: MemberDraft[]): MemberDraft[] {
return drafts.map((member) =>
member.removedAt ? member : { ...member, mcpPolicy: { mode: 'appOnly' as const } }
);
}
export interface MembersEditorSectionProps {
members: MemberDraft[];
onChange: (members: MemberDraft[]) => void;
@ -177,6 +199,14 @@ export const MembersEditorSection = ({
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
const [jsonText, setJsonText] = useState('');
const [jsonError, setJsonError] = useState<string | null>(null);
const [agentTeamsMcpLockedForAll, setAgentTeamsMcpLockedForAll] = useState(false);
const previousMcpPolicyByMemberIdRef = useRef<Map<string, MemberDraft['mcpPolicy']>>(new Map());
const emitMembersChange = (nextMembers: MemberDraft[]): void => {
onChange(
agentTeamsMcpLockedForAll ? forceActiveMembersToAgentTeamsMcp(nextMembers) : nextMembers
);
};
const toggleJsonEditor = (): void => {
if (!jsonEditorOpen) {
@ -195,7 +225,7 @@ export const MembersEditorSection = ({
setJsonText(text);
try {
const drafts = parseJsonToDrafts(text);
onChange(drafts);
emitMembersChange(drafts);
setJsonError(null);
} catch (e) {
setJsonError(e instanceof Error ? e.message : 'Invalid JSON');
@ -203,12 +233,12 @@ export const MembersEditorSection = ({
};
const updateMemberName = (memberId: string, name: string): void => {
onChange(members.map((c) => (c.id === memberId ? { ...c, name } : c)));
emitMembersChange(members.map((c) => (c.id === memberId ? { ...c, name } : c)));
};
const updateMemberRole = (memberId: string, roleSelection: string): void => {
const resolvedRole = roleSelection === NO_ROLE ? '' : roleSelection;
onChange(
emitMembersChange(
members.map((c) =>
c.id === memberId
? {
@ -222,19 +252,19 @@ export const MembersEditorSection = ({
};
const updateMemberCustomRole = (memberId: string, customRole: string): void => {
onChange(members.map((c) => (c.id === memberId ? { ...c, customRole } : c)));
emitMembersChange(members.map((c) => (c.id === memberId ? { ...c, customRole } : c)));
};
const updateMemberWorkflow = (memberId: string, workflow: string): void => {
onChange(members.map((c) => (c.id === memberId ? { ...c, workflow } : c)));
emitMembersChange(members.map((c) => (c.id === memberId ? { ...c, workflow } : c)));
};
const updateMemberWorkflowChips = (memberId: string, workflowChips: InlineChip[]): void => {
onChange(members.map((c) => (c.id === memberId ? { ...c, workflowChips } : c)));
emitMembersChange(members.map((c) => (c.id === memberId ? { ...c, workflowChips } : c)));
};
const updateMemberProvider = (memberId: string, providerId: TeamProviderId): void => {
onChange(
emitMembersChange(
members.map((c) =>
c.id === memberId
? (() => {
@ -255,11 +285,11 @@ export const MembersEditorSection = ({
};
const updateMemberModel = (memberId: string, model: string): void => {
onChange(members.map((c) => (c.id === memberId ? { ...c, model } : c)));
emitMembersChange(members.map((c) => (c.id === memberId ? { ...c, model } : c)));
};
const updateMemberEffort = (memberId: string, effort: string): void => {
onChange(
emitMembersChange(
members.map((c) =>
c.id === memberId
? {
@ -275,19 +305,53 @@ export const MembersEditorSection = ({
if (enabled && worktreeIsolationDisabledReason) {
return;
}
onChange(
emitMembersChange(
members.map((c) =>
c.id === memberId ? { ...c, isolation: enabled ? 'worktree' : undefined } : c
)
);
};
const updateMemberMcpPolicy = (memberId: string, mcpPolicy: MemberDraft['mcpPolicy']): void => {
if (agentTeamsMcpLockedForAll) {
return;
}
emitMembersChange(
members.map((c) =>
c.id === memberId ? { ...c, mcpPolicy: normalizeTeamMemberMcpPolicy(mcpPolicy) } : c
)
);
};
const updateAgentTeamsMcpLock = (enabled: boolean): void => {
if (enabled) {
const previous = new Map<string, MemberDraft['mcpPolicy']>();
for (const member of members) {
previous.set(member.id, cloneMcpPolicy(member.mcpPolicy));
}
previousMcpPolicyByMemberIdRef.current = previous;
setAgentTeamsMcpLockedForAll(true);
onChange(forceActiveMembersToAgentTeamsMcp(members));
return;
}
setAgentTeamsMcpLockedForAll(false);
const previous = previousMcpPolicyByMemberIdRef.current;
onChange(
members.map((member) => ({
...member,
mcpPolicy: cloneMcpPolicy(previous.get(member.id)),
}))
);
previous.clear();
};
const updateTeammateWorktreeDefault = (enabled: boolean): void => {
if (enabled && worktreeIsolationDisabledReason) {
return;
}
onTeammateWorktreeDefaultChange?.(enabled);
onChange(
emitMembersChange(
members.map((member) =>
member.removedAt ? member : { ...member, isolation: enabled ? 'worktree' : undefined }
)
@ -296,10 +360,10 @@ export const MembersEditorSection = ({
const removeMember = (memberId: string): void => {
if (!softDeleteMembers) {
onChange(members.filter((c) => c.id !== memberId));
emitMembersChange(members.filter((c) => c.id !== memberId));
return;
}
onChange(
emitMembersChange(
members.map((member) =>
member.id === memberId ? { ...member, removedAt: member.removedAt ?? Date.now() } : member
)
@ -307,32 +371,49 @@ export const MembersEditorSection = ({
};
const restoreMember = (memberId: string): void => {
onChange(
emitMembersChange(
members.map((member) => (member.id === memberId ? { ...member, removedAt: null } : member))
);
};
const activeMembers = members.filter((member) => !member.removedAt);
const removedMembers = members.filter((member) => member.removedAt);
const activeWorktreeMemberCount = activeMembers.filter(
(member) => member.isolation === 'worktree'
).length;
const allActiveMembersUseWorktrees =
activeMembers.length > 0 && activeWorktreeMemberCount === activeMembers.length;
const someActiveMembersUseWorktrees = activeWorktreeMemberCount > 0;
const teammateWorktreeDefaultChecked: boolean | 'indeterminate' = allActiveMembersUseWorktrees
? true
: someActiveMembersUseWorktrees
? 'indeterminate'
: false;
const newMemberUsesWorktree =
allActiveMembersUseWorktrees || (activeMembers.length === 0 && teammateWorktreeDefault);
const worktreeDefaultDisabled = Boolean(
worktreeIsolationDisabledReason && !allActiveMembersUseWorktrees
);
const addMember = (): void => {
const suggestedName = getNextSuggestedMemberName(members.map((member) => member.name));
onChange([
emitMembersChange([
...members,
createMemberDraft(
inheritModelSettingsByDefault
? {
name: suggestedName,
isolation: teammateWorktreeDefault ? 'worktree' : undefined,
isolation: newMemberUsesWorktree ? 'worktree' : undefined,
}
: {
name: suggestedName,
providerId: defaultProviderId,
isolation: teammateWorktreeDefault ? 'worktree' : undefined,
isolation: newMemberUsesWorktree ? 'worktree' : undefined,
}
),
]);
};
const activeMembers = members.filter((member) => !member.removedAt);
const removedMembers = members.filter((member) => member.removedAt);
const names = activeMembers.map((m) => m.name.trim().toLowerCase()).filter(Boolean);
const hasDuplicates = new Set(names).size !== names.length;
const memberColorMap = useMemo(
@ -344,6 +425,11 @@ export const MembersEditorSection = ({
`teammate-worktree-default-${(draftKeyPrefix ?? 'default').replace(/[^a-zA-Z0-9_-]/g, '-')}`,
[draftKeyPrefix]
);
const agentTeamsMcpDefaultControlId = useMemo(
() =>
`teammate-agent-teams-mcp-default-${(draftKeyPrefix ?? 'default').replace(/[^a-zA-Z0-9_-]/g, '-')}`,
[draftKeyPrefix]
);
const mentionSuggestions = useMemo(
() => buildMemberDraftSuggestions(members, memberColorMap),
@ -398,13 +484,14 @@ export const MembersEditorSection = ({
>
{showWorktreeIsolationControls ? (
<div
className="flex items-center gap-2 border-b border-[var(--color-border)] px-2.5 py-2"
className="flex items-center justify-between gap-3 border-b border-[var(--color-border)] px-2.5 py-2"
title={worktreeIsolationDisabledReason ?? undefined}
>
<div className="flex min-w-0 items-center gap-2">
<Checkbox
id={worktreeDefaultControlId}
checked={teammateWorktreeDefault}
disabled={Boolean(worktreeIsolationDisabledReason && !teammateWorktreeDefault)}
checked={teammateWorktreeDefaultChecked}
disabled={worktreeDefaultDisabled}
onCheckedChange={(checked) => updateTeammateWorktreeDefault(checked === true)}
/>
<Label
@ -415,6 +502,21 @@ export const MembersEditorSection = ({
<span className="truncate">Run teammates in separate worktrees</span>
</Label>
</div>
<div className="flex shrink-0 items-center gap-2">
<Checkbox
id={agentTeamsMcpDefaultControlId}
checked={agentTeamsMcpLockedForAll}
onCheckedChange={(checked) => updateAgentTeamsMcpLock(checked === true)}
/>
<Label
htmlFor={agentTeamsMcpDefaultControlId}
className="flex cursor-pointer items-center gap-1.5 text-xs font-normal text-[var(--color-text-secondary)]"
>
<Plug className="size-3.5 shrink-0" />
<span className="whitespace-nowrap">Agent Teams MCP only</span>
</Label>
</div>
</div>
) : null}
<div className={cn('space-y-2', showWorktreeIsolationControls && 'p-2')}>
{activeMembers.map((member, index) => (
@ -438,6 +540,8 @@ export const MembersEditorSection = ({
showWorktreeIsolationControls={showWorktreeIsolationControls}
worktreeIsolationDisabledReason={worktreeIsolationDisabledReason}
onWorktreeIsolationChange={updateMemberIsolation}
onMcpPolicyChange={updateMemberMcpPolicy}
agentTeamsMcpLocked={agentTeamsMcpLockedForAll}
inheritedProviderId={inheritedProviderId}
inheritedModel={inheritedModel}
inheritedEffort={inheritedEffort}
@ -488,6 +592,8 @@ export const MembersEditorSection = ({
onEffortChange={updateMemberEffort}
showWorktreeIsolationControls={showWorktreeIsolationControls}
onWorktreeIsolationChange={updateMemberIsolation}
onMcpPolicyChange={updateMemberMcpPolicy}
agentTeamsMcpLocked={agentTeamsMcpLockedForAll}
inheritedProviderId={inheritedProviderId}
inheritedModel={inheritedModel}
inheritedEffort={inheritedEffort}

View file

@ -2,6 +2,7 @@ import type { InlineChip } from '@renderer/types/inlineChip';
import type {
EffortLevel,
TeamFastMode,
TeamMemberMcpPolicy,
TeamProviderBackendId,
TeamProviderId,
} from '@shared/types';
@ -20,6 +21,7 @@ export interface MemberDraft {
model?: string;
effort?: EffortLevel;
fastMode?: TeamFastMode;
mcpPolicy?: TeamMemberMcpPolicy;
removedAt?: number | string | null;
}

View file

@ -6,6 +6,7 @@ import { isTeamEffortLevel, isTeamEffortLevelForProvider } from '@shared/utils/e
import { isLeadMember } from '@shared/utils/leadDetection';
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
import { normalizeTeamMemberMcpPolicy } from '@shared/utils/teamMemberMcpPolicy';
import { validateTeamMemberNameFormat } from '@shared/utils/teamMemberName';
import {
inferTeamProviderIdFromModel,
@ -48,6 +49,7 @@ export function createMemberDraft(initial?: Partial<MemberDraft>): MemberDraft {
model: normalizeExplicitTeamModelForUi(providerId, initial?.model ?? ''),
effort: initial?.effort,
fastMode: initial?.fastMode,
mcpPolicy: normalizeTeamMemberMcpPolicy(initial?.mcpPolicy),
removedAt: initial?.removedAt,
};
}
@ -63,6 +65,7 @@ export function createMemberDraftsFromInputs(
model?: string;
effort?: EffortLevel;
fastMode?: TeamFastMode;
mcpPolicy?: unknown;
isolation?: 'worktree';
removedAt?: number | string | null;
}[]
@ -85,6 +88,7 @@ export function createMemberDraftsFromInputs(
model: member.model ?? '',
effort: normalizeDraftEffort(member.effort),
fastMode: member.fastMode,
mcpPolicy: normalizeTeamMemberMcpPolicy(member.mcpPolicy),
removedAt: member.removedAt,
});
});
@ -340,6 +344,10 @@ export function buildMembersFromDrafts(
if (member.fastMode) {
result.fastMode = member.fastMode;
}
const mcpPolicy = normalizeTeamMemberMcpPolicy(member.mcpPolicy);
if (mcpPolicy) {
result.mcpPolicy = mcpPolicy;
}
return result;
})
.filter((member): member is NonNullable<typeof member> => member !== null);

View file

@ -2,12 +2,12 @@ import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { cn } from '@renderer/lib/utils';
import { Check } from 'lucide-react';
import { Check, Minus } from 'lucide-react';
const Checkbox = React.forwardRef<
React.ComponentRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
>(({ className, checked, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
@ -15,12 +15,14 @@ const Checkbox = React.forwardRef<
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-accent)]',
'disabled:cursor-not-allowed disabled:opacity-50',
'data-[state=checked]:border-[var(--color-accent)] data-[state=checked]:bg-[var(--color-accent)] data-[state=checked]:text-white',
'data-[state=indeterminate]:border-[var(--color-accent)] data-[state=indeterminate]:bg-[var(--color-accent)] data-[state=indeterminate]:text-white',
className
)}
checked={checked}
{...props}
>
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-current">
<Check className="size-3" />
{checked === 'indeterminate' ? <Minus className="size-3" /> : <Check className="size-3" />}
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));

View file

@ -13,6 +13,7 @@ interface HoverTooltipProps {
className?: string;
contentClassName?: string;
disabled?: boolean;
dismissOnClick?: boolean;
side?: HoverTooltipSide;
stopClickPropagation?: boolean;
title?: string;
@ -45,11 +46,13 @@ export const HoverTooltip = ({
className,
contentClassName,
disabled = false,
dismissOnClick = false,
side = 'top',
stopClickPropagation = false,
title,
}: Readonly<HoverTooltipProps>): React.JSX.Element => {
const TooltipWrapper = as;
const [dismissed, setDismissed] = React.useState(false);
if (disabled || !content) {
return <TooltipWrapper className={className}>{children}</TooltipWrapper>;
@ -58,16 +61,24 @@ export const HoverTooltip = ({
return (
<TooltipWrapper
className={cn('group/hover-tooltip relative inline-flex min-w-0', className)}
title={title}
onClick={
stopClickPropagation ? (event: React.MouseEvent) => event.stopPropagation() : undefined
title={dismissed ? undefined : title}
onBlur={dismissOnClick ? () => setDismissed(false) : undefined}
onClick={(event: React.MouseEvent) => {
if (dismissOnClick) {
setDismissed(true);
}
if (stopClickPropagation) {
event.stopPropagation();
}
}}
onMouseLeave={dismissOnClick ? () => setDismissed(false) : undefined}
>
{children}
<span
aria-hidden="true"
className={cn(
'pointer-events-none absolute z-[80] w-max max-w-72 rounded-md border border-[var(--color-border-emphasis)] bg-[var(--color-surface-overlay)] px-2.5 py-1.5 text-left text-xs font-normal leading-relaxed text-[var(--color-text)] opacity-0 shadow-2xl shadow-black/40 ring-1 ring-black/10 transition-opacity duration-100',
!dismissed &&
'group-focus-within/hover-tooltip:opacity-100 group-hover/hover-tooltip:opacity-100',
sideClassBySide[side],
alignClassByAlign[align],

View file

@ -143,4 +143,84 @@ describe('useCreateTeamDraft', () => {
root.unmount();
});
});
it('preserves per-member MCP policy in saved create-team drafts', async () => {
loadSnapshotMock.mockResolvedValue({
version: 1,
teamName: 'team-alpha',
members: [
{
id: 'member-1',
name: 'alice',
roleSelection: 'developer',
customRole: '',
mcpPolicy: { mode: 'appOnly' },
},
],
syncModelsWithLead: true,
teammateWorktreeDefault: false,
cwdMode: 'project',
selectedProjectPath: '',
customCwd: '',
soloTeam: false,
launchTeam: true,
teamColor: '',
updatedAt: Date.now(),
});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
let loadedDraft: ReturnType<typeof useCreateTeamDraft> | null = null;
await act(async () => {
root.render(
React.createElement(HookProbeWithDraft, {
onLoaded: (draft) => {
loadedDraft = draft;
},
})
);
await Promise.resolve();
await Promise.resolve();
});
const draft = loadedDraft as ReturnType<typeof useCreateTeamDraft> | null;
expect(draft).not.toBeNull();
if (!draft) {
throw new Error('Draft did not load');
}
expect(draft.members[0]?.mcpPolicy).toEqual({ mode: 'appOnly' });
act(() => {
draft.setMembers([
{
id: 'member-1',
name: 'alice',
roleSelection: 'developer',
customRole: '',
mcpPolicy: {
mode: 'strictAllowlist',
scopes: { user: true, project: false, local: false },
serverNames: ['github'],
},
},
]);
root.unmount();
});
expect(saveSnapshotMock).toHaveBeenCalledWith(
expect.objectContaining({
members: [
expect.objectContaining({
mcpPolicy: {
mode: 'strictAllowlist',
scopes: { user: true, project: false, local: false },
serverNames: ['github'],
},
}),
],
})
);
});
});

View file

@ -26,6 +26,7 @@ import {
setStoredCreateTeamMemberRuntimePreferences,
setStoredCreateTeamSyncModelsWithLead,
} from '@renderer/services/createTeamPreferences';
import { normalizeTeamMemberMcpPolicy } from '@shared/utils/teamMemberMcpPolicy';
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
@ -73,7 +74,7 @@ const DEBOUNCE_MS = 400;
function serializeMembers(members: MemberDraft[]): SerializedMemberDraft[] {
return members.map(
({ id, name, roleSelection, customRole, workflow, isolation, providerId, model, effort }) => ({
({
id,
name,
roleSelection,
@ -83,6 +84,18 @@ function serializeMembers(members: MemberDraft[]): SerializedMemberDraft[] {
providerId,
model,
effort,
mcpPolicy,
}) => ({
id,
name,
roleSelection,
customRole,
workflow,
isolation,
providerId,
model,
effort,
mcpPolicy: normalizeTeamMemberMcpPolicy(mcpPolicy),
})
);
}
@ -99,6 +112,7 @@ function deserializeMembers(serialized: SerializedMemberDraft[]): MemberDraft[]
providerId: m.providerId,
model: m.model,
effort: m.effort,
mcpPolicy: normalizeTeamMemberMcpPolicy(m.mcpPolicy),
})
);
}

View file

@ -10,10 +10,11 @@
*/
import { isTeamEffortLevel } from '@shared/utils/effortLevels';
import { normalizeTeamMemberMcpPolicy } from '@shared/utils/teamMemberMcpPolicy';
import { isTeamProviderId } from '@shared/utils/teamProvider';
import { del, get, set } from 'idb-keyval';
import type { TeamProviderId } from '@shared/types';
import type { TeamMemberMcpPolicy, TeamProviderId } from '@shared/types';
import type { EffortLevel } from '@shared/types';
// ---------------------------------------------------------------------------
@ -34,6 +35,7 @@ export interface SerializedMemberDraft {
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
mcpPolicy?: TeamMemberMcpPolicy;
}
export interface CreateTeamDraftSnapshot {
@ -64,6 +66,10 @@ const STORAGE_KEY = 'createTeamDraft:form';
function isValidMember(m: unknown): m is SerializedMemberDraft {
if (typeof m !== 'object' || m === null) return false;
const obj = m as Record<string, unknown>;
const mcpPolicyMode =
obj.mcpPolicy && typeof obj.mcpPolicy === 'object'
? (obj.mcpPolicy as Record<string, unknown>).mode
: undefined;
return (
typeof obj.id === 'string' &&
typeof obj.name === 'string' &&
@ -72,7 +78,10 @@ function isValidMember(m: unknown): m is SerializedMemberDraft {
(obj.isolation === undefined || obj.isolation === 'worktree') &&
(obj.providerId === undefined || isTeamProviderId(obj.providerId)) &&
(obj.model === undefined || typeof obj.model === 'string') &&
(obj.effort === undefined || isTeamEffortLevel(obj.effort))
(obj.effort === undefined || isTeamEffortLevel(obj.effort)) &&
(obj.mcpPolicy === undefined ||
mcpPolicyMode === 'inheritLead' ||
normalizeTeamMemberMcpPolicy(obj.mcpPolicy) !== undefined)
);
}

View file

@ -2035,6 +2035,7 @@ function buildConfigFallbackMemberSnapshots(snapshot: TeamViewSnapshot): TeamMem
providerBackendId: member.providerBackendId,
model: member.model,
effort: member.effort,
mcpPolicy: member.mcpPolicy,
selectedFastMode: member.fastMode,
cwd: member.cwd,
removedAt: member.removedAt,
@ -2082,6 +2083,7 @@ interface SummaryFallbackMemberSource {
agentId?: string;
role?: string;
color?: string;
mcpPolicy?: TeamMemberSnapshot['mcpPolicy'];
}
function normalizeSummaryTeammateName(
@ -2120,6 +2122,7 @@ function getSummaryRosterTeammateSources(summary: TeamSummary): SummaryFallbackM
agentId: member.agentId,
role: member.role,
color: member.color,
mcpPolicy: member.mcpPolicy,
});
}
return sources;
@ -2218,7 +2221,7 @@ function buildSummaryFallbackMemberSnapshots(
const seenNames = new Set<string>();
const buildSnapshot = (
name: string,
source?: { agentId?: string; role?: string; color?: string },
source?: Omit<SummaryFallbackMemberSource, 'name'>,
lead = false
): TeamMemberSnapshot | null => {
const trimmed = name.trim();
@ -2237,6 +2240,7 @@ function buildSummaryFallbackMemberSnapshots(
color: source?.color ?? getMemberColorByName(trimmed),
agentType: lead ? 'team-lead' : undefined,
role: source?.role ?? (lead ? 'Team Lead' : undefined),
mcpPolicy: source?.mcpPolicy,
};
};
@ -5602,7 +5606,11 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
description: request.description || '',
color: request.color,
memberCount: request.members.length,
members: request.members.map((m) => ({ name: m.name, role: m.role })),
members: request.members.map((m) => ({
name: m.name,
role: m.role,
mcpPolicy: m.mcpPolicy,
})),
taskCount: 0,
lastActivity: null,
projectPath: request.cwd || undefined,

View file

@ -15,12 +15,23 @@ export interface TeamMember {
model?: string;
effort?: EffortLevel;
fastMode?: TeamFastMode;
mcpPolicy?: TeamMemberMcpPolicy;
color?: string;
joinedAt?: number;
cwd?: string;
removedAt?: number;
}
export type TeamMemberMcpScope = 'user' | 'project' | 'local';
export type TeamMemberMcpMode = 'inheritLead' | 'inheritScopes' | 'strictAllowlist' | 'appOnly';
export interface TeamMemberMcpPolicy {
mode: TeamMemberMcpMode;
scopes?: Partial<Record<TeamMemberMcpScope, boolean>>;
serverNames?: string[];
}
export interface TeamConfig {
name: string;
description?: string;
@ -47,6 +58,7 @@ export interface TeamSummaryMember {
agentId?: string;
role?: string;
color?: string;
mcpPolicy?: TeamMemberMcpPolicy;
}
export interface TeamSummary {
@ -837,6 +849,7 @@ export interface ResolvedTeamMember {
providerBackendId?: TeamProviderBackendId;
model?: string;
effort?: EffortLevel;
mcpPolicy?: TeamMemberMcpPolicy;
selectedFastMode?: TeamFastMode;
resolvedFastMode?: boolean;
laneId?: string;
@ -906,6 +919,7 @@ export interface TeamMemberSnapshot {
providerBackendId?: TeamProviderBackendId;
model?: string;
effort?: EffortLevel;
mcpPolicy?: TeamMemberMcpPolicy;
selectedFastMode?: TeamFastMode;
resolvedFastMode?: boolean;
laneId?: string;
@ -1394,6 +1408,7 @@ export interface TeamProvisioningMemberInput {
model?: string;
effort?: EffortLevel;
fastMode?: TeamFastMode;
mcpPolicy?: TeamMemberMcpPolicy;
}
export type TeamWorktreeGitBlockReason =
@ -1661,6 +1676,7 @@ export interface AddMemberRequest {
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
mcpPolicy?: TeamMemberMcpPolicy;
}
export interface RemoveMemberRequest {

View file

@ -34,6 +34,17 @@ describe('isEphemeralProjectPath', () => {
);
});
it('detects provider launch stress temp project paths only under temp roots', () => {
expect(
isEphemeralProjectPath(
'/private/var/folders/7b/ydmc_b0n251bc4hss4tz8y880000gn/T/provider-launch-stress-live-rn5TFr/project'
)
).toBe(true);
expect(
isEphemeralProjectPath('/Users/test/projects/provider-launch-stress-live-real/project')
).toBe(false);
});
it('keeps normal project paths selectable', () => {
expect(isEphemeralProjectPath('/Users/test/projects/claude_team')).toBe(false);
expect(isEphemeralProjectPath('')).toBe(false);

View file

@ -11,6 +11,13 @@ function getBasename(normalizedPath: string): string {
return segments[segments.length - 1] ?? '';
}
function hasPathSegmentWithPrefix(normalizedPath: string, prefix: string): boolean {
return normalizedPath
.split('/')
.filter(Boolean)
.some((segment) => segment.startsWith(prefix));
}
function isKnownTempRoot(normalizedPath: string): boolean {
return (
normalizedPath.startsWith('/private/var/folders/') ||
@ -37,5 +44,9 @@ export function isEphemeralProjectPath(projectPath: string | null | undefined):
}
const basename = getBasename(normalizedPath);
return basename.startsWith('codex-agent-teams-appstyle-') && isKnownTempRoot(normalizedPath);
return (
(basename.startsWith('codex-agent-teams-appstyle-') ||
hasPathSegmentWithPrefix(normalizedPath, 'provider-launch-stress-live-')) &&
isKnownTempRoot(normalizedPath)
);
}

View file

@ -0,0 +1,141 @@
import type { TeamMemberMcpMode, TeamMemberMcpPolicy, TeamMemberMcpScope } from '@shared/types';
export const TEAM_MEMBER_MCP_SCOPES: readonly TeamMemberMcpScope[] = [
'user',
'project',
'local',
] as const;
const TEAM_MEMBER_MCP_MODES = new Set<TeamMemberMcpMode>([
'inheritLead',
'inheritScopes',
'strictAllowlist',
'appOnly',
]);
const DEFAULT_MCP_SCOPES: Record<TeamMemberMcpScope, boolean> = {
user: true,
project: true,
local: true,
};
function hasAnyResolvedMcpScope(scopes: Partial<Record<TeamMemberMcpScope, boolean>>): boolean {
return TEAM_MEMBER_MCP_SCOPES.some((scope) => scopes[scope] ?? DEFAULT_MCP_SCOPES[scope]);
}
function normalizeMcpMode(value: unknown): TeamMemberMcpMode | null {
return typeof value === 'string' && TEAM_MEMBER_MCP_MODES.has(value as TeamMemberMcpMode)
? (value as TeamMemberMcpMode)
: null;
}
export function normalizeTeamMemberMcpScopes(
value: unknown
): Partial<Record<TeamMemberMcpScope, boolean>> | undefined {
if (!value || typeof value !== 'object') {
return undefined;
}
const input = value as Record<string, unknown>;
const out: Partial<Record<TeamMemberMcpScope, boolean>> = {};
for (const scope of TEAM_MEMBER_MCP_SCOPES) {
if (typeof input[scope] === 'boolean') {
out[scope] = input[scope] as boolean;
}
}
return Object.keys(out).length > 0 ? out : undefined;
}
export function normalizeTeamMemberMcpServerNames(value: unknown): string[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const seen = new Set<string>();
const names: string[] = [];
for (const item of value) {
if (typeof item !== 'string') {
continue;
}
const name = item.trim();
if (!name || name.length > 128) {
continue;
}
const key = name.toLowerCase();
if (seen.has(key)) {
continue;
}
seen.add(key);
names.push(name);
if (names.length >= 100) {
break;
}
}
return names.length > 0 ? names : undefined;
}
export function normalizeTeamMemberMcpPolicy(value: unknown): TeamMemberMcpPolicy | undefined {
if (!value || typeof value !== 'object') {
return undefined;
}
const input = value as Record<string, unknown>;
const mode = normalizeMcpMode(input.mode);
if (!mode || mode === 'inheritLead') {
return undefined;
}
const scopes = normalizeTeamMemberMcpScopes(input.scopes);
const serverNames = normalizeTeamMemberMcpServerNames(input.serverNames);
if (mode === 'appOnly') {
return { mode };
}
if (scopes && !hasAnyResolvedMcpScope(scopes)) {
return {
mode: 'appOnly',
};
}
if (mode === 'strictAllowlist') {
return {
mode,
...(scopes ? { scopes } : {}),
...(serverNames ? { serverNames } : {}),
};
}
return {
mode,
...(scopes ? { scopes } : {}),
};
}
export function resolveTeamMemberMcpScopes(
policy: TeamMemberMcpPolicy | undefined
): Record<TeamMemberMcpScope, boolean> {
return {
user: policy?.scopes?.user ?? DEFAULT_MCP_SCOPES.user,
project: policy?.scopes?.project ?? DEFAULT_MCP_SCOPES.project,
local: policy?.scopes?.local ?? DEFAULT_MCP_SCOPES.local,
};
}
export function buildTeamMemberMcpSettingSources(policy: TeamMemberMcpPolicy | undefined): string {
if (policy?.mode !== 'inheritScopes') {
return 'user,project,local';
}
const scopes = resolveTeamMemberMcpScopes(policy);
const selected = TEAM_MEMBER_MCP_SCOPES.filter((scope) => scopes[scope]);
return selected.length > 0 ? selected.join(',') : 'user,project,local';
}
export function requiresStrictTeamMemberMcpConfig(
policy: TeamMemberMcpPolicy | undefined
): boolean {
return policy?.mode === 'strictAllowlist' || policy?.mode === 'appOnly';
}

View file

@ -133,6 +133,12 @@ function createStats(
} as Awaited<ReturnType<typeof fs.stat>>;
}
function createFsError(code: string): NodeJS.ErrnoException {
const error = new Error(code) as NodeJS.ErrnoException;
error.code = code;
return error;
}
// =============================================================================
// Tests
// =============================================================================
@ -354,6 +360,39 @@ describe('Editor IPC handlers', () => {
});
});
describe('project:listFiles', () => {
it('returns an empty list for deleted project paths', async () => {
vi.mocked(fs.stat).mockRejectedValue(createFsError('ENOENT'));
const result = await mockIpc.invoke('project:listFiles', '/tmp/deleted-project');
expect(result).toEqual({
success: true,
data: [],
});
});
it('returns an empty list for paths that are not directories', async () => {
vi.mocked(fs.stat).mockResolvedValue(createStats({ isDirectory: false, isFile: true }));
const result = await mockIpc.invoke('project:listFiles', '/tmp/project-file.txt');
expect(result).toEqual({
success: true,
data: [],
});
});
it('rejects empty explicit project paths', async () => {
const result = await mockIpc.invoke('project:listFiles', '');
expect(result).toEqual({
success: false,
error: expect.stringContaining('projectPath is required'),
});
});
});
describe('editor:gitStatus', () => {
it('rejects if editor not initialized', async () => {
const result = await mockIpc.invoke('editor:gitStatus');

View file

@ -2,10 +2,11 @@ import { createHash } from 'crypto';
import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises';
import os from 'os';
import path from 'path';
import { gzipSync } from 'zlib';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { gzipSync } from 'zlib';
const execCliMock = vi.hoisted(() => vi.fn());
const buildMergedCliPathMock = vi.hoisted(() => vi.fn());
const getCachedShellEnvMock = vi.hoisted(() => vi.fn());
const resolveInteractiveShellEnvBestEffortMock = vi.hoisted(() => vi.fn());
@ -13,6 +14,10 @@ vi.mock('@main/utils/childProcess', () => ({
execCli: execCliMock,
}));
vi.mock('@main/utils/cliPathMerge', () => ({
buildMergedCliPath: () => buildMergedCliPathMock(),
}));
vi.mock('@main/utils/shellEnv', () => ({
getCachedShellEnv: () => getCachedShellEnvMock(),
resolveInteractiveShellEnvBestEffort: (
@ -79,6 +84,8 @@ describe('OpenCodeRuntimeInstallerService resolver', () => {
process.env.PATH = '';
execCliMock.mockReset();
execCliMock.mockResolvedValue({ stdout: 'opencode 1.0.0\n', stderr: '' });
buildMergedCliPathMock.mockReset();
buildMergedCliPathMock.mockReturnValue('');
getCachedShellEnvMock.mockReset();
getCachedShellEnvMock.mockReturnValue(null);
resolveInteractiveShellEnvBestEffortMock.mockReset();
@ -211,6 +218,22 @@ describe('OpenCodeRuntimeInstallerService resolver', () => {
});
});
it('returns a verified OpenCode binary from the merged CLI PATH without interactive shell env resolution', async () => {
const binaryPath = path.join(tempRoot!, 'merged-cli-path', 'bin', 'opencode');
await mkdir(path.dirname(binaryPath), { recursive: true });
await writeFile(binaryPath, 'binary', { mode: 0o755 });
buildMergedCliPathMock.mockReturnValue(path.dirname(binaryPath));
await expect(resolveVerifiedOpenCodeRuntimeBinaryPath({ shellEnvTimeoutMs: 0 })).resolves.toBe(
binaryPath
);
expect(resolveInteractiveShellEnvBestEffortMock).not.toHaveBeenCalled();
expect(execCliMock).toHaveBeenCalledWith(binaryPath, ['--version'], {
timeout: 10_000,
windowsHide: true,
});
});
it('reports PATH-installed OpenCode as installed after best-effort shell env resolution', async () => {
const binaryPath = path.join(tempRoot!, 'homebrew', 'bin', 'opencode');
await mkdir(path.dirname(binaryPath), { recursive: true });
@ -227,6 +250,53 @@ describe('OpenCodeRuntimeInstallerService resolver', () => {
version: 'opencode 1.0.0',
});
});
it('prefers a working PATH OpenCode binary over a broken app-managed manifest', async () => {
const appManagedBinaryPath = path.join(
tempRoot!,
'data',
'runtimes',
'opencode',
'versions',
'1.0.0',
'opencode-test',
'opencode'
);
const pathBinaryPath = path.join(tempRoot!, 'homebrew', 'bin', 'opencode');
const manifestPath = path.join(tempRoot!, 'data', 'runtimes', 'opencode', 'current.json');
await mkdir(path.dirname(appManagedBinaryPath), { recursive: true });
await mkdir(path.dirname(pathBinaryPath), { recursive: true });
await mkdir(path.dirname(manifestPath), { recursive: true });
await writeFile(appManagedBinaryPath, 'broken binary', { mode: 0o755 });
await writeFile(pathBinaryPath, 'path binary', { mode: 0o755 });
await writeFile(
manifestPath,
`${JSON.stringify({
schemaVersion: 1,
version: '1.0.0',
platformPackage: 'opencode-test',
binaryPath: appManagedBinaryPath,
integrity: 'sha512-test',
installedAt: '2026-05-12T00:00:00.000Z',
})}\n`,
'utf8'
);
buildMergedCliPathMock.mockReturnValue(path.dirname(pathBinaryPath));
execCliMock.mockImplementation(async (binaryPath: string) => {
if (binaryPath === appManagedBinaryPath) {
throw new Error('broken app-managed runtime');
}
return { stdout: 'opencode 1.0.0\n', stderr: '' };
});
await expect(new OpenCodeRuntimeInstallerService().getStatus()).resolves.toMatchObject({
installed: true,
source: 'path',
state: 'ready',
binaryPath: pathBinaryPath,
version: 'opencode 1.0.0',
});
});
});
describe('OpenCodeRuntimeInstallerService package safety helpers', () => {

View file

@ -35,11 +35,23 @@ const liveDescribe =
? describe
: describe.skip;
const DEFAULT_ORCHESTRATOR_CLI =
'/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
const DEFAULT_LEAD_MODEL = 'claude-opus-4-6[1m]';
const DEFAULT_MEMBER_MODEL = 'haiku';
const DEFAULT_LEAD_EFFORT = 'medium' as const;
const DISABLE_USER_HOOKS_SETTINGS_ARG = "--settings '{\"disableAllHooks\":true}'";
interface LiveBootstrapSpec {
members: Array<{
name: string;
provider?: string;
model?: string;
effort?: string;
mcpConfigPath?: string;
mcpSettingSources?: string;
strictMcpConfig?: boolean;
}>;
}
liveDescribe('Anthropic launch selection live e2e', () => {
let tempDir: string;
@ -156,6 +168,7 @@ liveDescribe('Anthropic launch selection live e2e', () => {
restoreEnv('CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP', previousDisableRuntimeBootstrap);
restoreEnv('CLAUDE_TEAM_PROCESS_RUNTIME_READY_TIMEOUT_MS', previousRuntimeReadyTimeout);
restoreEnv('CLAUDE_TEAM_PROCESS_INBOX_POLLER_READY_TIMEOUT_MS', previousInboxPollerReadyTimeout);
clearBenignLiveWarningsIfOnlyBenign();
if (preserveArtifacts) {
process.stderr.write(`[AnthropicLaunchSelection.live] preserved temp dir: ${tempDir}\n`);
@ -180,7 +193,7 @@ liveDescribe('Anthropic launch selection live e2e', () => {
discardKnownAnthropicLaunchSelectionWarnings();
}, 180_000);
it('launches Opus 4.6 1M medium lead with explicit Haiku teammate without inherited effort', async () => {
it('launches Anthropic teammates with distinct model effort and MCP policies', async () => {
const orchestratorCli = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim();
expect(orchestratorCli).toBeTruthy();
await assertExecutable(orchestratorCli!);
@ -204,7 +217,7 @@ liveDescribe('Anthropic launch selection live e2e', () => {
model: leadModel,
effort: leadEffort,
skipPermissions: true,
extraCliArgs: "--settings '{\"disableAllHooks\":true}'",
extraCliArgs: DISABLE_USER_HOOKS_SETTINGS_ARG,
prompt: 'Keep the team idle after bootstrap. Do not start extra work.',
members: [
{
@ -212,10 +225,25 @@ liveDescribe('Anthropic launch selection live e2e', () => {
role: 'Reviewer',
providerId: 'anthropic',
model: memberModel,
mcpPolicy: { mode: 'appOnly' },
},
{
name: 'alice',
role: 'Developer',
mcpPolicy: {
mode: 'inheritScopes',
scopes: { user: false, project: false, local: true },
},
},
{
name: 'bob',
role: 'Auditor',
providerId: 'anthropic',
model: memberModel,
mcpPolicy: {
mode: 'strictAllowlist',
serverNames: ['github'],
},
},
],
},
@ -225,7 +253,15 @@ liveDescribe('Anthropic launch selection live e2e', () => {
);
const run = (
svc as unknown as { runs: Map<string, { allEffectiveMembers?: TeamMember[] }> }
svc as unknown as {
runs: Map<
string,
{
allEffectiveMembers?: TeamMember[];
bootstrapSpecPath?: string | null;
}
>;
}
).runs.get(response.runId);
expect(run?.allEffectiveMembers).toEqual([
expect.objectContaining({
@ -233,14 +269,60 @@ liveDescribe('Anthropic launch selection live e2e', () => {
providerId: 'anthropic',
model: memberModel,
effort: undefined,
mcpPolicy: { mode: 'appOnly' },
}),
expect.objectContaining({
name: 'alice',
providerId: 'anthropic',
model: leadModel,
effort: leadEffort,
mcpPolicy: {
mode: 'inheritScopes',
scopes: { user: false, project: false, local: true },
},
}),
expect.objectContaining({
name: 'bob',
providerId: 'anthropic',
model: memberModel,
effort: undefined,
mcpPolicy: {
mode: 'strictAllowlist',
serverNames: ['github'],
},
}),
]);
expect(run?.bootstrapSpecPath).toEqual(expect.any(String));
const bootstrapSpec = JSON.parse(
await fs.readFile(run!.bootstrapSpecPath!, 'utf8')
) as LiveBootstrapSpec;
const bootstrapMembersByName = new Map(
bootstrapSpec.members.map((member) => [member.name, member])
);
expect(bootstrapMembersByName.get('jack')).toMatchObject({
provider: 'anthropic',
model: memberModel,
mcpConfigPath: expect.any(String),
mcpSettingSources: 'user,project,local',
strictMcpConfig: true,
});
expect(bootstrapMembersByName.get('jack')).not.toHaveProperty('effort');
expect(bootstrapMembersByName.get('alice')).toMatchObject({
provider: 'anthropic',
model: leadModel,
effort: leadEffort,
mcpConfigPath: expect.any(String),
mcpSettingSources: 'local',
strictMcpConfig: false,
});
expect(bootstrapMembersByName.get('bob')).toMatchObject({
provider: 'anthropic',
model: memberModel,
mcpConfigPath: expect.any(String),
mcpSettingSources: 'user,project,local',
strictMcpConfig: true,
});
expect(bootstrapMembersByName.get('bob')).not.toHaveProperty('effort');
await waitUntil(async () => {
const last = progressEvents.at(-1);
@ -256,7 +338,7 @@ liveDescribe('Anthropic launch selection live e2e', () => {
if (statuses.teamLaunchState === 'partial_failure') {
throw new Error(await formatLaunchDiagnostics(svc!, teamName!, progressEvents));
}
return ['jack', 'alice'].every((memberName) => {
return ['jack', 'alice', 'bob'].every((memberName) => {
const member = statuses.statuses[memberName];
return (
member?.status === 'online' &&
@ -277,13 +359,16 @@ liveDescribe('Anthropic launch selection live e2e', () => {
snapshot.members.jack?.providerId === 'anthropic' &&
snapshot.members.jack.alive === true &&
snapshot.members.alice?.providerId === 'anthropic' &&
snapshot.members.alice.alive === true
snapshot.members.alice.alive === true &&
snapshot.members.bob?.providerId === 'anthropic' &&
snapshot.members.bob.alive === true
);
},
180_000,
2_000,
() => formatLaunchDiagnostics(svc!, teamName!, progressEvents)
);
clearBenignLiveWarningsIfOnlyBenign();
}, 480_000);
});
@ -574,3 +659,15 @@ function redactSecrets(text: string): string {
.replace(/sk-ant-api03-[A-Za-z0-9_-]+/g, '<redacted-anthropic-key>')
.replace(/\b(?:sk|ak)-[A-Za-z0-9_-]{20,}\b/g, '<redacted-api-key>');
}
function clearBenignLiveWarningsIfOnlyBenign(): void {
const warn = vi.mocked(console.warn);
if (
warn.mock.calls.length > 0 &&
warn.mock.calls.every((call) =>
call.map((part) => String(part)).join(' ').includes('[getConfig] slow read diag=')
)
) {
warn.mockClear();
}
}

View file

@ -25,8 +25,7 @@ const liveDescribe =
? describe
: describe.skip;
const DEFAULT_ORCHESTRATOR_CLI =
'/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
const DEFAULT_MODEL = 'haiku';
liveDescribe('Anthropic runtime memory live e2e', () => {

View file

@ -18,15 +18,16 @@ import {
getTeamsBasePath,
setClaudeBasePathOverride,
} from '../../../../src/main/utils/pathDecoder';
import {
assertExecutable,
formatMemberWorkSyncDiagnostics,
formatProgressDump,
type MemberWorkSyncLiveControlServer,
readRuntimeTurnSettledProcessedMetas,
restoreEnv,
startMemberWorkSyncControlServer,
throwIfClaudeTranscriptApiError,
type MemberWorkSyncLiveControlServer,
waitUntil,
} from './memberWorkSyncLiveHarness';
@ -48,8 +49,7 @@ const liveDescribe =
? describe
: describe.skip;
const DEFAULT_ORCHESTRATOR_CLI =
'/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
const DEFAULT_MODEL = 'sonnet';
const DEFAULT_EFFORT = 'low' as const;

View file

@ -12,15 +12,16 @@ import {
getTeamsBasePath,
setClaudeBasePathOverride,
} from '../../../../src/main/utils/pathDecoder';
import {
assertExecutable,
FatalWaitError,
formatMemberWorkSyncDiagnostics,
formatProgressDump,
type MemberWorkSyncLiveControlServer,
readRuntimeTurnSettledProcessedMetas,
restoreEnv,
startMemberWorkSyncControlServer,
type MemberWorkSyncLiveControlServer,
waitUntil,
} from './memberWorkSyncLiveHarness';
@ -45,8 +46,7 @@ const liveDescribe =
? describe
: describe.skip;
const DEFAULT_ORCHESTRATOR_CLI =
'/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
const DEFAULT_MODEL = 'gpt-5.4-mini';
const DEFAULT_EFFORT = 'low' as const;

View file

@ -11,11 +11,12 @@ import {
setClaudeBasePathOverride,
} from '../../../../src/main/utils/pathDecoder';
import { killProcessByPid } from '../../../../src/main/utils/processKill';
import {
createOpenCodeLiveHarness,
type OpenCodeLiveHarness,
waitForOpenCodeLanesStopped,
waitUntil,
type OpenCodeLiveHarness,
} from './openCodeLiveTestHarness';
import type { TeamProvisioningProgress } from '../../../../src/shared/types';
@ -36,8 +37,7 @@ const liveDescribe =
? describe
: describe.skip;
const DEFAULT_ORCHESTRATOR_CLI =
'/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
const DEFAULT_ANTHROPIC_MODEL = 'haiku';
const DEFAULT_CODEX_MODEL = 'gpt-5.4-mini';
const DEFAULT_OPENCODE_MODEL = 'openai/gpt-5.4-mini';

View file

@ -1,7 +1,6 @@
import { promises as fs } from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createOpenCodePromptDeliveryLedgerStore } from '../../../../src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger';
@ -15,11 +14,12 @@ import {
getTeamsBasePath,
setClaudeBasePathOverride,
} from '../../../../src/main/utils/pathDecoder';
import {
createOpenCodeLiveHarness,
type OpenCodeLiveHarness,
waitForOpenCodeLanesStopped,
waitForOpenCodeMemberIdle,
type OpenCodeLiveHarness,
} from './openCodeLiveTestHarness';
import type { ClaudeMultimodelBridgeService } from '../../../../src/main/services/runtime/ClaudeMultimodelBridgeService';
@ -31,8 +31,7 @@ const liveDescribe =
? describe
: describe.skip;
const DEFAULT_ORCHESTRATOR_CLI =
'/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
const DEFAULT_MODEL = 'opencode/big-pickle';
liveDescribe('OpenCode accept-fast delivery live e2e', () => {

View file

@ -1,9 +1,9 @@
import { constants as fsConstants, promises as fs } from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { applyOpenCodeAutoUpdatePolicy } from '../../../../src/main/services/runtime/openCodeAutoUpdatePolicy';
import { OpenCodeBridgeCommandClient } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient';
import {
createOpenCodeBridgeCommandLeaseStore,
@ -15,30 +15,29 @@ import {
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeHandshakeClient';
import { OpenCodeReadinessBridge } from '../../../../src/main/services/team/opencode/bridge/OpenCodeReadinessBridge';
import { OpenCodeStateChangingBridgeCommandService } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService';
import { getTeamBootstrapStatePath } from '../../../../src/main/services/team/TeamBootstrapStateReader';
import { TeamMembersMetaStore } from '../../../../src/main/services/team/TeamMembersMetaStore';
import { TeamMetaStore } from '../../../../src/main/services/team/TeamMetaStore';
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
import { TeamRuntimeAdapterRegistry } from '../../../../src/main/services/team/runtime/TeamRuntimeAdapter';
import { OpenCodeTeamRuntimeAdapter } from '../../../../src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter';
import { resolveAgentTeamsMcpLaunchSpec } from '../../../../src/main/services/team/TeamMcpConfigBuilder';
import {
readOpenCodeRuntimeLaneIndex,
upsertOpenCodeRuntimeLaneIndexEntry,
} from '../../../../src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader';
import { applyOpenCodeAutoUpdatePolicy } from '../../../../src/main/services/runtime/openCodeAutoUpdatePolicy';
import { OpenCodeTeamRuntimeAdapter } from '../../../../src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter';
import { TeamRuntimeAdapterRegistry } from '../../../../src/main/services/team/runtime/TeamRuntimeAdapter';
import { getTeamBootstrapStatePath } from '../../../../src/main/services/team/TeamBootstrapStateReader';
import { resolveAgentTeamsMcpLaunchSpec } from '../../../../src/main/services/team/TeamMcpConfigBuilder';
import { TeamMembersMetaStore } from '../../../../src/main/services/team/TeamMembersMetaStore';
import { TeamMetaStore } from '../../../../src/main/services/team/TeamMetaStore';
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
import {
getTeamsBasePath,
setClaudeBasePathOverride,
} from '../../../../src/main/utils/pathDecoder';
import type { RuntimeStoreManifestEvidence } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract';
import type { RuntimeStoreManifestReader } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService';
import type { OpenCodeBridgeCommandExecutor } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService';
import type {
TeamRuntimeLaunchInput,
TeamRuntimeStopInput,
} from '../../../../src/main/services/team/runtime/TeamRuntimeAdapter';
import type { RuntimeStoreManifestEvidence } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract';
import type { RuntimeStoreManifestReader } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService';
import type { OpenCodeBridgeCommandExecutor } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService';
const liveDescribe =
process.env.OPENCODE_E2E === '1' && process.env.OPENCODE_E2E_MIXED_RECOVERY === '1'
@ -47,8 +46,7 @@ const liveDescribe =
const liveMultiLaneIt = process.env.OPENCODE_E2E_MIXED_RECOVERY_MULTI === '1' ? it : it.skip;
const PROJECT_PATH = process.env.OPENCODE_E2E_PROJECT_PATH?.trim() || process.cwd();
const DEFAULT_ORCHESTRATOR_CLI =
'/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
const DEFAULT_MODEL = 'opencode/big-pickle';
liveDescribe('OpenCode mixed recovery live e2e', () => {
@ -349,7 +347,15 @@ async function commitMixedOpenCodeLaunchResult(input: {
memberName: string;
result: Awaited<ReturnType<OpenCodeTeamRuntimeAdapter['launch']>>;
}): Promise<Awaited<ReturnType<OpenCodeTeamRuntimeAdapter['launch']>>> {
return (input.service as any).guardCommittedOpenCodeSecondaryLaneEvidence({
const service = input.service as unknown as {
guardCommittedOpenCodeSecondaryLaneEvidence(args: {
teamName: string;
laneId: string;
memberName: string;
result: Awaited<ReturnType<OpenCodeTeamRuntimeAdapter['launch']>>;
}): Promise<Awaited<ReturnType<OpenCodeTeamRuntimeAdapter['launch']>>>;
};
return service.guardCommittedOpenCodeSecondaryLaneEvidence({
teamName: input.teamName,
laneId: input.laneId,
memberName: input.memberName,

View file

@ -1,9 +1,9 @@
import { constants as fsConstants, promises as fs } from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { applyOpenCodeAutoUpdatePolicy } from '../../../../src/main/services/runtime/openCodeAutoUpdatePolicy';
import { OpenCodeBridgeCommandClient } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient';
import {
createOpenCodeBridgeCommandLeaseStore,
@ -15,12 +15,11 @@ import {
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeHandshakeClient';
import { OpenCodeReadinessBridge } from '../../../../src/main/services/team/opencode/bridge/OpenCodeReadinessBridge';
import { OpenCodeStateChangingBridgeCommandService } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService';
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
import { TeamRuntimeAdapterRegistry } from '../../../../src/main/services/team/runtime/TeamRuntimeAdapter';
import { OpenCodeTeamRuntimeAdapter } from '../../../../src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter';
import { resolveAgentTeamsMcpLaunchSpec } from '../../../../src/main/services/team/TeamMcpConfigBuilder';
import { readOpenCodeRuntimeLaneIndex } from '../../../../src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader';
import { applyOpenCodeAutoUpdatePolicy } from '../../../../src/main/services/runtime/openCodeAutoUpdatePolicy';
import { OpenCodeTeamRuntimeAdapter } from '../../../../src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter';
import { TeamRuntimeAdapterRegistry } from '../../../../src/main/services/team/runtime/TeamRuntimeAdapter';
import { resolveAgentTeamsMcpLaunchSpec } from '../../../../src/main/services/team/TeamMcpConfigBuilder';
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
import {
getTeamsBasePath,
setClaudeBasePathOverride,
@ -37,8 +36,7 @@ const liveDescribe =
: describe.skip;
const PROJECT_PATH = process.env.OPENCODE_E2E_PROJECT_PATH?.trim() || process.cwd();
const DEFAULT_ORCHESTRATOR_CLI =
'/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
const DEFAULT_MODEL = 'opencode/big-pickle';
liveDescribe('OpenCode team provisioning live e2e', () => {

View file

@ -38,8 +38,7 @@ const liveDescribe =
? describe
: describe.skip;
const DEFAULT_ORCHESTRATOR_CLI =
'/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
const DEFAULT_ANTHROPIC_MODEL = 'haiku';
const DEFAULT_CODEX_MODEL = 'gpt-5.4-mini';
const DEFAULT_CODEX_EFFORT = 'low' as const;

View file

@ -93,12 +93,12 @@ describe('TeamMcpConfigBuilder', () => {
function readGeneratedServer(
configPath: string
): { command?: string; args?: string[]; env?: Record<string, string> } | undefined {
): { command?: string; args?: string[]; enabled?: boolean; env?: Record<string, string> } | undefined {
const raw = fs.readFileSync(configPath, 'utf8');
const parsed = JSON.parse(raw) as {
mcpServers?: Record<
string,
{ command?: string; args?: string[]; env?: Record<string, string> }
{ command?: string; args?: string[]; enabled?: boolean; env?: Record<string, string> }
>;
};
return parsed.mcpServers?.['agent-teams'];
@ -465,6 +465,70 @@ describe('TeamMcpConfigBuilder', () => {
expect(Object.keys(parsed.mcpServers)).toEqual(['agent-teams']);
});
it('inlines allowlisted MCP servers for strict member policies with Claude precedence', async () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-'));
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-project-'));
createdDirs.push(homeDir, projectDir);
mockHomeDir = homeDir;
fs.writeFileSync(
path.join(homeDir, '.claude.json'),
JSON.stringify(
{
mcpServers: {
github: { type: 'http', url: 'https://user.example.com/mcp' },
sentry: { command: 'node', args: ['sentry.js'] },
},
projects: {
[projectDir]: {
mcpServers: {
github: { type: 'http', url: 'https://local.example.com/mcp' },
},
},
},
},
null,
2
)
);
fs.writeFileSync(
path.join(projectDir, '.mcp.json'),
JSON.stringify(
{
mcpServers: {
github: { type: 'http', url: 'https://project.example.com/mcp' },
linear: { type: 'http', url: 'https://linear.example.com/mcp' },
},
},
null,
2
)
);
const builder = new TeamMcpConfigBuilder();
const configPath = await builder.writeConfigFile(projectDir, {
mode: 'strictAllowlist',
serverNames: ['GitHub', 'LINEAR'],
});
createdPaths.push(configPath);
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
mcpServers: Record<string, { command?: string; args?: string[]; type?: string; url?: string }>;
};
expect(Object.keys(parsed.mcpServers).sort()).toEqual(['agent-teams', 'github', 'linear']);
expect(parsed.mcpServers.github).toEqual({
type: 'http',
url: 'https://local.example.com/mcp',
});
expect(parsed.mcpServers.linear).toEqual({
type: 'http',
url: 'https://linear.example.com/mcp',
});
expect(parsed.mcpServers.sentry).toBeUndefined();
});
it('generated agent-teams server ignores same-named user MCP entry', async () => {
const { sourceEntry, tsxCli } = mockSourceWorkspaceEntryAvailable();
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-'));
@ -489,10 +553,70 @@ describe('TeamMcpConfigBuilder', () => {
createdPaths.push(configPath);
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
mcpServers: Record<string, { command?: string; args?: string[] }>;
mcpServers: Record<string, { command?: string; args?: string[]; enabled?: boolean }>;
};
expectNodeTsxSourceEntry(parsed.mcpServers['agent-teams'], tsxCli, sourceEntry);
expect(parsed.mcpServers['agent-teams']?.enabled).toBe(true);
});
it('forces generated agent-teams MCP even when user, project, local, or allowlist settings shadow it', async () => {
const { sourceEntry, tsxCli } = mockSourceWorkspaceEntryAvailable();
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-'));
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-project-'));
createdDirs.push(homeDir, projectDir);
mockHomeDir = homeDir;
fs.writeFileSync(
path.join(homeDir, '.claude.json'),
JSON.stringify(
{
mcpServers: {
'agent-teams': { command: 'node', args: ['user-shadow.js'], enabled: false },
},
projects: {
[projectDir]: {
mcpServers: {
'agent-teams': {
command: 'node',
args: ['local-shadow.js'],
enabled: false,
},
},
},
},
},
null,
2
)
);
fs.writeFileSync(
path.join(projectDir, '.mcp.json'),
JSON.stringify(
{
mcpServers: {
'agent-teams': { command: 'node', args: ['project-shadow.js'], enabled: false },
},
},
null,
2
)
);
const builder = new TeamMcpConfigBuilder();
const configPath = await builder.writeConfigFile(projectDir, {
mode: 'strictAllowlist',
serverNames: ['agent-teams'],
});
createdPaths.push(configPath);
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
mcpServers: Record<string, { command?: string; args?: string[]; enabled?: boolean }>;
};
expect(Object.keys(parsed.mcpServers)).toEqual(['agent-teams']);
expectNodeTsxSourceEntry(parsed.mcpServers['agent-teams'], tsxCli, sourceEntry);
expect(parsed.mcpServers['agent-teams']?.enabled).toBe(true);
});
it('forces the generated agent-teams MCP server on regardless of user, local, or project settings', async () => {

View file

@ -1,12 +1,12 @@
import type { ChildProcess } from 'child_process';
import { buildWorkspaceTrustPathCandidates } from '@features/workspace-trust/main';
import { EventEmitter } from 'events';
import * as fs from 'fs';
import { promises as fsPromises } from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildWorkspaceTrustPathCandidates } from '@features/workspace-trust/main';
import type { ChildProcess } from 'child_process';
const hoisted = vi.hoisted(() => ({
paths: {
@ -129,26 +129,19 @@ vi.mock('@main/utils/pathDecoder', async (importOriginal) => {
});
import {
getMixedLaunchFallbackRecoveryError,
TeamProvisioningService,
} from '@main/services/team/TeamProvisioningService';
killTmuxPaneForCurrentPlatformSync,
listRuntimeProcessTableForCurrentPlatform,
listTmuxPanePidsForCurrentPlatform,
listTmuxPaneRuntimeInfoForCurrentPlatform,
sendKeysToTmuxPaneForCurrentPlatform,
} from '@features/tmux-installer/main';
import { agentTeamsMcpHttpServer } from '@main/services/team/AgentTeamsMcpHttpServer';
import { TeamTaskActivityIntervalService } from '@main/services/team/TeamTaskActivityIntervalService';
import {
clearAutoResumeService,
getAutoResumeService,
initializeAutoResumeService,
} from '@main/services/team/AutoResumeService';
import { getTeamBootstrapStatePath } from '@main/services/team/TeamBootstrapStateReader';
import {
createPersistedLaunchSnapshot,
snapshotFromRuntimeMemberStatuses,
} from '@main/services/team/TeamLaunchStateEvaluator';
import {
getTeamLaunchStatePath,
getTeamLaunchSummaryPath,
} from '@main/services/team/TeamLaunchStateStore';
import { TeamConfigReader } from '@main/services/team/TeamConfigReader';
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
import { OPEN_CODE_BRIDGE_SCHEMA_VERSION } from '@main/services/team/opencode/bridge/OpenCodeBridgeCommandContract';
import { OpenCodeReadinessBridge } from '@main/services/team/opencode/bridge/OpenCodeReadinessBridge';
import {
@ -167,12 +160,26 @@ import {
OPENCODE_RUNTIME_STORE_DESCRIPTORS,
RuntimeStoreBatchWriter,
} from '@main/services/team/opencode/store/RuntimeStoreManifest';
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
import { OpenCodeTeamRuntimeAdapter } from '@main/services/team/runtime/OpenCodeTeamRuntimeAdapter';
import { TeamRuntimeAdapterRegistry } from '@main/services/team/runtime/TeamRuntimeAdapter';
import { getTeamBootstrapStatePath } from '@main/services/team/TeamBootstrapStateReader';
import { TeamConfigReader } from '@main/services/team/TeamConfigReader';
import {
createPersistedLaunchSnapshot,
snapshotFromRuntimeMemberStatuses,
} from '@main/services/team/TeamLaunchStateEvaluator';
import {
getTeamLaunchStatePath,
getTeamLaunchSummaryPath,
} from '@main/services/team/TeamLaunchStateStore';
import {
getMixedLaunchFallbackRecoveryError,
TeamProvisioningService,
} from '@main/services/team/TeamProvisioningService';
import { TeamTaskActivityIntervalService } from '@main/services/team/TeamTaskActivityIntervalService';
import { spawnCli } from '@main/utils/childProcess';
import { killProcessByPid } from '@main/utils/processKill';
import { encodePath } from '@main/utils/pathDecoder';
import { killProcessByPid } from '@main/utils/processKill';
import {
listWindowsProcessTable,
listWindowsProcessTableSync,
@ -181,13 +188,6 @@ import {
AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES,
AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES,
} from 'agent-teams-controller';
import {
killTmuxPaneForCurrentPlatformSync,
listRuntimeProcessTableForCurrentPlatform,
listTmuxPanePidsForCurrentPlatform,
listTmuxPaneRuntimeInfoForCurrentPlatform,
sendKeysToTmuxPaneForCurrentPlatform,
} from '@features/tmux-installer/main';
import pidusage from 'pidusage';
const EXPECTED_RUNTIME_PIDUSAGE_OPTIONS =
@ -625,6 +625,37 @@ function createMemberSpawnRun(params?: {
} as any;
}
type LeadActivityTestState = 'active' | 'idle' | 'offline';
interface LeadActivityTestRun {
runId: string;
teamName: string;
leadActivityState: LeadActivityTestState;
request: {
members: { name: string; role: string }[];
};
}
interface LeadActivityServiceInternals {
runs: Map<string, LeadActivityTestRun>;
aliveRunByTeam: Map<string, string>;
setLeadActivity(run: LeadActivityTestRun, state: LeadActivityTestState): void;
}
function toLeadActivityTestRun(
params: Parameters<typeof createMemberSpawnRun>[0],
leadActivityState: LeadActivityTestState,
leadName: string
): LeadActivityTestRun {
return {
...createMemberSpawnRun(params),
leadActivityState,
request: {
members: [{ name: leadName, role: 'Team Lead' }],
},
};
}
const TEST_OPENCODE_APP_MANAGED_BOOTSTRAP_PROMPT = [
'AGENT_TEAMS_APP_MANAGED_BOOTSTRAP_V1',
'<agent_teams_app_managed_briefing_source>',
@ -1274,6 +1305,7 @@ describe('TeamProvisioningService', () => {
},
expectedMembers: ['alice', 'bob'],
allEffectiveMembers: [{ name: 'alice' }, { name: 'bob' }],
teamLaunchedNotificationFired: undefined as boolean | undefined,
memberSpawnStatuses: new Map([
[
'alice',
@ -1332,6 +1364,205 @@ describe('TeamProvisioningService', () => {
}
});
it('does not latch the launched notification flag when called before all teammates join', async () => {
const { NotificationManager } =
await import('@main/services/infrastructure/NotificationManager');
const addTeamNotification = vi.fn(async (_payload: unknown) => undefined);
NotificationManager.setInstance({ addTeamNotification } as never);
try {
const svc = new TeamProvisioningService();
const run = {
runId: 'run-early-launch-toast',
teamName: 'early-launch-toast-team',
isLaunch: true,
request: {
cwd: tempClaudeRoot,
displayName: 'early-launch-toast-team',
},
expectedMembers: ['alice', 'bob'],
allEffectiveMembers: [{ name: 'alice' }, { name: 'bob' }],
teamLaunchedNotificationFired: undefined as boolean | undefined,
memberSpawnStatuses: new Map([
[
'alice',
createMemberSpawnStatusEntry({
status: 'online',
launchState: 'confirmed_alive',
runtimeAlive: true,
bootstrapConfirmed: true,
}),
],
[
'bob',
createMemberSpawnStatusEntry({
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: true,
bootstrapConfirmed: false,
}),
],
]),
};
const internals = svc as unknown as {
fireTeamLaunchedNotification(targetRun: typeof run): Promise<void>;
};
await internals.fireTeamLaunchedNotification(run);
expect(addTeamNotification).not.toHaveBeenCalled();
expect(run.teamLaunchedNotificationFired).toBeUndefined();
run.memberSpawnStatuses.set(
'bob',
createMemberSpawnStatusEntry({
status: 'online',
launchState: 'confirmed_alive',
runtimeAlive: true,
bootstrapConfirmed: true,
})
);
await internals.fireTeamLaunchedNotification(run);
expect(addTeamNotification).toHaveBeenCalledTimes(1);
} finally {
NotificationManager.resetInstance();
}
});
it('waits for current mixed secondary lane evidence before firing team launched', async () => {
const { NotificationManager } =
await import('@main/services/infrastructure/NotificationManager');
const addTeamNotification = vi.fn(async (_payload: unknown) => undefined);
NotificationManager.setInstance({ addTeamNotification } as never);
try {
const svc = new TeamProvisioningService();
const jackLane: {
laneId: string;
providerId: 'opencode';
member: { name: string; providerId: 'opencode' };
runId: string;
state: 'queued' | 'launching' | 'finished';
result: null | {
runId: string;
teamName: string;
launchPhase: 'finished';
teamLaunchState: 'clean_success';
members: Record<
string,
{
memberName: string;
providerId: 'opencode';
launchState: 'confirmed_alive';
agentToolAccepted: boolean;
runtimeAlive: boolean;
bootstrapConfirmed: boolean;
hardFailure: boolean;
}
>;
warnings: string[];
diagnostics: string[];
};
warnings: string[];
diagnostics: string[];
} = {
laneId: 'secondary:opencode:jack',
providerId: 'opencode',
member: { name: 'jack', providerId: 'opencode' },
runId: 'opencode-run-jack-current',
state: 'launching',
result: null,
warnings: [],
diagnostics: [],
};
const run = {
runId: 'run-mixed-lane-race',
teamName: 'mixed-lane-race-team',
isLaunch: true,
provisioningComplete: true,
processKilled: false,
cancelRequested: false,
progress: { state: 'ready' },
request: {
cwd: tempClaudeRoot,
displayName: 'mixed-lane-race-team',
},
expectedMembers: ['alice', 'jack'],
allEffectiveMembers: [{ name: 'alice' }, { name: 'jack', providerId: 'opencode' }],
mixedSecondaryLanes: [jackLane],
memberSpawnStatuses: new Map([
[
'alice',
createMemberSpawnStatusEntry({
status: 'online',
launchState: 'confirmed_alive',
runtimeAlive: true,
bootstrapConfirmed: true,
}),
],
[
'jack',
createMemberSpawnStatusEntry({
status: 'online',
launchState: 'confirmed_alive',
runtimeAlive: true,
bootstrapConfirmed: true,
}),
],
]),
};
const internals = svc as unknown as {
runs: Map<string, typeof run>;
aliveRunByTeam: Map<string, string>;
emitMemberSpawnChange(targetRun: typeof run, memberName: string): void;
};
internals.runs.set(run.runId, run);
internals.aliveRunByTeam.set(run.teamName, run.runId);
internals.emitMemberSpawnChange(run, 'jack');
await Promise.resolve();
expect(addTeamNotification).not.toHaveBeenCalled();
jackLane.state = 'finished';
jackLane.result = {
runId: 'opencode-run-jack-current',
teamName: 'mixed-lane-race-team',
launchPhase: 'finished',
teamLaunchState: 'clean_success',
members: {
jack: {
memberName: 'jack',
providerId: 'opencode',
launchState: 'confirmed_alive',
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: true,
hardFailure: false,
},
},
warnings: [],
diagnostics: [],
};
internals.emitMemberSpawnChange(run, 'jack');
await Promise.resolve();
expect(addTeamNotification).toHaveBeenCalledTimes(1);
expect(addTeamNotification).toHaveBeenCalledWith(
expect.objectContaining({
teamEventType: 'team_launched',
teamName: 'mixed-lane-race-team',
body: 'Team "mixed-lane-race-team" has been launched - all 2 teammates joined and are ready for tasks.',
})
);
} finally {
NotificationManager.resetInstance();
}
});
it('does not fire incomplete notification for pending-only teammates still joining', async () => {
const { NotificationManager } =
await import('@main/services/infrastructure/NotificationManager');
@ -2182,6 +2413,108 @@ describe('TeamProvisioningService', () => {
});
});
describe('lead activity task intervals', () => {
it('read-repairs active lead task intervals once when lead activity is polled', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-02T10:00:00.000Z'));
const resumeSpy = vi
.spyOn(TeamTaskActivityIntervalService.prototype, 'resumeActiveIntervalsForMember')
.mockReturnValue({ changedTasks: 1 });
try {
const svc = new TeamProvisioningService();
const internals = svc as unknown as LeadActivityServiceInternals;
const teamName = 'lead-activity-read-repair-team';
const run = toLeadActivityTestRun(
{
runId: 'run-lead-read-repair',
teamName,
expectedMembers: ['alice'],
},
'active',
'lead'
);
internals.runs.set(run.runId, run);
internals.aliveRunByTeam.set(teamName, run.runId);
expect(svc.getLeadActivityState(teamName)).toEqual({
state: 'active',
runId: run.runId,
});
expect(svc.getLeadActivityState(teamName)).toEqual({
state: 'active',
runId: run.runId,
});
expect(resumeSpy).toHaveBeenCalledTimes(1);
expect(resumeSpy).toHaveBeenCalledWith(teamName, 'lead', '2026-05-02T10:00:00.000Z');
} finally {
resumeSpy.mockRestore();
}
});
it('syncs lead task intervals only for the currently tracked run', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-02T10:05:00.000Z'));
const resumeSpy = vi
.spyOn(TeamTaskActivityIntervalService.prototype, 'resumeActiveIntervalsForMember')
.mockReturnValue({ changedTasks: 1 });
const pauseSpy = vi
.spyOn(TeamTaskActivityIntervalService.prototype, 'pauseActiveIntervalsForMember')
.mockReturnValue({ changedTasks: 1 });
try {
const svc = new TeamProvisioningService();
const internals = svc as unknown as LeadActivityServiceInternals;
const teamName = 'lead-activity-current-run-team';
const run = toLeadActivityTestRun(
{
runId: 'run-current-lead-activity',
teamName,
expectedMembers: ['alice'],
},
'idle',
'team-lead'
);
internals.runs.set(run.runId, run);
internals.aliveRunByTeam.set(teamName, run.runId);
internals.setLeadActivity(run, 'active');
internals.setLeadActivity(run, 'active');
internals.setLeadActivity(run, 'idle');
expect(resumeSpy).toHaveBeenCalledTimes(1);
expect(resumeSpy).toHaveBeenCalledWith(
teamName,
'team-lead',
'2026-05-02T10:05:00.000Z'
);
expect(pauseSpy).toHaveBeenCalledTimes(1);
expect(pauseSpy).toHaveBeenCalledWith(
teamName,
'team-lead',
'2026-05-02T10:05:00.000Z'
);
const staleRun = toLeadActivityTestRun(
{
runId: 'run-stale-lead-activity',
teamName,
expectedMembers: ['alice'],
},
'active',
'team-lead'
);
internals.runs.set(staleRun.runId, staleRun);
internals.setLeadActivity(staleRun, 'offline');
expect(pauseSpy).toHaveBeenCalledTimes(1);
} finally {
resumeSpy.mockRestore();
pauseSpy.mockRestore();
}
});
});
describe('member spawn status launch reads', () => {
it('coalesces concurrent active launch status reads and serves a short cached follow-up', async () => {
const svc = new TeamProvisioningService();
@ -14935,6 +15268,106 @@ describe('TeamProvisioningService', () => {
expect(launchArgs).not.toContain('--strict-mcp-config');
});
it('launches direct process teammate restarts with strict per-member MCP policy', async () => {
const teamName = 'process-strict-mcp-team';
const projectPath = path.join(tempProjectsBase, 'process-strict-mcp-project');
fs.mkdirSync(projectPath, { recursive: true });
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
const child = Object.assign(new EventEmitter(), {
pid: 4568,
stdin: { on: vi.fn(), unref: vi.fn() },
stdout: { pipe: vi.fn(), unref: vi.fn() },
stderr: { pipe: vi.fn(), unref: vi.fn() },
unref: vi.fn(),
});
vi.mocked(spawnCli).mockReturnValue(child as any);
const mcpConfigBuilder = {
writeConfigFile: vi.fn(async () => '/mock/strict-mcp-config.json'),
};
const svc = new TeamProvisioningService(
undefined,
undefined,
undefined,
undefined,
mcpConfigBuilder as any
);
const run = createMemberSpawnRun({
teamName,
expectedMembers: ['forge'],
memberSpawnStatuses: new Map(),
});
run.child = { pid: 111 };
run.request = { providerId: 'codex', skipPermissions: true };
run.detectedSessionId = 'lead-session-1';
const configuredMember = {
name: 'forge',
role: 'Developer',
providerId: 'codex',
model: 'gpt-5.4',
effort: 'medium',
agentType: 'general-purpose',
mcpPolicy: {
mode: 'strictAllowlist' as const,
scopes: { user: true, project: true, local: false },
serverNames: ['github'],
},
};
const config = {
name: 'Process Strict MCP Team',
projectPath,
leadSessionId: 'lead-session-1',
members: [{ name: 'team-lead', agentType: 'team-lead' }, configuredMember],
};
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
env: { CODEX_API_KEY: 'test-openai-key' },
authSource: 'openai_api_key',
providerArgs: [],
}));
(svc as any).buildTeamRuntimeLaunchArgsPlan = vi.fn(async () => ({
fastModeArgs: [],
runtimeTurnSettledHookArgs: [],
providerArgs: [],
settingsArgs: [],
extraArgs: [],
}));
(svc as any).materializeDirectProcessNativeBootstrapContext = vi.fn(async () => ({}));
(svc as any).updateDirectTmuxRestartMemberConfig = vi.fn(async () => {});
(svc as any).enqueueDirectRestartPrompt = vi.fn();
(svc as any).appendDirectProcessRuntimeEvent = vi.fn(async () => {});
await (svc as any).launchDirectProcessMemberRestart({
run,
teamName,
displayName: 'Process Strict MCP Team',
leadName: 'team-lead',
memberName: 'forge',
config,
configuredMember,
persistedRuntimeMembers: [],
});
child.emit('close', 0, null);
await new Promise((resolve) => setTimeout(resolve, 25));
expect(mcpConfigBuilder.writeConfigFile).toHaveBeenCalledWith(
projectPath,
configuredMember.mcpPolicy
);
const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[];
expect(launchArgs).toEqual(
expect.arrayContaining([
'--setting-sources',
'user,project,local',
'--mcp-config',
'/mock/strict-mcp-config.json',
'--strict-mcp-config',
])
);
});
it('rejects a second restart request while the first restart is still in flight', async () => {
const svc = new TeamProvisioningService();
const run = createMemberSpawnRun({
@ -15440,7 +15873,9 @@ describe('TeamProvisioningService', () => {
memberWorktreeManager?: { ensureMemberWorktree: ReturnType<typeof vi.fn> };
}) {
const mcpConfigBuilder = {
writeConfigFile: vi.fn(async () => path.join(tempClaudeRoot, 'mcp-config.json')),
writeConfigFile: vi.fn(async (_projectPath?: string, _policy?: unknown) =>
path.join(tempClaudeRoot, 'mcp-config.json')
),
removeConfigFile: vi.fn(async () => {}),
};
const membersMetaStore = {
@ -15509,6 +15944,9 @@ describe('TeamProvisioningService', () => {
model?: string;
effort?: string;
role?: string;
mcpConfigPath?: string;
mcpSettingSources?: string;
strictMcpConfig?: boolean;
}>;
};
}
@ -15718,6 +16156,96 @@ describe('TeamProvisioningService', () => {
await svc.cancelProvisioning(runId);
});
it('passes per-member MCP launch settings into deterministic bootstrap specs', async () => {
allowConsoleLogs();
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
vi.mocked(spawnCli).mockReturnValue(createRunningChild() as any);
const { svc, mcpConfigBuilder } = createSafeLaunchService();
mcpConfigBuilder.writeConfigFile.mockImplementation(async (_projectPath, policy) => {
const mode =
policy && typeof policy === 'object' && 'mode' in policy
? (policy as { mode?: unknown }).mode
: undefined;
if (mode === 'appOnly') return '/mock/member-mcp-app-only.json';
if (mode === 'inheritScopes') return '/mock/member-mcp-local-only.json';
if (mode === 'strictAllowlist') return '/mock/member-mcp-strict.json';
return '/mock/lead-mcp-config.json';
});
const { runId } = await svc.createTeam(
{
teamName: 'safe-member-mcp-policy-bootstrap',
cwd: tempClaudeRoot,
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
effort: 'medium',
members: [
{
name: 'alice',
providerId: 'codex',
mcpPolicy: { mode: 'appOnly' },
},
{
name: 'bob',
providerId: 'codex',
mcpPolicy: {
mode: 'inheritScopes',
scopes: { user: false, project: false, local: true },
},
},
{
name: 'jack',
providerId: 'codex',
mcpPolicy: {
mode: 'strictAllowlist',
serverNames: ['github'],
},
},
],
},
() => {}
);
const spawnArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[];
const bootstrapSpec = readBootstrapSpecFromSpawnArgs(spawnArgs);
expect(bootstrapSpec.members).toEqual([
expect.objectContaining({
name: 'alice',
mcpConfigPath: '/mock/member-mcp-app-only.json',
mcpSettingSources: 'user,project,local',
strictMcpConfig: true,
}),
expect.objectContaining({
name: 'bob',
mcpConfigPath: '/mock/member-mcp-local-only.json',
mcpSettingSources: 'local',
strictMcpConfig: false,
}),
expect.objectContaining({
name: 'jack',
mcpConfigPath: '/mock/member-mcp-strict.json',
mcpSettingSources: 'user,project,local',
strictMcpConfig: true,
}),
]);
expect(mcpConfigBuilder.writeConfigFile).toHaveBeenCalledWith(tempClaudeRoot, {
mode: 'appOnly',
});
expect(mcpConfigBuilder.writeConfigFile).toHaveBeenCalledWith(tempClaudeRoot, {
mode: 'inheritScopes',
scopes: { user: false, project: false, local: true },
});
expect(mcpConfigBuilder.writeConfigFile).toHaveBeenCalledWith(tempClaudeRoot, {
mode: 'strictAllowlist',
serverNames: ['github'],
});
expect(mcpConfigBuilder.writeConfigFile).toHaveBeenCalledWith(tempClaudeRoot);
await svc.cancelProvisioning(runId);
});
it('starts an Anthropic team without injecting lead effort into explicit teammate models', async () => {
allowConsoleLogs();
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');

View file

@ -1,11 +1,10 @@
import * as fs from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
import { TeamTaskActivityIntervalService } from '../../../../src/main/services/team/TeamTaskActivityIntervalService';
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
let tempDir = '';
@ -466,6 +465,50 @@ describe('TeamTaskActivityIntervalService', () => {
]);
});
it('reopens and closes lead work intervals across activity changes', async () => {
await writeTask('alpha', {
id: 'lead-task',
subject: 'Lead follow-up',
owner: 'team-lead',
status: 'in_progress',
workIntervals: [
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' },
],
historyEvents: [
{
id: 'event-created-active',
type: 'task_created',
status: 'in_progress',
timestamp: '2026-05-08T10:00:00.000Z',
actor: 'team-lead',
},
],
});
const service = new TeamTaskActivityIntervalService();
const resumeResult = service.resumeActiveIntervalsForMember(
'alpha',
'team-lead',
'2026-05-08T10:20:00.000Z'
);
expect(resumeResult.changedTasks).toBe(1);
expect((await readTask('alpha', 'lead-task')).workIntervals).toEqual([
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' },
{ startedAt: '2026-05-08T10:20:00.000Z' },
]);
const pauseResult = service.pauseActiveIntervalsForMember(
'alpha',
'team-lead',
'2026-05-08T10:25:00.000Z'
);
expect(pauseResult.changedTasks).toBe(1);
expect((await readTask('alpha', 'lead-task')).workIntervals).toEqual([
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' },
{ startedAt: '2026-05-08T10:20:00.000Z', completedAt: '2026-05-08T10:25:00.000Z' },
]);
});
it('does not resume intervals before the active work or review start', async () => {
await writeTask('alpha', {
id: 'work-task',

View file

@ -1,9 +1,8 @@
import Fastify from 'fastify';
import { constants as fsConstants, promises as fs } from 'fs';
import * as os from 'os';
import * as path from 'path';
import Fastify from 'fastify';
import { buildMemberWorkSyncRuntimeTurnSettledEnvironment } from '../../../../src/features/member-work-sync/main';
import { registerTeamRoutes } from '../../../../src/main/http/teams';
import { applyOpenCodeAutoUpdatePolicy } from '../../../../src/main/services/runtime/openCodeAutoUpdatePolicy';
@ -16,11 +15,10 @@ import {
createOpenCodeBridgeClientIdentity,
OpenCodeBridgeCommandHandshakePort,
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeHandshakeClient';
import type { RuntimeStoreManifestEvidence } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract';
import { OpenCodeReadinessBridge } from '../../../../src/main/services/team/opencode/bridge/OpenCodeReadinessBridge';
import {
OpenCodeStateChangingBridgeCommandService,
type OpenCodeBridgeCommandExecutor,
OpenCodeStateChangingBridgeCommandService,
type RuntimeStoreManifestReader,
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService';
import { readOpenCodeRuntimeLaneIndex } from '../../../../src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader';
@ -31,10 +29,10 @@ import { TeamProvisioningService } from '../../../../src/main/services/team/Team
import { getClaudeBasePath, getTeamsBasePath } from '../../../../src/main/utils/pathDecoder';
import type { HttpServices } from '../../../../src/main/http';
import type { RuntimeStoreManifestEvidence } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract';
import type { TaskRef } from '../../../../src/shared/types';
const DEFAULT_ORCHESTRATOR_CLI =
'/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
export interface InboxMessage {
from?: string;

View file

@ -1,5 +1,6 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
@ -637,6 +638,66 @@ describe('CLI status visibility during completed install state', () => {
});
});
it('does not show OpenCode retry install when the provider is effectively ready', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliInstallerState = 'idle';
storeState.openCodeRuntimeStatus = {
installed: false,
source: 'app-managed',
state: 'failed',
error: 'app-managed OpenCode install failed earlier',
};
storeState.openCodeRuntimeStatusLoading = false;
storeState.cliStatus = createInstalledCliStatus({
flavor: 'agent_teams_orchestrator',
displayName: 'Multimodel runtime',
supportsSelfUpdate: false,
showVersionDetails: false,
showBinaryPath: false,
authLoggedIn: true,
providers: [
{
providerId: 'opencode',
displayName: 'OpenCode (200+ models)',
supported: true,
authenticated: true,
authMethod: 'opencode_managed',
verificationState: 'verified',
statusMessage: 'Ready',
models: ['opencode/big-pickle'],
canLoginFromUi: false,
capabilities: {
teamLaunch: true,
oneShot: false,
},
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
modelCatalog: null,
},
],
});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(CliStatusBanner));
await Promise.resolve();
});
expect(host.textContent).toContain('Connected via opencode managed');
expect(host.textContent).not.toContain('Retry install');
const retryButton = Array.from(host.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Retry install'
);
expect(retryButton).toBeUndefined();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('always shows a provider-level Free models badge on the OpenCode dashboard card', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliInstallerState = 'idle';

View file

@ -460,9 +460,27 @@ vi.mock('@renderer/components/team/dialogs/ProvisioningProviderStatusList', () =
failIncompleteProviderChecks: (checks: unknown) => checks,
getPrimaryProvisioningFailureDetail: () => null,
getProvisioningFailureHint: () => 'hint',
getProvisioningProviderProgressMessage: () => 'Checking selected providers in parallel...',
getProvisioningProviderBackendSummary: () => null,
shouldHideProvisioningProviderStatusList: () => false,
updateProviderCheck: (checks: unknown) => checks,
updateProviderCheck: (
checks: {
providerId: string;
status: string;
details: string[];
backendSummary?: string | null;
}[],
providerId: string,
patch: { status: string; details: string[]; backendSummary?: string | null }
) =>
checks.map((check) =>
check.providerId === providerId
? {
...check,
...patch,
}
: check
),
}));
vi.mock('@renderer/components/team/dialogs/TeamModelSelector', () => ({
@ -2030,7 +2048,8 @@ describe('LaunchTeamDialog', () => {
await flush();
});
const callsAfterSameSignatureRerender = vi.mocked(runProviderPrepareDiagnostics).mock.calls.length;
const callsAfterSameSignatureRerender = vi.mocked(runProviderPrepareDiagnostics).mock.calls
.length;
await act(async () => {
resolvePrepare({

View file

@ -7,6 +7,7 @@ import {
getPrimaryProvisioningFailureDetail,
getProvisioningFailureHint,
getProvisioningProviderBackendSummary,
getProvisioningProviderProgressMessage,
ProvisioningProviderStatusList,
} from '@renderer/components/team/dialogs/ProvisioningProviderStatusList';
import { afterEach, describe, expect, it, vi } from 'vitest';
@ -88,6 +89,7 @@ describe('ProvisioningProviderStatusList', () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onOpenProviderSettings = vi.fn();
await act(async () => {
root.render(
@ -102,6 +104,7 @@ describe('ProvisioningProviderStatusList', () => {
],
},
],
onOpenProviderSettings,
})
);
await Promise.resolve();
@ -110,6 +113,7 @@ describe('ProvisioningProviderStatusList', () => {
expect(host.textContent).toContain('OpenCode (OpenCode CLI): OpenCode app MCP unreachable');
expect(host.textContent).not.toContain('Selected model checks');
expect(host.textContent).not.toContain('model unavailable');
expect(host.querySelector('button')).toBeNull();
await act(async () => {
root.unmount();
@ -200,6 +204,58 @@ describe('ProvisioningProviderStatusList', () => {
});
});
it('offers provider settings for actionable Codex auth notes', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onOpenProviderSettings = vi.fn();
await act(async () => {
root.render(
React.createElement(ProvisioningProviderStatusList, {
checks: [
{
providerId: 'codex',
status: 'notes',
backendSummary: 'Codex native - auth required',
details: [
'Codex native requires OPENAI_API_KEY or CODEX_API_KEY, or a connected ChatGPT account. Add one before launching Codex.',
'Default - available for launch',
'5.5 - available for launch',
],
},
{
providerId: 'anthropic',
status: 'notes',
details: ['Opus 4.6 - available for launch'],
},
],
onOpenProviderSettings,
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('Open Codex settings');
const buttons = host.querySelectorAll('button');
expect(buttons).toHaveLength(1);
const button = buttons[0];
expect(button).not.toBeNull();
await act(async () => {
button?.click();
await Promise.resolve();
});
expect(onOpenProviderSettings).toHaveBeenCalledWith('codex');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('summarizes OpenCode advisory ping misses without failure wording', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
@ -567,4 +623,16 @@ describe('ProvisioningProviderStatusList', () => {
'Deep verification is still running. OpenCode free models may take around 20 seconds.',
});
});
it('labels provider-scoped prepare refreshes without implying every provider restarted', () => {
expect(getProvisioningProviderProgressMessage(['opencode'], 3)).toBe(
'Checking OpenCode provider...'
);
expect(getProvisioningProviderProgressMessage(['anthropic', 'codex'], 3)).toBe(
'Checking Anthropic, Codex providers...'
);
expect(getProvisioningProviderProgressMessage(['anthropic', 'codex', 'opencode'], 3)).toBe(
'Checking selected providers in parallel...'
);
});
});

View file

@ -0,0 +1,119 @@
import { buildProviderPreparePlans } from '@renderer/components/team/dialogs/providerPreparePlans';
import { describe, expect, it } from 'vitest';
import type {
CliProviderStatus,
TeamProviderId,
TeamProvisioningModelCheckRequest,
} from '@shared/types';
type RuntimeSignatureProvider = {
providerId: TeamProviderId;
[key: string]: unknown;
};
function providerStatusMap(
entries: readonly (readonly [TeamProviderId, RuntimeSignatureProvider])[]
): ReadonlyMap<TeamProviderId, CliProviderStatus | null | undefined> {
return new Map(entries) as unknown as ReadonlyMap<
TeamProviderId,
CliProviderStatus | null | undefined
>;
}
function modelChecksMap(
entries: readonly (readonly [TeamProviderId, readonly TeamProvisioningModelCheckRequest[]])[]
): ReadonlyMap<TeamProviderId, readonly TeamProvisioningModelCheckRequest[]> {
return new Map(entries);
}
describe('providerPreparePlans', () => {
it('keeps unchanged provider signatures and cache keys stable when another provider changes', () => {
const providerIds: TeamProviderId[] = ['codex', 'opencode'];
const selectedModelChecksByProvider = modelChecksMap([
['codex', [{ providerId: 'codex', model: 'gpt-5.5' }]],
['opencode', [{ providerId: 'opencode', model: 'opencode/big-pickle' }]],
]);
const backendSummaryByProvider = new Map<TeamProviderId, string | null>([
['codex', 'Codex native'],
['opencode', 'OpenCode CLI'],
]);
const first = buildProviderPreparePlans({
cwd: '/tmp/project',
providerIds,
selectedModelChecksByProvider,
backendSummaryByProvider,
limitContext: false,
runtimeProviderStatusById: providerStatusMap([
[
'codex',
{
providerId: 'codex',
supported: true,
authenticated: true,
authMethod: 'chatgpt',
selectedBackendId: 'codex-native',
resolvedBackendId: 'codex-native',
},
],
[
'opencode',
{
providerId: 'opencode',
supported: true,
authenticated: true,
authMethod: 'oauth',
selectedBackendId: 'opencode-cli',
resolvedBackendId: 'opencode-cli',
},
],
]),
cachedModelResultsByCacheKey: new Map(),
});
const second = buildProviderPreparePlans({
cwd: '/tmp/project',
providerIds,
selectedModelChecksByProvider,
backendSummaryByProvider,
limitContext: false,
runtimeProviderStatusById: providerStatusMap([
[
'codex',
{
providerId: 'codex',
supported: true,
authenticated: false,
authMethod: null,
selectedBackendId: 'codex-native',
resolvedBackendId: 'codex-native',
},
],
[
'opencode',
{
providerId: 'opencode',
supported: true,
authenticated: true,
authMethod: 'oauth',
selectedBackendId: 'opencode-cli',
resolvedBackendId: 'opencode-cli',
},
],
]),
cachedModelResultsByCacheKey: new Map(),
});
const firstByProvider = new Map(first.map((plan) => [plan.providerId, plan]));
const secondByProvider = new Map(second.map((plan) => [plan.providerId, plan]));
expect(firstByProvider.get('codex')?.requestSignature).not.toBe(
secondByProvider.get('codex')?.requestSignature
);
expect(firstByProvider.get('opencode')?.requestSignature).toBe(
secondByProvider.get('opencode')?.requestSignature
);
expect(firstByProvider.get('opencode')?.cacheKey).toBe(
secondByProvider.get('opencode')?.cacheKey
);
});
});

View file

@ -6,12 +6,28 @@ import {
} from '@renderer/components/team/dialogs/providerPrepareRequestSignature';
import { describe, expect, it } from 'vitest';
import type { CliProviderStatus, TeamProviderId } from '@shared/types';
type RuntimeSignatureProvider = {
providerId: TeamProviderId;
[key: string]: unknown;
};
function providerStatusMap(
entries: readonly (readonly [TeamProviderId, RuntimeSignatureProvider])[]
): ReadonlyMap<TeamProviderId, CliProviderStatus | null | undefined> {
return new Map(entries) as unknown as ReadonlyMap<
TeamProviderId,
CliProviderStatus | null | undefined
>;
}
describe('providerPrepareRequestSignature', () => {
it('stays stable for semantically identical provider runtime snapshots', () => {
const providerIds = ['codex'] as const;
const first = buildProviderPrepareRuntimeStatusSignature(
providerIds,
new Map([
providerStatusMap([
[
'codex',
{
@ -52,11 +68,11 @@ describe('providerPrepareRequestSignature', () => {
canLoginFromUi: true,
},
],
]) as any
])
);
const second = buildProviderPrepareRuntimeStatusSignature(
providerIds,
new Map([
providerStatusMap([
[
'codex',
{
@ -97,7 +113,7 @@ describe('providerPrepareRequestSignature', () => {
canLoginFromUi: true,
},
],
]) as any
])
);
expect(first).toBe(second);
@ -107,7 +123,7 @@ describe('providerPrepareRequestSignature', () => {
const providerIds = ['codex'] as const;
const authenticated = buildProviderPrepareRuntimeStatusSignature(
providerIds,
new Map([
providerStatusMap([
[
'codex',
{
@ -128,11 +144,11 @@ describe('providerPrepareRequestSignature', () => {
canLoginFromUi: true,
},
],
]) as any
])
);
const unauthenticated = buildProviderPrepareRuntimeStatusSignature(
providerIds,
new Map([
providerStatusMap([
[
'codex',
{
@ -154,7 +170,7 @@ describe('providerPrepareRequestSignature', () => {
canLoginFromUi: true,
},
],
]) as any
])
);
expect(authenticated).not.toBe(unauthenticated);
@ -164,7 +180,7 @@ describe('providerPrepareRequestSignature', () => {
const providerIds = ['codex'] as const;
const first = buildProviderPrepareRuntimeStatusSignature(
providerIds,
new Map([
providerStatusMap([
[
'codex',
{
@ -214,11 +230,11 @@ describe('providerPrepareRequestSignature', () => {
canLoginFromUi: true,
},
],
]) as any
])
);
const second = buildProviderPrepareRuntimeStatusSignature(
providerIds,
new Map([
providerStatusMap([
[
'codex',
{
@ -268,7 +284,7 @@ describe('providerPrepareRequestSignature', () => {
canLoginFromUi: true,
},
],
]) as any
])
);
expect(first).not.toBe(second);
@ -278,7 +294,7 @@ describe('providerPrepareRequestSignature', () => {
const providerIds = ['opencode'] as const;
const first = buildProviderPrepareRuntimeStatusSignature(
providerIds,
new Map([
providerStatusMap([
[
'opencode',
{
@ -308,11 +324,11 @@ describe('providerPrepareRequestSignature', () => {
],
},
],
]) as any
])
);
const second = buildProviderPrepareRuntimeStatusSignature(
providerIds,
new Map([
providerStatusMap([
[
'opencode',
{
@ -342,7 +358,7 @@ describe('providerPrepareRequestSignature', () => {
],
},
],
]) as any
])
);
expect(first).toBe(second);
@ -352,7 +368,7 @@ describe('providerPrepareRequestSignature', () => {
const providerIds = ['opencode'] as const;
const first = buildProviderPrepareRuntimeStatusSignature(
providerIds,
new Map([
providerStatusMap([
[
'opencode',
{
@ -370,11 +386,11 @@ describe('providerPrepareRequestSignature', () => {
},
},
],
]) as any
])
);
const second = buildProviderPrepareRuntimeStatusSignature(
providerIds,
new Map([
providerStatusMap([
[
'opencode',
{
@ -400,7 +416,7 @@ describe('providerPrepareRequestSignature', () => {
},
},
],
]) as any
])
);
expect(first).toBe(second);
@ -409,7 +425,7 @@ describe('providerPrepareRequestSignature', () => {
it('still changes the full request signature when selected OpenCode model checks change', () => {
const runtimeStatusSignature = buildProviderPrepareRuntimeStatusSignature(
['opencode'],
new Map([
providerStatusMap([
[
'opencode',
{
@ -419,21 +435,15 @@ describe('providerPrepareRequestSignature', () => {
authMethod: 'oauth',
selectedBackendId: 'opencode-cli',
resolvedBackendId: 'opencode-cli',
models: [
'opencode/minimax-m2.5-free',
'opencode/qwen3.6-plus-free',
],
models: ['opencode/minimax-m2.5-free', 'opencode/qwen3.6-plus-free'],
modelCatalog: {
source: 'live',
status: 'ready',
models: [
{ id: 'opencode/minimax-m2.5-free' },
{ id: 'opencode/qwen3.6-plus-free' },
],
models: [{ id: 'opencode/minimax-m2.5-free' }, { id: 'opencode/qwen3.6-plus-free' }],
},
},
],
]) as any
])
);
const first = buildProviderPrepareRequestSignature({
@ -464,7 +474,7 @@ describe('providerPrepareRequestSignature', () => {
const providerIds = ['opencode'] as const;
const first = buildProviderPrepareRuntimeStatusSignature(
providerIds,
new Map([
providerStatusMap([
[
'opencode',
{
@ -492,11 +502,11 @@ describe('providerPrepareRequestSignature', () => {
],
},
],
]) as any
])
);
const second = buildProviderPrepareRuntimeStatusSignature(
providerIds,
new Map([
providerStatusMap([
[
'opencode',
{
@ -524,12 +534,97 @@ describe('providerPrepareRequestSignature', () => {
],
},
],
]) as any
])
);
expect(first).toBe(second);
});
it('ignores backend option status churn when the selected backend identity is unchanged', () => {
const providerIds = ['opencode'] as const;
const first = buildProviderPrepareRuntimeStatusSignature(
providerIds,
providerStatusMap([
[
'opencode',
{
providerId: 'opencode',
supported: true,
authenticated: true,
authMethod: 'oauth',
selectedBackendId: 'opencode-cli',
resolvedBackendId: 'opencode-cli',
availableBackends: [
{
id: 'opencode-cli',
available: false,
selectable: true,
state: 'degraded',
recommended: true,
audience: 'general',
statusMessage: 'PONG probe still running',
},
],
},
],
])
);
const second = buildProviderPrepareRuntimeStatusSignature(
providerIds,
providerStatusMap([
[
'opencode',
{
providerId: 'opencode',
supported: true,
authenticated: true,
authMethod: 'oauth',
selectedBackendId: 'opencode-cli',
resolvedBackendId: 'opencode-cli',
availableBackends: [
{
id: 'opencode-cli',
available: true,
selectable: true,
state: 'ready',
recommended: true,
audience: 'general',
statusMessage: 'Managed runtime verified',
},
],
},
],
])
);
expect(first).toBe(second);
});
it('ignores volatile member draft ids in provider prepare signatures', () => {
const first = buildProviderPrepareMembersSignature([
{
id: 'draft-before-poll',
name: 'tom',
roleSelection: '',
customRole: 'Developer',
providerId: 'opencode',
model: 'opencode/big-pickle',
},
]);
const second = buildProviderPrepareMembersSignature([
{
id: 'draft-after-poll',
name: 'tom',
roleSelection: '',
customRole: 'Developer',
providerId: 'opencode',
model: 'opencode/big-pickle',
},
]);
expect(first).toBe(second);
});
it('builds a stable composite request signature for unchanged member/model selections', () => {
const membersSignature = buildProviderPrepareMembersSignature([
{

View file

@ -93,6 +93,11 @@ describe('isThoughtProtocolNoise', () => {
).toBe(true);
});
it('does not hide ordinary one-word acknowledgements', () => {
expect(isThoughtProtocolNoise('OK')).toBe(false);
expect(isThoughtProtocolNoise('ok.')).toBe(false);
});
it('returns false for normal text', () => {
expect(isThoughtProtocolNoise('Reviewing the PR now.')).toBe(false);
});

View file

@ -0,0 +1,48 @@
import {
buildTeamMemberMcpSettingSources,
normalizeTeamMemberMcpPolicy,
requiresStrictTeamMemberMcpConfig,
} from '@shared/utils/teamMemberMcpPolicy';
import { describe, expect, it } from 'vitest';
describe('teamMemberMcpPolicy', () => {
it('normalizes inheritLead to the default unset policy', () => {
expect(normalizeTeamMemberMcpPolicy({ mode: 'inheritLead' })).toBeUndefined();
});
it('keeps partial scope overrides while defaulting unspecified scopes to enabled', () => {
const policy = normalizeTeamMemberMcpPolicy({
mode: 'inheritScopes',
scopes: { user: false },
});
expect(policy).toEqual({ mode: 'inheritScopes', scopes: { user: false } });
expect(buildTeamMemberMcpSettingSources(policy)).toBe('project,local');
expect(requiresStrictTeamMemberMcpConfig(policy)).toBe(false);
});
it('turns explicit no-scope policies into appOnly', () => {
for (const mode of ['inheritScopes', 'strictAllowlist'] as const) {
const policy = normalizeTeamMemberMcpPolicy({
mode,
scopes: { user: false, project: false, local: false },
serverNames: ['github'],
});
expect(policy).toEqual({ mode: 'appOnly' });
expect(requiresStrictTeamMemberMcpConfig(policy)).toBe(true);
}
});
it('deduplicates strict allowlist names case-insensitively', () => {
expect(
normalizeTeamMemberMcpPolicy({
mode: 'strictAllowlist',
serverNames: ['github', ' GitHub ', 'sentry'],
})
).toEqual({
mode: 'strictAllowlist',
serverNames: ['github', 'sentry'],
});
});
});