diff --git a/landing/assets/styles/cyberpunk-hero.scss b/landing/assets/styles/cyberpunk-hero.scss index c771cd8c..19716fff 100644 --- a/landing/assets/styles/cyberpunk-hero.scss +++ b/landing/assets/styles/cyberpunk-hero.scss @@ -586,6 +586,25 @@ opacity: 0.62; } +.cyber-action-button--primary .cyber-action-button__glow::after { + position: absolute; + inset: -42% -58%; + content: ""; + background: + linear-gradient( + 108deg, + transparent 0 42%, + rgba(255, 255, 255, 0.18) 47%, + rgba(255, 255, 255, 0.58) 50%, + rgba(0, 234, 255, 0.24) 53%, + transparent 59% 100% + ); + mix-blend-mode: screen; + opacity: 0; + transform: translate3d(-64%, 0, 0) skewX(-18deg); + animation: cyberActionButtonShine 4.8s cubic-bezier(0.25, 0.1, 0.22, 1) infinite; +} + .cyber-action-button__frame { position: absolute; inset: 0; @@ -1717,6 +1736,28 @@ } } +@keyframes cyberActionButtonShine { + 0%, + 54% { + opacity: 0; + transform: translate3d(-64%, 0, 0) skewX(-18deg); + } + + 61% { + opacity: 0.8; + } + + 76% { + opacity: 0.5; + } + + 84%, + 100% { + opacity: 0; + transform: translate3d(64%, 0, 0) skewX(-18deg); + } +} + @media (max-width: 1280px) { .cyber-hero__layout { grid-template-columns: minmax(500px, 0.9fr) minmax(0, 1.1fr); diff --git a/landing/composables/usePageSeo.ts b/landing/composables/usePageSeo.ts index 9eeccec6..61b0c852 100644 --- a/landing/composables/usePageSeo.ts +++ b/landing/composables/usePageSeo.ts @@ -39,7 +39,7 @@ export const usePageSeo = (titleKey: string, descriptionKey: string, options: Pa const resolvedImage = computed(() => { if (options.image) return options.image; return { - url: "/og-image.png", + url: "/og-image-agent-teams-v5.png", width: 1200, height: 630, type: "image/png", diff --git a/landing/error.vue b/landing/error.vue index b16b4f97..0afcf215 100644 --- a/landing/error.vue +++ b/landing/error.vue @@ -7,9 +7,34 @@ const props = defineProps<{ }>(); const { t } = useI18n(); +const config = useRuntimeConfig(); +const siteUrl = ((config.public.siteUrl as string) || "https://777genius.github.io/agent-teams-ai").replace(/\/+$/, ""); +const ogImage = `${siteUrl}/og-image-agent-teams-v5.png`; const statusCode = computed(() => props.error?.statusCode || 404); const isNotFound = computed(() => statusCode.value === 404); +const errorTitle = computed(() => (isNotFound.value ? t("error.notFoundTitle") : t("error.genericTitle"))); +const errorDescription = computed(() => (isNotFound.value ? t("error.notFoundDescription") : t("error.genericDescription"))); + +useSeoMeta({ + title: errorTitle, + description: errorDescription, + robots: "noindex, nofollow", + ogTitle: errorTitle, + ogDescription: errorDescription, + ogType: "website", + ogSiteName: "Agent Teams", + ogImage, + ogImageType: "image/png", + ogImageWidth: "1200", + ogImageHeight: "630", + ogImageAlt: "Agent Teams - AI agent orchestration", + twitterCard: "summary_large_image", + twitterTitle: errorTitle, + twitterDescription: errorDescription, + twitterImage: ogImage, + twitterImageAlt: "Agent Teams - AI agent orchestration" +}); const handleGoHome = () => clearError({ redirect: "/" }); diff --git a/landing/nuxt.config.ts b/landing/nuxt.config.ts index 3b75c2fc..a5675dd6 100644 --- a/landing/nuxt.config.ts +++ b/landing/nuxt.config.ts @@ -12,6 +12,9 @@ const muxPlaybackId = process.env.NUXT_PUBLIC_MUX_PLAYBACK_ID || "qyeNuDjFqoDALK const muxBackgroundPlaybackId = process.env.NUXT_PUBLIC_MUX_BACKGROUND_PLAYBACK_ID || muxPlaybackId; const baseURL = process.env.NUXT_APP_BASE_URL || "/"; const basePrefixedDocsPath = `${baseURL.replace(/\/?$/, "/")}docs`; +const defaultSeoTitle = "Agent Teams - AI Agent Orchestration for Developers"; +const defaultSeoDescription = "Free, open-source desktop app for AI agent teams. Start with a free model with no auth, then connect Claude, Codex, or OpenCode when you need more models."; +const defaultSeoImage = `${siteUrl.replace(/\/+$/, "")}/og-image-agent-teams-v5.png`; export default defineNuxtConfig({ compatibilityDate: "2026-01-19", @@ -20,6 +23,25 @@ export default defineNuxtConfig({ app: { baseURL, head: { + title: defaultSeoTitle, + meta: [ + { name: "description", content: defaultSeoDescription }, + { name: "robots", content: "noindex, nofollow" }, + { property: "og:title", content: defaultSeoTitle }, + { property: "og:description", content: defaultSeoDescription }, + { property: "og:type", content: "website" }, + { property: "og:site_name", content: "Agent Teams" }, + { property: "og:image", content: defaultSeoImage }, + { property: "og:image:type", content: "image/png" }, + { property: "og:image:width", content: "1200" }, + { property: "og:image:height", content: "630" }, + { property: "og:image:alt", content: "Agent Teams - AI agent orchestration" }, + { name: "twitter:card", content: "summary_large_image" }, + { name: "twitter:title", content: defaultSeoTitle }, + { name: "twitter:description", content: defaultSeoDescription }, + { name: "twitter:image", content: defaultSeoImage }, + { name: "twitter:image:alt", content: "Agent Teams - AI agent orchestration" } + ], link: [ { rel: "icon", type: "image/x-icon", href: `${baseURL}favicon.ico` }, { rel: "icon", type: "image/png", sizes: "32x32", href: `${baseURL}favicon-32.png` }, diff --git a/landing/public/og-image-agent-teams-v5.png b/landing/public/og-image-agent-teams-v5.png new file mode 100644 index 00000000..52e586fc Binary files /dev/null and b/landing/public/og-image-agent-teams-v5.png differ diff --git a/landing/public/og-image.png b/landing/public/og-image.png index 791372d6..52e586fc 100644 Binary files a/landing/public/og-image.png and b/landing/public/og-image.png differ diff --git a/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx b/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx index df3745fa..4646607f 100644 --- a/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx +++ b/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx @@ -44,6 +44,7 @@ import type { } from '../hooks/useRuntimeProviderManagement'; import type { RuntimeProviderConnectionDto, + RuntimeProviderDefaultModelSourceDto, RuntimeProviderDefaultScopeDto, RuntimeProviderDirectoryEntryDto, RuntimeProviderModelDto, @@ -140,14 +141,48 @@ function getProjectContextName(projectPath: string | null | undefined): string | function getDefaultScopeDescription(scope: RuntimeProviderDefaultScopeDto): string { return scope === 'all_projects' - ? 'Used by project contexts without their own OpenCode default. Local models are tested per project.' - : 'Applies only to the selected project context.'; + ? 'Default for every project that does not have its own OpenCode override.' + : 'Override only the selected project. Running teams are not changed.'; } function getDefaultScopeButtonLabel(scope: RuntimeProviderDefaultScopeDto): string { return scope === 'all_projects' ? 'Set all-projects default' : 'Set project default'; } +function getContextControlLabel(scope: RuntimeProviderDefaultScopeDto): string { + return scope === 'all_projects' ? 'Validation context' : 'Project override context'; +} + +function getContextControlHint( + scope: RuntimeProviderDefaultScopeDto, + projectPath: string | null | undefined +): string { + const projectName = getProjectContextName(projectPath) ?? projectPath?.trim(); + if (!projectName) { + return 'Select a project before testing local models or saving defaults.'; + } + return scope === 'all_projects' + ? `Tests use ${projectName}. Default applies unless a project has an override.` + : `Saving overrides only ${projectName}.`; +} + +function getDefaultModelSourceLabel( + source: RuntimeProviderDefaultModelSourceDto | null | undefined +): string | null { + switch (source) { + case 'project': + return 'project override'; + case 'all_projects': + return 'all projects'; + case 'opencode_config': + return 'OpenCode config'; + case 'fallback': + return 'fallback'; + default: + return null; + } +} + function isDefaultForScope( model: RuntimeProviderModelDto, state: RuntimeProviderManagementState, @@ -443,12 +478,12 @@ function RuntimeSummary({ state, onRefresh, disabled, - projectPath, -}: Pick & { +}: Pick & { onRefresh: () => void; }): JSX.Element { const runtime = state.view?.runtime; const loadingWithoutRuntime = state.loading && !runtime; + const defaultSourceLabel = getDefaultModelSourceLabel(state.view?.defaultModelSource); return (
) : null} -
-
- {projectPath - ? `Project context: ${getProjectContextName(projectPath) ?? 'current project'}` - : 'No project context selected'} + {defaultSourceLabel ? ( + Source: {defaultSourceLabel} + ) : null}
{state.loading ? (
-
OpenCode model scope
+
OpenCode defaults
{getDefaultScopeDescription(defaultScope)}
- {(['project', 'all_projects'] as const).map((scope) => ( + {(['all_projects', 'project'] as const).map((scope) => (
-
+
- +
setQuery(event.target.value)} + placeholder="Search model routes" + className="h-9 pl-10 pr-3 text-sm leading-5" + style={{ paddingLeft: 40 }} + /> +
- {models.map((model) => { + {visibleModels.length === 0 ? ( +
+ No OpenCode model routes match “{query.trim()}”. +
+ ) : null} + {visibleModels.map((model) => { const selected = state.selectedModelId === model.modelId; const testing = state.testingModelIds.includes(model.modelId); const savingDefault = state.savingDefaultModelId === model.modelId; @@ -1729,7 +1803,7 @@ export function RuntimeProviderManagementPanelView({ onProjectContextChange, }: RuntimeProviderManagementPanelViewProps): JSX.Element { const [selectedSection, setSelectedSection] = useState(null); - const [defaultScope, setDefaultScope] = useState('project'); + const [defaultScope, setDefaultScope] = useState('all_projects'); const providerQuery = state.providerQuery.trim().toLowerCase(); const filteredProviders = providerQuery ? state.providers.filter((provider) => @@ -1759,17 +1833,14 @@ export function RuntimeProviderManagementPanelView({ ? 'OpenCode provider catalog' : 'OpenCode providers'; const launchableModelCount = state.view?.configuredModels?.length ?? 0; - const activeSection = selectedSection ?? (launchableModelCount > 0 ? 'models' : 'providers'); + const modelsLoading = state.loading && launchableModelCount === 0; + const activeSection = + selectedSection ?? (modelsLoading || launchableModelCount > 0 ? 'models' : 'providers'); const hasProjectContext = Boolean(projectPath?.trim()); return (
- void actions.refresh()} - /> + void actions.refresh()} /> {state.error ? (
- {launchableModelCount === 0 ? ( + {modelsLoading ? ( +
+
+ + Loading OpenCode model routes... +
+ +
+ ) : null} + {!modelsLoading && launchableModelCount === 0 ? (
No launchable OpenCode model routes were reported yet. Configure a local route in OpenCode or use the Providers tab to inspect catalog providers. diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index 793f57a5..67440140 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -108,6 +108,11 @@ export interface ProjectScannerOptions { sessionIndexPersistDelayMs?: number; } +interface ScanInFlight { + generation: number; + promise: Promise; +} + function splitPathSegments(value: string): string[] { return value.split(/[/\\]+/).filter(Boolean); } @@ -184,6 +189,8 @@ export class ProjectScanner { // Short-lived scan cache to prevent duplicate scans within the same request cycle. // Both getProjects() and getRepositoryGroups() call scan() — the cache deduplicates. private scanCache: { projects: Project[]; timestamp: number } | null = null; + private scanInFlight: ScanInFlight | null = null; + private scanGeneration = 0; private static readonly SCAN_CACHE_TTL_MS = 2000; /** Cached project list for search — avoids re-scanning disk on every query */ @@ -243,6 +250,31 @@ export class ProjectScanner { return this.scanCache.projects; } + const generation = this.scanGeneration; + const inFlight = this.scanInFlight; + if (inFlight) { + if (inFlight.generation === generation) { + return inFlight.promise; + } + await inFlight.promise.catch(() => undefined); + if (this.scanInFlight?.promise === inFlight.promise) { + this.scanInFlight = null; + } + return this.scan(); + } + + const promise = this.performScan(generation); + this.scanInFlight = { generation, promise }; + try { + return await promise; + } finally { + if (this.scanInFlight?.promise === promise) { + this.scanInFlight = null; + } + } + } + + private async performScan(generation: number): Promise { const startedAt = Date.now(); let stage = 'start'; const slowWarnAfterMs = 10_000; @@ -263,7 +295,7 @@ export class ProjectScanner { stage = 'readdirProjectsDir'; const readdirStartedAt = Date.now(); - const entries = await this.fsProvider.readdir(this.projectsDir); + const entries = await this.readdirForProjectDiscovery(this.projectsDir); const readdirMs = Date.now() - readdirStartedAt; if (readdirMs >= 2000) { logger.warn(`[scan] readdir slow ms=${readdirMs} entries=${entries.length}`); @@ -298,7 +330,9 @@ export class ProjectScanner { `[scan] completed slow ms=${ms} projectDirs=${projectDirs.length} projects=${validProjects.length}` ); } - this.scanCache = { projects: validProjects, timestamp: Date.now() }; + if (this.scanGeneration === generation) { + this.scanCache = { projects: validProjects, timestamp: Date.now() }; + } return validProjects; } catch (error) { logger.error('Error scanning projects directory:', error); @@ -314,6 +348,14 @@ export class ProjectScanner { */ clearScanCache(): void { this.scanCache = null; + this.scanGeneration += 1; + } + + private async readdirForProjectDiscovery(dirPath: string): Promise { + if (this.fsProvider instanceof LocalFileSystemProvider) { + return this.fsProvider.readdir(dirPath, { prefetchEntryStats: false }); + } + return this.fsProvider.readdir(dirPath); } // =========================================================================== @@ -459,7 +501,7 @@ export class ProjectScanner { try { const projectPath = path.join(this.projectsDir, encodedName); const readdirStart = Date.now(); - const entries = await this.fsProvider.readdir(projectPath); + const entries = await this.readdirForProjectDiscovery(projectPath); const readdirMs = Date.now() - readdirStart; // Get session files (.jsonl at root level) diff --git a/src/main/services/infrastructure/LocalFileSystemProvider.ts b/src/main/services/infrastructure/LocalFileSystemProvider.ts index c21ce795..2bbbff94 100644 --- a/src/main/services/infrastructure/LocalFileSystemProvider.ts +++ b/src/main/services/infrastructure/LocalFileSystemProvider.ts @@ -23,6 +23,10 @@ const STAT_TIMEOUT_MS = 2000; // let callers stat only the files they actually need. const STAT_PREFETCH_LIMIT = 1500; +export interface LocalFileSystemProviderReaddirOptions { + prefetchEntryStats?: boolean; +} + async function statWithTimeout(filePath: string, timeoutMs: number): Promise { let timer: ReturnType | null = null; const timeout = new Promise((_resolve, reject) => { @@ -81,9 +85,12 @@ export class LocalFileSystemProvider implements FileSystemProvider { }; } - async readdir(dirPath: string): Promise { + async readdir( + dirPath: string, + options: LocalFileSystemProviderReaddirOptions = {} + ): Promise { const entries = await fs.promises.readdir(dirPath, { withFileTypes: true }); - if (entries.length > STAT_PREFETCH_LIMIT) { + if (options.prefetchEntryStats === false || entries.length > STAT_PREFETCH_LIMIT) { return entries.map((entry) => ({ name: entry.name, isFile: () => entry.isFile(), diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index e2bd6642..c462d184 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -44,7 +44,12 @@ interface RuntimeExtensionCapabilitiesResponse { interface RuntimeProviderCapabilitiesResponse { modelCatalog?: { dynamic?: boolean; - source?: 'anthropic-models-api' | 'app-server' | 'static-fallback' | 'runtime'; + source?: + | 'anthropic-models-api' + | 'anthropic-compatible-api' + | 'app-server' + | 'static-fallback' + | 'runtime'; }; reasoningEffort?: { supported?: boolean; @@ -82,7 +87,7 @@ interface RuntimeProviderModelCatalogItemResponse { supportsPersonality?: boolean; isDefault?: boolean; upgrade?: boolean; - source?: 'anthropic-models-api' | 'app-server' | 'static-fallback'; + source?: 'anthropic-models-api' | 'anthropic-compatible-api' | 'app-server' | 'static-fallback'; badgeLabel?: string | null; statusMessage?: string | null; metadata?: Record | null; @@ -91,7 +96,7 @@ interface RuntimeProviderModelCatalogItemResponse { interface RuntimeProviderModelCatalogResponse { schemaVersion?: number; providerId?: CliProviderId; - source?: 'anthropic-models-api' | 'app-server' | 'static-fallback'; + source?: 'anthropic-models-api' | 'anthropic-compatible-api' | 'app-server' | 'static-fallback'; status?: 'ready' | 'stale' | 'degraded' | 'unavailable'; fetchedAt?: string; staleAt?: string; @@ -573,6 +578,7 @@ function mapRuntimeProviderModelCatalog( !fetchedAt || !staleAt || (source !== 'anthropic-models-api' && + source !== 'anthropic-compatible-api' && source !== 'app-server' && source !== 'static-fallback') || (status !== 'ready' && status !== 'stale' && status !== 'degraded' && status !== 'unavailable') @@ -597,6 +603,7 @@ function mapRuntimeProviderModelCatalog( ); const itemSource = model.source === 'anthropic-models-api' || + model.source === 'anthropic-compatible-api' || model.source === 'app-server' || model.source === 'static-fallback' ? model.source diff --git a/src/main/services/runtime/ProviderConnectionService.ts b/src/main/services/runtime/ProviderConnectionService.ts index 50da552d..bf38e904 100644 --- a/src/main/services/runtime/ProviderConnectionService.ts +++ b/src/main/services/runtime/ProviderConnectionService.ts @@ -154,6 +154,28 @@ function buildAnthropicModelsUrl(baseUrl?: string | null): string { return url.toString(); } +function isAnthropicCompatibleBaseUrl(baseUrl?: string | null): boolean { + const trimmed = baseUrl?.trim(); + if (!trimmed) { + return false; + } + + try { + const host = new URL(trimmed).host; + return host !== 'api.anthropic.com' && host !== 'api-staging.anthropic.com'; + } catch { + return true; + } +} + +function hasAnthropicCompatibleAuthEnv(env: NodeJS.ProcessEnv): boolean { + if (!isAnthropicCompatibleBaseUrl(env.ANTHROPIC_BASE_URL)) { + return false; + } + + return Boolean(env.ANTHROPIC_AUTH_TOKEN?.trim() || env.ANTHROPIC_API_KEY?.trim()); +} + async function verifyAnthropicApiKeyWithApi( apiKey: string, baseUrl?: string | null @@ -376,6 +398,10 @@ export class ProviderConnectionService { return null; } + if (hasAnthropicCompatibleAuthEnv(env)) { + return null; + } + const storedKey = await this.apiKeyService.lookupPreferred('ANTHROPIC_API_KEY'); if (storedKey?.value.trim()) { return storedKey.value.trim(); @@ -392,6 +418,10 @@ export class ProviderConnectionService { options?: StoredApiKeyAccessOptions ): Promise { if (providerId === 'anthropic') { + if (hasAnthropicCompatibleAuthEnv(env)) { + return env; + } + const authMode = this.getConfiguredAuthMode(providerId); if (authMode === 'oauth') { delete env.ANTHROPIC_API_KEY; @@ -562,6 +592,10 @@ export class ProviderConnectionService { return null; } + if (hasAnthropicCompatibleAuthEnv(env)) { + return null; + } + if (typeof env.ANTHROPIC_API_KEY === 'string' && env.ANTHROPIC_API_KEY.trim()) { return null; } diff --git a/src/main/services/team/TeamBackupService.ts b/src/main/services/team/TeamBackupService.ts index 8d57d674..484af84d 100644 --- a/src/main/services/team/TeamBackupService.ts +++ b/src/main/services/team/TeamBackupService.ts @@ -76,6 +76,8 @@ const TEAM_RECURSIVE_SUBDIRS = ['.opencode-runtime', 'members']; const ATOMIC_WRITE_TEMP_FILE_PREFIX = '.tmp.'; const FILE_LOCK_SUFFIX = '.lock'; const QUARANTINED_OPENCODE_LANE_INDEX_RE = /^lanes\.invalid\.\d+\.json$/; +const MEMBER_WORK_SYNC_DIR = '.member-work-sync'; +const MEMBER_WORK_SYNC_JOURNAL_FILE = 'journal.jsonl'; // Subdirs under getAppDataPath() (our own storage, not in ~/.claude/) const APP_DATA_SUBDIRS = ['attachments']; const APP_DATA_DEEP_SUBDIRS = ['task-attachments']; @@ -122,6 +124,15 @@ function shouldCollectRecursiveBackupFile(relPath: string): boolean { if (QUARANTINED_OPENCODE_LANE_INDEX_RE.test(fileName)) { return false; } + const segments = relPath.split('/'); + const workSyncIndex = segments.lastIndexOf(MEMBER_WORK_SYNC_DIR); + if ( + segments[0] === 'members' && + workSyncIndex >= 2 && + segments[workSyncIndex + 1] === MEMBER_WORK_SYNC_JOURNAL_FILE + ) { + return false; + } return true; } @@ -140,12 +151,13 @@ async function collectRecursiveFiles( continue; } if (entry.isFile()) { - if (!shouldCollectRecursiveBackupFile(relPath)) { + const descriptorRelPath = relPrefix ? `${relPrefix}/${relPath}` : relPath; + if (!shouldCollectRecursiveBackupFile(descriptorRelPath)) { continue; } files.push({ sourcePath, - relPath: relPrefix ? `${relPrefix}/${relPath}` : relPath, + relPath: descriptorRelPath, }); } } @@ -167,12 +179,13 @@ function collectRecursiveFilesSync(rootDir: string, relPrefix: string): BackupFi continue; } if (entry.isFile()) { - if (!shouldCollectRecursiveBackupFile(relPath)) { + const descriptorRelPath = relPrefix ? `${relPrefix}/${relPath}` : relPath; + if (!shouldCollectRecursiveBackupFile(descriptorRelPath)) { continue; } files.push({ sourcePath, - relPath: relPrefix ? `${relPrefix}/${relPath}` : relPath, + relPath: descriptorRelPath, }); } } diff --git a/src/main/services/team/TeamLaunchStateEvaluator.ts b/src/main/services/team/TeamLaunchStateEvaluator.ts index 55628924..83711fdc 100644 --- a/src/main/services/team/TeamLaunchStateEvaluator.ts +++ b/src/main/services/team/TeamLaunchStateEvaluator.ts @@ -507,6 +507,7 @@ function normalizeLaunchIdentity( : 'default'; const catalogSource = raw.catalogSource === 'anthropic-models-api' || + raw.catalogSource === 'anthropic-compatible-api' || raw.catalogSource === 'app-server' || raw.catalogSource === 'static-fallback' || raw.catalogSource === 'runtime' || diff --git a/src/main/services/team/TeamMetaStore.ts b/src/main/services/team/TeamMetaStore.ts index 16ef2cab..af9a4899 100644 --- a/src/main/services/team/TeamMetaStore.ts +++ b/src/main/services/team/TeamMetaStore.ts @@ -75,6 +75,7 @@ function normalizeLaunchIdentity(value: unknown): ProviderModelLaunchIdentity | const catalogSource = raw.catalogSource === 'anthropic-models-api' || + raw.catalogSource === 'anthropic-compatible-api' || raw.catalogSource === 'app-server' || raw.catalogSource === 'static-fallback' || raw.catalogSource === 'runtime' || diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index f0a37908..48f46481 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -81,14 +81,8 @@ import { listWindowsProcessTable, listWindowsProcessTableSync, } from '@main/utils/windowsProcessTable'; +import { stripAgentBlocks, wrapAgentBlock } from '@shared/constants/agentBlocks'; import { - AGENT_BLOCK_CLOSE, - AGENT_BLOCK_OPEN, - stripAgentBlocks, - wrapAgentBlock, -} from '@shared/constants/agentBlocks'; -import { - CROSS_TEAM_PREFIX_TAG, CROSS_TEAM_SENT_SOURCE, CROSS_TEAM_SOURCE, parseCrossTeamPrefix, @@ -138,12 +132,6 @@ import { inferTeamProviderIdFromModel, normalizeOptionalTeamProviderId, } from '@shared/utils/teamProvider'; -import { - getTeamTaskWorkflowColumn, - isTeamTaskActivelyWorked, - isTeamTaskDeleted, - isTeamTaskNeedsFixActionable, -} from '@shared/utils/teamTaskState'; import { extractToolPreview, extractToolResultPreview, @@ -201,6 +189,50 @@ import { buildNativeAppManagedBootstrapSpecsWithDiagnostics, type NativeAppManagedBootstrapSpec, } from './bootstrap/NativeAppManagedBootstrapContextBuilder'; +import { getSystemLocale } from './provisioning/TeamProvisioningAgentLanguage'; +import { + buildDeterministicCreateBootstrapSpec, + buildDeterministicLaunchBootstrapSpec, + getProvisioningRunTimeoutMs, + removeDeterministicBootstrapSpecFile, + removeDeterministicBootstrapUserPromptFile, + type RuntimeBootstrapMemberMcpLaunchConfig, + writeDeterministicBootstrapSpecFile, + writeDeterministicBootstrapUserPromptFile, +} from './provisioning/TeamProvisioningBootstrapSpec'; +import { + buildEffectiveTeamMemberSpec, + buildEffectiveTeamMemberSpecs, + getExplicitLaunchModelSelection, + normalizeTeamMemberProviderId, + normalizeTeamProviderLike, + teamRequestIncludesCodexMember, +} from './provisioning/TeamProvisioningMemberSpecs'; +import { + buildDeterministicLaunchHydrationPrompt, + buildGeminiPostLaunchHydrationPrompt, + buildLeadRosterContextBlock, + buildMemberSpawnPrompt, + buildPersistentLeadContext, + buildRestartMemberSpawnMessage, + buildTaskBoardSnapshot, + extractBootstrapFailureReason, + extractHeartbeatTimestamp, + extractTranscriptMessageText, + getBootstrapTranscriptSuccessSource, + getCanonicalSendMessageFieldRule, + getCanonicalSendMessageToolRule, + isBootstrapInstructionPrompt, + isBootstrapTranscriptContextText, + isTaskBoardSnapshotWorkCandidate, + normalizeMemberDiagnosticText, + shouldUseGeminiStagedLaunch, +} from './provisioning/TeamProvisioningPromptBuilders'; +export type { RuntimeBootstrapMemberMcpLaunchConfig } from './provisioning/TeamProvisioningBootstrapSpec'; +export { + buildAddMemberSpawnMessage, + buildRestartMemberSpawnMessage, +} from './provisioning/TeamProvisioningPromptBuilders'; import { buildOpenCodePromptDeliveryAttemptId, createOpenCodePromptDeliveryLedgerStore, @@ -263,7 +295,6 @@ import { getOpenCodeLaneScopedRuntimeFilePath, getOpenCodeRuntimeManifestPath, getOpenCodeRuntimeRunTombstonesPath, - getOpenCodeTeamRuntimeDirectory, inspectOpenCodeRuntimeLaneStorage, migrateLegacyOpenCodeRuntimeState, OpenCodeRuntimeManifestEvidenceReader, @@ -271,7 +302,6 @@ import { readCommittedOpenCodeBootstrapSessionEvidence, readOpenCodeRuntimeLaneIndex, recoverStaleOpenCodeRuntimeLaneIndexEntry, - removeOpenCodeRuntimeLaneIndexEntry, setOpenCodeRuntimeActiveRunManifest, upsertOpenCodeRuntimeLaneIndexEntry, } from './opencode/store/OpenCodeRuntimeManifestEvidenceReader'; @@ -287,7 +317,6 @@ import { RuntimeStoreBatchWriter, } from './opencode/store/RuntimeStoreManifest'; import { OpenCodeTaskLogAttributionStore } from './taskLogs/stream/OpenCodeTaskLogAttributionStore'; -import { buildActionModeProtocol } from './actionModeInstructions'; import { getCurrentAgentTeamsMcpHttpTransportEvidence } from './AgentTeamsMcpHttpServer'; import { isAgentTeamsToolUse } from './agentTeamsToolNames'; import { atomicWriteAsync } from './atomicWrite'; @@ -344,7 +373,6 @@ import { createPersistedLaunchSnapshot, deriveTeamLaunchAggregateState, hasMixedPersistedLaunchMetadata, - normalizeLaunchFailureReasonText, snapshotFromRuntimeMemberStatuses, snapshotToMemberSpawnStatuses, } from './TeamLaunchStateEvaluator'; @@ -668,10 +696,8 @@ const { AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES, AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES, createController, - protocols, } = agentTeamsControllerModule; const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/; -const RUN_TIMEOUT_MS = 300_000; const VERIFY_TIMEOUT_MS = 15_000; const MCP_PREFLIGHT_INITIALIZE_TIMEOUT_MS = 45_000; const PROVIDER_MODEL_LIST_TIMEOUT_MS = 30_000; @@ -1417,14 +1443,6 @@ function findJsonObjectEnd(source: string, start: number): number { return -1; } -function getExplicitLaunchModelSelection(model: string | undefined): string | undefined { - const trimmed = model?.trim(); - if (!trimmed || isDefaultProviderModelSelection(trimmed)) { - return undefined; - } - return trimmed; -} - function getLaunchModelArg( providerId: TeamProviderId, model: string | undefined, @@ -1781,41 +1799,6 @@ function getTeamProviderLabel(providerId: TeamProviderId): string { } } -interface CanonicalSendMessageExample { - to: string; - summary: string; - message: string; -} - -// TODO(refactor): If more prompt-bound tool contracts appear here, move these -// canonical examples/rules into a small dedicated module (for example -// `teamPromptContracts.ts`) and cover them with schema-backed tests. Keep this -// layer narrow and explicit; do not grow it into a generic schema-to-prompt -// generator. -const SEND_MESSAGE_CANONICAL_FIELDS = ['to', 'summary', 'message'] as const; -const SEND_MESSAGE_FORBIDDEN_ALIAS_FIELDS = ['recipient', 'content'] as const; - -function buildCanonicalSendMessageExample(example: CanonicalSendMessageExample): string { - return `{ ${SEND_MESSAGE_CANONICAL_FIELDS.map((field) => `${field}: "${example[field]}"`).join(', ')} }`; -} - -function getCanonicalSendMessageFieldRule(): string { - return `CRITICAL: The SendMessage tool input must use the actual tool field names \`${SEND_MESSAGE_CANONICAL_FIELDS.join('`, `')}\`. Never invent alternate keys like \`${SEND_MESSAGE_FORBIDDEN_ALIAS_FIELDS.join('` or `')}\`. Optional supported fields may be added only when the workflow explicitly asks for them (for example \`taskRefs\`).`; -} - -function getCanonicalSendMessageToolRule(to: string): string { - return `Use the SendMessage tool with to="${to}".`; -} - -function getVisibleTaskReferenceFormattingRule(): string { - return [ - 'Task reference formatting (CRITICAL): In visible message/comment text, write task refs as plain # text, e.g. #abcd1234.', - 'Never wrap task refs or Markdown task links in backticks/code spans, because code spans are not linkified in Messages.', - 'Do NOT manually write [#abcd1234](task://...) in visible text.', - 'When a message tool supports taskRefs, include structured taskRefs metadata and let the app linkify the visible #abcd1234 text.', - ].join('\n'); -} - function getConfiguredRuntimeBackend(providerId: TeamProviderId): string | null { const runtimeConfig = ConfigManager.getInstance().getConfig().runtime.providerBackends; switch (providerId) { @@ -2470,7 +2453,30 @@ function isAnthropicDirectCredentialAuthSource(authSource: unknown): boolean { return isAnthropicApiKeyBackedAuthSource(authSource) || authSource === 'anthropic_auth_token'; } -function buildAnthropicCrossProviderDirectAuthEnvPatch(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { +function isAnthropicCompatibleBaseUrl(baseUrl?: string | null): boolean { + const trimmed = baseUrl?.trim(); + if (!trimmed) { + return false; + } + + try { + const host = new URL(trimmed).host; + return host !== 'api.anthropic.com' && host !== 'api-staging.anthropic.com'; + } catch { + return true; + } +} + +function hasAnthropicCompatibleAuthTokenEnv(env: NodeJS.ProcessEnv): boolean { + return Boolean( + isAnthropicCompatibleBaseUrl(env.ANTHROPIC_BASE_URL) && env.ANTHROPIC_AUTH_TOKEN?.trim() + ); +} + +function buildAnthropicCrossProviderDirectAuthEnvPatch( + env: NodeJS.ProcessEnv, + authSource: ProvisioningAuthSource +): NodeJS.ProcessEnv { const envPatch: NodeJS.ProcessEnv = {}; const apiKey = env.ANTHROPIC_API_KEY?.trim(); if (apiKey) { @@ -2480,6 +2486,11 @@ function buildAnthropicCrossProviderDirectAuthEnvPatch(env: NodeJS.ProcessEnv): if (baseUrl) { envPatch.ANTHROPIC_BASE_URL = baseUrl; } + if (authSource === 'anthropic_auth_token' && hasAnthropicCompatibleAuthTokenEnv(env)) { + envPatch.ANTHROPIC_API_KEY = apiKey || ''; + envPatch.ANTHROPIC_AUTH_TOKEN = env.ANTHROPIC_AUTH_TOKEN?.trim(); + return envPatch; + } for (const key of ANTHROPIC_HELPER_MODE_COMPETING_AUTH_ENV_KEYS) { if (key !== 'ANTHROPIC_API_KEY') { envPatch[key] = ''; @@ -3963,1351 +3974,6 @@ async function pathExistsAsDirectory(candidatePath: string): Promise { /** @deprecated Use wrapAgentBlock from @shared/constants/agentBlocks instead. */ const wrapInAgentBlock = wrapAgentBlock; - -function indentMultiline(text: string, indent: string): string { - return text - .split(/\r?\n/g) - .map((line) => `${indent}${line}`) - .join('\n'); -} - -function formatWorkflowBlock(workflow: string, indent: string): string { - const trimmed = workflow.trim(); - if (trimmed.length === 0) return ''; - const body = indentMultiline(trimmed, indent); - return `\n${indent}---BEGIN WORKFLOW---\n${body}\n${indent}---END WORKFLOW---`; -} - -type TeamMemberInput = TeamCreateRequest['members'][number]; - -function normalizeTeamMemberProviderId(providerId: unknown): TeamProviderId | undefined { - return normalizeOptionalTeamProviderId(providerId); -} - -function normalizeTeamProviderLike(providerId: unknown): TeamProviderId | undefined { - return normalizeOptionalTeamProviderId( - typeof providerId === 'string' ? providerId.trim().toLowerCase() : providerId - ); -} - -function teamRequestIncludesCodexMember( - request: Pick & Partial> -): boolean { - const defaultProviderId = normalizeTeamMemberProviderId(request.providerId) ?? 'anthropic'; - const members = Array.isArray(request.members) ? request.members : []; - return members.some((member) => { - const memberProviderId = - normalizeTeamMemberProviderId(member.providerId) ?? - normalizeTeamMemberProviderId((member as { provider?: unknown }).provider) ?? - defaultProviderId; - return memberProviderId === 'codex'; - }); -} - -function buildEffectiveTeamMemberSpec( - member: TeamMemberInput, - defaults: { - providerId?: TeamProviderId; - model?: string; - effort?: TeamCreateRequest['effort']; - } -): TeamMemberInput { - const memberProviderId = normalizeTeamMemberProviderId(member.providerId); - const defaultProviderId = normalizeTeamMemberProviderId(defaults.providerId); - const effectiveProviderId = memberProviderId ?? defaultProviderId ?? 'anthropic'; - const explicitMemberModel = getExplicitLaunchModelSelection(member.model); - const inheritsDefaultRuntime = memberProviderId == null || memberProviderId === defaultProviderId; - const model = - explicitMemberModel || - (inheritsDefaultRuntime ? getExplicitLaunchModelSelection(defaults.model) : undefined) || - undefined; - const effort = - member.effort ?? (inheritsDefaultRuntime && !explicitMemberModel ? defaults.effort : undefined); - - return { - ...member, - providerId: effectiveProviderId, - model, - effort, - }; -} - -function buildEffectiveTeamMemberSpecs( - members: TeamCreateRequest['members'], - defaults: { - providerId?: TeamProviderId; - model?: string; - effort?: TeamCreateRequest['effort']; - } -): TeamCreateRequest['members'] { - return members.map((member) => buildEffectiveTeamMemberSpec(member, defaults)); -} - -function buildMembersPrompt(members: TeamCreateRequest['members']): string { - return members - .map((member) => { - const rolePart = member.role?.trim() ? ` (role: ${member.role.trim()})` : ''; - const providerPart = - member.providerId && member.providerId !== 'anthropic' - ? ` [provider: ${member.providerId}]` - : ''; - const modelPart = member.model?.trim() ? ` [model: ${member.model.trim()}]` : ''; - const effortPart = member.effort ? ` [effort: ${member.effort}]` : ''; - const isolationPart = member.isolation === 'worktree' ? ' [isolation: worktree]' : ''; - const workflowPart = member.workflow?.trim() - ? `\n Workflow/instructions:${formatWorkflowBlock(member.workflow, ' ')}` - : ''; - return `- ${member.name}${rolePart}${providerPart}${modelPart}${effortPart}${isolationPart}${workflowPart}`; - }) - .join('\n'); -} - -/** Compact roster: name + role only, no workflow details. Used for post-compact reminders. */ -function buildCompactMembersRoster(members: TeamCreateRequest['members']): string { - return members - .map((member) => { - const rolePart = member.role?.trim() ? ` (${member.role.trim()})` : ''; - return `- ${member.name}${rolePart}`; - }) - .join('\n'); -} - -function buildTeammateAgentBlockReminder(): string { - return [ - `Hidden internal instructions rule (IMPORTANT):`, - `- If you send internal operational instructions to another agent/teammate that the human user must NOT see in the UI, wrap ONLY that hidden part in:`, - ` ${AGENT_BLOCK_OPEN}`, - ` ... hidden instructions only ...`, - ` ${AGENT_BLOCK_CLOSE}`, - `- Keep normal human-readable coordination outside the block.`, - `- NEVER use agent-only blocks in messages to "user".`, - ].join('\n'); -} - -function extractHeartbeatTimestamp(text: string, fallback?: string): string | undefined { - const trimmed = text.trim(); - if (!trimmed) return fallback?.trim() || undefined; - try { - const parsed = JSON.parse(trimmed) as { timestamp?: unknown }; - if (typeof parsed.timestamp === 'string' && parsed.timestamp.trim().length > 0) { - return parsed.timestamp.trim(); - } - } catch { - // Best-effort only. Non-JSON teammate messages still use the inbox timestamp fallback. - } - return fallback?.trim() || undefined; -} - -function extractBootstrapFailureReason(text: string): string | null { - const trimmed = normalizeLaunchFailureReasonText(text) ?? text.trim(); - if (!trimmed) return null; - if (isBootstrapInstructionPrompt(trimmed)) return null; - const lower = trimmed.toLowerCase(); - const looksLikeBootstrapFailure = - lower.includes('bootstrap failed') || - lower.includes('bootstrap failure') || - lower.includes('bootstrap error') || - lower.includes('bootstrap не удался') || - lower.includes('сбой bootstrap') || - ((lower.includes('member') || lower.includes('член')) && lower.includes('not found')) || - (lower.includes('не найден') && - (lower.includes('член') || lower.includes('member') || lower.includes('inbox'))) || - lower.includes('member_briefing tool is not available') || - lower.includes('member_briefing tool not found') || - lower.includes('lead_briefing tool is not available') || - lower.includes('lead_briefing tool not found') || - lower.includes('no such tool available: mcp__agent_teams__member_briefing') || - lower.includes('no such tool available: mcp__agent_teams__lead_briefing') || - lower.includes('agent calls that include team_name must also include name') || - (lower.includes('member_briefing') && - (lower.includes('not available') || - lower.includes('not found') || - lower.includes('lookup failure') || - lower.includes('validation error') || - lower.includes('api error') || - lower.includes('empty content') || - lower.includes('unspecified error'))) || - (lower.includes('lead_briefing') && - (lower.includes('not available') || - lower.includes('not found') || - lower.includes('lookup failure') || - lower.includes('validation error') || - lower.includes('api error') || - lower.includes('empty content') || - lower.includes('unspecified error'))) || - lower.includes('model is not supported') || - lower.includes('model is not available') || - lower.includes('model not available') || - lower.includes('model unavailable') || - lower.includes('model not found') || - lower.includes('unknown model') || - lower.includes('invalid model') || - lower.includes('unsupported model') || - lower.includes('not supported when using codex with a chatgpt account') || - lower.includes('please check the provided tool list'); - if (!looksLikeBootstrapFailure) return null; - return trimmed.slice(0, 280); -} - -function isBootstrapInstructionPrompt(text: string): boolean { - const normalized = text.replace(/\s+/g, ' ').trim().toLowerCase(); - if (!normalized.startsWith('you are bootstrapping into team ')) { - return false; - } - return ( - normalized.includes('your first action is to call the mcp tool') && - (normalized.includes('member_briefing') || normalized.includes('lead_briefing')) - ); -} - -function isBootstrapTranscriptSuccessText( - text: string, - teamName: string, - memberName: string -): boolean { - return getBootstrapTranscriptSuccessSource(text, teamName, memberName) !== null; -} - -function getBootstrapTranscriptSuccessSource( - text: string, - teamName: string, - memberName: string -): BootstrapTranscriptSuccessSource | null { - const normalizedText = text.replace(/\s+/g, ' ').trim().toLowerCase(); - if (!normalizedText) { - return null; - } - - const normalizedTeamName = teamName.trim().toLowerCase(); - const normalizedMemberName = memberName.trim().toLowerCase(); - if (!normalizedTeamName || !normalizedMemberName) { - return null; - } - - if ( - normalizedText.startsWith( - `member briefing for ${normalizedMemberName} on team "${normalizedTeamName}" (${normalizedTeamName}).` - ) || - normalizedText.startsWith( - `member briefing for ${normalizedMemberName} on team '${normalizedTeamName}' (${normalizedTeamName}).` - ) - ) { - return 'member_briefing'; - } - - return normalizedText.includes(`bootstrap выполнен для \`${normalizedMemberName}\``) && - normalizedText.includes(`команде \`${normalizedTeamName}\``) - ? 'assistant_text' - : null; -} - -function isBootstrapTranscriptContextText( - text: string, - teamName: string, - memberName: string -): boolean { - const normalizedText = text.replace(/\s+/g, ' ').trim().toLowerCase(); - const normalizedTeamName = teamName.trim().toLowerCase(); - const normalizedMemberName = memberName.trim().toLowerCase(); - if (!normalizedText || !normalizedTeamName || !normalizedMemberName) { - return false; - } - if ( - !normalizedText.includes(normalizedTeamName) || - !normalizedText.includes(normalizedMemberName) - ) { - return false; - } - return ( - normalizedText.includes('bootstrap') || - normalizedText.includes('bootstrapping') || - normalizedText.includes('member briefing') || - normalizedText.includes('task briefing') - ); -} - -function extractTranscriptTextContent(value: unknown): string[] { - if (typeof value === 'string') { - const trimmed = value.trim(); - return trimmed ? [trimmed] : []; - } - if (!Array.isArray(value)) { - return []; - } - const parts: string[] = []; - for (const item of value) { - if (!item || typeof item !== 'object') continue; - const record = item as { type?: unknown; text?: unknown; content?: unknown }; - if (record.type === 'text' && typeof record.text === 'string' && record.text.trim()) { - parts.push(record.text.trim()); - continue; - } - parts.push(...extractTranscriptTextContent(record.content)); - } - return parts; -} - -function extractTranscriptMessageText(record: unknown): string | null { - if (!record || typeof record !== 'object') { - return null; - } - const normalizedRecord = record as { - text?: unknown; - content?: unknown; - message?: unknown; - toolUseResult?: unknown; - }; - if (typeof normalizedRecord.text === 'string' && normalizedRecord.text.trim()) { - return normalizedRecord.text.trim(); - } - const fromContent = extractTranscriptTextContent(normalizedRecord.content); - if (fromContent.length > 0) { - return fromContent.join('\n'); - } - const fromToolUseResult = extractTranscriptTextContent(normalizedRecord.toolUseResult); - if (fromToolUseResult.length > 0) { - return fromToolUseResult.join('\n'); - } - if (normalizedRecord.message) { - return extractTranscriptMessageText(normalizedRecord.message); - } - return null; -} - -function normalizeMemberDiagnosticText(memberName: string, text: string): string { - return `${memberName}: ${text.trim()}`; -} - -function shouldUseGeminiStagedLaunch(providerId: TeamProviderId | undefined): boolean { - return resolveTeamProviderId(providerId) === 'gemini'; -} - -function buildGeminiMemberSpawnPrompt( - member: TeamCreateRequest['members'][number], - displayName: string, - teamName: string, - leadName: string -): string { - const role = member.role?.trim() || 'team member'; - const providerLine = - member.providerId && member.providerId !== 'anthropic' - ? `\nProvider override: ${member.providerId}.` - : ''; - const modelLine = member.model?.trim() ? `\nModel override: ${member.model.trim()}.` : ''; - const effortLine = member.effort ? `\nEffort override: ${member.effort}.` : ''; - const workflowBlock = member.workflow?.trim() ? `\nWorkflow:\n${member.workflow.trim()}` : ''; - - return `You are ${member.name}, a ${role} on team "${displayName}" (${teamName}).${providerLine}${modelLine}${effortLine}${workflowBlock} - -${getAgentLanguageInstruction()} -Your FIRST action: call MCP tool member_briefing with: -{ teamName: "${teamName}", memberName: "${member.name}" } -Call member_briefing directly. Do NOT use Agent, any subagent, or any delegated helper for this step. -If tool search says agent-teams is still connecting, wait briefly and retry tool search at most once. -If member_briefing is still unavailable after that one retry, SendMessage "${leadName}" exactly one short natural-language sentence with the exact error text, then stop this turn and wait. Do NOT send only "bootstrap failed". -Do NOT keep searching for member_briefing, check tasks, or send repeated status/idle messages after reporting the bootstrap failure. -${getCanonicalSendMessageFieldRule()} -${getVisibleTaskReferenceFormattingRule()} -Correct example: -${buildCanonicalSendMessageExample({ to: leadName, summary: 'bootstrap error', message: 'exact error text' })} -After member_briefing succeeds, stay silent until you have a real blocker, question, or task result. Do NOT send raw tool output, JSON, dict/object dumps, or internal state payloads. -- Review flow rule: review happens on the SAME work task. If task #X needs review and a reviewer exists or has been named, the owner completes #X and sends #X through review_request, and the reviewer handles review_start then review_approve/review_request_changes on #X. If no reviewer exists, leave #X completed. Do NOT create a separate "review task".`; -} - -function buildGeminiReconnectMemberSpawnPrompt( - member: TeamCreateRequest['members'][number], - teamName: string, - leadName: string -): string { - const role = member.role?.trim() || 'team member'; - const providerLine = - member.providerId && member.providerId !== 'anthropic' - ? `\nProvider override: ${member.providerId}.` - : ''; - const modelLine = member.model?.trim() ? `\nModel override: ${member.model.trim()}.` : ''; - const effortLine = member.effort ? `\nEffort override: ${member.effort}.` : ''; - const workflowBlock = member.workflow?.trim() ? `\nWorkflow:\n${member.workflow.trim()}` : ''; - - return `You are ${member.name}, a ${role} on team "${teamName}" (${teamName}).${providerLine}${modelLine}${effortLine}${workflowBlock} - -${getAgentLanguageInstruction()} -The team has just been reconnected after a restart. -Your FIRST action: call MCP tool member_briefing with: -{ teamName: "${teamName}", memberName: "${member.name}" } -Call member_briefing directly. Do NOT use Agent, any subagent, or any delegated helper for this step. -If tool search says agent-teams is still connecting, wait briefly and retry tool search at most once. -If member_briefing is still unavailable after that one retry, SendMessage "${leadName}" exactly one short natural-language sentence with the exact error text, then stop this turn and wait. Do NOT send only "bootstrap failed". -Do NOT keep searching for member_briefing, check tasks, or send repeated status/idle messages after reporting the bootstrap failure. -${getCanonicalSendMessageFieldRule()} -${getVisibleTaskReferenceFormattingRule()} -Correct example: -${buildCanonicalSendMessageExample({ to: leadName, summary: 'bootstrap error', message: 'exact error text' })} -After member_briefing succeeds, stay silent unless you have a real blocker, question, or task result. Do NOT send raw tool output, JSON, dict/object dumps, or internal state payloads. -- Review flow rule: review happens on the SAME work task. If task #X needs review and a reviewer exists or has been named, the owner completes #X and sends #X through review_request, and the reviewer handles review_start then review_approve/review_request_changes on #X. If no reviewer exists, leave #X completed. Do NOT create a separate "review task".`; -} - -function buildMemberReviewFlowReminder(): string { - return [ - '- Review flow rule: review is a state transition on the SAME work task, not a separate task.', - '- If your task #X needs review and a reviewer exists or has been named, finish the work on #X, call task_complete on #X, then use review_request on #X for that reviewer. If no reviewer exists, leave #X completed. Do NOT create a separate "review task".', - '- If you are the reviewer for task #X, call review_start on #X first, then review_approve or review_request_changes on #X itself.', - '- If review requests changes, resume/fix the SAME task #X, then task_complete #X and send #X back through review_request when ready.', - ].join('\n'); -} - -function buildMemberSpawnPrompt( - member: TeamCreateRequest['members'][number], - displayName: string, - teamName: string, - leadName: string, - options?: { restart?: boolean } -): string { - const role = member.role?.trim() || 'team member'; - const providerLine = - member.providerId && member.providerId !== 'anthropic' - ? `\nProvider override for this teammate: ${member.providerId}.` - : ''; - const modelLine = member.model?.trim() - ? `\nModel override for this teammate: ${member.model.trim()}.` - : ''; - const effortLine = member.effort ? `\nEffort override for this teammate: ${member.effort}.` : ''; - const workflowBlock = member.workflow?.trim() - ? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(member.workflow, '')}` - : ''; - const restartContext = options?.restart - ? '\n\nThe team has already been reconnected and you are being re-attached as a persistent teammate.\nThis is a teammate restart. Repeat bootstrap exactly once, then wait for normal work instructions.' - : ''; - const actionModeProtocol = protocols.buildActionModeProtocolText( - protocols.MEMBER_DELEGATE_DESCRIPTION - ); - return `You are ${member.name}, a ${role} on team "${displayName}" (${teamName}).${providerLine}${modelLine}${effortLine}${workflowBlock}${restartContext} - -${getAgentLanguageInstruction()} -Your FIRST action: call MCP tool member_briefing with: -{ teamName: "${teamName}", memberName: "${member.name}" } -Call member_briefing directly as your own MCP tool call. Do NOT use the Agent tool, any subagent, or any delegated helper for this step. -member_briefing is expected to be available in your initial MCP tool list. If it is missing or unavailable, treat that as a real bootstrap error and report the exact error text to your team lead. -Do NOT start work, claim tasks, or improvise workflow/task/process rules before member_briefing succeeds. -If tool search says agent-teams is still connecting, wait briefly and retry tool search at most once. -If member_briefing is still unavailable after that one retry, send exactly one short natural-language message to your team lead "${leadName}" that includes the exact failure reason (for example the API error, validation error, or lookup failure), then stop this turn and wait. Do NOT send only "bootstrap failed". -Do NOT keep searching for member_briefing, check tasks, or send repeated status/idle messages after reporting the bootstrap failure. -IMPORTANT: When sending messages to the team lead, always use the exact name "${leadName}" in the \`to\` field of SendMessage. Never abbreviate or shorten it (e.g. do NOT use "lead" instead of "team-lead"). -${getCanonicalSendMessageFieldRule()} -${getVisibleTaskReferenceFormattingRule()} -Correct example: -${buildCanonicalSendMessageExample({ to: leadName, summary: 'short update', message: 'your message' })} -After member_briefing succeeds: -- Do NOT send a "ready", "online", "status accepted", or other acknowledgement-only message just to confirm you started successfully. -- If bootstrap succeeded and you have no task yet, stay silent and wait for task assignments. -- If bootstrap succeeded and you have no task, produce ZERO assistant text for that turn and end it immediately after the successful tool result. -- Do NOT ask the user or the lead to send you a task ID, task description, or "next task" right after bootstrap. -- Only SendMessage the lead after bootstrap when there is a real blocker, a failed bootstrap, an explicit question, an urgent coordination need, or a completed task result to report. -- Never send raw tool output, JSON, dict/object dumps, Python-style structs, or internal state payloads to the lead or the user. If you need to report bootstrap/task/tool status, rewrite it as one short natural-language sentence. -- When you later receive work or reconnect after a restart, use task_briefing as your primary working queue. Use task_list only to search/browse inventory rows, not as your working queue. -- Act only on Actionable items in task_briefing. Awareness items are watch-only context unless the lead reroutes the task or you become the actionOwner. -- Use task_get when you need the full task context before starting a pending/needsFix task or when the in_progress briefing details are not enough. -- If a newly assigned task cannot be started immediately because you are still busy on another task, leave a short task comment on that waiting task right away with the reason and your best ETA, keep it in pending/TODO, and only move it to in_progress with task_start when you truly begin. -- CRITICAL: If someone comments on your task, you MUST reply on that same task via task_add_comment. Never leave a user/lead/teammate task comment unanswered, even if the reply is only a short acknowledgement or status update. Do NOT treat status changes or direct messages as a substitute for an on-task reply. -- CRITICAL: If a task gets a new comment and you are going to do additional implementation/fix/follow-up work on that same task, FIRST leave a short task comment saying what you are about to do, THEN move it to in_progress with task_start, THEN do the work, and when finished leave a short result comment and move it to done with task_complete. Never skip this comment -> reopen -> work -> comment -> done cycle. -- CRITICAL: When you finish a task, your results (findings, research report, analysis, code changes summary, or any deliverable) MUST be posted as a task comment via task_add_comment BEFORE calling task_complete. Save the comment.id from the response — you will need it in the next step. The task comment is the primary delivery channel — the user reads results on the task board. A SendMessage to the lead is NOT a substitute: direct messages are ephemeral and not visible on the board. If you only SendMessage without a task comment, the user will never see your work. -- After task_complete, notify your team lead via SendMessage. Keep the visible message human-readable only: include the task ref as plain # text (not a code span and not a manual task:// Markdown link), a brief summary (2-4 sentences), where the full result lives, and the next step. Do NOT paste tool-like calls such as task_get_comment { ... } into the visible message text. Instead write "Full details in task comment ". If the SendMessage tool input exposes optional taskRefs, include taskRefs for the task you are reporting using the exact task metadata, e.g. taskRefs: [{ taskId: "", displayId: "", teamName: "${teamName}" }]. Example visible message: "#abcd1234 done. Found 3 competitors, two lack kanban. Full details in task comment e5f6a7b8. Moving to #efgh5678." -- Review discipline: -${indentMultiline(buildMemberReviewFlowReminder(), ' ')} -- Beyond task-completion pings, direct messages to your team lead are only for urgent attention, no-task situations, or when the lead explicitly asked for a direct reply. -- If a task-scoped update is already recorded in a task comment, do NOT send a duplicate SendMessage to the lead with the same content unless you need urgent non-task attention. When skipping a message, stay silent — never output meta-commentary about skipped or already-delivered messages. -${buildTeammateAgentBlockReminder()} -${actionModeProtocol}`; -} - -function buildReconnectMemberSpawnPrompt( - member: TeamCreateRequest['members'][number], - teamName: string, - leadName: string, - hasTasks: boolean -): string { - const role = member.role?.trim() || 'team member'; - const providerLine = - member.providerId && member.providerId !== 'anthropic' - ? `\n Provider override for this teammate: ${member.providerId}.` - : ''; - const modelLine = member.model?.trim() - ? `\n Model override for this teammate: ${member.model.trim()}.` - : ''; - const effortLine = member.effort - ? `\n Effort override for this teammate: ${member.effort}.` - : ''; - const workflowBlock = member.workflow?.trim() - ? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(member.workflow, ' ')}` - : ''; - const actionModeProtocol = indentMultiline( - protocols.buildActionModeProtocolText(protocols.MEMBER_DELEGATE_DESCRIPTION), - ' ' - ); - const providerArgLine = - member.providerId && member.providerId !== 'anthropic' - ? ` - provider: "${member.providerId}"\n` - : ''; - const modelArgLine = member.model?.trim() ? ` - model: "${member.model.trim()}"\n` : ''; - const effortArgLine = member.effort ? ` - effort: "${member.effort}"\n` : ''; - return ` For "${member.name}": -${providerArgLine}${modelArgLine}${effortArgLine} - prompt: - You are ${member.name}, a ${role} on team "${teamName}" (${teamName}).${providerLine}${modelLine}${effortLine}${workflowBlock} - - ${getAgentLanguageInstruction()} - The team has been reconnected after a restart. - ${ - hasTasks - ? 'You may have assigned tasks in states like in_progress, needsFix, pending, review, completed, or approved from the previous session.' - : 'You have no assigned tasks currently.' - } - Your FIRST action: call MCP tool member_briefing with: - { teamName: "${teamName}", memberName: "${member.name}" } - Call member_briefing directly as your own MCP tool call. Do NOT use the Agent tool, any subagent, or any delegated helper for this step. - member_briefing is expected to be available in your initial MCP tool list. If it is missing or unavailable, treat that as a real bootstrap error and report the exact error text to your team lead. - Do NOT start work, claim tasks, or improvise workflow/task/process rules before member_briefing succeeds. - If tool search says agent-teams is still connecting, wait briefly and retry tool search at most once. - If member_briefing is still unavailable after that one retry, send exactly one short natural-language message to your team lead "${leadName}" that includes the exact failure reason (for example the API error, validation error, or lookup failure), then stop this turn and wait. Do NOT send only "bootstrap failed". - Do NOT keep searching for member_briefing, check tasks, or send repeated status/idle messages after reporting the bootstrap failure. - IMPORTANT: When sending messages to the team lead, always use the exact name "${leadName}" in the \`to\` field of SendMessage. Never abbreviate or shorten it (e.g. do NOT use "lead" instead of "team-lead"). -${indentMultiline(getVisibleTaskReferenceFormattingRule(), ' ')} - ${buildTeammateAgentBlockReminder()} -${actionModeProtocol} - - After member_briefing succeeds: - - Do NOT send a "ready", "online", "status accepted", or other acknowledgement-only message just to confirm you reconnected successfully. - - If reconnect bootstrap succeeded and you have no immediate blocker or question, stay silent and continue with your queue. - - If reconnect bootstrap succeeded and you have no immediate blocker, question, or task, produce ZERO assistant text for that turn and end it immediately. - - Do NOT ask the user or the lead to send you a task ID, task description, or "next task" right after reconnect bootstrap. - - Never send raw tool output, JSON, dict/object dumps, Python-style structs, or internal state payloads to the lead or the user. If you need to report bootstrap/task/tool status, rewrite it as one short natural-language sentence. - - Use task_briefing as your primary working queue. Use task_list only to search/browse inventory rows, not as your working queue. - - Act only on Actionable items in task_briefing. Awareness items are watch-only context unless the lead reroutes the task or you become the actionOwner. - - If task_briefing shows any in_progress task, resume/finish those first. Call task_get only if you need more context than task_briefing already gave you. - - After that, prioritize tasks marked Needs fixes after review, then normal pending tasks. - - Before you start any needsFix or pending task, call task_get for that specific task. - - If a newly assigned needsFix or pending task must wait because you are still finishing another task, leave a short task comment on that waiting task with the reason and your best ETA, keep it in pending/TODO (use task_set_status pending if needed), and only run task_start when you truly begin. - - CRITICAL: If someone comments on your task, you MUST reply on that same task via task_add_comment. Never leave a user/lead/teammate task comment unanswered, even if the reply is only a short acknowledgement or status update. Do NOT treat status changes or direct messages as a substitute for an on-task reply. - - If you are the one about to do the implementation/fixes and the owner is missing or someone else, run task_set_owner to yourself immediately before task_start. - - Only then run task_start when you truly begin. - - If a task gets a new comment and you are going to do additional implementation/fix/follow-up work on it, FIRST leave a short task comment saying what you are about to do, THEN run task_start, then do the work, and when finished leave a short result comment and run task_complete again. Never skip this comment -> reopen -> work -> comment -> done cycle. - - CRITICAL: When you finish a task, your results (findings, research report, analysis, code changes summary, or any deliverable) MUST be posted as a task comment BEFORE calling task_complete. The task comment is the primary delivery channel — the user reads results on the task board. A SendMessage to the lead is NOT a substitute: direct messages are ephemeral and not visible on the board. If you only SendMessage without a task comment, the user will never see your work. - - After task_complete, notify your team lead via SendMessage. The task_add_comment response contains comment.id (UUID) - take its first 8 characters as the short commentId. Keep the visible message human-readable only: include the task ref as plain # text (not a code span and not a manual task:// Markdown link), a brief summary (2-4 sentences), where the full result lives, and the next step. Do NOT paste tool-like calls such as task_get_comment { ... } into the visible message text. Instead write "Full details in task comment ". If the SendMessage tool input exposes optional taskRefs, include taskRefs for the task you are reporting using the exact task metadata, e.g. taskRefs: [{ taskId: "", displayId: "", teamName: "${teamName}" }]. Example visible message: "#abcd1234 done. Found 3 competitors, two lack kanban. Full details in task comment e5f6a7b8. Moving to #efgh5678." - - Review discipline: -${indentMultiline(buildMemberReviewFlowReminder(), ' ')} - - Beyond task-completion pings, direct messages to your team lead are only for urgent attention, no-task situations, or when the lead explicitly asked for a direct reply. - - If a task-scoped update is already recorded in a task comment, do NOT send a duplicate SendMessage to the lead with the same content unless you need urgent non-task attention. When skipping a message, stay silent — never output meta-commentary about skipped or already-delivered messages. - - If you have no tasks, wait for new assignments.`; -} - -function buildAgentToolArgsSuffix( - member: Pick< - TeamCreateRequest['members'][number], - 'providerId' | 'model' | 'effort' | 'isolation' - >, - mcpLaunchConfig?: RuntimeBootstrapMemberMcpLaunchConfig | null -): string { - const providerPart = - member.providerId && member.providerId !== 'anthropic' - ? `, provider="${member.providerId}"` - : ''; - const modelPart = member.model?.trim() ? `, model="${member.model.trim()}"` : ''; - const effortPart = member.effort ? `, effort="${member.effort}"` : ''; - const isolationPart = member.isolation === 'worktree' ? ', isolation="worktree"' : ''; - const mcpConfigPart = mcpLaunchConfig?.mcpConfigPath - ? `, mcp_config="${mcpLaunchConfig.mcpConfigPath}"` - : ''; - const mcpSettingSourcesPart = mcpLaunchConfig?.mcpSettingSources - ? `, mcp_setting_sources="${mcpLaunchConfig.mcpSettingSources}"` - : ''; - const strictMcpConfigPart = - mcpLaunchConfig?.strictMcpConfig === undefined - ? '' - : `, strict_mcp_config=${mcpLaunchConfig.strictMcpConfig ? 'true' : 'false'}`; - return `${providerPart}${modelPart}${effortPart}${isolationPart}${mcpConfigPart}${mcpSettingSourcesPart}${strictMcpConfigPart}`; -} - -export function buildAddMemberSpawnMessage( - teamName: string, - displayName: string, - leadName: string, - member: Pick< - TeamCreateRequest['members'][number], - 'name' | 'role' | 'workflow' | 'providerId' | 'model' | 'effort' | 'isolation' - >, - mcpLaunchConfig?: RuntimeBootstrapMemberMcpLaunchConfig | null -): string { - const roleHint = - typeof member.role === 'string' && member.role.trim() - ? ` with role "${member.role.trim()}"` - : ''; - const workflowHint = - typeof member.workflow === 'string' && member.workflow.trim() - ? ` Their workflow: ${member.workflow.trim()}` - : ''; - - const prompt = buildMemberSpawnPrompt( - { - name: member.name, - ...(member.role ? { role: member.role } : {}), - ...(member.workflow ? { workflow: member.workflow } : {}), - ...(member.providerId ? { providerId: member.providerId } : {}), - ...(member.model ? { model: member.model } : {}), - ...(member.effort ? { effort: member.effort } : {}), - }, - displayName, - teamName, - leadName - ); - const agentArgs = buildAgentToolArgsSuffix(member, mcpLaunchConfig); - - return ( - `A new teammate "${member.name}"${roleHint} has been added to the team. ` + - `Please spawn them immediately using the **Agent** tool with team_name="${teamName}", name="${member.name}", subagent_type="general-purpose"${agentArgs}, and the exact prompt below:${workflowHint}\n\n` + - indentMultiline(prompt, ' ') - ); -} - -export function buildRestartMemberSpawnMessage( - teamName: string, - displayName: string, - leadName: string, - member: Pick< - TeamCreateRequest['members'][number], - 'name' | 'role' | 'workflow' | 'providerId' | 'model' | 'effort' | 'isolation' - >, - mcpLaunchConfig?: RuntimeBootstrapMemberMcpLaunchConfig | null -): string { - const roleHint = - typeof member.role === 'string' && member.role.trim() - ? ` with role "${member.role.trim()}"` - : ''; - const workflowHint = - typeof member.workflow === 'string' && member.workflow.trim() - ? ` Their workflow: ${member.workflow.trim()}` - : ''; - - const prompt = buildMemberSpawnPrompt( - { - name: member.name, - ...(member.role ? { role: member.role } : {}), - ...(member.workflow ? { workflow: member.workflow } : {}), - ...(member.providerId ? { providerId: member.providerId } : {}), - ...(member.model ? { model: member.model } : {}), - ...(member.effort ? { effort: member.effort } : {}), - }, - displayName, - teamName, - leadName - ); - const agentArgs = buildAgentToolArgsSuffix(member, mcpLaunchConfig); - - return ( - `Teammate "${member.name}"${roleHint} was restarted from the UI. ` + - `Please respawn them immediately using the **Agent** tool with team_name="${teamName}", name="${member.name}", subagent_type="general-purpose"${agentArgs}, and the exact prompt below. ` + - `This is a restart of an existing persistent teammate, not a new teammate. ` + - `If the Agent tool returns duplicate_skipped with reason bootstrap_pending, treat that as a pending restart and wait for teammate check-in. ` + - `If it returns duplicate_skipped with reason already_running, do not report success - it means the previous runtime still appears active and the restart may not have applied.${workflowHint ? workflowHint : ''}\n\n` + - indentMultiline(prompt, ' ') - ); -} - -interface RuntimeBootstrapMemberSpec { - name: string; - prompt?: string; - cwd?: string; - model?: string; - provider?: TeamProviderId; - effort?: EffortLevel; - isolation?: 'worktree'; - agentType?: string; - description?: string; - useSplitPane?: boolean; - planModeRequired?: boolean; - mcpConfigPath?: string; - mcpSettingSources?: string; - strictMcpConfig?: boolean; - nativeAppManagedBootstrap?: NativeAppManagedBootstrapSpec; -} - -export interface RuntimeBootstrapMemberMcpLaunchConfig { - mcpConfigPath: string; - mcpSettingSources: string; - strictMcpConfig: boolean; -} - -interface RuntimeBootstrapSpec { - version: 1; - runId: string; - mode: 'create' | 'launch'; - initiator: { - kind: 'app'; - source: 'claude_team_agent_teams_orchestrator'; - }; - team: { - name: string; - displayName?: string; - description?: string; - color?: string; - cwd: string; - }; - lead: { - agentLanguage?: string; - permissionSeedTools?: string[]; - }; - members: RuntimeBootstrapMemberSpec[]; - launch?: { - bootstrapTimeoutMs?: number; - continueOnPartialFailure?: boolean; - }; - ui?: { - emitStructuredEvents?: boolean; - }; -} - -const DETERMINISTIC_BOOTSTRAP_MIN_TIMEOUT_MS = 120_000; -const DETERMINISTIC_BOOTSTRAP_TIMEOUT_PER_MEMBER_MS = 75_000; -const DETERMINISTIC_BOOTSTRAP_MAX_TIMEOUT_MS = 900_000; -const DETERMINISTIC_BOOTSTRAP_OUTER_TIMEOUT_GRACE_MS = 30_000; - -function getDeterministicBootstrapTimeoutMs(memberCount: number): number { - const perMemberBudget = Math.max(0, memberCount) * DETERMINISTIC_BOOTSTRAP_TIMEOUT_PER_MEMBER_MS; - return Math.min( - DETERMINISTIC_BOOTSTRAP_MAX_TIMEOUT_MS, - Math.max(DETERMINISTIC_BOOTSTRAP_MIN_TIMEOUT_MS, perMemberBudget) - ); -} - -function getProvisioningRunTimeoutMs( - run: Pick -): number { - if (!run.deterministicBootstrap) { - return RUN_TIMEOUT_MS; - } - - return Math.max( - RUN_TIMEOUT_MS, - getDeterministicBootstrapTimeoutMs(run.effectiveMembers.length) + - DETERMINISTIC_BOOTSTRAP_OUTER_TIMEOUT_GRACE_MS - ); -} - -function buildDeterministicCreateBootstrapSpec( - runId: string, - request: TeamCreateRequest, - effectiveMembers: TeamCreateRequest['members'], - nativeAppManagedBootstrapByMember: ReadonlyMap = new Map(), - mcpLaunchConfigByMember: ReadonlyMap = new Map() -): RuntimeBootstrapSpec { - return { - version: 1, - runId, - mode: 'create', - initiator: { - kind: 'app', - source: 'claude_team_agent_teams_orchestrator', - }, - team: { - name: request.teamName, - ...(request.displayName?.trim() ? { displayName: request.displayName.trim() } : {}), - ...(request.description?.trim() ? { description: request.description.trim() } : {}), - ...(request.color?.trim() ? { color: request.color.trim() } : {}), - cwd: request.cwd, - }, - lead: { - agentLanguage: getConfiguredAgentLanguageName(), - ...(request.skipPermissions === false - ? { - permissionSeedTools: [ - ...AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES, - 'Edit', - 'Write', - 'NotebookEdit', - ], - } - : {}), - }, - members: effectiveMembers.map((member) => { - const mcpLaunchConfig = mcpLaunchConfigByMember.get(member.name); - return { - name: member.name, - ...(member.role?.trim() ? { role: member.role.trim() } : {}), - ...(member.workflow?.trim() ? { workflow: member.workflow.trim() } : {}), - ...(request.cwd ? { cwd: request.cwd } : {}), - ...(member.model?.trim() ? { model: member.model.trim() } : {}), - ...(member.providerId ? { provider: member.providerId } : {}), - ...(member.effort ? { effort: member.effort } : {}), - ...(member.isolation === 'worktree' ? { isolation: 'worktree' as const } : {}), - ...(member.role?.trim() ? { description: member.role.trim() } : {}), - ...(mcpLaunchConfig ? mcpLaunchConfig : {}), - ...(nativeAppManagedBootstrapByMember.get(member.name) - ? { nativeAppManagedBootstrap: nativeAppManagedBootstrapByMember.get(member.name)! } - : {}), - }; - }), - launch: { - bootstrapTimeoutMs: getDeterministicBootstrapTimeoutMs(effectiveMembers.length), - continueOnPartialFailure: true, - }, - ui: { - emitStructuredEvents: true, - }, - }; -} - -function buildDeterministicLaunchBootstrapSpec( - runId: string, - request: TeamLaunchRequest, - effectiveMembers: TeamCreateRequest['members'], - nativeAppManagedBootstrapByMember: ReadonlyMap = new Map(), - mcpLaunchConfigByMember: ReadonlyMap = new Map() -): RuntimeBootstrapSpec { - return { - version: 1, - runId, - mode: 'launch', - initiator: { - kind: 'app', - source: 'claude_team_agent_teams_orchestrator', - }, - team: { - name: request.teamName, - cwd: request.cwd, - }, - lead: { - agentLanguage: getConfiguredAgentLanguageName(), - ...(request.skipPermissions === false - ? { - permissionSeedTools: [ - ...AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES, - 'Edit', - 'Write', - 'NotebookEdit', - ], - } - : {}), - }, - members: effectiveMembers.map((member) => { - const mcpLaunchConfig = mcpLaunchConfigByMember.get(member.name); - return { - name: member.name, - ...(request.cwd ? { cwd: request.cwd } : {}), - ...(member.model?.trim() ? { model: member.model.trim() } : {}), - ...(member.providerId ? { provider: member.providerId } : {}), - ...(member.effort ? { effort: member.effort } : {}), - ...(member.isolation === 'worktree' ? { isolation: 'worktree' as const } : {}), - ...(member.role?.trim() ? { role: member.role.trim() } : {}), - ...(member.workflow?.trim() ? { workflow: member.workflow.trim() } : {}), - ...(member.role?.trim() ? { description: member.role.trim() } : {}), - ...(mcpLaunchConfig ? mcpLaunchConfig : {}), - ...(nativeAppManagedBootstrapByMember.get(member.name) - ? { nativeAppManagedBootstrap: nativeAppManagedBootstrapByMember.get(member.name)! } - : {}), - }; - }), - launch: { - bootstrapTimeoutMs: getDeterministicBootstrapTimeoutMs(effectiveMembers.length), - continueOnPartialFailure: true, - }, - ui: { - emitStructuredEvents: true, - }, - }; -} - -async function writeDeterministicBootstrapSpecFile(spec: RuntimeBootstrapSpec): Promise { - const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'agent-teams-bootstrap-')); - const filePath = path.join(tempDir, `${spec.team.name}-${randomUUID()}.json`); - await fs.promises.writeFile(filePath, JSON.stringify(spec), { - encoding: 'utf8', - mode: 0o600, - }); - return filePath; -} - -async function removeDeterministicBootstrapTempFile(filePath: string | null): Promise { - if (!filePath) return; - await fs.promises.rm(filePath, { force: true }).catch(() => {}); - await fs.promises.rmdir(path.dirname(filePath)).catch(() => {}); -} - -async function removeDeterministicBootstrapSpecFile(filePath: string | null): Promise { - await removeDeterministicBootstrapTempFile(filePath); -} - -async function writeDeterministicBootstrapUserPromptFile(prompt: string): Promise { - const tempDir = await fs.promises.mkdtemp( - path.join(os.tmpdir(), 'agent-teams-bootstrap-prompt-') - ); - const filePath = path.join(tempDir, `${randomUUID()}.txt`); - await fs.promises.writeFile(filePath, prompt, { - encoding: 'utf8', - mode: 0o600, - }); - return filePath; -} - -async function removeDeterministicBootstrapUserPromptFile(filePath: string | null): Promise { - await removeDeterministicBootstrapTempFile(filePath); -} - -function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string { - return wrapInAgentBlock( - [ - `Internal task board tooling (MCP):`, - `- Use the board-management MCP tools for tasks that must appear on the team board (assigned work, substantial work, or when the user explicitly asks to create a task).`, - ``, - `Execution discipline (CRITICAL — prevents misleading task boards):`, - `- Start a task (move to in_progress) ONLY when you are actually beginning work on it.`, - `- Complete a task ONLY when it is truly finished (and any required verification is done).`, - `- If you assign work to a teammate who already has another in_progress task, create/keep the newly assigned task in pending/TODO. Do NOT move it to in_progress on their behalf before they actually start.`, - `- Never bulk-move many tasks at the end of a session — update status incrementally as you work.`, - `- Record meaningful progress, decisions, and blockers as task comments so context is preserved on the board.`, - `- CRITICAL: Task results (findings, reports, analysis, code changes) MUST be posted as task comments — the user reads results on the task board. Direct messages alone are not visible on the board and the user will miss them.`, - ``, - `Parallelization guideline (IMPORTANT):`, - `- If a task is genuinely parallelizable, split it into multiple smaller tasks owned by different members.`, - ` - Prefer splitting by independent deliverables (e.g. frontend/backend, API/UI, parsing/rendering, tests/docs) rather than arbitrary slices.`, - ` - Use blockedBy only when one piece truly cannot start without another; otherwise link with related.`, - ` - Do NOT split when work is inherently sequential, requires one person to keep consistent context, or the overhead would exceed the benefit.`, - ` - When splitting, make each task have a clear completion criterion and a single accountable owner.`, - ``, - `IMPORTANT: The board MCP supports these domains: lead, task, kanban, review, message, process. There is NO "member" domain — team members are managed by spawning teammates via the Task tool, not via the board MCP.`, - ``, - `Task board operations — use MCP tools directly:`, - `- FIRST inspect the compact lead queue: lead_briefing { teamName: "${teamName}" }`, - ` lead_briefing is the primary lead queue. Decisions about what to act on now come from lead_briefing, not from raw task_list rows.`, - `- Get task details: task_get { teamName: "${teamName}", taskId: "" }`, - `- Get a single comment without loading full task: task_get_comment { teamName: "${teamName}", taskId: "", commentId: "" }`, - ` When an inbox row provides structured task metadata (teamName/taskId/commentId), treat those identifiers as authoritative and use them directly. Do NOT infer alternate task ids or namespaces from visible prose.`, - `- Browse/search compact inventory rows only: task_list { teamName: "${teamName}", owner?: "", status?: "pending|in_progress|completed", reviewState?: "none|review|needsFix|approved", kanbanColumn?: "review|approved", relatedTo?: "", blockedBy?: "", limit?: }`, - ` task_list is inventory/search/drill-down only. Do NOT treat task_list as the lead's working queue.`, - `- Create task: task_create { teamName: "${teamName}", subject: "...", description?: "...", owner?: "", createdBy?: "", blockedBy?: ["1","2"], related?: ["3"] }`, - `- Create task from user message (preferred when you have a MessageId from a relayed inbox message): task_create_from_message { teamName: "${teamName}", messageId: "", subject: "...", owner?: "", createdBy?: "", blockedBy?: ["1","2"], related?: ["3"] }`, - `- Assign/reassign owner: task_set_owner { teamName: "${teamName}", taskId: "", owner: "" }`, - `- Clear owner: task_set_owner { teamName: "${teamName}", taskId: "", owner: null }`, - `- Start task (preferred over set-status): task_start { teamName: "${teamName}", taskId: "" }`, - `- Complete task (preferred over set-status): task_complete { teamName: "${teamName}", taskId: "" }`, - `- Update status: task_set_status { teamName: "${teamName}", taskId: "", status: "pending|in_progress|completed|deleted" }`, - `- Add comment: task_add_comment { teamName: "${teamName}", taskId: "", text: "...", from: "${leadName}" }`, - `- Attach file to task: task_attach_file { teamName: "${teamName}", taskId: "", filePath: "", mode?: "copy|link", filename?: "", mimeType?: "" }`, - `- Attach file to a specific comment:`, - ` 1) Find commentId: task_get { teamName: "${teamName}", taskId: "" }`, - ` 2) Attach: task_attach_comment_file { teamName: "${teamName}", taskId: "", commentId: "", filePath: "", mode?: "copy|link", filename?: "", mimeType?: "" }`, - `- Create with deps (blocked work MUST be pending): task_create { teamName: "${teamName}", subject: "...", owner: "", createdBy: "", blockedBy: ["1","2"], related?: ["3"], startImmediately: false }`, - `- Link dependency: task_link { teamName: "${teamName}", taskId: "", targetId: "", relationship: "blocked-by" }`, - `- Link related: task_link { teamName: "${teamName}", taskId: "", targetId: "", relationship: "related" }`, - `- Unlink: task_unlink { teamName: "${teamName}", taskId: "", targetId: "", relationship: "blocked-by" }`, - `- Set clarification flag: task_set_clarification { teamName: "${teamName}", taskId: "", value: "lead"|"user"|"clear" }`, - ``, - `Review operations — use MCP tools directly (text comments do NOT change kanban state):`, - `- Request review (after task_complete): review_request { teamName: "${teamName}", taskId: "", from: "${leadName}", reviewer: "" }`, - `- Start review (reviewer signals they are beginning): review_start { teamName: "${teamName}", taskId: "", from: "" }`, - `- Approve review: review_approve { teamName: "${teamName}", taskId: "", from: "", note?: "", notifyOwner: true }`, - ` Call review_approve EXACTLY ONCE per review. Include your review feedback in the "note" field of that single call. Do NOT call it twice (once to approve, once with a note). The tool auto-creates a comment from the note.`, - `- Request changes: review_request_changes { teamName: "${teamName}", taskId: "", from: "", comment: "" }`, - `CRITICAL: Review is a state transition on the EXISTING work task. When implementation for task #X needs review, move #X through the review flow with review_request/review_start/review_approve/review_request_changes. Do NOT create a new separate task just to represent that review.`, - `CRITICAL: Only send task #X into review when a concrete reviewer exists for #X. If no reviewer exists yet, keep #X completed until you assign/decide the reviewer. Do NOT use review_request just to park the task in REVIEW without an actual reviewer.`, - `CRITICAL: Writing "approved" or "LGTM" as a task comment does NOT move the task on the kanban board. You MUST call the review_approve MCP tool. Without the tool call the task stays stuck in the REVIEW column.`, - ``, - `Background service operations — use MCP tools directly (dev servers, watchers, databases, etc.; NOT teammate-agent liveness):`, - protocols.buildProcessProtocolText(teamName), - ``, - `Attachment storage modes (IMPORTANT):`, - `- Default is copy (safe, robust).`, - `- Use mode: "link" to try a hardlink (no duplication). It may fall back to copy unless you disable fallback.`, - ``, - `Dependency guidelines:`, - `- Use blockedBy when a task cannot start until another is done.`, - `- If you set blockedBy, create the task in pending (for example with startImmediately: false). Do NOT put blocked tasks into in_progress.`, - `- Use related to link related work (e.g. frontend + backend) without blocking.`, - `- Review tasks: By default, NEVER create a separate "review task". Reviews belong to the existing work task (#X) and must use the dedicated review flow on #X.`, - ` - Correct flow: finish implementation on #X -> task_complete #X -> review_request #X -> reviewer runs review_start #X -> reviewer runs review_approve or review_request_changes on #X.`, - ` - Only move #X into REVIEW when a real reviewer exists for #X. If nobody is reviewing it yet, keep #X completed until the reviewer is decided.`, - ` - The REVIEW column is for the same task #X moving through review. It is NOT a signal to create another task for review.`, - ` - Dependencies do not auto-start tasks; the owner must explicitly start it when ready.`, - `- Avoid over-specifying. Only add dependencies when execution order matters.`, - ``, - `Notification policy:`, - `- Task assignment notifications are handled by the board runtime, so do NOT send a separate SendMessage for the same assignment unless you have extra context that is not already on the task.`, - `- Review requests are also handled by the board runtime: review_request already notifies the reviewer, so do NOT send a second manual SendMessage for the same review request unless you are adding materially new context that is not already on the task.`, - `- When beginning a review, always call review_start first to move the task into the REVIEW column on the kanban board.`, - `- If you receive a task-scoped system notification like "Comment on #...", reply on-task only when you have a substantive update: a decision, blocker, clarification answer, review result, or a concrete next-step change that belongs on the board.`, - `- Do NOT post acknowledgement-only task comments such as "Принято", "Ок", "На связи", "Жду", or similar low-signal echoes. If the notification is FYI and no durable update is needed, say nothing and write nothing.`, - `- Teammate task comments are auto-forwarded to you. When that happens, reply on-task first only if the task actually needs a durable board update. Direct messages are allowed only as an additional urgent wake-up ping or clearly non-task coordination, never as a substitute for a substantive task comment.`, - `- When you skip sending a message because it would be a duplicate or was already delivered, produce NO text output about it. Do not write meta-commentary like "(Already relayed…)", "(No additional relay needed…)", or similar. Just silently move on.`, - `- Ownership must reflect the person actually doing the implementation/fix work. If someone takes over execution, update the owner immediately before they start. Do NOT leave the lead/planner as owner when another member is doing the work.`, - `- Set createdBy when creating tasks so workflow history shows who created the task.`, - ``, - `Clarification handling (CRITICAL — MANDATORY for correct task board state):`, - `- When a teammate needs clarification (needsClarification: "lead"), you MUST reply via task comment first. This is the durable answer on the board.`, - `- If you also send a SendMessage for urgency/visibility, treat it as an extra notification only — never as a substitute for the task-comment reply.`, - `- Clarification flags are not assumed to auto-clear. After the blocker is truly resolved, clear the flag explicitly with:`, - ` task_set_clarification { teamName: "${teamName}", taskId: "", value: "clear" }`, - `- If you cannot answer and the user needs to decide — ESCALATION PROTOCOL:`, - ` 1) FIRST, set the flag to "user" via MCP tool task_set_clarification (this updates the task board):`, - ` { teamName: "${teamName}", taskId: "", value: "user" }`, - ` 2) THEN, send a message to "user" explaining the question.`, - ` 3) THEN, reply to the teammate telling them to wait.`, - ` IMPORTANT: Always update the task board BEFORE sending messages. Without the flag, the task board won't show that the task is blocked waiting for user input.`, - ].join('\n') - ); -} - -function buildLeadRosterContextBlock( - teamName: string, - leadName: string, - teammates: { name: string; role?: string }[] -): string | null { - if (teammates.length === 0) return null; - - const summary = teammates - .map((member) => (member.role ? `${member.name} (${member.role})` : member.name)) - .join(', '); - - return [ - `Current durable team context:`, - `- Team name: ${teamName}`, - `- You are the live team lead "${leadName}"`, - `- Persistent teammates currently configured: ${summary}`, - `- This team is NOT in solo mode`, - `- If the user asks who is on the team, answer from this durable roster unless newer durable state explicitly says otherwise.`, - ].join('\n'); -} - -/** - * Builds the durable lead context — constraints, communication protocol, board MCP ops, - * and agent block policy — that must survive context compaction. - * - * Used by: deterministic launch hydration and post-compact reinjection. - */ -function buildPersistentLeadContext(opts: { - teamName: string; - leadName: string; - isSolo: boolean; - members: TeamCreateRequest['members']; - /** When true, emit a compact roster (name + role only, no workflows). Used for post-compact reminders. */ - compact?: boolean; -}): string { - const { teamName, leadName, isSolo, members, compact } = opts; - const languageInstruction = getAgentLanguageInstruction(); - const agentBlockPolicy = buildAgentBlockUsagePolicy(); - const actionModeProtocol = buildActionModeProtocol(); - const teamCtlOps = buildTeamCtlOpsInstructions(teamName, leadName); - - const soloConstraint = isSolo - ? `\n- SOLO MODE: This team CURRENTLY has ZERO teammates.` + - `\n - FORBIDDEN (until teammates exist): Do NOT spawn teammates via the Task tool with a team_name parameter — there are no teammates to spawn yet.` + - `\n - FORBIDDEN (until teammates exist): Do NOT call SendMessage to any teammate name — no teammates exist yet.` + - `\n - ALLOWED: You may message "user" (the human operator) via SendMessage.` + - `\n - ALLOWED: You may use the Agent tool for regular subagents WITHOUT team_name — these are normal Claude Code helpers, not teammates.` + - `\n - If teammates are added later (e.g. via UI), you may then spawn them using the Agent tool with team_name + name.` + - `\n - TASK BOARD FIRST (MANDATORY): Do NOT do substantial work silently or off-board.` + - `\n - Before you start meaningful implementation, debugging, research, review, or follow-up work, make sure there is a visible team-board task for it and that task is assigned to you.` + - `\n - If the user asks for new work, your first move is to create/update the relevant board task(s), then start work from those tasks.` + - `\n - If scope changes mid-task, update the existing task or create a follow-up task before continuing.` + - `\n - If you notice you already began meaningful work without a task, stop, put it on the board, then continue.` + - `\n - Work on tasks directly yourself. Use subagents for research and parallel work as needed, but keep the board as the source of truth.` + - `\n - PROGRESS REPORTING (MANDATORY): Since you have no teammates, "user" is your only communication channel.` + - `\n - SendMessage "user" at minimum: when you start a task (after marking it in_progress), when you complete a task, and when you hit a meaningful milestone/blocker/decision.` + - `\n - Avoid long silent stretches. If something is taking longer than expected, send a brief update and the next step.` + - `\n - TASK STATUS DISCIPLINE (MANDATORY):` + - `\n - Only move a task to in_progress when you are actively starting work on it.` + - `\n - Only move a task to completed when it is truly finished.` + - `\n - Never bulk-move many tasks at the end — update status incrementally as you work.` + - `\n - Default to working ONE task at a time (keep at most one task in_progress in solo mode), unless you explicitly need parallel background work (in that case explain why to "user").` + - `\n - Record meaningful progress/decisions as task comments so the task board stays accurate and high-signal.` - : ''; - - const membersBlock = compact ? buildCompactMembersRoster(members) : buildMembersPrompt(members); - const membersFooter = membersBlock - ? `Members:\n${membersBlock}` - : 'Members: (none — solo team lead)'; - - return `${languageInstruction} - -Constraints: -- Do NOT call TeamDelete under any circumstances. -- Do NOT use TodoWrite. -- Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN). -- Do NOT shut down, terminate, or clean up the team or its members. -- Do NOT spawn or create a member named "user". "user" is a reserved system name for the human operator — it is NOT a teammate. -- Keep assistant text minimal. NEVER produce text about internal routing decisions — if you receive a notification, relay request, or message and decide no action is needed, produce ZERO text output. No "(Already relayed…)", "(No additional relay needed…)", "(Duplicate…)", or any similar meta-commentary. If there is nothing to do, say nothing. -- NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough. -- NEVER use SendMessage with to="*" (broadcast). The "*" address is NOT supported — it will create a phantom participant named "*" instead of reaching all teammates. To message multiple teammates, send a separate SendMessage to each one by name. -- Keep the task board high-signal: avoid creating tasks for trivial micro-items. -- Use the team task board for assigned/substantial work. -- DELEGATION-FIRST (behavior rule for ALL future turns): When "user" gives you work, your top priority is to (a) decompose into tasks, (b) create tasks on the team board, (c) assign them to teammates, and (d) SendMessage "user" a short confirmation (task IDs + owners). Do NOT start implementing yourself unless the team is truly in SOLO MODE (no teammates). -- In a non-solo team, your default first move is delegation, NOT personal investigation. Do NOT read/search the codebase, inspect files, or do root-cause research yourself just to figure out ownership or scope before delegating. -- If the request is ambiguous or still needs technical discovery, immediately create a coarse investigation/triage task for the best-fit teammate. That teammate owns the code inspection, scope refinement, and creation of any follow-up tasks needed for execution. -- Only do lead-side research first if the human explicitly asked YOU for analysis/planning, or if there is genuinely no appropriate teammate to own the investigation. -- Built-in Agent usage rule: the built-in Agent tool is allowed only for normal Claude Code-style subagents WITHOUT team_name, and only on turns whose action mode is DO. In ASK or DELEGATE mode, treat Agent as forbidden. Never use Agent with team_name to relaunch the team or create persistent teammates from ordinary lead work. -- Do NOT use the built-in TaskCreate tool for team-board tasks. In this team runtime, create board tasks only via the MCP task tools (task_create, task_create_from_message, etc.). -- When messaging "user" (the human): write plain human language. If a task needs a status update, do it yourself via the board MCP tools; never ask the user to run a command.${soloConstraint} - -${teamCtlOps} - -${actionModeProtocol} - -Communication protocol (CRITICAL — you are running headless, no one sees your text output): -- When you receive a from a teammate and that message expects any reaction from you, your default action is to reply to THAT teammate using the SendMessage tool. Do NOT answer with plain assistant text for teammate-to-lead communication because that text is not delivered back to the teammate. -- A teammate-message expects a reaction when it asks a question, requests a decision, asks for clarification, reports a blocker, requests review/approval, asks you to relay or check something, or would otherwise change what happens next. -- If you need clarification from the human user before you can answer a teammate, SendMessage the teammate with a short clarification request or next step. Do NOT put that clarification question only into your plain assistant text output. -- Your plain text output is invisible to teammates — they are separate processes and can only read their inbox. -- Example: if you receive ..., respond with SendMessage(${buildCanonicalSendMessageExample({ to: 'alice', summary: 'short reply', message: 'your reply' })}). -- Example: if alice asks "Сколько времени осталось?" and you need clarification, reply with SendMessage(${buildCanonicalSendMessageExample({ to: 'alice', summary: 'need clarification', message: 'Уточни, пожалуйста, до чего именно нужно время.' })}) instead of asking that question in plain assistant text. -- Do NOT reply to low-value acknowledgements or presence pings such as "ready", "online", "status accepted", "awaiting task", or "received" unless you need to give the teammate a concrete next action. -- Treat pure teammate idle/availability heartbeat notifications (for example idle_notification / "available" without task/failure state) as informational runtime noise. Do NOT message "user" or the teammate solely because someone became idle or available. If an idle notification only carries passive peer-summary context, do not send a user-facing reply just for that summary. Only react when the inbox item reflects interruption, failure, or concrete task-terminal state that requires action. -- Cross-team communication: when work needs expertise, coordination, review, or a decision from ANOTHER team, CALL the MCP tool named "cross_team_send" with teamName: "${teamName}" and a focused actionable message. -- Before sending cross-team, use MCP tool "cross_team_list_targets" with teamName: "${teamName}" to discover valid target teams. -- To review messages your team already sent to other teams, use MCP tool "cross_team_get_outbox" with teamName: "${teamName}". -- Cross-team delivery goes to the target team's lead inbox and may be relayed to that live lead automatically. -- Prefer cross-team messaging when your team is blocked by another team's scope, needs another team's domain expertise, needs a review/approval from another team, or must coordinate a shared decision. -- Prefer concise messages that state: what you need, why that team is relevant, the expected response, and any task or file references they need. -- Keep cross-team requests high-signal: one focused request per topic, with clear next action and desired outcome. -- Before sending a follow-up on the same topic, check "cross_team_get_outbox" so you do not resend the same request unnecessarily. -- If you receive a message that is clearly from another team (for example prefixed with "<${CROSS_TEAM_PREFIX_TAG} ... />"), treat it as an actionable cross-team request and respond to the originating team by CALLING the MCP tool "cross_team_send" when a reply, decision, or status update is needed. -- Cross-team requests may include a stable conversationId in their metadata. When you reply to that thread, preserve the same conversationId and pass replyToConversationId with that same value so the system can correlate the reply reliably. -- If the relay prompt shows explicit cross-team reply metadata/instructions for a message, follow that metadata exactly when calling "cross_team_send". -- NEVER put "cross_team_send" into a SendMessage recipient or message_send "to" field. "cross_team_send" is a TOOL NAME, not a teammate or inbox name. -- Correct example: - cross_team_send({ teamName: "${teamName}", toTeam: "other-team", text: "your reply", conversationId: "", replyToConversationId: "" }) -- Never write protocol markup yourself in message text. Do NOT include "<${CROSS_TEAM_PREFIX_TAG} ... />" or any other metadata wrapper in the visible reply body; send plain user-visible text only. -- When a cross-team request arrives, do NOT appear silent: first emit a brief plain-text status update visible in your own team's Messages/Activity (for example: "Accepted cross-team request from @other-team. Investigating and delegating now."), then do the research, task creation, or delegation work. -- For cross-team work, your canonical progress trail should be team-visible first. Use plain text updates, task comments, and task state changes so your own team can see what is happening. -- Do not wait silently on another team: if cross-team coordination is blocking progress, send the request promptly, then continue any useful local work that does not depend on that answer. -- After a meaningful cross-team exchange, update the relevant task or plan context so your team retains the decision, dependency, or answer. -- Reply to the requesting team when a concrete answer, decision, blocker, or status update is ready. Do NOT default to messaging "user" for cross-team coordination unless the human explicitly asked to be kept informed or the update is clearly human-relevant. -- Golden format for cross-team requests: include (1) brief context, (2) the concrete ask, (3) why your team needs that team specifically, (4) the expected output or decision, and (5) any deadline or blocking impact if relevant. -- Golden format for cross-team replies: answer the concrete ask first, then include the decision, recommendation, or status, and finally any important caveats, next steps, or handoff expectations. -- Do NOT use cross-team messaging when your own team can answer the question locally, when no action/decision is required, when you are only thinking out loud, or when a task update belongs on your own board instead of another team's inbox. -- If the issue is internal to your team, resolve it through your own task board and teammates first; use cross-team only for genuine inter-team dependency, expertise, approval, or coordination. -- Do NOT spam other teams, and do NOT use cross-team messaging for trivial FYIs that do not require action, coordination, or domain knowledge. - -Message formatting: -- When mentioning teammates by name in messages and text output, always use @ prefix (e.g. @alice, @bob) for UI highlighting. When mentioning another team, also use @ (e.g. @signal-ops). Do NOT use @ in tool parameters (recipient, owner, etc.) — those require plain names. -${getVisibleTaskReferenceFormattingRule()} -${agentBlockPolicy} - -${membersFooter}`; -} - -function buildAgentBlockUsagePolicy(): string { - return `Agent-only formatting policy (applies to ALL messages you write): -- Humans can see teammate inbox messages and coordination text in the UI. -- Keep normal reasoning, decisions, and user-facing communication OUTSIDE agent-only blocks. -- Use agent-only blocks specifically for hidden internal instructions sent between agents/teammates that the human user must NOT see in the UI. -- Any internal operational instructions about tooling/scripts MUST be hidden inside an agent-only block, including: - - how to use internal MCP tools, exact tool names, and argument shapes - - review command phrases like "review_approve" / "review_request_changes" - - internal file paths under ~/.claude/ (teams, tasks, kanban state, etc.) - - meta coordination lines like "All teammates are online and have received their assignments via --notify." -- Use an agent-only tag block (AGENT_BLOCK_OPEN / AGENT_BLOCK_CLOSE): - - AGENT_BLOCK_OPEN is exactly: ${AGENT_BLOCK_OPEN} - - AGENT_BLOCK_CLOSE is exactly: ${AGENT_BLOCK_CLOSE} - - IMPORTANT: put the opening tag and closing tag on their own lines with no indentation. -- Example (copy/paste exactly, no indentation): -${AGENT_BLOCK_OPEN} -(internal instructions: commands, script usage, paths, etc.) -${AGENT_BLOCK_CLOSE} -- Put ONLY the internal instructions inside the agent-only block. -- CRITICAL: Messages to "user" (the human) must NEVER contain agent-only blocks. Write them as plain readable text — the human sees these messages directly in the UI. Agent-only blocks are stripped before display, so a message containing ONLY an agent-only block will appear completely empty. -- CRITICAL: Messages to "user" must NEVER mention internal tooling, MCP tools, scripts, or CLI commands — not even in plain text. The user interacts through the UI, NOT the terminal. Specifically, NEVER include in user-facing messages: - - internal MCP tool names or argument shapes - - any node/bash commands - - internal file paths (~/.claude/teams/, etc.) - - instructions to run commands in terminal - - task references without a leading # (for example write #abcd1234, not abcd1234) - Instead, describe the action in human-friendly language (e.g. "Task #6 is complete." instead of showing a command to mark it complete). If you need to update task status, do it YOURSELF — never ask the user to run a command. -- CRITICAL: When processing relayed inbox messages, follow the relay prompt's reply visibility. Some relay turns record plain text only as internal lead activity. User-visible replies must be explicit when the relay prompt says the batch is internal. Do NOT wrap your entire response in an agent-only block. If you need agent-only instructions, put them in a separate block and include concise visible text only when the relay prompt allows or requests it.`; -} - -function getSystemLocale(): string { - try { - return Intl.DateTimeFormat().resolvedOptions().locale; - } catch { - return process.env.LANG?.split('.')[0]?.replace('_', '-') ?? 'en'; - } -} - -function getConfiguredAgentLanguageName(): string { - const config = ConfigManager.getInstance().getConfig(); - const langCode = config.general.agentLanguage || 'system'; - const systemLocale = getSystemLocale(); - return resolveLanguageName(langCode, systemLocale); -} - -function getAgentLanguageInstruction(): string { - const languageName = getConfiguredAgentLanguageName(); - return `IMPORTANT: Communicate in ${languageName}. All messages, summaries, and task descriptions MUST be in ${languageName}.`; -} - -function isTaskBoardSnapshotWorkCandidate(task: TeamTask): boolean { - if (!task.id || task.id.startsWith('_internal') || isTeamTaskDeleted(task)) { - return false; - } - - const workflowColumn = getTeamTaskWorkflowColumn(task); - if (workflowColumn === 'review' || workflowColumn === 'approved') { - return false; - } - - return ( - task.status === 'pending' || - isTeamTaskNeedsFixActionable(task) || - isTeamTaskActivelyWorked(task) - ); -} - -/** Build a full task board snapshot for the lead. */ -function buildTaskBoardSnapshot(tasks: TeamTask[]): string { - const active = tasks.filter(isTaskBoardSnapshotWorkCandidate); - if (active.length === 0) return '\nNo pending tasks on the board.\n'; - - const lines = active.map((t) => { - const owner = t.owner ? ` (owner: ${t.owner})` : ' (unassigned)'; - const desc = t.description ? ` — ${t.description.slice(0, 120)}` : ''; - const stateLabel = [t.status, isTeamTaskNeedsFixActionable(t) ? 'needsFix' : null] - .filter(Boolean) - .join(', '); - const deps = t.blockedBy?.length - ? ` [blocked by: ${t.blockedBy - .map((id) => tasks.find((candidate) => candidate.id === id)) - .filter((task): task is TeamTask => Boolean(task)) - .map((task) => formatTaskDisplayLabel(task)) - .join(', ')}]` - : ''; - return ` - ${formatTaskDisplayLabel(t)} (taskId: ${t.id}) [${stateLabel}]${owner} ${t.subject}${deps}${desc}`; - }); - return `\nCurrent actionable task board (pending/in_progress/needsFix):\n${lines.join('\n')}\n`; -} - -function buildDeterministicLaunchHydrationPrompt( - request: TeamLaunchRequest, - members: TeamCreateRequest['members'], - tasks: TeamTask[], - isResume: boolean -): string { - const leadName = - members.find((member) => member.role?.toLowerCase().includes('lead'))?.name || 'team-lead'; - const isSolo = members.length === 0; - const projectName = path.basename(request.cwd); - const startLabel = isResume ? 'Team Start (resume)' : 'Team Start'; - const startupLabel = isResume ? 'resume/bootstrap' : 'launch/bootstrap'; - const headerModeLabel = isResume ? 'Deterministic resume' : 'Deterministic launch'; - const userPromptBlock = request.prompt?.trim() - ? `\nOriginal user instructions to apply after ${isResume ? 'resume' : 'startup'} is stable:\n${request.prompt.trim()}\n` - : ''; - const hasOriginalUserPrompt = Boolean(request.prompt?.trim()); - const taskBoardSnapshot = buildTaskBoardSnapshot(tasks); - const persistentContext = buildPersistentLeadContext({ - teamName: request.teamName, - leadName, - isSolo, - members, - }); - const nextSteps = isSolo - ? `This ${startupLabel} step has already been completed deterministically by the runtime. -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 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.' - : '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.' -}` - : `This ${startupLabel} step has already been completed deterministically by the runtime. -Do NOT call TeamCreate. -Do NOT use Agent to spawn or restore teammates. -Do NOT repeat the launch summary. -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.' - : '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.' -} -Treat teammates whose bootstrap is still pending as not-yet-available for blocking assignments.`; - - return `${startLabel} [${headerModeLabel} | Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"] - -You are running headless in a non-interactive CLI session. Do not ask questions. -You are "${leadName}", the team lead. -${getAgentLanguageInstruction()}${userPromptBlock} - -${nextSteps} - -${taskBoardSnapshot} -${persistentContext} - -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( - run: ProvisioningRun, - leadName: string, - members: TeamCreateRequest['members'], - tasks: TeamTask[] -): string { - const isSolo = members.length === 0; - const userPromptBlock = run.request.prompt?.trim() - ? `\nOriginal user instructions to apply now:\n${run.request.prompt.trim()}\n` - : ''; - const hasOriginalUserPrompt = Boolean(run.request.prompt?.trim()); - const taskBoardSnapshot = buildTaskBoardSnapshot(tasks); - const teammateBootstrapSnapshot = members.length - ? `Current teammate launch status:\n${members - .map((member) => { - const status = run.memberSpawnStatuses.get(member.name); - const label = - status?.launchState === 'failed_to_start' - ? `failed to start${status.hardFailureReason ? ` - ${status.hardFailureReason}` : status.error ? ` - ${status.error}` : ''}` - : status?.launchState === 'confirmed_alive' - ? 'bootstrap confirmed' - : status?.launchState === 'runtime_pending_permission' - ? status?.runtimeAlive - ? 'runtime online and waiting for permission approval' - : 'waiting for permission approval' - : status?.runtimeAlive - ? 'runtime online and ready for instructions' - : status?.launchState === 'runtime_pending_bootstrap' - ? 'spawn accepted, runtime not confirmed yet' - : status?.status === 'spawning' - ? 'spawn in progress' - : 'runtime state unclear'; - return `- @${member.name}: ${label}`; - }) - .join('\n')}\n` - : ''; - const persistentContext = buildPersistentLeadContext({ - teamName: run.teamName, - leadName, - isSolo, - members, - }); - 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 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 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 - team readiness check for team "${run.teamName}". - -The first launch/reconnect turn has already completed. -Do NOT call TeamCreate again. -Do NOT respawn teammates unless you are explicitly retrying a teammate that truly failed to start. -Do NOT repeat the previous launch summary. -You are "${leadName}", the team lead. -${getAgentLanguageInstruction()}${userPromptBlock} - -${nextStepInstruction} - -${teammateBootstrapSnapshot}${taskBoardSnapshot} -${persistentContext} - -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.`; -} - /** * Unconditionally clears all post-compact reminder state on a run. * Called from cleanupRun, cancel, and error paths. @@ -36588,7 +35254,7 @@ export class TeamProvisioningService { } } - // 1. Explicit ANTHROPIC_API_KEY — works with `-p` mode directly + // 1. Explicit ANTHROPIC_API_KEY - works with `-p` mode directly if ( typeof providerEnv.ANTHROPIC_API_KEY === 'string' && providerEnv.ANTHROPIC_API_KEY.trim().length > 0 @@ -36601,7 +35267,18 @@ export class TeamProvisioningService { }; } - // 2. Proxy token (ANTHROPIC_AUTH_TOKEN) — `-p` mode does NOT read this var, + // 2. Anthropic-compatible runtimes (Ollama/LM Studio/gateways) expect a bearer + // token and often require ANTHROPIC_API_KEY to stay empty. + if (hasAnthropicCompatibleAuthTokenEnv(providerEnv)) { + return { + env: providerEnv, + authSource: 'anthropic_auth_token', + geminiRuntimeAuth: null, + providerArgs: providerEnvResult.providerArgs, + }; + } + + // 3. Proxy token (ANTHROPIC_AUTH_TOKEN) - `-p` mode does NOT read this var, // so we must copy it into ANTHROPIC_API_KEY for it to work. if ( typeof providerEnv.ANTHROPIC_AUTH_TOKEN === 'string' && @@ -36616,7 +35293,7 @@ export class TeamProvisioningService { }; } - // 3. No explicit API key — let the CLI handle its own OAuth auth. + // 4. No explicit API key - let the CLI handle its own OAuth auth. // Claude CLI reads credentials from its own storage and refreshes // tokens in-memory. Injecting CLAUDE_CODE_OAUTH_TOKEN from the // credentials file causes 401 errors because the stored token is @@ -36675,7 +35352,10 @@ export class TeamProvisioningService { providerId === 'anthropic' && isAnthropicDirectCredentialAuthSource(env.authSource) ) { - Object.assign(envPatch, buildAnthropicCrossProviderDirectAuthEnvPatch(env.env)); + Object.assign( + envPatch, + buildAnthropicCrossProviderDirectAuthEnvPatch(env.env, env.authSource) + ); } const flattenedArgs = providerId === 'anthropic' && env.anthropicApiKeyHelper diff --git a/src/main/services/team/provisioning/TeamProvisioningAgentLanguage.ts b/src/main/services/team/provisioning/TeamProvisioningAgentLanguage.ts new file mode 100644 index 00000000..3916baaf --- /dev/null +++ b/src/main/services/team/provisioning/TeamProvisioningAgentLanguage.ts @@ -0,0 +1,22 @@ +import { ConfigManager } from '@main/services/infrastructure/ConfigManager'; +import { resolveLanguageName } from '@shared/utils/agentLanguage'; + +export function getSystemLocale(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().locale; + } catch { + return process.env.LANG?.split('.')[0]?.replace('_', '-') ?? 'en'; + } +} + +export function getConfiguredAgentLanguageName(): string { + const config = ConfigManager.getInstance().getConfig(); + const langCode = config.general.agentLanguage || 'system'; + const systemLocale = getSystemLocale(); + return resolveLanguageName(langCode, systemLocale); +} + +export function getAgentLanguageInstruction(): string { + const languageName = getConfiguredAgentLanguageName(); + return `IMPORTANT: Communicate in ${languageName}. All messages, summaries, and task descriptions MUST be in ${languageName}.`; +} diff --git a/src/main/services/team/provisioning/TeamProvisioningBootstrapSpec.ts b/src/main/services/team/provisioning/TeamProvisioningBootstrapSpec.ts new file mode 100644 index 00000000..1dc770cc --- /dev/null +++ b/src/main/services/team/provisioning/TeamProvisioningBootstrapSpec.ts @@ -0,0 +1,264 @@ +import * as agentTeamsControllerModule from 'agent-teams-controller'; +import { randomUUID } from 'crypto'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { getConfiguredAgentLanguageName } from './TeamProvisioningAgentLanguage'; + +import type { NativeAppManagedBootstrapSpec } from '../bootstrap/NativeAppManagedBootstrapContextBuilder'; +import type { + EffortLevel, + TeamCreateRequest, + TeamLaunchRequest, + TeamProviderId, +} from '@shared/types'; + +const { AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES } = agentTeamsControllerModule; +const RUN_TIMEOUT_MS = 300_000; + +export interface TeamProvisioningRunTimeoutInput { + deterministicBootstrap: boolean; + effectiveMembers: TeamCreateRequest['members']; +} + +interface RuntimeBootstrapMemberSpec { + name: string; + prompt?: string; + cwd?: string; + model?: string; + provider?: TeamProviderId; + effort?: EffortLevel; + isolation?: 'worktree'; + agentType?: string; + description?: string; + useSplitPane?: boolean; + planModeRequired?: boolean; + mcpConfigPath?: string; + mcpSettingSources?: string; + strictMcpConfig?: boolean; + nativeAppManagedBootstrap?: NativeAppManagedBootstrapSpec; +} + +export interface RuntimeBootstrapMemberMcpLaunchConfig { + mcpConfigPath: string; + mcpSettingSources: string; + strictMcpConfig: boolean; +} + +export interface RuntimeBootstrapSpec { + version: 1; + runId: string; + mode: 'create' | 'launch'; + initiator: { + kind: 'app'; + source: 'claude_team_agent_teams_orchestrator'; + }; + team: { + name: string; + displayName?: string; + description?: string; + color?: string; + cwd: string; + }; + lead: { + agentLanguage?: string; + permissionSeedTools?: string[]; + }; + members: RuntimeBootstrapMemberSpec[]; + launch?: { + bootstrapTimeoutMs?: number; + continueOnPartialFailure?: boolean; + }; + ui?: { + emitStructuredEvents?: boolean; + }; +} + +const DETERMINISTIC_BOOTSTRAP_MIN_TIMEOUT_MS = 120_000; +const DETERMINISTIC_BOOTSTRAP_TIMEOUT_PER_MEMBER_MS = 75_000; +const DETERMINISTIC_BOOTSTRAP_MAX_TIMEOUT_MS = 900_000; +const DETERMINISTIC_BOOTSTRAP_OUTER_TIMEOUT_GRACE_MS = 30_000; + +export function getDeterministicBootstrapTimeoutMs(memberCount: number): number { + const perMemberBudget = Math.max(0, memberCount) * DETERMINISTIC_BOOTSTRAP_TIMEOUT_PER_MEMBER_MS; + return Math.min( + DETERMINISTIC_BOOTSTRAP_MAX_TIMEOUT_MS, + Math.max(DETERMINISTIC_BOOTSTRAP_MIN_TIMEOUT_MS, perMemberBudget) + ); +} + +export function getProvisioningRunTimeoutMs(run: TeamProvisioningRunTimeoutInput): number { + if (!run.deterministicBootstrap) { + return RUN_TIMEOUT_MS; + } + + return Math.max( + RUN_TIMEOUT_MS, + getDeterministicBootstrapTimeoutMs(run.effectiveMembers.length) + + DETERMINISTIC_BOOTSTRAP_OUTER_TIMEOUT_GRACE_MS + ); +} + +export function buildDeterministicCreateBootstrapSpec( + runId: string, + request: TeamCreateRequest, + effectiveMembers: TeamCreateRequest['members'], + nativeAppManagedBootstrapByMember: ReadonlyMap = new Map(), + mcpLaunchConfigByMember: ReadonlyMap = new Map() +): RuntimeBootstrapSpec { + return { + version: 1, + runId, + mode: 'create', + initiator: { + kind: 'app', + source: 'claude_team_agent_teams_orchestrator', + }, + team: { + name: request.teamName, + ...(request.displayName?.trim() ? { displayName: request.displayName.trim() } : {}), + ...(request.description?.trim() ? { description: request.description.trim() } : {}), + ...(request.color?.trim() ? { color: request.color.trim() } : {}), + cwd: request.cwd, + }, + lead: { + agentLanguage: getConfiguredAgentLanguageName(), + ...(request.skipPermissions === false + ? { + permissionSeedTools: [ + ...AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES, + 'Edit', + 'Write', + 'NotebookEdit', + ], + } + : {}), + }, + members: effectiveMembers.map((member) => { + const mcpLaunchConfig = mcpLaunchConfigByMember.get(member.name); + return { + name: member.name, + ...(member.role?.trim() ? { role: member.role.trim() } : {}), + ...(member.workflow?.trim() ? { workflow: member.workflow.trim() } : {}), + ...(request.cwd ? { cwd: request.cwd } : {}), + ...(member.model?.trim() ? { model: member.model.trim() } : {}), + ...(member.providerId ? { provider: member.providerId } : {}), + ...(member.effort ? { effort: member.effort } : {}), + ...(member.isolation === 'worktree' ? { isolation: 'worktree' as const } : {}), + ...(member.role?.trim() ? { description: member.role.trim() } : {}), + ...(mcpLaunchConfig ? mcpLaunchConfig : {}), + ...(nativeAppManagedBootstrapByMember.get(member.name) + ? { nativeAppManagedBootstrap: nativeAppManagedBootstrapByMember.get(member.name)! } + : {}), + }; + }), + launch: { + bootstrapTimeoutMs: getDeterministicBootstrapTimeoutMs(effectiveMembers.length), + continueOnPartialFailure: true, + }, + ui: { + emitStructuredEvents: true, + }, + }; +} + +export function buildDeterministicLaunchBootstrapSpec( + runId: string, + request: TeamLaunchRequest, + effectiveMembers: TeamCreateRequest['members'], + nativeAppManagedBootstrapByMember: ReadonlyMap = new Map(), + mcpLaunchConfigByMember: ReadonlyMap = new Map() +): RuntimeBootstrapSpec { + return { + version: 1, + runId, + mode: 'launch', + initiator: { + kind: 'app', + source: 'claude_team_agent_teams_orchestrator', + }, + team: { + name: request.teamName, + cwd: request.cwd, + }, + lead: { + agentLanguage: getConfiguredAgentLanguageName(), + ...(request.skipPermissions === false + ? { + permissionSeedTools: [ + ...AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES, + 'Edit', + 'Write', + 'NotebookEdit', + ], + } + : {}), + }, + members: effectiveMembers.map((member) => { + const mcpLaunchConfig = mcpLaunchConfigByMember.get(member.name); + return { + name: member.name, + ...(request.cwd ? { cwd: request.cwd } : {}), + ...(member.model?.trim() ? { model: member.model.trim() } : {}), + ...(member.providerId ? { provider: member.providerId } : {}), + ...(member.effort ? { effort: member.effort } : {}), + ...(member.isolation === 'worktree' ? { isolation: 'worktree' as const } : {}), + ...(member.role?.trim() ? { role: member.role.trim() } : {}), + ...(member.workflow?.trim() ? { workflow: member.workflow.trim() } : {}), + ...(member.role?.trim() ? { description: member.role.trim() } : {}), + ...(mcpLaunchConfig ? mcpLaunchConfig : {}), + ...(nativeAppManagedBootstrapByMember.get(member.name) + ? { nativeAppManagedBootstrap: nativeAppManagedBootstrapByMember.get(member.name)! } + : {}), + }; + }), + launch: { + bootstrapTimeoutMs: getDeterministicBootstrapTimeoutMs(effectiveMembers.length), + continueOnPartialFailure: true, + }, + ui: { + emitStructuredEvents: true, + }, + }; +} + +export async function writeDeterministicBootstrapSpecFile( + spec: RuntimeBootstrapSpec +): Promise { + const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'agent-teams-bootstrap-')); + const filePath = path.join(tempDir, `${spec.team.name}-${randomUUID()}.json`); + await fs.promises.writeFile(filePath, JSON.stringify(spec), { + encoding: 'utf8', + mode: 0o600, + }); + return filePath; +} + +async function removeDeterministicBootstrapTempFile(filePath: string | null): Promise { + if (!filePath) return; + await fs.promises.rm(filePath, { force: true }).catch(() => {}); + await fs.promises.rmdir(path.dirname(filePath)).catch(() => {}); +} + +export async function removeDeterministicBootstrapSpecFile(filePath: string | null): Promise { + await removeDeterministicBootstrapTempFile(filePath); +} + +export async function writeDeterministicBootstrapUserPromptFile(prompt: string): Promise { + const tempDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'agent-teams-bootstrap-prompt-') + ); + const filePath = path.join(tempDir, `${randomUUID()}.txt`); + await fs.promises.writeFile(filePath, prompt, { + encoding: 'utf8', + mode: 0o600, + }); + return filePath; +} + +export async function removeDeterministicBootstrapUserPromptFile( + filePath: string | null +): Promise { + await removeDeterministicBootstrapTempFile(filePath); +} diff --git a/src/main/services/team/provisioning/TeamProvisioningMemberSpecs.ts b/src/main/services/team/provisioning/TeamProvisioningMemberSpecs.ts new file mode 100644 index 00000000..738be40e --- /dev/null +++ b/src/main/services/team/provisioning/TeamProvisioningMemberSpecs.ts @@ -0,0 +1,77 @@ +import { isDefaultProviderModelSelection } from '@shared/utils/providerModelSelection'; +import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; + +import type { TeamCreateRequest, TeamProviderId } from '@shared/types'; + +export function getExplicitLaunchModelSelection(model: string | undefined): string | undefined { + const trimmed = model?.trim(); + if (!trimmed || isDefaultProviderModelSelection(trimmed)) { + return undefined; + } + return trimmed; +} + +export type TeamMemberInput = TeamCreateRequest['members'][number]; + +export function normalizeTeamMemberProviderId(providerId: unknown): TeamProviderId | undefined { + return normalizeOptionalTeamProviderId(providerId); +} + +export function normalizeTeamProviderLike(providerId: unknown): TeamProviderId | undefined { + return normalizeOptionalTeamProviderId( + typeof providerId === 'string' ? providerId.trim().toLowerCase() : providerId + ); +} + +export function teamRequestIncludesCodexMember( + request: Pick & Partial> +): boolean { + const defaultProviderId = normalizeTeamMemberProviderId(request.providerId) ?? 'anthropic'; + const members = Array.isArray(request.members) ? request.members : []; + return members.some((member) => { + const memberProviderId = + normalizeTeamMemberProviderId(member.providerId) ?? + normalizeTeamMemberProviderId((member as { provider?: unknown }).provider) ?? + defaultProviderId; + return memberProviderId === 'codex'; + }); +} + +export function buildEffectiveTeamMemberSpec( + member: TeamMemberInput, + defaults: { + providerId?: TeamProviderId; + model?: string; + effort?: TeamCreateRequest['effort']; + } +): TeamMemberInput { + const memberProviderId = normalizeTeamMemberProviderId(member.providerId); + const defaultProviderId = normalizeTeamMemberProviderId(defaults.providerId); + const effectiveProviderId = memberProviderId ?? defaultProviderId ?? 'anthropic'; + const explicitMemberModel = getExplicitLaunchModelSelection(member.model); + const inheritsDefaultRuntime = memberProviderId == null || memberProviderId === defaultProviderId; + const model = + explicitMemberModel || + (inheritsDefaultRuntime ? getExplicitLaunchModelSelection(defaults.model) : undefined) || + undefined; + const effort = + member.effort ?? (inheritsDefaultRuntime && !explicitMemberModel ? defaults.effort : undefined); + + return { + ...member, + providerId: effectiveProviderId, + model, + effort, + }; +} + +export function buildEffectiveTeamMemberSpecs( + members: TeamCreateRequest['members'], + defaults: { + providerId?: TeamProviderId; + model?: string; + effort?: TeamCreateRequest['effort']; + } +): TeamCreateRequest['members'] { + return members.map((member) => buildEffectiveTeamMemberSpec(member, defaults)); +} diff --git a/src/main/services/team/provisioning/TeamProvisioningPromptBuilders.ts b/src/main/services/team/provisioning/TeamProvisioningPromptBuilders.ts new file mode 100644 index 00000000..a4ce7a4c --- /dev/null +++ b/src/main/services/team/provisioning/TeamProvisioningPromptBuilders.ts @@ -0,0 +1,1089 @@ +import { resolveTeamProviderId } from '@main/services/runtime/providerRuntimeEnv'; +import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN, wrapAgentBlock } from '@shared/constants/agentBlocks'; +import { CROSS_TEAM_PREFIX_TAG } from '@shared/constants/crossTeam'; +import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; +import { + getTeamTaskWorkflowColumn, + isTeamTaskActivelyWorked, + isTeamTaskDeleted, + isTeamTaskNeedsFixActionable, +} from '@shared/utils/teamTaskState'; +import * as agentTeamsControllerModule from 'agent-teams-controller'; +import * as path from 'path'; + +import { buildActionModeProtocol } from '../actionModeInstructions'; +import { normalizeLaunchFailureReasonText } from '../TeamLaunchStateEvaluator'; + +import { getAgentLanguageInstruction } from './TeamProvisioningAgentLanguage'; + +import type { RuntimeBootstrapMemberMcpLaunchConfig } from './TeamProvisioningBootstrapSpec'; +import type { + MemberSpawnStatusEntry, + TeamCreateRequest, + TeamLaunchRequest, + TeamProviderId, + TeamTask, +} from '@shared/types'; + +const { protocols } = agentTeamsControllerModule; + +export interface TeamProvisioningHydrationRun { + teamName: string; + request: Pick; + memberSpawnStatuses: ReadonlyMap; +} + +type BootstrapTranscriptSuccessSource = 'member_briefing' | 'assistant_text'; + +interface CanonicalSendMessageExample { + to: string; + summary: string; + message: string; +} + +const SEND_MESSAGE_CANONICAL_FIELDS = ['to', 'summary', 'message'] as const; +const SEND_MESSAGE_FORBIDDEN_ALIAS_FIELDS = ['recipient', 'content'] as const; + +export function buildCanonicalSendMessageExample(example: CanonicalSendMessageExample): string { + return `{ ${SEND_MESSAGE_CANONICAL_FIELDS.map((field) => `${field}: "${example[field]}"`).join(', ')} }`; +} + +export function getCanonicalSendMessageFieldRule(): string { + return `CRITICAL: The SendMessage tool input must use the actual tool field names \`${SEND_MESSAGE_CANONICAL_FIELDS.join('`, `')}\`. Never invent alternate keys like \`${SEND_MESSAGE_FORBIDDEN_ALIAS_FIELDS.join('` or `')}\`. Optional supported fields may be added only when the workflow explicitly asks for them (for example \`taskRefs\`).`; +} + +export function getCanonicalSendMessageToolRule(to: string): string { + return `Use the SendMessage tool with to="${to}".`; +} + +export function getVisibleTaskReferenceFormattingRule(): string { + return [ + 'Task reference formatting (CRITICAL): In visible message/comment text, write task refs as plain # text, e.g. #abcd1234.', + 'Never wrap task refs or Markdown task links in backticks/code spans, because code spans are not linkified in Messages.', + 'Do NOT manually write [#abcd1234](task://...) in visible text.', + 'When a message tool supports taskRefs, include structured taskRefs metadata and let the app linkify the visible #abcd1234 text.', + ].join('\n'); +} + +/** @deprecated Use wrapAgentBlock from @shared/constants/agentBlocks instead. */ +const wrapInAgentBlock = wrapAgentBlock; + +function indentMultiline(text: string, indent: string): string { + return text + .split(/\r?\n/g) + .map((line) => `${indent}${line}`) + .join('\n'); +} + +function formatWorkflowBlock(workflow: string, indent: string): string { + const trimmed = workflow.trim(); + if (trimmed.length === 0) return ''; + const body = indentMultiline(trimmed, indent); + return `\n${indent}---BEGIN WORKFLOW---\n${body}\n${indent}---END WORKFLOW---`; +} + +export function buildMembersPrompt(members: TeamCreateRequest['members']): string { + return members + .map((member) => { + const rolePart = member.role?.trim() ? ` (role: ${member.role.trim()})` : ''; + const providerPart = + member.providerId && member.providerId !== 'anthropic' + ? ` [provider: ${member.providerId}]` + : ''; + const modelPart = member.model?.trim() ? ` [model: ${member.model.trim()}]` : ''; + const effortPart = member.effort ? ` [effort: ${member.effort}]` : ''; + const isolationPart = member.isolation === 'worktree' ? ' [isolation: worktree]' : ''; + const workflowPart = member.workflow?.trim() + ? `\n Workflow/instructions:${formatWorkflowBlock(member.workflow, ' ')}` + : ''; + return `- ${member.name}${rolePart}${providerPart}${modelPart}${effortPart}${isolationPart}${workflowPart}`; + }) + .join('\n'); +} + +/** Compact roster: name + role only, no workflow details. Used for post-compact reminders. */ +export function buildCompactMembersRoster(members: TeamCreateRequest['members']): string { + return members + .map((member) => { + const rolePart = member.role?.trim() ? ` (${member.role.trim()})` : ''; + return `- ${member.name}${rolePart}`; + }) + .join('\n'); +} + +export function buildTeammateAgentBlockReminder(): string { + return [ + `Hidden internal instructions rule (IMPORTANT):`, + `- If you send internal operational instructions to another agent/teammate that the human user must NOT see in the UI, wrap ONLY that hidden part in:`, + ` ${AGENT_BLOCK_OPEN}`, + ` ... hidden instructions only ...`, + ` ${AGENT_BLOCK_CLOSE}`, + `- Keep normal human-readable coordination outside the block.`, + `- NEVER use agent-only blocks in messages to "user".`, + ].join('\n'); +} + +export function extractHeartbeatTimestamp(text: string, fallback?: string): string | undefined { + const trimmed = text.trim(); + if (!trimmed) return fallback?.trim() || undefined; + try { + const parsed = JSON.parse(trimmed) as { timestamp?: unknown }; + if (typeof parsed.timestamp === 'string' && parsed.timestamp.trim().length > 0) { + return parsed.timestamp.trim(); + } + } catch { + // Best-effort only. Non-JSON teammate messages still use the inbox timestamp fallback. + } + return fallback?.trim() || undefined; +} + +export function extractBootstrapFailureReason(text: string): string | null { + const trimmed = normalizeLaunchFailureReasonText(text) ?? text.trim(); + if (!trimmed) return null; + if (isBootstrapInstructionPrompt(trimmed)) return null; + const lower = trimmed.toLowerCase(); + const looksLikeBootstrapFailure = + lower.includes('bootstrap failed') || + lower.includes('bootstrap failure') || + lower.includes('bootstrap error') || + lower.includes('bootstrap не удался') || + lower.includes('сбой bootstrap') || + ((lower.includes('member') || lower.includes('член')) && lower.includes('not found')) || + (lower.includes('не найден') && + (lower.includes('член') || lower.includes('member') || lower.includes('inbox'))) || + lower.includes('member_briefing tool is not available') || + lower.includes('member_briefing tool not found') || + lower.includes('lead_briefing tool is not available') || + lower.includes('lead_briefing tool not found') || + lower.includes('no such tool available: mcp__agent_teams__member_briefing') || + lower.includes('no such tool available: mcp__agent_teams__lead_briefing') || + lower.includes('agent calls that include team_name must also include name') || + (lower.includes('member_briefing') && + (lower.includes('not available') || + lower.includes('not found') || + lower.includes('lookup failure') || + lower.includes('validation error') || + lower.includes('api error') || + lower.includes('empty content') || + lower.includes('unspecified error'))) || + (lower.includes('lead_briefing') && + (lower.includes('not available') || + lower.includes('not found') || + lower.includes('lookup failure') || + lower.includes('validation error') || + lower.includes('api error') || + lower.includes('empty content') || + lower.includes('unspecified error'))) || + lower.includes('model is not supported') || + lower.includes('model is not available') || + lower.includes('model not available') || + lower.includes('model unavailable') || + lower.includes('model not found') || + lower.includes('unknown model') || + lower.includes('invalid model') || + lower.includes('unsupported model') || + lower.includes('not supported when using codex with a chatgpt account') || + lower.includes('please check the provided tool list'); + if (!looksLikeBootstrapFailure) return null; + return trimmed.slice(0, 280); +} + +export function isBootstrapInstructionPrompt(text: string): boolean { + const normalized = text.replace(/\s+/g, ' ').trim().toLowerCase(); + if (!normalized.startsWith('you are bootstrapping into team ')) { + return false; + } + return ( + normalized.includes('your first action is to call the mcp tool') && + (normalized.includes('member_briefing') || normalized.includes('lead_briefing')) + ); +} + +export function isBootstrapTranscriptSuccessText( + text: string, + teamName: string, + memberName: string +): boolean { + return getBootstrapTranscriptSuccessSource(text, teamName, memberName) !== null; +} + +export function getBootstrapTranscriptSuccessSource( + text: string, + teamName: string, + memberName: string +): BootstrapTranscriptSuccessSource | null { + const normalizedText = text.replace(/\s+/g, ' ').trim().toLowerCase(); + if (!normalizedText) { + return null; + } + + const normalizedTeamName = teamName.trim().toLowerCase(); + const normalizedMemberName = memberName.trim().toLowerCase(); + if (!normalizedTeamName || !normalizedMemberName) { + return null; + } + + if ( + normalizedText.startsWith( + `member briefing for ${normalizedMemberName} on team "${normalizedTeamName}" (${normalizedTeamName}).` + ) || + normalizedText.startsWith( + `member briefing for ${normalizedMemberName} on team '${normalizedTeamName}' (${normalizedTeamName}).` + ) + ) { + return 'member_briefing'; + } + + return normalizedText.includes(`bootstrap выполнен для \`${normalizedMemberName}\``) && + normalizedText.includes(`команде \`${normalizedTeamName}\``) + ? 'assistant_text' + : null; +} + +export function isBootstrapTranscriptContextText( + text: string, + teamName: string, + memberName: string +): boolean { + const normalizedText = text.replace(/\s+/g, ' ').trim().toLowerCase(); + const normalizedTeamName = teamName.trim().toLowerCase(); + const normalizedMemberName = memberName.trim().toLowerCase(); + if (!normalizedText || !normalizedTeamName || !normalizedMemberName) { + return false; + } + if ( + !normalizedText.includes(normalizedTeamName) || + !normalizedText.includes(normalizedMemberName) + ) { + return false; + } + return ( + normalizedText.includes('bootstrap') || + normalizedText.includes('bootstrapping') || + normalizedText.includes('member briefing') || + normalizedText.includes('task briefing') + ); +} + +export function extractTranscriptTextContent(value: unknown): string[] { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed ? [trimmed] : []; + } + if (!Array.isArray(value)) { + return []; + } + const parts: string[] = []; + for (const item of value) { + if (!item || typeof item !== 'object') continue; + const record = item as { type?: unknown; text?: unknown; content?: unknown }; + if (record.type === 'text' && typeof record.text === 'string' && record.text.trim()) { + parts.push(record.text.trim()); + continue; + } + parts.push(...extractTranscriptTextContent(record.content)); + } + return parts; +} + +export function extractTranscriptMessageText(record: unknown): string | null { + if (!record || typeof record !== 'object') { + return null; + } + const normalizedRecord = record as { + text?: unknown; + content?: unknown; + message?: unknown; + toolUseResult?: unknown; + }; + if (typeof normalizedRecord.text === 'string' && normalizedRecord.text.trim()) { + return normalizedRecord.text.trim(); + } + const fromContent = extractTranscriptTextContent(normalizedRecord.content); + if (fromContent.length > 0) { + return fromContent.join('\n'); + } + const fromToolUseResult = extractTranscriptTextContent(normalizedRecord.toolUseResult); + if (fromToolUseResult.length > 0) { + return fromToolUseResult.join('\n'); + } + if (normalizedRecord.message) { + return extractTranscriptMessageText(normalizedRecord.message); + } + return null; +} + +export function normalizeMemberDiagnosticText(memberName: string, text: string): string { + return `${memberName}: ${text.trim()}`; +} + +export function shouldUseGeminiStagedLaunch(providerId: TeamProviderId | undefined): boolean { + return resolveTeamProviderId(providerId) === 'gemini'; +} + +export function buildGeminiMemberSpawnPrompt( + member: TeamCreateRequest['members'][number], + displayName: string, + teamName: string, + leadName: string +): string { + const role = member.role?.trim() || 'team member'; + const providerLine = + member.providerId && member.providerId !== 'anthropic' + ? `\nProvider override: ${member.providerId}.` + : ''; + const modelLine = member.model?.trim() ? `\nModel override: ${member.model.trim()}.` : ''; + const effortLine = member.effort ? `\nEffort override: ${member.effort}.` : ''; + const workflowBlock = member.workflow?.trim() ? `\nWorkflow:\n${member.workflow.trim()}` : ''; + + return `You are ${member.name}, a ${role} on team "${displayName}" (${teamName}).${providerLine}${modelLine}${effortLine}${workflowBlock} + +${getAgentLanguageInstruction()} +Your FIRST action: call MCP tool member_briefing with: +{ teamName: "${teamName}", memberName: "${member.name}" } +Call member_briefing directly. Do NOT use Agent, any subagent, or any delegated helper for this step. +If tool search says agent-teams is still connecting, wait briefly and retry tool search at most once. +If member_briefing is still unavailable after that one retry, SendMessage "${leadName}" exactly one short natural-language sentence with the exact error text, then stop this turn and wait. Do NOT send only "bootstrap failed". +Do NOT keep searching for member_briefing, check tasks, or send repeated status/idle messages after reporting the bootstrap failure. +${getCanonicalSendMessageFieldRule()} +${getVisibleTaskReferenceFormattingRule()} +Correct example: +${buildCanonicalSendMessageExample({ to: leadName, summary: 'bootstrap error', message: 'exact error text' })} +After member_briefing succeeds, stay silent until you have a real blocker, question, or task result. Do NOT send raw tool output, JSON, dict/object dumps, or internal state payloads. +- Review flow rule: review happens on the SAME work task. If task #X needs review and a reviewer exists or has been named, the owner completes #X and sends #X through review_request, and the reviewer handles review_start then review_approve/review_request_changes on #X. If no reviewer exists, leave #X completed. Do NOT create a separate "review task".`; +} + +export function buildGeminiReconnectMemberSpawnPrompt( + member: TeamCreateRequest['members'][number], + teamName: string, + leadName: string +): string { + const role = member.role?.trim() || 'team member'; + const providerLine = + member.providerId && member.providerId !== 'anthropic' + ? `\nProvider override: ${member.providerId}.` + : ''; + const modelLine = member.model?.trim() ? `\nModel override: ${member.model.trim()}.` : ''; + const effortLine = member.effort ? `\nEffort override: ${member.effort}.` : ''; + const workflowBlock = member.workflow?.trim() ? `\nWorkflow:\n${member.workflow.trim()}` : ''; + + return `You are ${member.name}, a ${role} on team "${teamName}" (${teamName}).${providerLine}${modelLine}${effortLine}${workflowBlock} + +${getAgentLanguageInstruction()} +The team has just been reconnected after a restart. +Your FIRST action: call MCP tool member_briefing with: +{ teamName: "${teamName}", memberName: "${member.name}" } +Call member_briefing directly. Do NOT use Agent, any subagent, or any delegated helper for this step. +If tool search says agent-teams is still connecting, wait briefly and retry tool search at most once. +If member_briefing is still unavailable after that one retry, SendMessage "${leadName}" exactly one short natural-language sentence with the exact error text, then stop this turn and wait. Do NOT send only "bootstrap failed". +Do NOT keep searching for member_briefing, check tasks, or send repeated status/idle messages after reporting the bootstrap failure. +${getCanonicalSendMessageFieldRule()} +${getVisibleTaskReferenceFormattingRule()} +Correct example: +${buildCanonicalSendMessageExample({ to: leadName, summary: 'bootstrap error', message: 'exact error text' })} +After member_briefing succeeds, stay silent unless you have a real blocker, question, or task result. Do NOT send raw tool output, JSON, dict/object dumps, or internal state payloads. +- Review flow rule: review happens on the SAME work task. If task #X needs review and a reviewer exists or has been named, the owner completes #X and sends #X through review_request, and the reviewer handles review_start then review_approve/review_request_changes on #X. If no reviewer exists, leave #X completed. Do NOT create a separate "review task".`; +} + +export function buildMemberReviewFlowReminder(): string { + return [ + '- Review flow rule: review is a state transition on the SAME work task, not a separate task.', + '- If your task #X needs review and a reviewer exists or has been named, finish the work on #X, call task_complete on #X, then use review_request on #X for that reviewer. If no reviewer exists, leave #X completed. Do NOT create a separate "review task".', + '- If you are the reviewer for task #X, call review_start on #X first, then review_approve or review_request_changes on #X itself.', + '- If review requests changes, resume/fix the SAME task #X, then task_complete #X and send #X back through review_request when ready.', + ].join('\n'); +} + +export function buildMemberSpawnPrompt( + member: TeamCreateRequest['members'][number], + displayName: string, + teamName: string, + leadName: string, + options?: { restart?: boolean } +): string { + const role = member.role?.trim() || 'team member'; + const providerLine = + member.providerId && member.providerId !== 'anthropic' + ? `\nProvider override for this teammate: ${member.providerId}.` + : ''; + const modelLine = member.model?.trim() + ? `\nModel override for this teammate: ${member.model.trim()}.` + : ''; + const effortLine = member.effort ? `\nEffort override for this teammate: ${member.effort}.` : ''; + const workflowBlock = member.workflow?.trim() + ? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(member.workflow, '')}` + : ''; + const restartContext = options?.restart + ? '\n\nThe team has already been reconnected and you are being re-attached as a persistent teammate.\nThis is a teammate restart. Repeat bootstrap exactly once, then wait for normal work instructions.' + : ''; + const actionModeProtocol = protocols.buildActionModeProtocolText( + protocols.MEMBER_DELEGATE_DESCRIPTION + ); + return `You are ${member.name}, a ${role} on team "${displayName}" (${teamName}).${providerLine}${modelLine}${effortLine}${workflowBlock}${restartContext} + +${getAgentLanguageInstruction()} +Your FIRST action: call MCP tool member_briefing with: +{ teamName: "${teamName}", memberName: "${member.name}" } +Call member_briefing directly as your own MCP tool call. Do NOT use the Agent tool, any subagent, or any delegated helper for this step. +member_briefing is expected to be available in your initial MCP tool list. If it is missing or unavailable, treat that as a real bootstrap error and report the exact error text to your team lead. +Do NOT start work, claim tasks, or improvise workflow/task/process rules before member_briefing succeeds. +If tool search says agent-teams is still connecting, wait briefly and retry tool search at most once. +If member_briefing is still unavailable after that one retry, send exactly one short natural-language message to your team lead "${leadName}" that includes the exact failure reason (for example the API error, validation error, or lookup failure), then stop this turn and wait. Do NOT send only "bootstrap failed". +Do NOT keep searching for member_briefing, check tasks, or send repeated status/idle messages after reporting the bootstrap failure. +IMPORTANT: When sending messages to the team lead, always use the exact name "${leadName}" in the \`to\` field of SendMessage. Never abbreviate or shorten it (e.g. do NOT use "lead" instead of "team-lead"). +${getCanonicalSendMessageFieldRule()} +${getVisibleTaskReferenceFormattingRule()} +Correct example: +${buildCanonicalSendMessageExample({ to: leadName, summary: 'short update', message: 'your message' })} +After member_briefing succeeds: +- Do NOT send a "ready", "online", "status accepted", or other acknowledgement-only message just to confirm you started successfully. +- If bootstrap succeeded and you have no task yet, stay silent and wait for task assignments. +- If bootstrap succeeded and you have no task, produce ZERO assistant text for that turn and end it immediately after the successful tool result. +- Do NOT ask the user or the lead to send you a task ID, task description, or "next task" right after bootstrap. +- Only SendMessage the lead after bootstrap when there is a real blocker, a failed bootstrap, an explicit question, an urgent coordination need, or a completed task result to report. +- Never send raw tool output, JSON, dict/object dumps, Python-style structs, or internal state payloads to the lead or the user. If you need to report bootstrap/task/tool status, rewrite it as one short natural-language sentence. +- When you later receive work or reconnect after a restart, use task_briefing as your primary working queue. Use task_list only to search/browse inventory rows, not as your working queue. +- Act only on Actionable items in task_briefing. Awareness items are watch-only context unless the lead reroutes the task or you become the actionOwner. +- Use task_get when you need the full task context before starting a pending/needsFix task or when the in_progress briefing details are not enough. +- If a newly assigned task cannot be started immediately because you are still busy on another task, leave a short task comment on that waiting task right away with the reason and your best ETA, keep it in pending/TODO, and only move it to in_progress with task_start when you truly begin. +- CRITICAL: If someone comments on your task, you MUST reply on that same task via task_add_comment. Never leave a user/lead/teammate task comment unanswered, even if the reply is only a short acknowledgement or status update. Do NOT treat status changes or direct messages as a substitute for an on-task reply. +- CRITICAL: If a task gets a new comment and you are going to do additional implementation/fix/follow-up work on that same task, FIRST leave a short task comment saying what you are about to do, THEN move it to in_progress with task_start, THEN do the work, and when finished leave a short result comment and move it to done with task_complete. Never skip this comment -> reopen -> work -> comment -> done cycle. +- CRITICAL: When you finish a task, your results (findings, research report, analysis, code changes summary, or any deliverable) MUST be posted as a task comment via task_add_comment BEFORE calling task_complete. Save the comment.id from the response — you will need it in the next step. The task comment is the primary delivery channel — the user reads results on the task board. A SendMessage to the lead is NOT a substitute: direct messages are ephemeral and not visible on the board. If you only SendMessage without a task comment, the user will never see your work. +- After task_complete, notify your team lead via SendMessage. Keep the visible message human-readable only: include the task ref as plain # text (not a code span and not a manual task:// Markdown link), a brief summary (2-4 sentences), where the full result lives, and the next step. Do NOT paste tool-like calls such as task_get_comment { ... } into the visible message text. Instead write "Full details in task comment ". If the SendMessage tool input exposes optional taskRefs, include taskRefs for the task you are reporting using the exact task metadata, e.g. taskRefs: [{ taskId: "", displayId: "", teamName: "${teamName}" }]. Example visible message: "#abcd1234 done. Found 3 competitors, two lack kanban. Full details in task comment e5f6a7b8. Moving to #efgh5678." +- Review discipline: +${indentMultiline(buildMemberReviewFlowReminder(), ' ')} +- Beyond task-completion pings, direct messages to your team lead are only for urgent attention, no-task situations, or when the lead explicitly asked for a direct reply. +- If a task-scoped update is already recorded in a task comment, do NOT send a duplicate SendMessage to the lead with the same content unless you need urgent non-task attention. When skipping a message, stay silent — never output meta-commentary about skipped or already-delivered messages. +${buildTeammateAgentBlockReminder()} +${actionModeProtocol}`; +} + +export function buildReconnectMemberSpawnPrompt( + member: TeamCreateRequest['members'][number], + teamName: string, + leadName: string, + hasTasks: boolean +): string { + const role = member.role?.trim() || 'team member'; + const providerLine = + member.providerId && member.providerId !== 'anthropic' + ? `\n Provider override for this teammate: ${member.providerId}.` + : ''; + const modelLine = member.model?.trim() + ? `\n Model override for this teammate: ${member.model.trim()}.` + : ''; + const effortLine = member.effort + ? `\n Effort override for this teammate: ${member.effort}.` + : ''; + const workflowBlock = member.workflow?.trim() + ? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(member.workflow, ' ')}` + : ''; + const actionModeProtocol = indentMultiline( + protocols.buildActionModeProtocolText(protocols.MEMBER_DELEGATE_DESCRIPTION), + ' ' + ); + const providerArgLine = + member.providerId && member.providerId !== 'anthropic' + ? ` - provider: "${member.providerId}"\n` + : ''; + const modelArgLine = member.model?.trim() ? ` - model: "${member.model.trim()}"\n` : ''; + const effortArgLine = member.effort ? ` - effort: "${member.effort}"\n` : ''; + return ` For "${member.name}": +${providerArgLine}${modelArgLine}${effortArgLine} - prompt: + You are ${member.name}, a ${role} on team "${teamName}" (${teamName}).${providerLine}${modelLine}${effortLine}${workflowBlock} + + ${getAgentLanguageInstruction()} + The team has been reconnected after a restart. + ${ + hasTasks + ? 'You may have assigned tasks in states like in_progress, needsFix, pending, review, completed, or approved from the previous session.' + : 'You have no assigned tasks currently.' + } + Your FIRST action: call MCP tool member_briefing with: + { teamName: "${teamName}", memberName: "${member.name}" } + Call member_briefing directly as your own MCP tool call. Do NOT use the Agent tool, any subagent, or any delegated helper for this step. + member_briefing is expected to be available in your initial MCP tool list. If it is missing or unavailable, treat that as a real bootstrap error and report the exact error text to your team lead. + Do NOT start work, claim tasks, or improvise workflow/task/process rules before member_briefing succeeds. + If tool search says agent-teams is still connecting, wait briefly and retry tool search at most once. + If member_briefing is still unavailable after that one retry, send exactly one short natural-language message to your team lead "${leadName}" that includes the exact failure reason (for example the API error, validation error, or lookup failure), then stop this turn and wait. Do NOT send only "bootstrap failed". + Do NOT keep searching for member_briefing, check tasks, or send repeated status/idle messages after reporting the bootstrap failure. + IMPORTANT: When sending messages to the team lead, always use the exact name "${leadName}" in the \`to\` field of SendMessage. Never abbreviate or shorten it (e.g. do NOT use "lead" instead of "team-lead"). +${indentMultiline(getVisibleTaskReferenceFormattingRule(), ' ')} + ${buildTeammateAgentBlockReminder()} +${actionModeProtocol} + + After member_briefing succeeds: + - Do NOT send a "ready", "online", "status accepted", or other acknowledgement-only message just to confirm you reconnected successfully. + - If reconnect bootstrap succeeded and you have no immediate blocker or question, stay silent and continue with your queue. + - If reconnect bootstrap succeeded and you have no immediate blocker, question, or task, produce ZERO assistant text for that turn and end it immediately. + - Do NOT ask the user or the lead to send you a task ID, task description, or "next task" right after reconnect bootstrap. + - Never send raw tool output, JSON, dict/object dumps, Python-style structs, or internal state payloads to the lead or the user. If you need to report bootstrap/task/tool status, rewrite it as one short natural-language sentence. + - Use task_briefing as your primary working queue. Use task_list only to search/browse inventory rows, not as your working queue. + - Act only on Actionable items in task_briefing. Awareness items are watch-only context unless the lead reroutes the task or you become the actionOwner. + - If task_briefing shows any in_progress task, resume/finish those first. Call task_get only if you need more context than task_briefing already gave you. + - After that, prioritize tasks marked Needs fixes after review, then normal pending tasks. + - Before you start any needsFix or pending task, call task_get for that specific task. + - If a newly assigned needsFix or pending task must wait because you are still finishing another task, leave a short task comment on that waiting task with the reason and your best ETA, keep it in pending/TODO (use task_set_status pending if needed), and only run task_start when you truly begin. + - CRITICAL: If someone comments on your task, you MUST reply on that same task via task_add_comment. Never leave a user/lead/teammate task comment unanswered, even if the reply is only a short acknowledgement or status update. Do NOT treat status changes or direct messages as a substitute for an on-task reply. + - If you are the one about to do the implementation/fixes and the owner is missing or someone else, run task_set_owner to yourself immediately before task_start. + - Only then run task_start when you truly begin. + - If a task gets a new comment and you are going to do additional implementation/fix/follow-up work on it, FIRST leave a short task comment saying what you are about to do, THEN run task_start, then do the work, and when finished leave a short result comment and run task_complete again. Never skip this comment -> reopen -> work -> comment -> done cycle. + - CRITICAL: When you finish a task, your results (findings, research report, analysis, code changes summary, or any deliverable) MUST be posted as a task comment BEFORE calling task_complete. The task comment is the primary delivery channel — the user reads results on the task board. A SendMessage to the lead is NOT a substitute: direct messages are ephemeral and not visible on the board. If you only SendMessage without a task comment, the user will never see your work. + - After task_complete, notify your team lead via SendMessage. The task_add_comment response contains comment.id (UUID) - take its first 8 characters as the short commentId. Keep the visible message human-readable only: include the task ref as plain # text (not a code span and not a manual task:// Markdown link), a brief summary (2-4 sentences), where the full result lives, and the next step. Do NOT paste tool-like calls such as task_get_comment { ... } into the visible message text. Instead write "Full details in task comment ". If the SendMessage tool input exposes optional taskRefs, include taskRefs for the task you are reporting using the exact task metadata, e.g. taskRefs: [{ taskId: "", displayId: "", teamName: "${teamName}" }]. Example visible message: "#abcd1234 done. Found 3 competitors, two lack kanban. Full details in task comment e5f6a7b8. Moving to #efgh5678." + - Review discipline: +${indentMultiline(buildMemberReviewFlowReminder(), ' ')} + - Beyond task-completion pings, direct messages to your team lead are only for urgent attention, no-task situations, or when the lead explicitly asked for a direct reply. + - If a task-scoped update is already recorded in a task comment, do NOT send a duplicate SendMessage to the lead with the same content unless you need urgent non-task attention. When skipping a message, stay silent — never output meta-commentary about skipped or already-delivered messages. + - If you have no tasks, wait for new assignments.`; +} + +function buildAgentToolArgsSuffix( + member: Pick< + TeamCreateRequest['members'][number], + 'providerId' | 'model' | 'effort' | 'isolation' + >, + mcpLaunchConfig?: RuntimeBootstrapMemberMcpLaunchConfig | null +): string { + const providerPart = + member.providerId && member.providerId !== 'anthropic' + ? `, provider="${member.providerId}"` + : ''; + const modelPart = member.model?.trim() ? `, model="${member.model.trim()}"` : ''; + const effortPart = member.effort ? `, effort="${member.effort}"` : ''; + const isolationPart = member.isolation === 'worktree' ? ', isolation="worktree"' : ''; + const mcpConfigPart = mcpLaunchConfig?.mcpConfigPath + ? `, mcp_config="${mcpLaunchConfig.mcpConfigPath}"` + : ''; + const mcpSettingSourcesPart = mcpLaunchConfig?.mcpSettingSources + ? `, mcp_setting_sources="${mcpLaunchConfig.mcpSettingSources}"` + : ''; + const strictMcpConfigPart = + mcpLaunchConfig?.strictMcpConfig === undefined + ? '' + : `, strict_mcp_config=${mcpLaunchConfig.strictMcpConfig ? 'true' : 'false'}`; + return `${providerPart}${modelPart}${effortPart}${isolationPart}${mcpConfigPart}${mcpSettingSourcesPart}${strictMcpConfigPart}`; +} + +export function buildAddMemberSpawnMessage( + teamName: string, + displayName: string, + leadName: string, + member: Pick< + TeamCreateRequest['members'][number], + 'name' | 'role' | 'workflow' | 'providerId' | 'model' | 'effort' | 'isolation' + >, + mcpLaunchConfig?: RuntimeBootstrapMemberMcpLaunchConfig | null +): string { + const roleHint = + typeof member.role === 'string' && member.role.trim() + ? ` with role "${member.role.trim()}"` + : ''; + const workflowHint = + typeof member.workflow === 'string' && member.workflow.trim() + ? ` Their workflow: ${member.workflow.trim()}` + : ''; + + const prompt = buildMemberSpawnPrompt( + { + name: member.name, + ...(member.role ? { role: member.role } : {}), + ...(member.workflow ? { workflow: member.workflow } : {}), + ...(member.providerId ? { providerId: member.providerId } : {}), + ...(member.model ? { model: member.model } : {}), + ...(member.effort ? { effort: member.effort } : {}), + }, + displayName, + teamName, + leadName + ); + const agentArgs = buildAgentToolArgsSuffix(member, mcpLaunchConfig); + + return ( + `A new teammate "${member.name}"${roleHint} has been added to the team. ` + + `Please spawn them immediately using the **Agent** tool with team_name="${teamName}", name="${member.name}", subagent_type="general-purpose"${agentArgs}, and the exact prompt below:${workflowHint}\n\n` + + indentMultiline(prompt, ' ') + ); +} + +export function buildRestartMemberSpawnMessage( + teamName: string, + displayName: string, + leadName: string, + member: Pick< + TeamCreateRequest['members'][number], + 'name' | 'role' | 'workflow' | 'providerId' | 'model' | 'effort' | 'isolation' + >, + mcpLaunchConfig?: RuntimeBootstrapMemberMcpLaunchConfig | null +): string { + const roleHint = + typeof member.role === 'string' && member.role.trim() + ? ` with role "${member.role.trim()}"` + : ''; + const workflowHint = + typeof member.workflow === 'string' && member.workflow.trim() + ? ` Their workflow: ${member.workflow.trim()}` + : ''; + + const prompt = buildMemberSpawnPrompt( + { + name: member.name, + ...(member.role ? { role: member.role } : {}), + ...(member.workflow ? { workflow: member.workflow } : {}), + ...(member.providerId ? { providerId: member.providerId } : {}), + ...(member.model ? { model: member.model } : {}), + ...(member.effort ? { effort: member.effort } : {}), + }, + displayName, + teamName, + leadName + ); + const agentArgs = buildAgentToolArgsSuffix(member, mcpLaunchConfig); + + return ( + `Teammate "${member.name}"${roleHint} was restarted from the UI. ` + + `Please respawn them immediately using the **Agent** tool with team_name="${teamName}", name="${member.name}", subagent_type="general-purpose"${agentArgs}, and the exact prompt below. ` + + `This is a restart of an existing persistent teammate, not a new teammate. ` + + `If the Agent tool returns duplicate_skipped with reason bootstrap_pending, treat that as a pending restart and wait for teammate check-in. ` + + `If it returns duplicate_skipped with reason already_running, do not report success - it means the previous runtime still appears active and the restart may not have applied.${workflowHint ? workflowHint : ''}\n\n` + + indentMultiline(prompt, ' ') + ); +} + +export function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string { + return wrapInAgentBlock( + [ + `Internal task board tooling (MCP):`, + `- Use the board-management MCP tools for tasks that must appear on the team board (assigned work, substantial work, or when the user explicitly asks to create a task).`, + ``, + `Execution discipline (CRITICAL — prevents misleading task boards):`, + `- Start a task (move to in_progress) ONLY when you are actually beginning work on it.`, + `- Complete a task ONLY when it is truly finished (and any required verification is done).`, + `- If you assign work to a teammate who already has another in_progress task, create/keep the newly assigned task in pending/TODO. Do NOT move it to in_progress on their behalf before they actually start.`, + `- Never bulk-move many tasks at the end of a session — update status incrementally as you work.`, + `- Record meaningful progress, decisions, and blockers as task comments so context is preserved on the board.`, + `- CRITICAL: Task results (findings, reports, analysis, code changes) MUST be posted as task comments — the user reads results on the task board. Direct messages alone are not visible on the board and the user will miss them.`, + ``, + `Parallelization guideline (IMPORTANT):`, + `- If a task is genuinely parallelizable, split it into multiple smaller tasks owned by different members.`, + ` - Prefer splitting by independent deliverables (e.g. frontend/backend, API/UI, parsing/rendering, tests/docs) rather than arbitrary slices.`, + ` - Use blockedBy only when one piece truly cannot start without another; otherwise link with related.`, + ` - Do NOT split when work is inherently sequential, requires one person to keep consistent context, or the overhead would exceed the benefit.`, + ` - When splitting, make each task have a clear completion criterion and a single accountable owner.`, + ``, + `IMPORTANT: The board MCP supports these domains: lead, task, kanban, review, message, process. There is NO "member" domain — team members are managed by spawning teammates via the Task tool, not via the board MCP.`, + ``, + `Task board operations — use MCP tools directly:`, + `- FIRST inspect the compact lead queue: lead_briefing { teamName: "${teamName}" }`, + ` lead_briefing is the primary lead queue. Decisions about what to act on now come from lead_briefing, not from raw task_list rows.`, + `- Get task details: task_get { teamName: "${teamName}", taskId: "" }`, + `- Get a single comment without loading full task: task_get_comment { teamName: "${teamName}", taskId: "", commentId: "" }`, + ` When an inbox row provides structured task metadata (teamName/taskId/commentId), treat those identifiers as authoritative and use them directly. Do NOT infer alternate task ids or namespaces from visible prose.`, + `- Browse/search compact inventory rows only: task_list { teamName: "${teamName}", owner?: "", status?: "pending|in_progress|completed", reviewState?: "none|review|needsFix|approved", kanbanColumn?: "review|approved", relatedTo?: "", blockedBy?: "", limit?: }`, + ` task_list is inventory/search/drill-down only. Do NOT treat task_list as the lead's working queue.`, + `- Create task: task_create { teamName: "${teamName}", subject: "...", description?: "...", owner?: "", createdBy?: "", blockedBy?: ["1","2"], related?: ["3"] }`, + `- Create task from user message (preferred when you have a MessageId from a relayed inbox message): task_create_from_message { teamName: "${teamName}", messageId: "", subject: "...", owner?: "", createdBy?: "", blockedBy?: ["1","2"], related?: ["3"] }`, + `- Assign/reassign owner: task_set_owner { teamName: "${teamName}", taskId: "", owner: "" }`, + `- Clear owner: task_set_owner { teamName: "${teamName}", taskId: "", owner: null }`, + `- Start task (preferred over set-status): task_start { teamName: "${teamName}", taskId: "" }`, + `- Complete task (preferred over set-status): task_complete { teamName: "${teamName}", taskId: "" }`, + `- Update status: task_set_status { teamName: "${teamName}", taskId: "", status: "pending|in_progress|completed|deleted" }`, + `- Add comment: task_add_comment { teamName: "${teamName}", taskId: "", text: "...", from: "${leadName}" }`, + `- Attach file to task: task_attach_file { teamName: "${teamName}", taskId: "", filePath: "", mode?: "copy|link", filename?: "", mimeType?: "" }`, + `- Attach file to a specific comment:`, + ` 1) Find commentId: task_get { teamName: "${teamName}", taskId: "" }`, + ` 2) Attach: task_attach_comment_file { teamName: "${teamName}", taskId: "", commentId: "", filePath: "", mode?: "copy|link", filename?: "", mimeType?: "" }`, + `- Create with deps (blocked work MUST be pending): task_create { teamName: "${teamName}", subject: "...", owner: "", createdBy: "", blockedBy: ["1","2"], related?: ["3"], startImmediately: false }`, + `- Link dependency: task_link { teamName: "${teamName}", taskId: "", targetId: "", relationship: "blocked-by" }`, + `- Link related: task_link { teamName: "${teamName}", taskId: "", targetId: "", relationship: "related" }`, + `- Unlink: task_unlink { teamName: "${teamName}", taskId: "", targetId: "", relationship: "blocked-by" }`, + `- Set clarification flag: task_set_clarification { teamName: "${teamName}", taskId: "", value: "lead"|"user"|"clear" }`, + ``, + `Review operations — use MCP tools directly (text comments do NOT change kanban state):`, + `- Request review (after task_complete): review_request { teamName: "${teamName}", taskId: "", from: "${leadName}", reviewer: "" }`, + `- Start review (reviewer signals they are beginning): review_start { teamName: "${teamName}", taskId: "", from: "" }`, + `- Approve review: review_approve { teamName: "${teamName}", taskId: "", from: "", note?: "", notifyOwner: true }`, + ` Call review_approve EXACTLY ONCE per review. Include your review feedback in the "note" field of that single call. Do NOT call it twice (once to approve, once with a note). The tool auto-creates a comment from the note.`, + `- Request changes: review_request_changes { teamName: "${teamName}", taskId: "", from: "", comment: "" }`, + `CRITICAL: Review is a state transition on the EXISTING work task. When implementation for task #X needs review, move #X through the review flow with review_request/review_start/review_approve/review_request_changes. Do NOT create a new separate task just to represent that review.`, + `CRITICAL: Only send task #X into review when a concrete reviewer exists for #X. If no reviewer exists yet, keep #X completed until you assign/decide the reviewer. Do NOT use review_request just to park the task in REVIEW without an actual reviewer.`, + `CRITICAL: Writing "approved" or "LGTM" as a task comment does NOT move the task on the kanban board. You MUST call the review_approve MCP tool. Without the tool call the task stays stuck in the REVIEW column.`, + ``, + `Background service operations — use MCP tools directly (dev servers, watchers, databases, etc.; NOT teammate-agent liveness):`, + protocols.buildProcessProtocolText(teamName), + ``, + `Attachment storage modes (IMPORTANT):`, + `- Default is copy (safe, robust).`, + `- Use mode: "link" to try a hardlink (no duplication). It may fall back to copy unless you disable fallback.`, + ``, + `Dependency guidelines:`, + `- Use blockedBy when a task cannot start until another is done.`, + `- If you set blockedBy, create the task in pending (for example with startImmediately: false). Do NOT put blocked tasks into in_progress.`, + `- Use related to link related work (e.g. frontend + backend) without blocking.`, + `- Review tasks: By default, NEVER create a separate "review task". Reviews belong to the existing work task (#X) and must use the dedicated review flow on #X.`, + ` - Correct flow: finish implementation on #X -> task_complete #X -> review_request #X -> reviewer runs review_start #X -> reviewer runs review_approve or review_request_changes on #X.`, + ` - Only move #X into REVIEW when a real reviewer exists for #X. If nobody is reviewing it yet, keep #X completed until the reviewer is decided.`, + ` - The REVIEW column is for the same task #X moving through review. It is NOT a signal to create another task for review.`, + ` - Dependencies do not auto-start tasks; the owner must explicitly start it when ready.`, + `- Avoid over-specifying. Only add dependencies when execution order matters.`, + ``, + `Notification policy:`, + `- Task assignment notifications are handled by the board runtime, so do NOT send a separate SendMessage for the same assignment unless you have extra context that is not already on the task.`, + `- Review requests are also handled by the board runtime: review_request already notifies the reviewer, so do NOT send a second manual SendMessage for the same review request unless you are adding materially new context that is not already on the task.`, + `- When beginning a review, always call review_start first to move the task into the REVIEW column on the kanban board.`, + `- If you receive a task-scoped system notification like "Comment on #...", reply on-task only when you have a substantive update: a decision, blocker, clarification answer, review result, or a concrete next-step change that belongs on the board.`, + `- Do NOT post acknowledgement-only task comments such as "Принято", "Ок", "На связи", "Жду", or similar low-signal echoes. If the notification is FYI and no durable update is needed, say nothing and write nothing.`, + `- Teammate task comments are auto-forwarded to you. When that happens, reply on-task first only if the task actually needs a durable board update. Direct messages are allowed only as an additional urgent wake-up ping or clearly non-task coordination, never as a substitute for a substantive task comment.`, + `- When you skip sending a message because it would be a duplicate or was already delivered, produce NO text output about it. Do not write meta-commentary like "(Already relayed…)", "(No additional relay needed…)", or similar. Just silently move on.`, + `- Ownership must reflect the person actually doing the implementation/fix work. If someone takes over execution, update the owner immediately before they start. Do NOT leave the lead/planner as owner when another member is doing the work.`, + `- Set createdBy when creating tasks so workflow history shows who created the task.`, + ``, + `Clarification handling (CRITICAL — MANDATORY for correct task board state):`, + `- When a teammate needs clarification (needsClarification: "lead"), you MUST reply via task comment first. This is the durable answer on the board.`, + `- If you also send a SendMessage for urgency/visibility, treat it as an extra notification only — never as a substitute for the task-comment reply.`, + `- Clarification flags are not assumed to auto-clear. After the blocker is truly resolved, clear the flag explicitly with:`, + ` task_set_clarification { teamName: "${teamName}", taskId: "", value: "clear" }`, + `- If you cannot answer and the user needs to decide — ESCALATION PROTOCOL:`, + ` 1) FIRST, set the flag to "user" via MCP tool task_set_clarification (this updates the task board):`, + ` { teamName: "${teamName}", taskId: "", value: "user" }`, + ` 2) THEN, send a message to "user" explaining the question.`, + ` 3) THEN, reply to the teammate telling them to wait.`, + ` IMPORTANT: Always update the task board BEFORE sending messages. Without the flag, the task board won't show that the task is blocked waiting for user input.`, + ].join('\n') + ); +} + +export function buildLeadRosterContextBlock( + teamName: string, + leadName: string, + teammates: { name: string; role?: string }[] +): string | null { + if (teammates.length === 0) return null; + + const summary = teammates + .map((member) => (member.role ? `${member.name} (${member.role})` : member.name)) + .join(', '); + + return [ + `Current durable team context:`, + `- Team name: ${teamName}`, + `- You are the live team lead "${leadName}"`, + `- Persistent teammates currently configured: ${summary}`, + `- This team is NOT in solo mode`, + `- If the user asks who is on the team, answer from this durable roster unless newer durable state explicitly says otherwise.`, + ].join('\n'); +} + +/** + * Builds the durable lead context — constraints, communication protocol, board MCP ops, + * and agent block policy — that must survive context compaction. + * + * Used by: deterministic launch hydration and post-compact reinjection. + */ +export function buildPersistentLeadContext(opts: { + teamName: string; + leadName: string; + isSolo: boolean; + members: TeamCreateRequest['members']; + /** When true, emit a compact roster (name + role only, no workflows). Used for post-compact reminders. */ + compact?: boolean; +}): string { + const { teamName, leadName, isSolo, members, compact } = opts; + const languageInstruction = getAgentLanguageInstruction(); + const agentBlockPolicy = buildAgentBlockUsagePolicy(); + const actionModeProtocol = buildActionModeProtocol(); + const teamCtlOps = buildTeamCtlOpsInstructions(teamName, leadName); + + const soloConstraint = isSolo + ? `\n- SOLO MODE: This team CURRENTLY has ZERO teammates.` + + `\n - FORBIDDEN (until teammates exist): Do NOT spawn teammates via the Task tool with a team_name parameter — there are no teammates to spawn yet.` + + `\n - FORBIDDEN (until teammates exist): Do NOT call SendMessage to any teammate name — no teammates exist yet.` + + `\n - ALLOWED: You may message "user" (the human operator) via SendMessage.` + + `\n - ALLOWED: You may use the Agent tool for regular subagents WITHOUT team_name — these are normal Claude Code helpers, not teammates.` + + `\n - If teammates are added later (e.g. via UI), you may then spawn them using the Agent tool with team_name + name.` + + `\n - TASK BOARD FIRST (MANDATORY): Do NOT do substantial work silently or off-board.` + + `\n - Before you start meaningful implementation, debugging, research, review, or follow-up work, make sure there is a visible team-board task for it and that task is assigned to you.` + + `\n - If the user asks for new work, your first move is to create/update the relevant board task(s), then start work from those tasks.` + + `\n - If scope changes mid-task, update the existing task or create a follow-up task before continuing.` + + `\n - If you notice you already began meaningful work without a task, stop, put it on the board, then continue.` + + `\n - Work on tasks directly yourself. Use subagents for research and parallel work as needed, but keep the board as the source of truth.` + + `\n - PROGRESS REPORTING (MANDATORY): Since you have no teammates, "user" is your only communication channel.` + + `\n - SendMessage "user" at minimum: when you start a task (after marking it in_progress), when you complete a task, and when you hit a meaningful milestone/blocker/decision.` + + `\n - Avoid long silent stretches. If something is taking longer than expected, send a brief update and the next step.` + + `\n - TASK STATUS DISCIPLINE (MANDATORY):` + + `\n - Only move a task to in_progress when you are actively starting work on it.` + + `\n - Only move a task to completed when it is truly finished.` + + `\n - Never bulk-move many tasks at the end — update status incrementally as you work.` + + `\n - Default to working ONE task at a time (keep at most one task in_progress in solo mode), unless you explicitly need parallel background work (in that case explain why to "user").` + + `\n - Record meaningful progress/decisions as task comments so the task board stays accurate and high-signal.` + : ''; + + const membersBlock = compact ? buildCompactMembersRoster(members) : buildMembersPrompt(members); + const membersFooter = membersBlock + ? `Members:\n${membersBlock}` + : 'Members: (none — solo team lead)'; + + return `${languageInstruction} + +Constraints: +- Do NOT call TeamDelete under any circumstances. +- Do NOT use TodoWrite. +- Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN). +- Do NOT shut down, terminate, or clean up the team or its members. +- Do NOT spawn or create a member named "user". "user" is a reserved system name for the human operator — it is NOT a teammate. +- Keep assistant text minimal. NEVER produce text about internal routing decisions — if you receive a notification, relay request, or message and decide no action is needed, produce ZERO text output. No "(Already relayed…)", "(No additional relay needed…)", "(Duplicate…)", or any similar meta-commentary. If there is nothing to do, say nothing. +- NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough. +- NEVER use SendMessage with to="*" (broadcast). The "*" address is NOT supported — it will create a phantom participant named "*" instead of reaching all teammates. To message multiple teammates, send a separate SendMessage to each one by name. +- Keep the task board high-signal: avoid creating tasks for trivial micro-items. +- Use the team task board for assigned/substantial work. +- DELEGATION-FIRST (behavior rule for ALL future turns): When "user" gives you work, your top priority is to (a) decompose into tasks, (b) create tasks on the team board, (c) assign them to teammates, and (d) SendMessage "user" a short confirmation (task IDs + owners). Do NOT start implementing yourself unless the team is truly in SOLO MODE (no teammates). +- In a non-solo team, your default first move is delegation, NOT personal investigation. Do NOT read/search the codebase, inspect files, or do root-cause research yourself just to figure out ownership or scope before delegating. +- If the request is ambiguous or still needs technical discovery, immediately create a coarse investigation/triage task for the best-fit teammate. That teammate owns the code inspection, scope refinement, and creation of any follow-up tasks needed for execution. +- Only do lead-side research first if the human explicitly asked YOU for analysis/planning, or if there is genuinely no appropriate teammate to own the investigation. +- Built-in Agent usage rule: the built-in Agent tool is allowed only for normal Claude Code-style subagents WITHOUT team_name, and only on turns whose action mode is DO. In ASK or DELEGATE mode, treat Agent as forbidden. Never use Agent with team_name to relaunch the team or create persistent teammates from ordinary lead work. +- Do NOT use the built-in TaskCreate tool for team-board tasks. In this team runtime, create board tasks only via the MCP task tools (task_create, task_create_from_message, etc.). +- When messaging "user" (the human): write plain human language. If a task needs a status update, do it yourself via the board MCP tools; never ask the user to run a command.${soloConstraint} + +${teamCtlOps} + +${actionModeProtocol} + +Communication protocol (CRITICAL — you are running headless, no one sees your text output): +- When you receive a from a teammate and that message expects any reaction from you, your default action is to reply to THAT teammate using the SendMessage tool. Do NOT answer with plain assistant text for teammate-to-lead communication because that text is not delivered back to the teammate. +- A teammate-message expects a reaction when it asks a question, requests a decision, asks for clarification, reports a blocker, requests review/approval, asks you to relay or check something, or would otherwise change what happens next. +- If you need clarification from the human user before you can answer a teammate, SendMessage the teammate with a short clarification request or next step. Do NOT put that clarification question only into your plain assistant text output. +- Your plain text output is invisible to teammates — they are separate processes and can only read their inbox. +- Example: if you receive ..., respond with SendMessage(${buildCanonicalSendMessageExample({ to: 'alice', summary: 'short reply', message: 'your reply' })}). +- Example: if alice asks "Сколько времени осталось?" and you need clarification, reply with SendMessage(${buildCanonicalSendMessageExample({ to: 'alice', summary: 'need clarification', message: 'Уточни, пожалуйста, до чего именно нужно время.' })}) instead of asking that question in plain assistant text. +- Do NOT reply to low-value acknowledgements or presence pings such as "ready", "online", "status accepted", "awaiting task", or "received" unless you need to give the teammate a concrete next action. +- Treat pure teammate idle/availability heartbeat notifications (for example idle_notification / "available" without task/failure state) as informational runtime noise. Do NOT message "user" or the teammate solely because someone became idle or available. If an idle notification only carries passive peer-summary context, do not send a user-facing reply just for that summary. Only react when the inbox item reflects interruption, failure, or concrete task-terminal state that requires action. +- Cross-team communication: when work needs expertise, coordination, review, or a decision from ANOTHER team, CALL the MCP tool named "cross_team_send" with teamName: "${teamName}" and a focused actionable message. +- Before sending cross-team, use MCP tool "cross_team_list_targets" with teamName: "${teamName}" to discover valid target teams. +- To review messages your team already sent to other teams, use MCP tool "cross_team_get_outbox" with teamName: "${teamName}". +- Cross-team delivery goes to the target team's lead inbox and may be relayed to that live lead automatically. +- Prefer cross-team messaging when your team is blocked by another team's scope, needs another team's domain expertise, needs a review/approval from another team, or must coordinate a shared decision. +- Prefer concise messages that state: what you need, why that team is relevant, the expected response, and any task or file references they need. +- Keep cross-team requests high-signal: one focused request per topic, with clear next action and desired outcome. +- Before sending a follow-up on the same topic, check "cross_team_get_outbox" so you do not resend the same request unnecessarily. +- If you receive a message that is clearly from another team (for example prefixed with "<${CROSS_TEAM_PREFIX_TAG} ... />"), treat it as an actionable cross-team request and respond to the originating team by CALLING the MCP tool "cross_team_send" when a reply, decision, or status update is needed. +- Cross-team requests may include a stable conversationId in their metadata. When you reply to that thread, preserve the same conversationId and pass replyToConversationId with that same value so the system can correlate the reply reliably. +- If the relay prompt shows explicit cross-team reply metadata/instructions for a message, follow that metadata exactly when calling "cross_team_send". +- NEVER put "cross_team_send" into a SendMessage recipient or message_send "to" field. "cross_team_send" is a TOOL NAME, not a teammate or inbox name. +- Correct example: + cross_team_send({ teamName: "${teamName}", toTeam: "other-team", text: "your reply", conversationId: "", replyToConversationId: "" }) +- Never write protocol markup yourself in message text. Do NOT include "<${CROSS_TEAM_PREFIX_TAG} ... />" or any other metadata wrapper in the visible reply body; send plain user-visible text only. +- When a cross-team request arrives, do NOT appear silent: first emit a brief plain-text status update visible in your own team's Messages/Activity (for example: "Accepted cross-team request from @other-team. Investigating and delegating now."), then do the research, task creation, or delegation work. +- For cross-team work, your canonical progress trail should be team-visible first. Use plain text updates, task comments, and task state changes so your own team can see what is happening. +- Do not wait silently on another team: if cross-team coordination is blocking progress, send the request promptly, then continue any useful local work that does not depend on that answer. +- After a meaningful cross-team exchange, update the relevant task or plan context so your team retains the decision, dependency, or answer. +- Reply to the requesting team when a concrete answer, decision, blocker, or status update is ready. Do NOT default to messaging "user" for cross-team coordination unless the human explicitly asked to be kept informed or the update is clearly human-relevant. +- Golden format for cross-team requests: include (1) brief context, (2) the concrete ask, (3) why your team needs that team specifically, (4) the expected output or decision, and (5) any deadline or blocking impact if relevant. +- Golden format for cross-team replies: answer the concrete ask first, then include the decision, recommendation, or status, and finally any important caveats, next steps, or handoff expectations. +- Do NOT use cross-team messaging when your own team can answer the question locally, when no action/decision is required, when you are only thinking out loud, or when a task update belongs on your own board instead of another team's inbox. +- If the issue is internal to your team, resolve it through your own task board and teammates first; use cross-team only for genuine inter-team dependency, expertise, approval, or coordination. +- Do NOT spam other teams, and do NOT use cross-team messaging for trivial FYIs that do not require action, coordination, or domain knowledge. + +Message formatting: +- When mentioning teammates by name in messages and text output, always use @ prefix (e.g. @alice, @bob) for UI highlighting. When mentioning another team, also use @ (e.g. @signal-ops). Do NOT use @ in tool parameters (recipient, owner, etc.) — those require plain names. +${getVisibleTaskReferenceFormattingRule()} +${agentBlockPolicy} + +${membersFooter}`; +} + +export function buildAgentBlockUsagePolicy(): string { + return `Agent-only formatting policy (applies to ALL messages you write): +- Humans can see teammate inbox messages and coordination text in the UI. +- Keep normal reasoning, decisions, and user-facing communication OUTSIDE agent-only blocks. +- Use agent-only blocks specifically for hidden internal instructions sent between agents/teammates that the human user must NOT see in the UI. +- Any internal operational instructions about tooling/scripts MUST be hidden inside an agent-only block, including: + - how to use internal MCP tools, exact tool names, and argument shapes + - review command phrases like "review_approve" / "review_request_changes" + - internal file paths under ~/.claude/ (teams, tasks, kanban state, etc.) + - meta coordination lines like "All teammates are online and have received their assignments via --notify." +- Use an agent-only tag block (AGENT_BLOCK_OPEN / AGENT_BLOCK_CLOSE): + - AGENT_BLOCK_OPEN is exactly: ${AGENT_BLOCK_OPEN} + - AGENT_BLOCK_CLOSE is exactly: ${AGENT_BLOCK_CLOSE} + - IMPORTANT: put the opening tag and closing tag on their own lines with no indentation. +- Example (copy/paste exactly, no indentation): +${AGENT_BLOCK_OPEN} +(internal instructions: commands, script usage, paths, etc.) +${AGENT_BLOCK_CLOSE} +- Put ONLY the internal instructions inside the agent-only block. +- CRITICAL: Messages to "user" (the human) must NEVER contain agent-only blocks. Write them as plain readable text — the human sees these messages directly in the UI. Agent-only blocks are stripped before display, so a message containing ONLY an agent-only block will appear completely empty. +- CRITICAL: Messages to "user" must NEVER mention internal tooling, MCP tools, scripts, or CLI commands — not even in plain text. The user interacts through the UI, NOT the terminal. Specifically, NEVER include in user-facing messages: + - internal MCP tool names or argument shapes + - any node/bash commands + - internal file paths (~/.claude/teams/, etc.) + - instructions to run commands in terminal + - task references without a leading # (for example write #abcd1234, not abcd1234) + Instead, describe the action in human-friendly language (e.g. "Task #6 is complete." instead of showing a command to mark it complete). If you need to update task status, do it YOURSELF — never ask the user to run a command. +- CRITICAL: When processing relayed inbox messages, follow the relay prompt's reply visibility. Some relay turns record plain text only as internal lead activity. User-visible replies must be explicit when the relay prompt says the batch is internal. Do NOT wrap your entire response in an agent-only block. If you need agent-only instructions, put them in a separate block and include concise visible text only when the relay prompt allows or requests it.`; +} + +export function isTaskBoardSnapshotWorkCandidate(task: TeamTask): boolean { + if (!task.id || task.id.startsWith('_internal') || isTeamTaskDeleted(task)) { + return false; + } + + const workflowColumn = getTeamTaskWorkflowColumn(task); + if (workflowColumn === 'review' || workflowColumn === 'approved') { + return false; + } + + return ( + task.status === 'pending' || + isTeamTaskNeedsFixActionable(task) || + isTeamTaskActivelyWorked(task) + ); +} + +/** Build a full task board snapshot for the lead. */ +export function buildTaskBoardSnapshot(tasks: TeamTask[]): string { + const active = tasks.filter(isTaskBoardSnapshotWorkCandidate); + if (active.length === 0) return '\nNo pending tasks on the board.\n'; + + const lines = active.map((t) => { + const owner = t.owner ? ` (owner: ${t.owner})` : ' (unassigned)'; + const desc = t.description ? ` — ${t.description.slice(0, 120)}` : ''; + const stateLabel = [t.status, isTeamTaskNeedsFixActionable(t) ? 'needsFix' : null] + .filter(Boolean) + .join(', '); + const deps = t.blockedBy?.length + ? ` [blocked by: ${t.blockedBy + .map((id) => tasks.find((candidate) => candidate.id === id)) + .filter((task): task is TeamTask => Boolean(task)) + .map((task) => formatTaskDisplayLabel(task)) + .join(', ')}]` + : ''; + return ` - ${formatTaskDisplayLabel(t)} (taskId: ${t.id}) [${stateLabel}]${owner} ${t.subject}${deps}${desc}`; + }); + return `\nCurrent actionable task board (pending/in_progress/needsFix):\n${lines.join('\n')}\n`; +} + +export function buildDeterministicLaunchHydrationPrompt( + request: TeamLaunchRequest, + members: TeamCreateRequest['members'], + tasks: TeamTask[], + isResume: boolean +): string { + const leadName = + members.find((member) => member.role?.toLowerCase().includes('lead'))?.name || 'team-lead'; + const isSolo = members.length === 0; + const projectName = path.basename(request.cwd); + const startLabel = isResume ? 'Team Start (resume)' : 'Team Start'; + const startupLabel = isResume ? 'resume/bootstrap' : 'launch/bootstrap'; + const headerModeLabel = isResume ? 'Deterministic resume' : 'Deterministic launch'; + const userPromptBlock = request.prompt?.trim() + ? `\nOriginal user instructions to apply after ${isResume ? 'resume' : 'startup'} is stable:\n${request.prompt.trim()}\n` + : ''; + const hasOriginalUserPrompt = Boolean(request.prompt?.trim()); + const taskBoardSnapshot = buildTaskBoardSnapshot(tasks); + const persistentContext = buildPersistentLeadContext({ + teamName: request.teamName, + leadName, + isSolo, + members, + }); + const nextSteps = isSolo + ? `This ${startupLabel} step has already been completed deterministically by the runtime. +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 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.' + : '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.' +}` + : `This ${startupLabel} step has already been completed deterministically by the runtime. +Do NOT call TeamCreate. +Do NOT use Agent to spawn or restore teammates. +Do NOT repeat the launch summary. +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.' + : '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.' +} +Treat teammates whose bootstrap is still pending as not-yet-available for blocking assignments.`; + + return `${startLabel} [${headerModeLabel} | Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"] + +You are running headless in a non-interactive CLI session. Do not ask questions. +You are "${leadName}", the team lead. +${getAgentLanguageInstruction()}${userPromptBlock} + +${nextSteps} + +${taskBoardSnapshot} +${persistentContext} + +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.`; +} + +export function buildGeminiPostLaunchHydrationPrompt( + run: TeamProvisioningHydrationRun, + leadName: string, + members: TeamCreateRequest['members'], + tasks: TeamTask[] +): string { + const isSolo = members.length === 0; + const userPromptBlock = run.request.prompt?.trim() + ? `\nOriginal user instructions to apply now:\n${run.request.prompt.trim()}\n` + : ''; + const hasOriginalUserPrompt = Boolean(run.request.prompt?.trim()); + const taskBoardSnapshot = buildTaskBoardSnapshot(tasks); + const teammateBootstrapSnapshot = members.length + ? `Current teammate launch status:\n${members + .map((member) => { + const status = run.memberSpawnStatuses.get(member.name); + const label = + status?.launchState === 'failed_to_start' + ? `failed to start${status.hardFailureReason ? ` - ${status.hardFailureReason}` : status.error ? ` - ${status.error}` : ''}` + : status?.launchState === 'confirmed_alive' + ? 'bootstrap confirmed' + : status?.launchState === 'runtime_pending_permission' + ? status?.runtimeAlive + ? 'runtime online and waiting for permission approval' + : 'waiting for permission approval' + : status?.runtimeAlive + ? 'runtime online and ready for instructions' + : status?.launchState === 'runtime_pending_bootstrap' + ? 'spawn accepted, runtime not confirmed yet' + : status?.status === 'spawning' + ? 'spawn in progress' + : 'runtime state unclear'; + return `- @${member.name}: ${label}`; + }) + .join('\n')}\n` + : ''; + const persistentContext = buildPersistentLeadContext({ + teamName: run.teamName, + leadName, + isSolo, + members, + }); + 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 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 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 - team readiness check for team "${run.teamName}". + +The first launch/reconnect turn has already completed. +Do NOT call TeamCreate again. +Do NOT respawn teammates unless you are explicitly retrying a teammate that truly failed to start. +Do NOT repeat the previous launch summary. +You are "${leadName}", the team lead. +${getAgentLanguageInstruction()}${userPromptBlock} + +${nextStepInstruction} + +${teammateBootstrapSnapshot}${taskBoardSnapshot} +${persistentContext} + +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.`; +} diff --git a/src/renderer/assets/atlascloud-logo.svg b/src/renderer/assets/atlascloud-logo.svg new file mode 100644 index 00000000..f8dd2d45 --- /dev/null +++ b/src/renderer/assets/atlascloud-logo.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index 63dcbdae..9007878e 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -14,6 +14,7 @@ import { useCodexAccountSnapshot, } from '@features/codex-account/renderer'; import { api, isElectronMode } from '@renderer/api'; +import atlasCloudLogo from '@renderer/assets/atlascloud-logo.svg'; import { confirm } from '@renderer/components/common/ConfirmDialog'; import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; import { @@ -61,6 +62,8 @@ import { ChevronRight, ChevronUp, Download, + ExternalLink, + Handshake, HelpCircle, Loader2, LogIn, @@ -72,8 +75,7 @@ import { } from 'lucide-react'; import { - getAnthropicDashboardRateLimits, - getCodexDashboardRateLimits, + getDashboardRateLimitsForProvider, isDashboardRateLimitSubscriptionMode, shouldShowDashboardRateLimitSkeleton, } from './providerDashboardRateLimits'; @@ -104,6 +106,11 @@ const VARIANT_STYLES: Record = { /** Minimum banner height — prevents layout shift between states (loading → installed → checking). */ const BANNER_MIN_H = 'min-h-[4.25rem]'; const ANTHROPIC_LIMIT_REFRESH_INTERVAL_MS = 60 * 1000; +const SHOW_ATLAS_CLOUD_OPENCODE_BANNER = false; +const ATLAS_CLOUD_OPENCODE_PROVIDER_ID = 'atlascloud'; +const ATLAS_CLOUD_CODING_PLAN_URL = 'https://www.atlascloud.ai/console/coding-plan'; +const ATLAS_CLOUD_DESCRIPTION = + "Atlas Cloud is a full-modal AI inference platform that gives developers a single AI API to access video generation, image generation, and LLM APIs. Instead of managing multiple vendor integrations, you connect once and get unified access to 300+ curated models across all modalities. Check out Atlas Cloud's new coding plan promotion for more budget-friendly API access."; const DashboardRateLimitChips = ({ providerId, @@ -378,6 +385,7 @@ interface InstalledBannerProps { onProviderLogin: (providerId: CliProviderId) => void; onProviderLogout: (providerId: CliProviderId) => void; onProviderManage: (providerId: CliProviderId) => void; + onOpenCodeProviderConnect: (providerId: string) => void; onProviderRefresh: (providerId: CliProviderId) => void; onCodexReconnect: () => void; onCodexDeviceCodeLogin: () => void; @@ -675,6 +683,100 @@ function getOpenCodeDashboardChips( ]; } +const OpenCodeAtlasCloudBanner = ({ + disabled, + onConnect, +}: { + disabled: boolean; + onConnect: () => void; +}): React.JSX.Element => ( +
+
+
+ Atlas Cloud + + Atlas Cloud coding plan + + + Sponsor + + + OpenCode provider + +
+
+ + + +
+
+

+ {ATLAS_CLOUD_DESCRIPTION} +

+
+); + const InstalledBanner = ({ cliStatus, sourceProviderMap, @@ -699,6 +801,7 @@ const InstalledBanner = ({ onProviderLogin, onProviderLogout, onProviderManage, + onOpenCodeProviderConnect, onProviderRefresh, onCodexReconnect, onCodexDeviceCodeLogin, @@ -832,9 +935,7 @@ const InstalledBanner = ({ : getProviderRuntimeBackendSummary(provider); const connectionModeSummary = getProviderConnectionModeSummary(provider); const credentialSummary = getProviderCredentialSummary(provider); - const codexDashboardRateLimits = getCodexDashboardRateLimits(provider); - const anthropicDashboardRateLimits = getAnthropicDashboardRateLimits(provider); - const dashboardRateLimits = codexDashboardRateLimits ?? anthropicDashboardRateLimits; + const dashboardRateLimits = getDashboardRateLimitsForProvider(provider); const hasDashboardRateLimits = Boolean(dashboardRateLimits?.length); const isSubscriptionRateLimitMode = isDashboardRateLimitSubscriptionMode({ provider, @@ -865,17 +966,18 @@ const InstalledBanner = ({ const anthropicRateLimitsLoading = provider.providerId === 'anthropic' && (anthropicRateLimitsRefreshing || provider.modelCatalogRefreshState === 'loading'); - const showRateLimitSkeleton = - (showSkeleton && - shouldShowDashboardRateLimitSkeleton({ - provider, - sourceProvider, - configuredAuthModes: providerConnectionAuthModes, - })) || - (isSubscriptionRateLimitMode && - !hasDashboardRateLimits && - ((provider.providerId === 'codex' && codexRateLimitsLoading) || - anthropicRateLimitsLoading)); + const rateLimitsLoading = + showSkeleton || + (provider.providerId === 'codex' && codexRateLimitsLoading) || + anthropicRateLimitsLoading || + isSubscriptionRateLimitMode; + const showRateLimitSkeleton = shouldShowDashboardRateLimitSkeleton({ + provider, + sourceProvider, + configuredAuthModes: providerConnectionAuthModes, + hasRateLimits: hasDashboardRateLimits, + loading: rateLimitsLoading, + }); const statusText = showSkeleton ? 'Checking...' : formatProviderStatusText(provider); const modelCatalogLoading = provider.modelCatalogRefreshState === 'loading' || @@ -1150,6 +1252,14 @@ const InstalledBanner = ({ />
)} + {!showSkeleton && + SHOW_ATLAS_CLOUD_OPENCODE_BANNER && + provider.providerId === 'opencode' ? ( + onOpenCodeProviderConnect(ATLAS_CLOUD_OPENCODE_PROVIDER_ID)} + /> + ) : null} {!showSkeleton && dashboardRateLimits && dashboardRateLimits.length > 0 && (
{ action: 'login' | 'logout'; } | null>(null); const [manageProviderId, setManageProviderId] = useState('anthropic'); + const [manageRuntimeProviderId, setManageRuntimeProviderId] = useState(null); const [manageDialogOpen, setManageDialogOpen] = useState(false); const [isVerifyingAuth, setIsVerifyingAuth] = useState(false); const [showTroubleshoot, setShowTroubleshoot] = useState(false); @@ -1451,9 +1562,23 @@ export const CliStatusBanner = (): React.JSX.Element | null => { const handleProviderManage = useCallback((providerId: CliProviderId) => { setManageProviderId(providerId); + setManageRuntimeProviderId(null); setManageDialogOpen(true); }, []); + const handleOpenCodeProviderConnect = useCallback((providerId: string) => { + setManageProviderId('opencode'); + setManageRuntimeProviderId(providerId); + setManageDialogOpen(true); + }, []); + + const handleManageDialogOpenChange = useCallback((open: boolean) => { + setManageDialogOpen(open); + if (!open) { + setManageRuntimeProviderId(null); + } + }, []); + const handleProviderRefresh = useCallback( (providerId: CliProviderId) => { void (async () => { @@ -1535,7 +1660,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => { <> { ? manageProviderId : (visibleCliProviders[0]?.providerId ?? 'anthropic') } + initialRuntimeProviderId={manageRuntimeProviderId} + initialRuntimeProviderAction={manageRuntimeProviderId ? 'connect' : null} providerStatusLoading={cliProviderStatusLoading} disabled={isBusy || cliStatusLoading || !renderCliStatus.binaryPath} codexRuntimeStatus={codexRuntimeStatus} @@ -1661,6 +1788,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => { onProviderLogin={handleProviderLogin} onProviderLogout={handleProviderLogout} onProviderManage={handleProviderManage} + onOpenCodeProviderConnect={handleOpenCodeProviderConnect} onProviderRefresh={handleProviderRefresh} onCodexReconnect={handleCodexDashboardLogin} onCodexDeviceCodeLogin={handleCodexDashboardDeviceCodeLogin} @@ -1896,6 +2024,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => { onProviderLogin={handleProviderLogin} onProviderLogout={handleProviderLogout} onProviderManage={handleProviderManage} + onOpenCodeProviderConnect={handleOpenCodeProviderConnect} onProviderRefresh={handleProviderRefresh} onCodexReconnect={handleCodexDashboardLogin} onCodexDeviceCodeLogin={handleCodexDashboardDeviceCodeLogin} @@ -1965,6 +2094,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => { onProviderLogin={handleProviderLogin} onProviderLogout={handleProviderLogout} onProviderManage={handleProviderManage} + onOpenCodeProviderConnect={handleOpenCodeProviderConnect} onProviderRefresh={handleProviderRefresh} onCodexReconnect={handleCodexDashboardLogin} onCodexDeviceCodeLogin={handleCodexDashboardDeviceCodeLogin} @@ -2194,6 +2324,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => { onProviderLogin={handleProviderLogin} onProviderLogout={handleProviderLogout} onProviderManage={handleProviderManage} + onOpenCodeProviderConnect={handleOpenCodeProviderConnect} onProviderRefresh={handleProviderRefresh} onCodexReconnect={handleCodexDashboardLogin} onCodexDeviceCodeLogin={handleCodexDashboardDeviceCodeLogin} diff --git a/src/renderer/components/dashboard/providerDashboardRateLimits.test.ts b/src/renderer/components/dashboard/providerDashboardRateLimits.test.ts index 3d920c86..c95e62de 100644 --- a/src/renderer/components/dashboard/providerDashboardRateLimits.test.ts +++ b/src/renderer/components/dashboard/providerDashboardRateLimits.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from 'vitest'; import { getAnthropicDashboardRateLimits, getCodexDashboardRateLimits, + getDashboardRateLimitsForProvider, shouldShowDashboardRateLimitSkeleton, } from './providerDashboardRateLimits'; @@ -195,6 +196,31 @@ describe('providerDashboardRateLimits', () => { ]); }); + test('routes dashboard rate limits through the provider-specific formatter', () => { + const items = getDashboardRateLimitsForProvider( + createProvider({ + authMethod: 'claude.ai', + subscriptionRateLimits: { + primary: { + usedPercent: 25, + windowDurationMins: 300, + resetsAt: null, + }, + secondary: null, + }, + }) + ); + + expect(items).toEqual([ + { + label: '5h left', + remaining: '75%', + resetsAt: 'reset unknown', + isDepleted: false, + }, + ]); + }); + test('marks fully depleted limits when no quota remains', () => { const connection = createCodexConnection(); @@ -238,10 +264,45 @@ describe('providerDashboardRateLimits', () => { configuredAuthModes: { anthropic: 'oauth', }, + hasRateLimits: false, + loading: true, }) ).toBe(true); }); + test('keeps Anthropic rate limit skeletons visible while subscription limits are missing', () => { + expect( + shouldShowDashboardRateLimitSkeleton({ + provider: createProvider({ + authenticated: true, + authMethod: 'claude.ai', + subscriptionRateLimits: null, + }), + configuredAuthModes: { + anthropic: 'oauth', + }, + hasRateLimits: false, + loading: true, + }) + ).toBe(true); + }); + + test('hides rate limit skeletons when formatted limit data is present', () => { + expect( + shouldShowDashboardRateLimitSkeleton({ + provider: createProvider({ + authenticated: true, + authMethod: 'claude.ai', + }), + configuredAuthModes: { + anthropic: 'oauth', + }, + hasRateLimits: true, + loading: true, + }) + ).toBe(false); + }); + test('hides Anthropic rate limit skeletons when API key mode is selected', () => { expect( shouldShowDashboardRateLimitSkeleton({ @@ -258,6 +319,8 @@ describe('providerDashboardRateLimits', () => { configuredAuthModes: { anthropic: 'api_key', }, + hasRateLimits: false, + loading: true, }) ).toBe(false); }); @@ -276,6 +339,8 @@ describe('providerDashboardRateLimits', () => { configuredAuthModes: { codex: 'chatgpt', }, + hasRateLimits: false, + loading: true, }) ).toBe(true); }); @@ -300,6 +365,8 @@ describe('providerDashboardRateLimits', () => { configuredAuthModes: { codex: 'api_key', }, + hasRateLimits: false, + loading: true, }) ).toBe(false); }); diff --git a/src/renderer/components/dashboard/providerDashboardRateLimits.ts b/src/renderer/components/dashboard/providerDashboardRateLimits.ts index 969a72ed..80d18b03 100644 --- a/src/renderer/components/dashboard/providerDashboardRateLimits.ts +++ b/src/renderer/components/dashboard/providerDashboardRateLimits.ts @@ -26,6 +26,11 @@ export interface DashboardRateLimitSkeletonModeInput { }; } +export interface DashboardRateLimitSkeletonInput extends DashboardRateLimitSkeletonModeInput { + hasRateLimits: boolean; + loading: boolean; +} + function firstKnown(...values: (T | null | undefined)[]): T | null { for (const value of values) { if (value !== null && typeof value !== 'undefined') { @@ -143,9 +148,9 @@ export function isDashboardRateLimitSubscriptionMode({ } export function shouldShowDashboardRateLimitSkeleton( - input: DashboardRateLimitSkeletonModeInput + input: DashboardRateLimitSkeletonInput ): boolean { - return isDashboardRateLimitSubscriptionMode(input); + return input.loading && !input.hasRateLimits && isDashboardRateLimitSubscriptionMode(input); } function buildRateLimitLabel( @@ -272,3 +277,17 @@ export function getAnthropicDashboardRateLimits( return items.length > 0 ? items : null; } + +export function getDashboardRateLimitsForProvider( + provider: CliProviderStatus +): DashboardRateLimitItem[] | null { + switch (provider.providerId) { + case 'codex': + return getCodexDashboardRateLimits(provider); + case 'anthropic': + return getAnthropicDashboardRateLimits(provider); + case 'gemini': + case 'opencode': + return null; + } +} diff --git a/src/renderer/components/team/dialogs/OpenCodeContextConfigHint.tsx b/src/renderer/components/team/dialogs/OpenCodeContextConfigHint.tsx new file mode 100644 index 00000000..b1c8c9d0 --- /dev/null +++ b/src/renderer/components/team/dialogs/OpenCodeContextConfigHint.tsx @@ -0,0 +1,95 @@ +import React, { useState } from 'react'; + +import { Button } from '@renderer/components/ui/button'; +import { ChevronDown, ChevronRight, ExternalLink, Info } from 'lucide-react'; + +const OPENCODE_CONFIG_DOCS_URL = 'https://opencode.ai/docs/config/'; +const OPENCODE_PROVIDERS_DOCS_URL = 'https://opencode.ai/docs/providers'; + +const OPENCODE_CONTEXT_CONFIG_EXAMPLE = `{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "local": { + "models": { + "your-model": { + "limit": { + "context": 10000, + "output": 2000 + } + } + } + } + }, + "compaction": { + "auto": true, + "prune": true, + "reserved": 2000 + } +}`; + +export const OpenCodeContextConfigHint = (): React.JSX.Element => { + const [expanded, setExpanded] = useState(false); + + return ( +
+ + + {expanded ? ( +
+

+ Add matching limits to the OpenCode config for the provider and model used by this + teammate. This helps OpenCode compact and prune before local models overflow their + context window. +

+
+            {OPENCODE_CONTEXT_CONFIG_EXAMPLE}
+          
+

+ Replace local and{' '} + your-model with the provider and model IDs from your + OpenCode setup. Prompt instructions like{' '} + stay below 10000 tokens are weaker because the + request is assembled before the model reads them. +

+ +
+ ) : null} +
+ ); +}; diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx index 92b09fa6..4674b85e 100644 --- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx +++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx @@ -87,6 +87,8 @@ interface OpenCodeModelGroup { groupId: string; groupLabel: string; rank: number; + sortLabel: string; + firstIndex: number; options: TeamRuntimeModelOption[]; } @@ -446,7 +448,7 @@ function getOpenCodeReadinessMessage(providerStatus: CliProviderStatus | null | return 'The app is still checking the OpenCode runtime. Wait for provider status to finish, then try again.'; } if (!providerStatus.supported) { - return 'OpenCode is not installed, not found, or the detected runtime is not supported. Install or update OpenCode, then refresh provider status.'; + return 'OpenCode is not installed, not found, or the detected runtime is not supported. Install or update OpenCode, then refresh provider status. You can also use the Install button on the home page.'; } if (!providerStatus.authenticated) { if (hasFreeOpenCodeModelRoute(providerStatus)) { @@ -1150,21 +1152,32 @@ export const TeamModelSelector: React.FC = ({ continue; } - const routeGroup = metadata.routeGroup; - const existingGroup = groups.get(routeGroup.id); + const sourceGroup = metadata.sourceInfo; + const groupId = sourceGroup ? `source:${sourceGroup.id}` : `route:${metadata.routeGroup.id}`; + const groupLabel = sourceGroup?.label ?? metadata.routeGroup.label; + const existingGroup = groups.get(groupId); if (existingGroup) { existingGroup.options.push(option); + existingGroup.rank = Math.min(existingGroup.rank, metadata.routeGroup.rank); + existingGroup.firstIndex = Math.min(existingGroup.firstIndex, metadata.index); } else { - groups.set(routeGroup.id, { - groupId: routeGroup.id, - groupLabel: routeGroup.label, - rank: routeGroup.rank, + groups.set(groupId, { + groupId, + groupLabel, + rank: metadata.routeGroup.rank, + sortLabel: groupLabel.toLowerCase(), + firstIndex: metadata.index, options: [option], }); } } - return Array.from(groups.values()).sort((left, right) => left.rank - right.rank); + return Array.from(groups.values()).sort( + (left, right) => + left.rank - right.rank || + left.sortLabel.localeCompare(right.sortLabel, undefined, { sensitivity: 'base' }) || + left.firstIndex - right.firstIndex + ); }, [effectiveProviderId, visibleOpenCodeModelMetadata]); const visibleDefaultModelOptions = visibleModelOptions.filter((option) => !option.value.trim()); const visibleConcreteModelOptionCount = @@ -1318,11 +1331,6 @@ export const TeamModelSelector: React.FC = ({ > {opt.label} - {openCodeMetadata?.sourceInfo ? ( - - {openCodeMetadata.sourceInfo.label} - - ) : null} {openCodePricingInfo?.summary ? ( ({ ), })); +vi.mock('@renderer/components/ui/button', () => ({ + Button: ({ + children, + className, + onClick, + disabled, + 'aria-expanded': ariaExpanded, + 'aria-label': ariaLabel, + }: { + children: React.ReactNode; + className?: string; + onClick?: React.MouseEventHandler; + disabled?: boolean; + 'aria-expanded'?: boolean; + 'aria-label'?: string; + }) => + React.createElement( + 'button', + { + className, + disabled, + onClick, + type: 'button', + 'aria-expanded': ariaExpanded, + 'aria-label': ariaLabel, + }, + children + ), +})); + vi.mock('@renderer/components/team/dialogs/TeamModelSelector', () => ({ formatTeamModelSummary: (providerId: string, model: string, effort?: string) => [providerId, model || 'Default', effort].filter(Boolean).join(' · '), @@ -250,4 +280,31 @@ describe('LeadModelRow', () => { root.unmount(); }); }); + + it('shows the OpenCode context config hint inside OpenCode provider settings after effort', () => { + const { host, root } = renderLeadModelRow({ + providerId: 'opencode', + model: 'local/model', + showAnthropicContextLimit: false, + }); + + const modelButton = host.querySelector( + 'button[aria-label="opencode provider, local/model"]' + )!; + act(() => { + modelButton.click(); + }); + + const text = host.textContent ?? ''; + const effortIndex = text.indexOf('effort-selector'); + const hintIndex = text.indexOf('OpenCode local models can use an OpenCode context budget'); + + expect(hintIndex).toBeGreaterThan(-1); + expect(effortIndex).toBeGreaterThan(-1); + expect(hintIndex).toBeGreaterThan(effortIndex); + + act(() => { + root.unmount(); + }); + }); }); diff --git a/src/renderer/components/team/members/LeadModelRow.tsx b/src/renderer/components/team/members/LeadModelRow.tsx index 634c984c..e68a5549 100644 --- a/src/renderer/components/team/members/LeadModelRow.tsx +++ b/src/renderer/components/team/members/LeadModelRow.tsx @@ -8,6 +8,7 @@ import { } from '@renderer/components/team/dialogs/AnthropicExtraUsageWarning'; import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector'; import { LimitContextCheckbox } from '@renderer/components/team/dialogs/LimitContextCheckbox'; +import { OpenCodeContextConfigHint } from '@renderer/components/team/dialogs/OpenCodeContextConfigHint'; import { getProviderScopedTeamModelLabel, getTeamProviderLabel, @@ -229,6 +230,7 @@ export const LeadModelRow = ({ model={model} limitContext={limitContext} /> + {providerId === 'opencode' ? : null} {showAnthropicContextLimit ? ( { }); }); + it('shows the OpenCode context config hint inside OpenCode teammate provider settings after effort', () => { + const { host, root } = renderMemberDraftRow({ + member: createMemberDraft({ + id: 'member-1', + name: 'alice', + roleSelection: 'developer', + providerId: 'opencode', + model: 'local/model', + }), + }); + + const modelButton = host.querySelector( + 'button[aria-label="opencode provider, local/model"]' + )!; + act(() => { + modelButton.click(); + }); + + const text = host.textContent ?? ''; + const effortIndex = text.indexOf('effort-selector'); + const hintIndex = text.indexOf('OpenCode local models can use an OpenCode context budget'); + + expect(hintIndex).toBeGreaterThan(-1); + expect(effortIndex).toBeGreaterThan(-1); + expect(hintIndex).toBeGreaterThan(effortIndex); + + act(() => { + root.unmount(); + }); + }); + it('shows model launch issues inline and keeps model controls expandable', () => { const issueText = 'Member alice uses Anthropic effort "medium", but Haiku 4.5 does not support it in the current runtime.'; diff --git a/src/renderer/components/team/members/MemberDraftRow.tsx b/src/renderer/components/team/members/MemberDraftRow.tsx index 9ab071f4..83702416 100644 --- a/src/renderer/components/team/members/MemberDraftRow.tsx +++ b/src/renderer/components/team/members/MemberDraftRow.tsx @@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; import { AnthropicExtraUsageWarning } from '@renderer/components/team/dialogs/AnthropicExtraUsageWarning'; import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector'; +import { OpenCodeContextConfigHint } from '@renderer/components/team/dialogs/OpenCodeContextConfigHint'; import { formatTeamModelSummary, getProviderScopedTeamModelLabel, @@ -874,6 +875,7 @@ export const MemberDraftRow = ({ model={effectiveModel} limitContext={limitContext} /> + {effectiveProviderId === 'opencode' ? : null} {effectiveProviderId === 'anthropic' ? (
diff --git a/src/shared/types/cliInstaller.ts b/src/shared/types/cliInstaller.ts index f3c88699..80735c30 100644 --- a/src/shared/types/cliInstaller.ts +++ b/src/shared/types/cliInstaller.ts @@ -128,6 +128,7 @@ export type CliProviderReasoningEffort = export type CliProviderModelCatalogSource = | 'anthropic-models-api' + | 'anthropic-compatible-api' | 'app-server' | 'static-fallback'; export type CliProviderModelCatalogStatus = 'ready' | 'stale' | 'degraded' | 'unavailable'; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index c3442c5b..d6b96e11 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -985,6 +985,7 @@ export interface ProviderModelLaunchIdentity { catalogId: string | null; catalogSource: | 'anthropic-models-api' + | 'anthropic-compatible-api' | 'app-server' | 'static-fallback' | 'runtime' diff --git a/src/shared/utils/anthropicLaunchModel.ts b/src/shared/utils/anthropicLaunchModel.ts index 6013a944..ae487f92 100644 --- a/src/shared/utils/anthropicLaunchModel.ts +++ b/src/shared/utils/anthropicLaunchModel.ts @@ -19,6 +19,11 @@ function isAnthropicSonnetModel(model: string): boolean { return baseModel === 'sonnet' || baseModel.startsWith('claude-sonnet-'); } +function isAnthropicOpusModel(model: string): boolean { + const baseModel = stripOneMillionSuffix(model); + return baseModel === 'opus' || baseModel.startsWith('claude-opus-'); +} + function getStandardContextAlias(model: string): string | null { const baseModel = stripOneMillionSuffix(model); if (baseModel === 'opus' || baseModel.startsWith('claude-opus-')) { @@ -125,6 +130,10 @@ export function resolveAnthropicLaunchModel(params: { return baseModel; } + if (!isAnthropicOpusModel(baseModel)) { + return selectedOneMillionContext ? `${baseModel}[1m]` : baseModel; + } + const preferredLongContextModel = `${baseModel}[1m]`; if (availableModels.size === 0) { diff --git a/test/main/services/discovery/ProjectScanner.scanDedup.safe-e2e.test.ts b/test/main/services/discovery/ProjectScanner.scanDedup.safe-e2e.test.ts new file mode 100644 index 00000000..f32c1da3 --- /dev/null +++ b/test/main/services/discovery/ProjectScanner.scanDedup.safe-e2e.test.ts @@ -0,0 +1,174 @@ +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { ProjectScanner } from '../../../../src/main/services/discovery/ProjectScanner'; +import { subprojectRegistry } from '../../../../src/main/services/discovery/SubprojectRegistry'; + +import type { + FileSystemProvider, + FsDirent, + FsStatResult, + ReadStreamOptions, +} from '../../../../src/main/services/infrastructure/FileSystemProvider'; + +interface CountingProvider extends FileSystemProvider { + readonly readdirCounts: Map; + getMaxConcurrentReaddirs(): number; + releaseBlockedRead(): void; + waitForBlockedRead(): Promise; +} + +function createSessionLine(cwd: string): string { + return JSON.stringify({ + uuid: crypto.randomUUID(), + type: 'user', + cwd, + timestamp: '2026-01-01T00:00:00.000Z', + message: { role: 'user', content: 'hello' }, + }); +} + +function createProject(projectsDir: string, encodedName: string, cwd: string): string { + const projectDir = path.join(projectsDir, encodedName); + fs.mkdirSync(projectDir, { recursive: true }); + fs.writeFileSync(path.join(projectDir, 'session-1.jsonl'), `${createSessionLine(cwd)}\n`); + return projectDir; +} + +function toStatResult(stats: fs.Stats): FsStatResult { + return { + size: stats.size, + mtimeMs: stats.mtimeMs, + birthtimeMs: stats.birthtimeMs, + isFile: () => stats.isFile(), + isDirectory: () => stats.isDirectory(), + }; +} + +function toDirent(entry: fs.Dirent): FsDirent { + return { + name: entry.name, + isFile: () => entry.isFile(), + isDirectory: () => entry.isDirectory(), + }; +} + +function createCountingProvider(blockFirstReadPath?: string): CountingProvider { + const readdirCounts = new Map(); + let blockedReadStartedResolve: (() => void) | null = null; + let releaseBlockedReadResolve: (() => void) | null = null; + let activeReaddirs = 0; + let maxConcurrentReaddirs = 0; + const blockedReadStarted = blockFirstReadPath + ? new Promise((resolve) => { + blockedReadStartedResolve = resolve; + }) + : Promise.resolve(); + + return { + type: 'local', + readdirCounts, + async exists(filePath: string): Promise { + return fs.existsSync(filePath); + }, + async readFile(filePath: string, encoding: BufferEncoding = 'utf8'): Promise { + return fs.promises.readFile(filePath, encoding); + }, + async stat(filePath: string): Promise { + return toStatResult(await fs.promises.stat(filePath)); + }, + async readdir(dirPath: string): Promise { + activeReaddirs += 1; + maxConcurrentReaddirs = Math.max(maxConcurrentReaddirs, activeReaddirs); + try { + const count = (readdirCounts.get(dirPath) ?? 0) + 1; + readdirCounts.set(dirPath, count); + if (dirPath === blockFirstReadPath && count === 1) { + blockedReadStartedResolve?.(); + await new Promise((resolve) => { + releaseBlockedReadResolve = resolve; + }); + } + return (await fs.promises.readdir(dirPath, { withFileTypes: true })).map(toDirent); + } finally { + activeReaddirs -= 1; + } + }, + createReadStream(filePath: string, opts?: ReadStreamOptions): fs.ReadStream { + return fs.createReadStream(filePath, { + start: opts?.start, + encoding: opts?.encoding, + }); + }, + dispose(): void { + releaseBlockedReadResolve?.(); + }, + getMaxConcurrentReaddirs(): number { + return maxConcurrentReaddirs; + }, + releaseBlockedRead(): void { + releaseBlockedReadResolve?.(); + }, + waitForBlockedRead(): Promise { + return blockedReadStarted; + }, + }; +} + +describe('ProjectScanner scan dedup safe e2e', () => { + const tempDirs: string[] = []; + + afterEach(() => { + subprojectRegistry.clear(); + for (const dir of tempDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } + tempDirs.length = 0; + }); + + it('shares one in-flight scan between project and repository-group startup reads', async () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scanner-dedup-')); + tempDirs.push(rootDir); + const projectsDir = path.join(rootDir, 'projects'); + const encodedName = '-Users-test-dedup-project'; + const projectDir = createProject(projectsDir, encodedName, '/Users/test/dedup-project'); + const provider = createCountingProvider(); + const scanner = new ProjectScanner(projectsDir, undefined, provider); + + const [projects, groups] = await Promise.all([ + scanner.scan(), + scanner.scanWithWorktreeGrouping(), + ]); + + expect(projects.map((project) => project.id)).toContain(encodedName); + expect(groups.map((group) => group.id)).toContain(encodedName); + expect(provider.readdirCounts.get(projectsDir)).toBe(1); + expect(provider.readdirCounts.get(projectDir)).toBe(1); + }); + + it('does not cache an in-flight scan after clearScanCache invalidates it', async () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scanner-dedup-clear-')); + tempDirs.push(rootDir); + const projectsDir = path.join(rootDir, 'projects'); + createProject(projectsDir, '-Users-test-clear-project', '/Users/test/clear-project'); + const provider = createCountingProvider(projectsDir); + const scanner = new ProjectScanner(projectsDir, undefined, provider); + + const firstScan = scanner.scan(); + await provider.waitForBlockedRead(); + scanner.clearScanCache(); + const secondScan = scanner.scan(); + const thirdScan = scanner.scan(); + provider.releaseBlockedRead(); + await expect(firstScan).resolves.toHaveLength(1); + await expect(secondScan).resolves.toHaveLength(1); + await expect(thirdScan).resolves.toHaveLength(1); + + expect(provider.readdirCounts.get(projectsDir)).toBe(2); + expect(provider.getMaxConcurrentReaddirs()).toBe(1); + }); +}); diff --git a/test/main/services/infrastructure/LocalFileSystemProvider.test.ts b/test/main/services/infrastructure/LocalFileSystemProvider.test.ts new file mode 100644 index 00000000..1ecc7057 --- /dev/null +++ b/test/main/services/infrastructure/LocalFileSystemProvider.test.ts @@ -0,0 +1,53 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { LocalFileSystemProvider } from '../../../../src/main/services/infrastructure/LocalFileSystemProvider'; + +describe('LocalFileSystemProvider', () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } + tempDirs.length = 0; + }); + + function createFixture(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'local-fs-provider-')); + tempDirs.push(dir); + fs.writeFileSync(path.join(dir, 'session.jsonl'), '{}\n', 'utf8'); + fs.mkdirSync(path.join(dir, 'nested')); + return dir; + } + + it('can return bare dirents without eager stat metadata', async () => { + const dir = createFixture(); + const provider = new LocalFileSystemProvider(); + + const entries = await provider.readdir(dir, { prefetchEntryStats: false }); + const fileEntry = entries.find((entry) => entry.name === 'session.jsonl'); + const dirEntry = entries.find((entry) => entry.name === 'nested'); + + expect(fileEntry?.isFile()).toBe(true); + expect(fileEntry?.size).toBeUndefined(); + expect(fileEntry?.mtimeMs).toBeUndefined(); + expect(dirEntry?.isDirectory()).toBe(true); + }); + + it('keeps eager stat metadata as the default behavior', async () => { + const dir = createFixture(); + const provider = new LocalFileSystemProvider(); + + const entries = await provider.readdir(dir); + const fileEntry = entries.find((entry) => entry.name === 'session.jsonl'); + + expect(fileEntry?.isFile()).toBe(true); + expect(fileEntry?.size).toBe(Buffer.byteLength('{}\n')); + expect(typeof fileEntry?.mtimeMs).toBe('number'); + expect(typeof fileEntry?.birthtimeMs).toBe('number'); + }); +}); diff --git a/test/main/services/runtime/ProviderConnectionService.test.ts b/test/main/services/runtime/ProviderConnectionService.test.ts index 5376160a..5caa893c 100644 --- a/test/main/services/runtime/ProviderConnectionService.test.ts +++ b/test/main/services/runtime/ProviderConnectionService.test.ts @@ -38,6 +38,7 @@ describe('ProviderConnectionService', () => { const originalOpenAiApiKey = process.env.OPENAI_API_KEY; const originalCodexApiKey = process.env.CODEX_API_KEY; const originalAnthropicApiKey = process.env.ANTHROPIC_API_KEY; + const originalAnthropicAuthToken = process.env.ANTHROPIC_AUTH_TOKEN; const originalAnthropicBaseUrl = process.env.ANTHROPIC_BASE_URL; function createConfig(authMode: 'auto' | 'oauth' | 'api_key' = 'auto') { @@ -128,6 +129,7 @@ describe('ProviderConnectionService', () => { delete process.env.OPENAI_API_KEY; delete process.env.CODEX_API_KEY; delete process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_AUTH_TOKEN; delete process.env.ANTHROPIC_BASE_URL; }); @@ -150,6 +152,12 @@ describe('ProviderConnectionService', () => { process.env.ANTHROPIC_API_KEY = originalAnthropicApiKey; } + if (originalAnthropicAuthToken === undefined) { + delete process.env.ANTHROPIC_AUTH_TOKEN; + } else { + process.env.ANTHROPIC_AUTH_TOKEN = originalAnthropicAuthToken; + } + if (originalAnthropicBaseUrl === undefined) { delete process.env.ANTHROPIC_BASE_URL; } else { @@ -182,6 +190,33 @@ describe('ProviderConnectionService', () => { expect(result.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); }); + it('preserves Anthropic-compatible bearer token env even when OAuth mode is selected', async () => { + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const service = new ProviderConnectionService( + { + lookupPreferred: vi.fn().mockResolvedValue(null), + } as never, + { + getConfig: () => createConfig('oauth'), + } as never + ); + + const result = await service.applyConfiguredConnectionEnv( + { + ANTHROPIC_BASE_URL: 'http://localhost:11434', + ANTHROPIC_API_KEY: '', + ANTHROPIC_AUTH_TOKEN: 'ollama', + }, + 'anthropic' + ); + + expect(result.ANTHROPIC_BASE_URL).toBe('http://localhost:11434'); + expect(result.ANTHROPIC_API_KEY).toBe(''); + expect(result.ANTHROPIC_AUTH_TOKEN).toBe('ollama'); + }); + it('injects the stored Anthropic API key when api_key mode is selected', async () => { const lookupPreferred = vi.fn().mockResolvedValue({ envVarName: 'ANTHROPIC_API_KEY', @@ -212,6 +247,37 @@ describe('ProviderConnectionService', () => { expect(result.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); }); + it('does not replace Anthropic-compatible bearer token env with stored API key mode credentials', async () => { + const lookupPreferred = vi.fn().mockResolvedValue({ + envVarName: 'ANTHROPIC_API_KEY', + value: 'stored-key', + }); + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const service = new ProviderConnectionService( + { + lookupPreferred, + } as never, + { + getConfig: () => createConfig('api_key'), + } as never + ); + + const result = await service.applyConfiguredConnectionEnv( + { + ANTHROPIC_BASE_URL: 'http://localhost:11434', + ANTHROPIC_API_KEY: '', + ANTHROPIC_AUTH_TOKEN: 'ollama', + }, + 'anthropic' + ); + + expect(lookupPreferred).not.toHaveBeenCalled(); + expect(result.ANTHROPIC_API_KEY).toBe(''); + expect(result.ANTHROPIC_AUTH_TOKEN).toBe('ollama'); + }); + it('does not decrypt stored Anthropic keys when metadata-only env building is requested', async () => { const lookupPreferred = vi.fn().mockResolvedValue({ envVarName: 'ANTHROPIC_API_KEY', @@ -279,6 +345,31 @@ describe('ProviderConnectionService', () => { expect(issue).toContain('ANTHROPIC_API_KEY'); }); + it('does not report a missing Anthropic API key for Anthropic-compatible bearer token env', async () => { + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const service = new ProviderConnectionService( + { + lookupPreferred: vi.fn().mockResolvedValue(null), + } as never, + { + getConfig: () => createConfig('api_key'), + } as never + ); + + const issue = await service.getConfiguredConnectionIssue( + { + ANTHROPIC_BASE_URL: 'http://localhost:11434', + ANTHROPIC_API_KEY: '', + ANTHROPIC_AUTH_TOKEN: 'ollama', + }, + 'anthropic' + ); + + expect(issue).toBeNull(); + }); + it('treats a stored Anthropic API key as configured even when env is empty', async () => { const lookupPreferred = vi.fn().mockResolvedValue({ envVarName: 'ANTHROPIC_API_KEY', diff --git a/test/main/services/team/TeamBackupService.test.ts b/test/main/services/team/TeamBackupService.test.ts index e22e7d62..e211587d 100644 --- a/test/main/services/team/TeamBackupService.test.ts +++ b/test/main/services/team/TeamBackupService.test.ts @@ -1,7 +1,6 @@ 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'; const hoisted = vi.hoisted(() => ({ @@ -316,12 +315,13 @@ describe('TeamBackupService', () => { } }); - it('backs up member-scoped work sync files', async () => { + it('backs up member-scoped work sync status without copying the append-only journal', async () => { const service = new TeamBackupService(); const teamName = 'member-work-sync-team'; const teamDir = path.join(hoisted.teamsBase, teamName); const memberDir = path.join(teamDir, 'members', 'jack'); const workSyncDir = path.join(memberDir, '.member-work-sync'); + const runtimeWorkSyncDir = path.join(teamDir, '.opencode-runtime', '.member-work-sync'); const status = { teamName, memberName: 'jack', @@ -366,6 +366,23 @@ describe('TeamBackupService', () => { ); await fs.writeFile(path.join(workSyncDir, '.tmp.deadbeef'), '{"partial":', 'utf8'); await fs.writeFile(path.join(workSyncDir, 'journal.jsonl.lock'), '123\n', 'utf8'); + await fs.mkdir(runtimeWorkSyncDir, { recursive: true }); + await fs.writeFile( + path.join(runtimeWorkSyncDir, 'journal.jsonl'), + '{"runtime":true}\n', + 'utf8' + ); + const staleBackupJournalPath = path.join( + hoisted.backupsBase, + 'teams', + teamName, + 'members', + 'jack', + '.member-work-sync', + 'journal.jsonl' + ); + await fs.mkdir(path.dirname(staleBackupJournalPath), { recursive: true }); + await fs.writeFile(staleBackupJournalPath, '{"old":true}\n', 'utf8'); await service.initialize(); await service.backupTeam(teamName); @@ -383,14 +400,46 @@ describe('TeamBackupService', () => { fs.readFile(path.join(backupMemberDir, '.member-work-sync', 'status.json'), 'utf8') ).resolves.toBe(JSON.stringify({ schemaVersion: 2, status })); await expect( - fs.readFile(path.join(backupMemberDir, '.member-work-sync', 'journal.jsonl'), 'utf8') - ).resolves.toContain('"event":"status_written"'); + fs.stat(path.join(backupMemberDir, '.member-work-sync', 'journal.jsonl')) + ).rejects.toMatchObject({ code: 'ENOENT' }); await expect( fs.stat(path.join(backupMemberDir, '.member-work-sync', '.tmp.deadbeef')) ).rejects.toMatchObject({ code: 'ENOENT' }); await expect( fs.stat(path.join(backupMemberDir, '.member-work-sync', 'journal.jsonl.lock')) ).rejects.toMatchObject({ code: 'ENOENT' }); + await expect( + fs.readFile( + path.join( + hoisted.backupsBase, + 'teams', + teamName, + '.opencode-runtime', + '.member-work-sync', + 'journal.jsonl' + ), + 'utf8' + ) + ).resolves.toBe('{"runtime":true}\n'); + + const manifest = JSON.parse( + await fs.readFile( + path.join(hoisted.backupsBase, 'teams', teamName, 'manifest.json'), + 'utf8' + ) + ) as { fileStats: Record }; + expect( + Object.prototype.hasOwnProperty.call( + manifest.fileStats, + 'members/jack/.member-work-sync/status.json' + ) + ).toBe(true); + expect( + Object.prototype.hasOwnProperty.call( + manifest.fileStats, + 'members/jack/.member-work-sync/journal.jsonl' + ) + ).toBe(false); } finally { service.dispose(); } diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index f340c039..c2e291bb 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -476,6 +476,32 @@ describe('TeamProvisioningService prepare/auth behavior', () => { expect(result.args).toContain('--anthropic-safe-passthrough'); }); + it('passes Anthropic-compatible bearer env to non-Anthropic leads without injecting ANTHROPIC_API_KEY', async () => { + const svc = new TeamProvisioningService(); + vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ + env: { + ANTHROPIC_BASE_URL: 'http://localhost:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama', + ANTHROPIC_API_KEY: '', + }, + authSource: 'anthropic_auth_token', + geminiRuntimeAuth: null, + providerArgs: ['--anthropic-compatible-passthrough'], + }); + + const result = await (svc as any).buildCrossProviderMemberArgs( + 'codex', + [{ name: 'bob', providerId: 'anthropic', model: 'qwen3.6' }], + { teamRuntimeAuth: { teamName: 'mixed-team', authMaterialId: 'run-1' } } + ); + + expect(result.usesAnthropicApiKeyHelper).toBe(false); + expect(result.envPatch.ANTHROPIC_BASE_URL).toBe('http://localhost:11434'); + expect(result.envPatch.ANTHROPIC_AUTH_TOKEN).toBe('ollama'); + expect(result.envPatch.ANTHROPIC_API_KEY).toBe(''); + expect(result.args).toContain('--anthropic-compatible-passthrough'); + }); + it('does not inherit lead effort for an Anthropic teammate with an explicit model', async () => { const svc = new TeamProvisioningService(); @@ -3161,6 +3187,24 @@ describe('TeamProvisioningService prepare/auth behavior', () => { expect(result.env.ANTHROPIC_API_KEY).toBe('proxy-token'); }); + it('preserves Anthropic-compatible Ollama auth token without mapping it into ANTHROPIC_API_KEY', async () => { + const svc = new TeamProvisioningService(); + vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({ + ANTHROPIC_BASE_URL: 'http://localhost:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama', + ANTHROPIC_API_KEY: '', + PATH: '/usr/bin', + SHELL: '/bin/zsh', + }); + + const result = await (svc as any).buildProvisioningEnv(); + + expect(result.authSource).toBe('anthropic_auth_token'); + expect(result.env.ANTHROPIC_BASE_URL).toBe('http://localhost:11434'); + expect(result.env.ANTHROPIC_AUTH_TOKEN).toBe('ollama'); + expect(result.env.ANTHROPIC_API_KEY).toBe(''); + }); + it('prefers explicit ANTHROPIC_API_KEY over ANTHROPIC_AUTH_TOKEN', async () => { const svc = new TeamProvisioningService(); vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({ diff --git a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts index 05dadc4f..5ae544a9 100644 --- a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts +++ b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts @@ -377,7 +377,8 @@ describe('TeamModelSelector disabled Codex models', () => { const groupLabels = Array.from( host.querySelectorAll('[data-testid="team-model-selector-opencode-group"] h4') ).map((heading) => heading.textContent ?? ''); - expect(groupLabels).toContain('Other OpenCode catalog'); + expect(groupLabels).toContain('OpenCode'); + expect(groupLabels).toContain('OpenRouter'); expect(host.textContent).toContain('OpenCode'); expect(host.textContent).toContain('OpenRouter'); @@ -1401,6 +1402,60 @@ describe('TeamModelSelector disabled Codex models', () => { }); }); + it('points missing OpenCode runtime users to the home page install button', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = { + providers: [ + { + providerId: 'opencode', + supported: false, + authenticated: false, + statusMessage: 'OpenCode runtime missing', + detailMessage: 'No JSON object found in CLI output', + capabilities: { teamLaunch: false }, + models: [], + }, + ], + }; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onProviderChange = vi.fn(); + + await act(async () => { + root.render( + React.createElement(TeamModelSelector, { + providerId: 'anthropic', + onProviderChange, + value: '', + onValueChange: () => undefined, + }) + ); + await Promise.resolve(); + }); + + const openCodeButton = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('OpenCode') + ); + + await act(async () => { + openCodeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(onProviderChange).not.toHaveBeenCalled(); + expect(host.textContent).toContain('OpenCode is not ready for team launch'); + expect(host.textContent).toContain( + 'OpenCode is not installed, not found, or the detected runtime is not supported. Install or update OpenCode, then refresh provider status. You can also use the Install button on the home page.' + ); + expect(host.textContent).toContain('Reason: No JSON object found in CLI output'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('uses backend OpenCode readiness detail as the disabled reason', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliStatus = { @@ -2111,6 +2166,7 @@ describe('TeamModelSelector disabled Codex models', () => { ); expect(openRouterButton).toBeTruthy(); + expect(openRouterButton?.textContent).not.toContain('OpenRouter'); await act(async () => { openRouterButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); @@ -2409,10 +2465,9 @@ describe('TeamModelSelector disabled Codex models', () => { expect(host.textContent).toContain('big-pickle'); expect(host.textContent).toContain('GPT-5.4'); expect(host.textContent).toContain('moonshotai/kimi-k2'); + expect(host.textContent).toContain('OpenCode'); expect(host.textContent).toContain('OpenAI'); expect(host.textContent).toContain('OpenRouter'); - expect(host.textContent).toContain('Free built-in'); - expect(host.textContent).toContain('Other OpenCode catalog'); await act(async () => { root.unmount(); @@ -2420,7 +2475,7 @@ describe('TeamModelSelector disabled Codex models', () => { }); }); - it('groups OpenCode catalog routes by configured, free, connected, and other sources', async () => { + it('groups OpenCode catalog routes by source provider and keeps route badges', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliStatus = { providers: [ @@ -2581,10 +2636,14 @@ describe('TeamModelSelector disabled Codex models', () => { await Promise.resolve(); }); - expect(host.textContent).toContain('OpenCode config'); - expect(host.textContent).toContain('Free built-in'); - expect(host.textContent).toContain('Connected providers'); - expect(host.textContent).toContain('Other OpenCode catalog'); + const sourceGroupLabels = Array.from( + host.querySelectorAll('[data-testid="team-model-selector-opencode-group"] h4') + ).map((heading) => heading.textContent ?? ''); + expect(sourceGroupLabels).toEqual( + expect.arrayContaining(['llama.cpp', 'OpenCode', 'OpenRouter', 'DeepSeek']) + ); + expect(sourceGroupLabels).not.toContain('OpenCode config'); + expect(sourceGroupLabels).not.toContain('Connected providers'); expect(host.textContent).toContain('Local'); expect(host.textContent).toContain('Needs test'); expect(host.textContent).toContain('Connected'); diff --git a/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts b/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts index ed600272..5e14073d 100644 --- a/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts +++ b/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts @@ -113,6 +113,16 @@ function createActions(): RuntimeProviderManagementActions { }; } +function setInputValue(input: HTMLInputElement, value: string): void { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + if (!setter) { + throw new Error('HTMLInputElement value setter not found'); + } + + setter.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); +} + describe('RuntimeProviderManagementPanelView', () => { beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); @@ -146,17 +156,21 @@ describe('RuntimeProviderManagementPanelView', () => { expect(host.textContent).toContain('Checking runtime'); expect(host.textContent).toContain('Loading managed OpenCode runtime'); - expect(host.textContent).toContain('Loading OpenCode providers'); - expect(host.querySelector('[data-testid="runtime-provider-loading-skeleton"]')).not.toBeNull(); - expect(host.querySelectorAll('.skeleton-shimmer').length).toBeGreaterThanOrEqual(10); + expect(host.textContent).toContain('Loading OpenCode model routes'); + expect( + host.querySelector('[data-testid="runtime-provider-model-loading-skeleton"]') + ).not.toBeNull(); + expect(host.querySelectorAll('.skeleton-shimmer').length).toBeGreaterThanOrEqual(8); expect(host.textContent).toContain('Checking...'); const refreshButton = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('Checking...') ); expect(refreshButton?.disabled).toBe(true); + + expect(host.textContent).not.toContain('No launchable OpenCode model routes were reported yet'); }); - it('shows the project as a compact operation context, not a selected global profile', async () => { + it('keeps project context out of the runtime summary and labels it as validation context', async () => { const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); @@ -164,7 +178,27 @@ describe('RuntimeProviderManagementPanelView', () => { await act(async () => { root.render( React.createElement(RuntimeProviderManagementPanelView, { - state: createState(), + state: createState({ + view: { + ...createState().view!, + configuredModels: [ + { + providerId: 'llama.cpp', + modelId: 'llama.cpp/qwen-test:0.5b', + displayName: 'qwen-test:0.5b', + sourceLabel: 'llama.cpp', + free: false, + default: false, + availability: 'available', + accessKind: 'verified', + routeKind: 'configured_local', + proofState: 'verified', + requiresExecutionProof: false, + accessReason: null, + }, + ], + }, + }), actions: createActions(), disabled: false, projectPath: '/Users/belief/dev/projects/321', @@ -173,12 +207,13 @@ describe('RuntimeProviderManagementPanelView', () => { await Promise.resolve(); }); - expect(host.textContent).toContain('Project context: 321'); + expect(host.textContent).toContain('OpenCode defaults'); + expect(host.textContent).toContain('Validation context'); + expect(host.textContent).toContain('Tests use 321. Default applies unless'); + expect(host.textContent).not.toContain('Project context: 321'); + expect(host.textContent).not.toContain('Current context: 321'); expect(host.textContent).not.toContain('Managing selected project profile'); expect(host.textContent).not.toContain('/Users/belief/dev/projects/321'); - expect( - host.querySelector('[title="Current project context: /Users/belief/dev/projects/321"]') - ).not.toBeNull(); }); it('renders configured OpenCode model routes with local proof actions', async () => { @@ -237,7 +272,7 @@ describe('RuntimeProviderManagementPanelView', () => { await Promise.resolve(); }); await act(async () => { - buttons.find((button) => button.textContent?.includes('Set project default'))?.click(); + buttons.find((button) => button.textContent?.includes('Set all-projects default'))?.click(); await Promise.resolve(); }); @@ -246,7 +281,7 @@ describe('RuntimeProviderManagementPanelView', () => { expect(actions.setDefaultModel).toHaveBeenCalledWith( 'llama.cpp', 'llama.cpp/qwen-test:0.5b', - 'project' + 'all_projects' ); }); @@ -287,12 +322,6 @@ describe('RuntimeProviderManagementPanelView', () => { await Promise.resolve(); }); - await act(async () => { - Array.from(host.querySelectorAll('button')) - .find((button) => button.textContent?.includes('All projects')) - ?.click(); - await Promise.resolve(); - }); await act(async () => { Array.from(host.querySelectorAll('button')) .find((button) => button.textContent?.includes('Set all-projects default')) @@ -300,7 +329,10 @@ describe('RuntimeProviderManagementPanelView', () => { await Promise.resolve(); }); - expect(host.textContent).toContain('Used by project contexts without their own OpenCode default'); + expect(host.textContent).toContain( + 'Default for every project that does not have its own OpenCode override' + ); + expect(host.textContent).toContain('Validation context'); expect(actions.setDefaultModel).toHaveBeenCalledWith( 'llama.cpp', 'llama.cpp/qwen-test:0.5b', @@ -308,6 +340,80 @@ describe('RuntimeProviderManagementPanelView', () => { ); }); + it('filters launchable OpenCode model routes by route text', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const localModel = { + providerId: 'llama.cpp', + modelId: 'llama.cpp/qwen-test:0.5b', + displayName: 'qwen-test:0.5b', + sourceLabel: 'llama.cpp', + free: false, + default: false, + availability: 'available' as const, + accessKind: 'verified' as const, + routeKind: 'configured_local' as const, + proofState: 'verified' as const, + requiresExecutionProof: false, + accessReason: null, + }; + const freeModel = { + providerId: 'opencode', + modelId: 'opencode/big-pickle', + displayName: 'big-pickle', + sourceLabel: 'OpenCode', + free: true, + default: false, + availability: 'available' as const, + accessKind: 'builtin_free' as const, + routeKind: 'builtin_free' as const, + proofState: 'not_required' as const, + requiresExecutionProof: false, + accessReason: null, + }; + + await act(async () => { + root.render( + React.createElement(RuntimeProviderManagementPanelView, { + state: createState({ + view: { + ...createState().view!, + configuredModels: [localModel, freeModel], + }, + }), + actions: createActions(), + disabled: false, + projectPath: '/tmp/project-a', + }) + ); + await Promise.resolve(); + }); + + const searchInput = host.querySelector( + 'input[placeholder="Search model routes"]' + ); + expect(searchInput).not.toBeNull(); + expect(host.textContent).toContain('qwen-test:0.5b'); + expect(host.textContent).toContain('big-pickle'); + + await act(async () => { + setInputValue(searchInput!, 'pickle'); + await Promise.resolve(); + }); + + expect(host.textContent).not.toContain('qwen-test:0.5b'); + expect(host.textContent).toContain('big-pickle'); + + await act(async () => { + setInputValue(searchInput!, 'missing-route'); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('No OpenCode model routes match'); + expect(host.textContent).toContain('missing-route'); + }); + it('opens launchable routes first when they exist and keeps providers in a separate tab', async () => { const host = document.createElement('div'); document.body.appendChild(host); diff --git a/test/shared/utils/anthropicLaunchModel.test.ts b/test/shared/utils/anthropicLaunchModel.test.ts index 70554aa4..3c72fbc1 100644 --- a/test/shared/utils/anthropicLaunchModel.test.ts +++ b/test/shared/utils/anthropicLaunchModel.test.ts @@ -1,7 +1,6 @@ -import { describe, expect, it } from 'vitest'; - import { resolveAnthropicLaunchModel } from '@shared/utils/anthropicLaunchModel'; import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection'; +import { describe, expect, it } from 'vitest'; describe('resolveAnthropicLaunchModel', () => { it('keeps legacy long-context fallback behavior when no runtime catalog is available', () => { @@ -84,6 +83,22 @@ describe('resolveAnthropicLaunchModel', () => { ).toBe('opus[1m]'); }); + it('preserves explicit Anthropic-compatible model ids instead of manufacturing 1M variants', () => { + expect( + resolveAnthropicLaunchModel({ + selectedModel: 'qwen3.6', + limitContext: false, + }) + ).toBe('qwen3.6'); + expect( + resolveAnthropicLaunchModel({ + selectedModel: 'qwen3.6', + limitContext: false, + availableLaunchModels: ['qwen3.6'], + }) + ).toBe('qwen3.6'); + }); + it('honors explicit 1M Sonnet selections unless 200K context is requested', () => { expect( resolveAnthropicLaunchModel({