From a591ccf297feaa8eb457c1a20f383bf5ba817c86 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 4 Apr 2026 20:04:16 +0300 Subject: [PATCH] Stabilize team provisioning and runtime diagnostics --- .../src/internal/runtimeHelpers.js | 38 + agent-teams-controller/src/internal/tasks.js | 48 +- mcp-server/src/tools/processTools.ts | 12 +- mcp-server/src/tools/taskTools.ts | 5 + src/main/index.ts | 11 +- src/main/ipc/cliInstaller.ts | 64 +- src/main/ipc/configValidation.ts | 61 +- src/main/ipc/extensions.ts | 18 +- .../extensions/apikeys/ApiKeyService.ts | 31 + src/main/services/extensions/index.ts | 2 +- .../infrastructure/CliInstallerService.ts | 30 +- .../services/infrastructure/ConfigManager.ts | 33 +- .../runtime/ClaudeMultimodelBridgeService.ts | 183 +++- .../services/runtime/geminiRuntimeAuth.ts | 69 +- .../services/runtime/providerRuntimeEnv.ts | 25 +- .../schedule/ScheduledTaskExecutor.ts | 11 +- .../services/team/ClaudeBinaryResolver.ts | 4 +- src/main/services/team/TeamConfigReader.ts | 102 ++ src/main/services/team/TeamDataService.ts | 73 +- .../services/team/TeamMembersMetaStore.ts | 6 +- .../services/team/TeamProvisioningService.ts | 974 ++++++++++++++++-- src/main/workers/team-fs-worker.ts | 95 ++ src/preload/constants/ipcChannels.ts | 3 + src/preload/index.ts | 4 + src/renderer/api/httpClient.ts | 1 + .../chat/items/linkedTool/renderHelpers.tsx | 66 ++ .../components/dashboard/CliStatusBanner.tsx | 360 ++++--- .../ProviderRuntimeBackendSelector.tsx | 194 ++++ .../runtime/ProviderRuntimeSettingsDialog.tsx | 414 ++++++++ .../settings/hooks/useSettingsHandlers.ts | 7 + .../settings/sections/CliStatusSection.tsx | 374 ++++--- .../components/team/ClaudeLogsPanel.tsx | 3 + .../components/team/ClaudeLogsSection.tsx | 16 +- .../components/team/CliLogsRichView.tsx | 4 + .../team/CollapsibleTeamSection.tsx | 28 +- .../team/ProvisioningProgressBlock.tsx | 22 +- .../components/team/TeamDetailView.tsx | 65 +- src/renderer/components/team/TeamListView.tsx | 63 +- .../team/TeamProvisioningBanner.tsx | 41 +- .../components/team/activity/ActivityItem.tsx | 141 ++- .../team/dialogs/CreateTeamDialog.tsx | 77 +- .../team/dialogs/LaunchTeamDialog.tsx | 309 +++++- .../ProvisioningProviderStatusList.tsx | 31 +- .../team/dialogs/TeamModelSelector.tsx | 17 +- .../components/team/kanban/KanbanTaskCard.tsx | 3 +- .../components/team/members/LeadModelRow.tsx | 10 + .../components/team/members/MemberCard.tsx | 4 + .../team/members/MemberDraftRow.tsx | 10 + .../components/team/members/MemberList.tsx | 40 +- .../team/members/MembersEditorSection.tsx | 10 +- .../team/members/TeamRosterEditorSection.tsx | 6 + .../team/members/membersEditorUtils.ts | 13 +- .../team/sidebar/TeamSidebarRail.tsx | 34 +- src/renderer/hooks/useCliInstaller.ts | 14 +- src/renderer/hooks/useResizablePanel.ts | 118 ++- src/renderer/store/index.ts | 7 +- .../store/slices/cliInstallerSlice.ts | 232 ++++- src/renderer/store/slices/teamSlice.ts | 22 +- .../utils/bootstrapPromptSanitizer.ts | 229 ++++ src/renderer/utils/memberHelpers.ts | 36 +- src/renderer/utils/streamJsonParser.ts | 3 +- src/renderer/utils/teamMessageFiltering.ts | 8 +- .../utils/toolRendering/toolSummaryHelpers.ts | 4 +- src/shared/types/cliInstaller.ts | 25 + src/shared/types/notifications.ts | 7 + src/shared/types/team.ts | 21 +- src/shared/utils/teamProvider.ts | 16 + src/shared/utils/toolSummary.ts | 80 +- .../team/TeamProvisioningService.test.ts | 30 + 69 files changed, 4493 insertions(+), 624 deletions(-) create mode 100644 src/renderer/components/runtime/ProviderRuntimeBackendSelector.tsx create mode 100644 src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx create mode 100644 src/renderer/utils/bootstrapPromptSanitizer.ts create mode 100644 src/shared/utils/teamProvider.ts diff --git a/agent-teams-controller/src/internal/runtimeHelpers.js b/agent-teams-controller/src/internal/runtimeHelpers.js index dae273fb..e6869831 100644 --- a/agent-teams-controller/src/internal/runtimeHelpers.js +++ b/agent-teams-controller/src/internal/runtimeHelpers.js @@ -255,6 +255,43 @@ function resolveTeamMembers(paths) { }; } +function getCurrentRuntimeMemberIdentity() { + const args = Array.isArray(process.argv) ? process.argv.slice(2) : []; + let agentName = ''; + let agentId = ''; + let teamName = ''; + + for (let i = 0; i < args.length; i += 1) { + const arg = typeof args[i] === 'string' ? args[i] : ''; + const next = typeof args[i + 1] === 'string' ? args[i + 1].trim() : ''; + if (!next) continue; + if (arg === '--agent-name') { + agentName = next; + continue; + } + if (arg === '--agent-id') { + agentId = next; + continue; + } + if (arg === '--team-name') { + teamName = next; + } + } + + const normalizedAgentName = typeof agentName === 'string' ? agentName.trim() : ''; + const normalizedAgentId = typeof agentId === 'string' ? agentId.trim() : ''; + const normalizedTeamName = typeof teamName === 'string' ? teamName.trim() : ''; + if (!normalizedAgentName && !normalizedAgentId) { + return null; + } + + return { + agentName: normalizedAgentName, + agentId: normalizedAgentId, + teamName: normalizedTeamName, + }; +} + function resolveLeadSessionId(paths) { const config = readTeamConfig(paths); return config && typeof config.leadSessionId === 'string' && config.leadSessionId.trim() @@ -459,6 +496,7 @@ module.exports = { readMembersMeta, readTeamConfig, resolveTeamMembers, + getCurrentRuntimeMemberIdentity, resolveLeadSessionId, saveTaskAttachmentFile, }; diff --git a/agent-teams-controller/src/internal/tasks.js b/agent-teams-controller/src/internal/tasks.js index 9f298e5a..1060ca7f 100644 --- a/agent-teams-controller/src/internal/tasks.js +++ b/agent-teams-controller/src/internal/tasks.js @@ -28,6 +28,13 @@ function isSameTaskMember(left, right, leadName) { ); } +function mergeMemberRecord(base, overlay) { + return { + ...(base && typeof base === 'object' ? base : {}), + ...(overlay && typeof overlay === 'object' ? overlay : {}), + }; +} + function quoteMarkdown(text) { return String(text) .split('\n') @@ -563,13 +570,14 @@ Failure to follow this protocol means the task board will show incorrect status. * Context-free — does NOT follow the (context, ...) convention. */ function buildProcessProtocolText(teamName) { - return `BACKGROUND PROCESS REGISTRATION — when you start a background process (dev server, watcher, database, etc.): + return `BACKGROUND SERVICE PROCESS REGISTRATION — this is ONLY for extra background services started by teammates (dev server, watcher, database, etc.). It is NOT a list of teammate agents themselves. 1. Launch with & to get PID: pnpm dev & 2. Register immediately with MCP tool process_register (--port and --url are optional, use when the process listens on a port): { teamName: "${teamName}", pid: , label: "", from: "", port?: , url?: "http://localhost:", command?: "" } 3. VERIFY registration succeeded (MANDATORY — never skip this step) using MCP tool process_list: { teamName: "${teamName}" } + process_list shows ONLY registered background services for the team. It does NOT show whether teammate agents themselves are alive. 4. When stopping a process, use MCP tool process_stop: { teamName: "${teamName}", pid: } 5. To fully remove a process record (e.g. after it has been stopped and is no longer needed), use MCP tool process_unregister: @@ -606,9 +614,43 @@ async function memberBriefing(context, memberName) { if (resolved.removedNames && resolved.removedNames.has(requestedMemberKey)) { throw new Error(`Member is removed from the team: ${requestedMemberName}`); } - const member = + let member = resolved.members.find((entry) => normalizeMemberName(entry && entry.name) === requestedMemberKey) || null; + if (!member) { + const runtimeIdentity = runtimeHelpers.getCurrentRuntimeMemberIdentity(); + const runtimeAgentName = normalizeMemberName(runtimeIdentity && runtimeIdentity.agentName); + const runtimeAgentId = String((runtimeIdentity && runtimeIdentity.agentId) || '').trim().toLowerCase(); + const runtimeTeamName = String((runtimeIdentity && runtimeIdentity.teamName) || '').trim().toLowerCase(); + const requestedAgentId = `${requestedMemberKey}@${String(context.teamName || '').trim().toLowerCase()}`; + const isCurrentRuntimeMember = + requestedMemberKey && + ((runtimeAgentName && runtimeAgentName === requestedMemberKey) || + (runtimeAgentId && runtimeAgentId === requestedAgentId)) && + (!runtimeTeamName || runtimeTeamName === String(context.teamName || '').trim().toLowerCase()); + if (isCurrentRuntimeMember) { + const configMembers = Array.isArray(config.members) ? config.members : []; + const configMember = + configMembers.find((entry) => normalizeMemberName(entry && entry.name) === requestedMemberKey) || + null; + const metaMember = + Array.isArray(resolved.members) + ? resolved.members.find((entry) => normalizeMemberName(entry && entry.name) === requestedMemberKey) + : null; + member = mergeMemberRecord( + { + name: requestedMemberName, + ...(runtimeIdentity && runtimeIdentity.agentName + ? { name: String(runtimeIdentity.agentName).trim() } + : {}), + ...(typeof config.projectPath === 'string' && config.projectPath.trim() + ? { cwd: config.projectPath.trim() } + : {}), + }, + mergeMemberRecord(configMember || {}, metaMember || {}) + ); + } + } if (!member) { throw new Error( `Member not found in team metadata or inboxes: ${requestedMemberName}` @@ -730,4 +772,4 @@ module.exports = { updateTask: (context, taskRef, updater) => taskStore.updateTask(context.paths, taskRef, updater), updateTaskFields, -}; \ No newline at end of file +}; diff --git a/mcp-server/src/tools/processTools.ts b/mcp-server/src/tools/processTools.ts index 4c682835..ac40b4e3 100644 --- a/mcp-server/src/tools/processTools.ts +++ b/mcp-server/src/tools/processTools.ts @@ -12,7 +12,8 @@ const toolContextSchema = { export function registerProcessTools(server: Pick) { server.addTool({ name: 'process_register', - description: 'Register a running process for a team member', + description: + 'Register a background service started by a teammate, such as a dev server, watcher, or database. This is not for teammate-agent liveness.', parameters: z.object({ ...toolContextSchema, pid: z.number().int().positive(), @@ -51,7 +52,8 @@ export function registerProcessTools(server: Pick) { server.addTool({ name: 'process_list', - description: 'List registered team processes', + description: + 'List registered background services for the team, such as dev servers, watchers, or databases. This does not show teammate-agent liveness.', parameters: z.object({ ...toolContextSchema, }), @@ -63,7 +65,8 @@ export function registerProcessTools(server: Pick) { server.addTool({ name: 'process_unregister', - description: 'Unregister a previously registered process', + description: + 'Unregister a previously registered background service while keeping teammate-agent state separate.', parameters: z.object({ ...toolContextSchema, pid: z.number().int().positive(), @@ -76,7 +79,8 @@ export function registerProcessTools(server: Pick) { server.addTool({ name: 'process_stop', - description: 'Mark a registered process as stopped while preserving history', + description: + 'Mark a registered background service as stopped while preserving history. This is not for stopping teammate agents.', parameters: z.object({ ...toolContextSchema, pid: z.number().int().positive(), diff --git a/mcp-server/src/tools/taskTools.ts b/mcp-server/src/tools/taskTools.ts index f85358ed..3e91d6d3 100644 --- a/mcp-server/src/tools/taskTools.ts +++ b/mcp-server/src/tools/taskTools.ts @@ -12,6 +12,10 @@ const toolContextSchema = { claudeDir: z.string().min(1).optional(), }; +const ALWAYS_LOAD_META = { + 'anthropic/alwaysLoad': true, +} as const; + const relationshipTypeSchema = z.enum(['blocked-by', 'blocks', 'related']); /** Allowed message source types for task_create_from_message provenance. Fail closed — only explicit user-originated sources. */ @@ -468,6 +472,7 @@ export function registerTaskTools(server: Pick) { server.addTool({ name: 'member_briefing', description: 'Get bootstrap briefing for a team member', + _meta: ALWAYS_LOAD_META, parameters: z.object({ ...toolContextSchema, memberName: z.string().min(1), diff --git a/src/main/index.ts b/src/main/index.ts index 29d569db..7b769c35 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -62,6 +62,7 @@ import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers'; import { setReviewMainWindow } from './ipc/review'; import { ApiKeyService, + RUNTIME_MANAGED_API_KEY_ENV_VARS, ExtensionFacadeService, GlamaMcpEnrichmentService, McpCatalogAggregator, @@ -536,9 +537,6 @@ function wireFileWatcherEvents(context: ServiceContext): void { if (match && teamDataService) { const inboxName = match[1]; - // Mark member as online when their first inbox message arrives (spawn tracking). - teamProvisioningService.markMemberOnlineFromInbox(teamName, inboxName); - void teamDataService .getLeadMemberName(teamName) .then((leadName) => { @@ -716,7 +714,7 @@ function reconfigureLocalContextForClaudeRoot(): void { /** * Initializes all services. */ -function initializeServices(): void { +async function initializeServices(): Promise { logger.info('Initializing services...'); // Initialize SSH connection manager @@ -839,6 +837,7 @@ function initializeServices(): void { const pluginInstallService = new PluginInstallService(pluginCatalogService); const mcpInstallService = new McpInstallService(mcpAggregator); const apiKeyService = new ApiKeyService(); + await apiKeyService.syncProcessEnv(RUNTIME_MANAGED_API_KEY_ENV_VARS); // warmup() and ensureInstalled() are deferred to after window creation // (did-finish-load handler) to avoid thread pool contention at startup. httpServer = new HttpServer(); @@ -1392,7 +1391,7 @@ function createWindow(): void { /** * Application ready handler. */ -void app.whenReady().then(() => { +void app.whenReady().then(async () => { logger.info('App ready, initializing...'); // Pre-warm interactive shell env cache (non-blocking). @@ -1403,7 +1402,7 @@ void app.whenReady().then(() => { try { // Initialize services first - initializeServices(); + await initializeServices(); // Apply configuration settings const config = configManager.getConfig(); diff --git a/src/main/ipc/cliInstaller.ts b/src/main/ipc/cliInstaller.ts index 8511140d..2906e42d 100644 --- a/src/main/ipc/cliInstaller.ts +++ b/src/main/ipc/cliInstaller.ts @@ -9,6 +9,7 @@ import { CLI_INSTALLER_GET_STATUS, + CLI_INSTALLER_GET_PROVIDER_STATUS, CLI_INSTALLER_INSTALL, CLI_INSTALLER_INVALIDATE_STATUS, // eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload @@ -18,13 +19,19 @@ import { createLogger } from '@shared/utils/logger'; import type { CliInstallerService } from '../services'; import { ClaudeBinaryResolver } from '../services/team/ClaudeBinaryResolver'; -import type { CliInstallationStatus, IpcResult } from '@shared/types'; +import type { + CliInstallationStatus, + CliProviderId, + CliProviderStatus, + IpcResult, +} from '@shared/types'; import type { IpcMain, IpcMainInvokeEvent } from 'electron'; const logger = createLogger('IPC:cliInstaller'); let service: CliInstallerService; let statusInFlight: Promise | null = null; +const providerStatusInFlight = new Map>(); let cachedStatus: { value: CliInstallationStatus; at: number } | null = null; const STATUS_CACHE_TTL_MS = 5_000; @@ -40,6 +47,7 @@ export function initializeCliInstallerHandlers(installerService: CliInstallerSer */ export function registerCliInstallerHandlers(ipcMain: IpcMain): void { ipcMain.handle(CLI_INSTALLER_GET_STATUS, handleGetStatus); + ipcMain.handle(CLI_INSTALLER_GET_PROVIDER_STATUS, handleGetProviderStatus); ipcMain.handle(CLI_INSTALLER_INSTALL, handleInstall); ipcMain.handle(CLI_INSTALLER_INVALIDATE_STATUS, handleInvalidateStatus); @@ -51,6 +59,7 @@ export function registerCliInstallerHandlers(ipcMain: IpcMain): void { */ export function removeCliInstallerHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(CLI_INSTALLER_GET_STATUS); + ipcMain.removeHandler(CLI_INSTALLER_GET_PROVIDER_STATUS); ipcMain.removeHandler(CLI_INSTALLER_INSTALL); ipcMain.removeHandler(CLI_INSTALLER_INVALIDATE_STATUS); @@ -99,6 +108,58 @@ async function handleGetStatus( } } +function patchCachedProviderStatus(providerStatus: CliProviderStatus | null): void { + if (!cachedStatus || !providerStatus) { + return; + } + + const nextProviders = cachedStatus.value.providers.map((provider) => + provider.providerId === providerStatus.providerId ? providerStatus : provider + ); + const authenticatedProvider = nextProviders.find((provider) => provider.authenticated) ?? null; + + cachedStatus = { + value: { + ...cachedStatus.value, + providers: nextProviders, + authLoggedIn: nextProviders.some((provider) => provider.authenticated), + authMethod: authenticatedProvider?.authMethod ?? null, + }, + at: Date.now(), + }; +} + +async function handleGetProviderStatus( + _event: IpcMainInvokeEvent, + providerId: CliProviderId +): Promise> { + try { + const inFlight = providerStatusInFlight.get(providerId); + if (inFlight) { + const status = await inFlight; + return { success: true, data: status }; + } + + const request = service + .getProviderStatus(providerId) + .then((status) => { + patchCachedProviderStatus(status); + return status; + }) + .finally(() => { + providerStatusInFlight.delete(providerId); + }); + + providerStatusInFlight.set(providerId, request); + const status = await request; + return { success: true, data: status }; + } catch (error) { + const msg = getErrorMessage(error); + logger.error(`Error in cliInstaller:getProviderStatus(${providerId}):`, msg); + return { success: false, error: msg }; + } +} + async function handleInstall(_event: IpcMainInvokeEvent): Promise> { try { await service.install(); @@ -112,6 +173,7 @@ async function handleInstall(_event: IpcMainInvokeEvent): Promise { cachedStatus = null; + providerStatusInFlight.clear(); ClaudeBinaryResolver.clearCache(); return { success: true, data: undefined }; } diff --git a/src/main/ipc/configValidation.ts b/src/main/ipc/configValidation.ts index d9a5104e..5c5aa516 100644 --- a/src/main/ipc/configValidation.ts +++ b/src/main/ipc/configValidation.ts @@ -12,6 +12,7 @@ import type { HttpServerConfig, NotificationConfig, NotificationTrigger, + RuntimeConfig, SshPersistConfig, } from '../services'; @@ -31,6 +32,7 @@ interface ValidationFailure { export type ConfigUpdateValidationResult = | ValidationSuccess<'notifications'> | ValidationSuccess<'general'> + | ValidationSuccess<'runtime'> | ValidationSuccess<'display'> | ValidationSuccess<'httpServer'> | ValidationSuccess<'ssh'> @@ -39,6 +41,7 @@ export type ConfigUpdateValidationResult = const VALID_SECTIONS = new Set([ 'notifications', 'general', + 'runtime', 'display', 'httpServer', 'ssh', @@ -398,6 +401,60 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V }; } +function validateRuntimeSection(data: unknown): ValidationSuccess<'runtime'> | ValidationFailure { + if (!isPlainObject(data)) { + return { valid: false, error: 'runtime update must be an object' }; + } + + const result: Partial = {}; + + for (const [key, value] of Object.entries(data)) { + if (key !== 'providerBackends') { + return { valid: false, error: `runtime.${key} is not a valid setting` }; + } + + if (!isPlainObject(value)) { + return { valid: false, error: 'runtime.providerBackends must be an object' }; + } + + const providerBackends: Partial = {}; + + for (const [providerId, backendId] of Object.entries(value)) { + if (providerId === 'gemini') { + if (backendId !== 'auto' && backendId !== 'api' && backendId !== 'cli-sdk') { + return { + valid: false, + error: 'runtime.providerBackends.gemini must be one of: auto, api, cli-sdk', + }; + } + providerBackends.gemini = backendId; + continue; + } + + if (providerId === 'codex') { + if (backendId !== 'auto' && backendId !== 'adapter') { + return { + valid: false, + error: 'runtime.providerBackends.codex must be one of: auto, adapter', + }; + } + providerBackends.codex = backendId; + continue; + } + + return { valid: false, error: `runtime.providerBackends.${providerId} is not supported` }; + } + + result.providerBackends = providerBackends as RuntimeConfig['providerBackends']; + } + + return { + valid: true, + section: 'runtime', + data: result, + }; +} + function validateDisplaySection(data: unknown): ValidationSuccess<'display'> | ValidationFailure { if (!isPlainObject(data)) { return { valid: false, error: 'display update must be an object' }; @@ -544,7 +601,7 @@ export function validateConfigUpdatePayload( if (typeof section !== 'string' || !VALID_SECTIONS.has(section as ConfigSection)) { return { valid: false, - error: 'Section must be one of: notifications, general, display, httpServer, ssh', + error: 'Section must be one of: notifications, general, runtime, display, httpServer, ssh', }; } @@ -553,6 +610,8 @@ export function validateConfigUpdatePayload( return validateNotificationsSection(data); case 'general': return validateGeneralSection(data); + case 'runtime': + return validateRuntimeSection(data); case 'display': return validateDisplaySection(data); case 'httpServer': diff --git a/src/main/ipc/extensions.ts b/src/main/ipc/extensions.ts index 0efa4db2..514a9a15 100644 --- a/src/main/ipc/extensions.ts +++ b/src/main/ipc/extensions.ts @@ -30,7 +30,10 @@ import { createLogger } from '@shared/utils/logger'; import { GitHubStarsService } from '../services/extensions/catalog/GitHubStarsService'; -import type { ApiKeyService } from '../services/extensions/apikeys/ApiKeyService'; +import { + RUNTIME_MANAGED_API_KEY_ENV_VARS, + type ApiKeyService, +} from '../services/extensions/apikeys/ApiKeyService'; import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService'; import type { McpInstallService } from '../services/extensions/install/McpInstallService'; import type { PluginInstallService } from '../services/extensions/install/PluginInstallService'; @@ -388,7 +391,12 @@ async function handleApiKeysSave( ): Promise> { return wrapHandler('apiKeysSave', () => { if (!request) throw new Error('Request is required'); - return getApiKeyService().save(request); + return getApiKeyService() + .save(request) + .then(async (entry) => { + await getApiKeyService().syncProcessEnv(RUNTIME_MANAGED_API_KEY_ENV_VARS); + return entry; + }); }); } @@ -398,7 +406,11 @@ async function handleApiKeysDelete( ): Promise> { return wrapHandler('apiKeysDelete', () => { if (typeof id !== 'string' || !id) throw new Error('Key ID is required'); - return getApiKeyService().delete(id); + return getApiKeyService() + .delete(id) + .then(async () => { + await getApiKeyService().syncProcessEnv(RUNTIME_MANAGED_API_KEY_ENV_VARS); + }); }); } diff --git a/src/main/services/extensions/apikeys/ApiKeyService.ts b/src/main/services/extensions/apikeys/ApiKeyService.ts index f72564d7..10374a30 100644 --- a/src/main/services/extensions/apikeys/ApiKeyService.ts +++ b/src/main/services/extensions/apikeys/ApiKeyService.ts @@ -50,10 +50,13 @@ const PBKDF2_ITERATIONS = 100_000; const PBKDF2_KEY_BYTES = 32; const PBKDF2_SALT = 'claude-apikey-storage-v1'; +export const RUNTIME_MANAGED_API_KEY_ENV_VARS = ['GEMINI_API_KEY'] as const; + export class ApiKeyService { private readonly filePath: string; private cache: StoredApiKey[] | null = null; private aesKey: Buffer | null = null; + private readonly originalProcessEnv = new Map(); constructor(claudeDir?: string) { const baseDir = claudeDir ?? path.join(os.homedir(), '.claude'); @@ -163,6 +166,34 @@ export class ApiKeyService { }; } + async syncProcessEnv(envVarNames: readonly string[]): Promise { + if (!envVarNames.length) { + return; + } + + const lookups = await this.lookup([...envVarNames]); + const valueByEnv = new Map(lookups.map((entry) => [entry.envVarName, entry.value])); + + for (const envVarName of envVarNames) { + if (!this.originalProcessEnv.has(envVarName)) { + this.originalProcessEnv.set(envVarName, process.env[envVarName]); + } + + const nextValue = valueByEnv.get(envVarName); + if (nextValue && nextValue.trim().length > 0) { + process.env[envVarName] = nextValue; + continue; + } + + const originalValue = this.originalProcessEnv.get(envVarName); + if (typeof originalValue === 'string' && originalValue.length > 0) { + process.env[envVarName] = originalValue; + } else { + delete process.env[envVarName]; + } + } + } + // ── Encryption ────────────────────────────────────────────────────────── /** diff --git a/src/main/services/extensions/index.ts b/src/main/services/extensions/index.ts index 957965c1..1e056147 100644 --- a/src/main/services/extensions/index.ts +++ b/src/main/services/extensions/index.ts @@ -2,7 +2,7 @@ * Extension services barrel export. */ -export { ApiKeyService } from './apikeys/ApiKeyService'; +export { ApiKeyService, RUNTIME_MANAGED_API_KEY_ENV_VARS } from './apikeys/ApiKeyService'; export { GitHubStarsService } from './catalog/GitHubStarsService'; export { GlamaMcpEnrichmentService } from './catalog/GlamaMcpEnrichmentService'; export { McpCatalogAggregator } from './catalog/McpCatalogAggregator'; diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index 0310ee44..823f777b 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -41,7 +41,13 @@ import { ClaudeMultimodelBridgeService } from '../runtime/ClaudeMultimodelBridge import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver'; import { getConfiguredCliFlavor, getCliFlavorUiOptions } from '../team/cliFlavor'; -import type { CliInstallationStatus, CliInstallerProgress, CliPlatform } from '@shared/types'; +import type { + CliInstallationStatus, + CliInstallerProgress, + CliPlatform, + CliProviderId, + CliProviderStatus, +} from '@shared/types'; import type { BrowserWindow } from 'electron'; import type { IncomingMessage } from 'http'; @@ -131,6 +137,11 @@ function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallat providers: status.providers.map((provider) => ({ ...provider, capabilities: { ...provider.capabilities }, + selectedBackendId: provider.selectedBackendId ?? null, + resolvedBackendId: provider.resolvedBackendId ?? null, + availableBackends: provider.availableBackends?.map((backend) => ({ ...backend })) ?? [], + externalRuntimeDiagnostics: + provider.externalRuntimeDiagnostics?.map((diagnostic) => ({ ...diagnostic })) ?? [], backend: provider.backend ? { ...provider.backend } : null, models: [...provider.models], })), @@ -479,6 +490,23 @@ export class CliInstallerService { } } + async getProviderStatus(providerId: CliProviderId): Promise { + await resolveInteractiveShellEnv(); + + const binaryPath = await ClaudeBinaryResolver.resolve(); + if (!binaryPath) { + return null; + } + + const flavor = getConfiguredCliFlavor(); + if (flavor !== 'free-code') { + const fullStatus = await this.getStatus(); + return fullStatus.providers.find((provider) => provider.providerId === providerId) ?? null; + } + + return this.multimodelBridgeService.getProviderStatus(binaryPath, providerId); + } + /** * Gathers CLI status information, mutating the provided result object. * Split from getStatus() to enable overall timeout via Promise.race — diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts index f6aa1d3f..d12ef4bc 100644 --- a/src/main/services/infrastructure/ConfigManager.ts +++ b/src/main/services/infrastructure/ConfigManager.ts @@ -215,6 +215,13 @@ export interface GeneralConfig { telemetryEnabled: boolean; } +export interface RuntimeConfig { + providerBackends: { + gemini: 'auto' | 'api' | 'cli-sdk'; + codex: 'auto' | 'adapter'; + }; +} + export interface DisplayConfig { showTimestamps: boolean; compactMode: boolean; @@ -247,6 +254,7 @@ export interface HttpServerConfig { export interface AppConfig { notifications: NotificationConfig; general: GeneralConfig; + runtime: RuntimeConfig; display: DisplayConfig; sessions: SessionsConfig; ssh: SshPersistConfig; @@ -299,6 +307,12 @@ const DEFAULT_CONFIG: AppConfig = { customProjectPaths: [], telemetryEnabled: true, }, + runtime: { + providerBackends: { + gemini: 'auto', + codex: 'auto', + }, + }, display: { showTimestamps: true, compactMode: false, @@ -468,6 +482,12 @@ export class ConfigManager { triggers: mergedTriggers, }, general: mergedGeneral, + runtime: { + providerBackends: { + ...DEFAULT_CONFIG.runtime.providerBackends, + ...(loaded.runtime?.providerBackends ?? {}), + }, + }, display: { ...DEFAULT_CONFIG.display, ...(loaded.display ?? {}), @@ -540,10 +560,21 @@ export class ConfigManager { section: K, data: Partial ): Partial { - if (section !== 'general') { + if (section !== 'general' && section !== 'runtime') { return data; } + if (section === 'runtime') { + const runtimeUpdate = data as Partial; + return { + ...runtimeUpdate, + providerBackends: { + ...this.config.runtime.providerBackends, + ...runtimeUpdate.providerBackends, + }, + } as unknown as Partial; + } + if (!Object.prototype.hasOwnProperty.call(data, 'claudeRootPath')) { return data; } diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index 5dc5638e..f04fe630 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -8,7 +8,9 @@ import { import { createLogger } from '@shared/utils/logger'; import type { CliProviderId, CliProviderStatus } from '@shared/types'; +import { configManager } from '../infrastructure/ConfigManager'; import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth'; +import { applyConfiguredRuntimeBackendsEnv } from './providerRuntimeEnv'; const logger = createLogger('ClaudeMultimodelBridgeService'); @@ -51,6 +53,53 @@ interface ProviderModelsCommandResponse { >; } +interface UnifiedRuntimeStatusResponse { + schemaVersion?: number; + providers?: Record< + string, + { + supported?: boolean; + authenticated?: boolean; + authMethod?: string | null; + verificationState?: 'verified' | 'unknown' | 'offline' | 'error'; + canLoginFromUi?: boolean; + statusMessage?: string | null; + detailMessage?: string | null; + selectedBackendId?: string | null; + resolvedBackendId?: string | null; + availableBackends?: Array<{ + id?: string; + label?: string; + description?: string; + selectable?: boolean; + recommended?: boolean; + available?: boolean; + statusMessage?: string | null; + detailMessage?: string | null; + }>; + externalRuntimeDiagnostics?: Array<{ + id?: string; + label?: string; + detected?: boolean; + statusMessage?: string | null; + detailMessage?: string | null; + }>; + models?: Array; + capabilities?: { + teamLaunch?: boolean; + oneShot?: boolean; + }; + backend?: { + kind?: string; + label?: string; + endpointLabel?: string | null; + projectId?: string | null; + authMethodDetail?: string | null; + } | null; + } + >; +} + const ORDERED_PROVIDER_IDS: CliProviderId[] = ['anthropic', 'codex', 'gemini']; function extractJsonObject(raw: string): T { @@ -83,6 +132,10 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat teamLaunch: false, oneShot: false, }, + selectedBackendId: null, + resolvedBackendId: null, + availableBackends: [], + externalRuntimeDiagnostics: [], backend: null, }; } @@ -117,11 +170,12 @@ export class ClaudeMultimodelBridgeService { if (home) { env.HOME = home; } - return env; + return applyConfiguredRuntimeBackendsEnv(env, configManager.getConfig().runtime); } private buildProviderCliEnv(binaryPath: string, providerId: CliProviderId): NodeJS.ProcessEnv { const env = { ...this.buildCliEnv(binaryPath) }; + delete env.CLAUDE_CODE_ENTRY_PROVIDER; delete env.CLAUDE_CODE_USE_OPENAI; delete env.CLAUDE_CODE_USE_BEDROCK; delete env.CLAUDE_CODE_USE_VERTEX; @@ -129,14 +183,116 @@ export class ClaudeMultimodelBridgeService { delete env.CLAUDE_CODE_USE_GEMINI; if (providerId === 'codex') { - env.CLAUDE_CODE_USE_OPENAI = '1'; + env.CLAUDE_CODE_ENTRY_PROVIDER = 'codex'; } else if (providerId === 'gemini') { - env.CLAUDE_CODE_USE_GEMINI = '1'; + env.CLAUDE_CODE_ENTRY_PROVIDER = 'gemini'; } return env; } + private isUnifiedRuntimeUnsupported(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + const lower = message.toLowerCase(); + return ( + lower.includes('unknown command') || + lower.includes('unknown option') || + lower.includes('no such command') || + lower.includes('did you mean') || + lower.includes('runtime status') + ); + } + + private mapRuntimeProviderStatus( + providerId: CliProviderId, + runtimeStatus: NonNullable[string] | undefined + ): CliProviderStatus { + const provider = createDefaultProviderStatus(providerId); + if (!runtimeStatus) { + return provider; + } + + return { + ...provider, + supported: runtimeStatus.supported === true, + authenticated: runtimeStatus.authenticated === true, + authMethod: runtimeStatus.authMethod ?? null, + verificationState: runtimeStatus.verificationState ?? 'unknown', + statusMessage: runtimeStatus.statusMessage ?? null, + canLoginFromUi: runtimeStatus.canLoginFromUi !== false, + capabilities: { + teamLaunch: runtimeStatus.capabilities?.teamLaunch === true, + oneShot: runtimeStatus.capabilities?.oneShot === true, + }, + selectedBackendId: runtimeStatus.selectedBackendId ?? null, + resolvedBackendId: runtimeStatus.resolvedBackendId ?? null, + availableBackends: + runtimeStatus.availableBackends?.map((backend) => ({ + id: backend.id ?? 'unknown', + label: backend.label ?? backend.id ?? 'Unknown', + description: backend.description ?? '', + selectable: backend.selectable !== false, + recommended: backend.recommended === true, + available: backend.available === true, + statusMessage: backend.statusMessage ?? null, + detailMessage: backend.detailMessage ?? null, + })) ?? [], + externalRuntimeDiagnostics: + runtimeStatus.externalRuntimeDiagnostics?.map((diagnostic) => ({ + id: diagnostic.id ?? 'unknown', + label: diagnostic.label ?? diagnostic.id ?? 'Unknown', + detected: diagnostic.detected === true, + statusMessage: diagnostic.statusMessage ?? null, + detailMessage: diagnostic.detailMessage ?? null, + })) ?? [], + models: extractModelIds(runtimeStatus.models), + backend: runtimeStatus.backend?.kind + ? { + kind: runtimeStatus.backend.kind, + label: runtimeStatus.backend.label ?? runtimeStatus.backend.kind, + endpointLabel: runtimeStatus.backend.endpointLabel ?? null, + projectId: runtimeStatus.backend.projectId ?? null, + authMethodDetail: runtimeStatus.backend.authMethodDetail ?? null, + } + : null, + }; + } + + async getProviderStatus( + binaryPath: string, + providerId: CliProviderId + ): Promise { + await resolveInteractiveShellEnv(); + const env = this.buildCliEnv(binaryPath); + + try { + const { stdout } = await execCli( + binaryPath, + ['runtime', 'status', '--json', '--provider', providerId], + { + timeout: PROVIDER_STATUS_TIMEOUT_MS, + env, + } + ); + const parsed = extractJsonObject(stdout); + return this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId]); + } catch (error) { + if (!this.isUnifiedRuntimeUnsupported(error)) { + logger.warn( + `Provider-scoped runtime status unavailable for ${providerId}, falling back to full probe: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + + const providers = await this.getProviderStatuses(binaryPath); + return ( + providers.find((provider) => provider.providerId === providerId) ?? + createDefaultProviderStatus(providerId) + ); + } + private async buildGeminiStatus(binaryPath: string): Promise { const provider = createDefaultProviderStatus('gemini'); const env = this.buildProviderCliEnv(binaryPath, 'gemini'); @@ -200,6 +356,27 @@ export class ClaudeMultimodelBridgeService { await resolveInteractiveShellEnv(); const env = this.buildCliEnv(binaryPath); + try { + const { stdout } = await execCli(binaryPath, ['runtime', 'status', '--json'], { + timeout: PROVIDER_STATUS_TIMEOUT_MS, + env, + }); + const parsed = extractJsonObject(stdout); + const providers = ORDERED_PROVIDER_IDS.map((providerId) => + this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId]) + ); + onUpdate?.(providers); + return providers; + } catch (error) { + if (!this.isUnifiedRuntimeUnsupported(error)) { + logger.warn( + `Unified runtime status unavailable, falling back to legacy probes: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + const [statusResult, modelsResult] = await Promise.allSettled([ execCli(binaryPath, ['auth', 'status', '--json', '--provider', 'all'], { timeout: PROVIDER_STATUS_TIMEOUT_MS, diff --git a/src/main/services/runtime/geminiRuntimeAuth.ts b/src/main/services/runtime/geminiRuntimeAuth.ts index 0eb81a47..bc320daf 100644 --- a/src/main/services/runtime/geminiRuntimeAuth.ts +++ b/src/main/services/runtime/geminiRuntimeAuth.ts @@ -2,8 +2,8 @@ import * as fs from 'fs'; import * as path from 'path'; export type GeminiGlobalConfig = { - geminiBackendPreference?: 'auto' | 'api' | 'cli'; - geminiResolvedBackend?: 'api' | 'cli'; + geminiBackendPreference?: 'auto' | 'api' | 'cli' | 'cli-sdk'; + geminiResolvedBackend?: 'api' | 'cli' | 'cli-sdk'; geminiLastAuthMethod?: string; geminiProjectId?: string; }; @@ -11,11 +11,37 @@ export type GeminiGlobalConfig = { export type GeminiRuntimeAuthState = { authenticated: boolean; authMethod: string | null; - resolvedBackend: 'auto' | 'api' | 'cli'; + resolvedBackend: 'auto' | 'api' | 'cli-sdk'; projectId: string | null; statusMessage: string | null; }; +function normalizeGeminiBackend( + value: string | null | undefined +): GeminiRuntimeAuthState['resolvedBackend'] { + if (value === 'api') return 'api'; + if (value === 'cli' || value === 'cli-sdk') return 'cli-sdk'; + return 'auto'; +} + +function resolveEffectiveGeminiBackend( + requestedBackend: GeminiRuntimeAuthState['resolvedBackend'], + authMethod: string | null, + hasGeminiApiKey: boolean, + hasAdcWithProject: boolean +): Exclude | 'auto' { + if (requestedBackend !== 'auto') { + return requestedBackend; + } + if (hasGeminiApiKey || hasAdcWithProject) { + return 'api'; + } + if (authMethod === 'cli_oauth_personal') { + return 'cli-sdk'; + } + return 'auto'; +} + export async function readGeminiGlobalConfig( env: NodeJS.ProcessEnv ): Promise { @@ -43,11 +69,11 @@ export async function resolveGeminiRuntimeAuth( env: NodeJS.ProcessEnv ): Promise { const config = await readGeminiGlobalConfig(env); - const resolvedBackend = + const resolvedBackend = normalizeGeminiBackend( env.CLAUDE_CODE_GEMINI_BACKEND?.trim() || - config?.geminiResolvedBackend?.trim() || - config?.geminiBackendPreference?.trim() || - 'auto'; + config?.geminiResolvedBackend?.trim() || + config?.geminiBackendPreference?.trim() + ); const authMethod = config?.geminiLastAuthMethod?.trim() ?? null; const projectId = env.GOOGLE_CLOUD_PROJECT?.trim() || @@ -56,34 +82,41 @@ export async function resolveGeminiRuntimeAuth( config?.geminiProjectId?.trim() || null; const hasGeminiApiKey = Boolean(env.GEMINI_API_KEY?.trim()); + const hasAdcWithProject = Boolean( + (authMethod === 'adc_authorized_user' || authMethod === 'adc_service_account') && projectId + ); + const effectiveBackend = resolveEffectiveGeminiBackend( + resolvedBackend, + authMethod, + hasGeminiApiKey, + hasAdcWithProject + ); if (hasGeminiApiKey) { return { authenticated: true, authMethod: 'api_key', - resolvedBackend: - resolvedBackend === 'api' || resolvedBackend === 'cli' ? resolvedBackend : 'auto', + resolvedBackend: effectiveBackend, projectId, statusMessage: null, }; } - if ((authMethod === 'adc_authorized_user' || authMethod === 'adc_service_account') && projectId) { + if (hasAdcWithProject) { return { authenticated: true, authMethod, - resolvedBackend: - resolvedBackend === 'api' || resolvedBackend === 'cli' ? resolvedBackend : 'auto', + resolvedBackend: effectiveBackend, projectId, statusMessage: null, }; } - if (authMethod === 'cli_oauth_personal' && resolvedBackend === 'cli') { + if (authMethod === 'cli_oauth_personal' && effectiveBackend === 'cli-sdk') { return { authenticated: true, authMethod, - resolvedBackend: 'cli', + resolvedBackend: 'cli-sdk', projectId, statusMessage: null, }; @@ -93,19 +126,17 @@ export async function resolveGeminiRuntimeAuth( return { authenticated: false, authMethod, - resolvedBackend: - resolvedBackend === 'api' || resolvedBackend === 'cli' ? resolvedBackend : 'auto', + resolvedBackend: effectiveBackend, projectId, statusMessage: - 'Gemini CLI OAuth was detected, but the active Gemini backend is not set to cli.', + 'Gemini CLI OAuth was detected, but the active Gemini backend is not set to CLI SDK.', }; } return { authenticated: false, authMethod, - resolvedBackend: - resolvedBackend === 'api' || resolvedBackend === 'cli' ? resolvedBackend : 'auto', + resolvedBackend, projectId, statusMessage: 'Gemini provider is not configured for runtime use. Set GEMINI_API_KEY or Google ADC credentials (plus GOOGLE_CLOUD_PROJECT when needed) and retry.', diff --git a/src/main/services/runtime/providerRuntimeEnv.ts b/src/main/services/runtime/providerRuntimeEnv.ts index 2739c2d6..ae93cc8c 100644 --- a/src/main/services/runtime/providerRuntimeEnv.ts +++ b/src/main/services/runtime/providerRuntimeEnv.ts @@ -1,6 +1,9 @@ import type { TeamProviderId } from '@shared/types'; +import { ConfigManager } from '../infrastructure/ConfigManager'; + const THIRD_PARTY_PROVIDER_ENV_KEYS = [ + 'CLAUDE_CODE_ENTRY_PROVIDER', 'CLAUDE_CODE_USE_OPENAI', 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', @@ -8,6 +11,24 @@ const THIRD_PARTY_PROVIDER_ENV_KEYS = [ 'CLAUDE_CODE_USE_GEMINI', ] as const; +const BACKEND_SELECTION_ENV_KEYS = [ + 'CLAUDE_CODE_GEMINI_BACKEND', + 'CLAUDE_CODE_CODEX_BACKEND', +] as const; + +export function applyConfiguredRuntimeBackendsEnv( + env: NodeJS.ProcessEnv, + runtimeConfig = ConfigManager.getInstance().getConfig().runtime +): NodeJS.ProcessEnv { + for (const key of BACKEND_SELECTION_ENV_KEYS) { + env[key] = undefined; + } + + env.CLAUDE_CODE_GEMINI_BACKEND = runtimeConfig.providerBackends.gemini; + env.CLAUDE_CODE_CODEX_BACKEND = runtimeConfig.providerBackends.codex; + return env; +} + export function applyProviderRuntimeEnv( env: NodeJS.ProcessEnv, providerId: TeamProviderId | undefined @@ -20,9 +41,9 @@ export function applyProviderRuntimeEnv( } if (resolvedProvider === 'codex') { - env.CLAUDE_CODE_USE_OPENAI = '1'; + env.CLAUDE_CODE_ENTRY_PROVIDER = 'codex'; } else if (resolvedProvider === 'gemini') { - env.CLAUDE_CODE_USE_GEMINI = '1'; + env.CLAUDE_CODE_ENTRY_PROVIDER = 'gemini'; } return env; diff --git a/src/main/services/schedule/ScheduledTaskExecutor.ts b/src/main/services/schedule/ScheduledTaskExecutor.ts index 00c04d39..3775f148 100644 --- a/src/main/services/schedule/ScheduledTaskExecutor.ts +++ b/src/main/services/schedule/ScheduledTaskExecutor.ts @@ -13,7 +13,10 @@ import { buildEnrichedEnv } from '@main/utils/cliEnv'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { createLogger } from '@shared/utils/logger'; -import { applyProviderRuntimeEnv } from '../runtime/providerRuntimeEnv'; +import { + applyConfiguredRuntimeBackendsEnv, + applyProviderRuntimeEnv, +} from '../runtime/providerRuntimeEnv'; import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver'; import type { ScheduleLaunchConfig, ScheduleRun } from '@shared/types'; @@ -103,7 +106,11 @@ export class ScheduledTaskExecutor { logger.info(`[${request.runId}] Spawning: ${binaryPath} ${args.join(' ')}`); const env = applyProviderRuntimeEnv( - { ...buildEnrichedEnv(binaryPath), ...shellEnv, CLAUDECODE: undefined }, + applyConfiguredRuntimeBackendsEnv({ + ...buildEnrichedEnv(binaryPath), + ...shellEnv, + CLAUDECODE: undefined, + }), request.config.providerId ); diff --git a/src/main/services/team/ClaudeBinaryResolver.ts b/src/main/services/team/ClaudeBinaryResolver.ts index 13a75782..10c87acb 100644 --- a/src/main/services/team/ClaudeBinaryResolver.ts +++ b/src/main/services/team/ClaudeBinaryResolver.ts @@ -183,9 +183,11 @@ function getRepoLocalCliCandidates(): string[] { const repoRoot = process.cwd(); return [ + // Prefer an already compiled repo-local binary when available. + path.resolve(repoRoot, '..', 'free-code-gemini-research', 'dist', 'cli'), + // Fall back to launcher scripts for normal local development. path.resolve(repoRoot, '..', 'free-code-gemini-research', 'cli'), path.resolve(repoRoot, '..', 'free-code-gemini-research', 'cli-dev'), - path.resolve(repoRoot, '..', 'free-code-gemini-research', 'dist', 'cli'), ]; } diff --git a/src/main/services/team/TeamConfigReader.ts b/src/main/services/team/TeamConfigReader.ts index a908d795..7e440103 100644 --- a/src/main/services/team/TeamConfigReader.ts +++ b/src/main/services/team/TeamConfigReader.ts @@ -24,6 +24,63 @@ const MAX_CONFIG_READ_BYTES = 10 * 1024 * 1024; // 10MB hard limit for full conf const PER_TEAM_READ_TIMEOUT_MS = 5_000; const MAX_SESSION_HISTORY_IN_SUMMARY = 2000; const MAX_PROJECT_PATH_HISTORY_IN_SUMMARY = 200; +const MAX_LAUNCH_STATE_BYTES = 32 * 1024; +const TEAM_LAUNCH_STATE_FILE = 'launch-state.json'; + +interface PartialLaunchStateSummary { + partialLaunchFailure: true; + expectedMemberCount: number; + confirmedMemberCount: number; + missingMembers: string[]; +} + +async function readPartialLaunchStateSummary( + teamDir: string +): Promise { + const launchStatePath = path.join(teamDir, TEAM_LAUNCH_STATE_FILE); + try { + const stat = await fs.promises.stat(launchStatePath); + if (!stat.isFile() || stat.size > MAX_LAUNCH_STATE_BYTES) { + return null; + } + const raw = await readFileUtf8WithTimeout(launchStatePath, PER_TEAM_READ_TIMEOUT_MS); + const parsed = JSON.parse(raw) as { + state?: unknown; + expectedMembers?: unknown; + confirmedMembers?: unknown; + missingMembers?: unknown; + }; + if (parsed.state !== 'partial_launch_failure') { + return null; + } + const expectedMembers = Array.isArray(parsed.expectedMembers) + ? parsed.expectedMembers.filter( + (name): name is string => typeof name === 'string' && name.trim().length > 0 + ) + : []; + const confirmedMembers = Array.isArray(parsed.confirmedMembers) + ? parsed.confirmedMembers.filter( + (name): name is string => typeof name === 'string' && name.trim().length > 0 + ) + : []; + const missingMembers = Array.isArray(parsed.missingMembers) + ? parsed.missingMembers.filter( + (name): name is string => typeof name === 'string' && name.trim().length > 0 + ) + : []; + if (expectedMembers.length === 0 || missingMembers.length === 0) { + return null; + } + return { + partialLaunchFailure: true, + expectedMemberCount: expectedMembers.length, + confirmedMemberCount: confirmedMembers.length, + missingMembers, + }; + } catch { + return null; + } +} async function mapLimit( items: readonly T[], @@ -132,6 +189,7 @@ export class TeamConfigReader { private async readTeamSummary(teamsDir: string, teamName: string): Promise { const configPath = path.join(teamsDir, teamName, 'config.json'); + const teamDir = path.join(teamsDir, teamName); try { let config: TeamConfig | null = null; @@ -204,6 +262,8 @@ export class TeamConfigReader { // Case-insensitive dedup: key is lowercase name, value keeps the original casing const memberMap = new Map(); const removedKeys = new Set(); + const expectedTeammateNames = new Set(); + const confirmedArtifactNames = new Set(); const mergeMember = (m: TeamMember): void => { const name = m.name?.trim(); @@ -235,6 +295,7 @@ export class TeamConfigReader { removedKeys.add(key); continue; } + expectedTeammateNames.add(name); mergeMember(member); } } catch { @@ -245,11 +306,28 @@ export class TeamConfigReader { if (config && Array.isArray(config.members)) { for (const member of config.members) { if (member && typeof member.name === 'string') { + const name = member.name.trim(); + if (name && name !== 'user' && !isLeadMember(member)) { + confirmedArtifactNames.add(name); + } mergeMember(member); } } } + try { + const inboxDir = path.join(teamDir, 'inboxes'); + const inboxEntries = await fs.promises.readdir(inboxDir, { withFileTypes: true }); + for (const entry of inboxEntries) { + if (!entry.isFile() || !entry.name.endsWith('.json')) continue; + const inboxName = entry.name.slice(0, -'.json'.length).trim(); + if (!inboxName || inboxName === 'user' || isLeadMember({ name: inboxName })) continue; + confirmedArtifactNames.add(inboxName); + } + } catch { + // best-effort + } + // Defense: drop CLI auto-suffixed duplicates (alice-2) when base name exists. const allNames = Array.from(memberMap.values()).map((m) => m.name); const keepName = createCliAutoSuffixNameGuard(allNames); @@ -262,6 +340,29 @@ export class TeamConfigReader { } const members = Array.from(memberMap.values()); + const partialLaunchState = + (await readPartialLaunchStateSummary(teamDir)) ?? + (() => { + if ( + !leadSessionId || + expectedTeammateNames.size === 0 || + confirmedArtifactNames.size === 0 + ) { + return null; + } + const missingMembers = Array.from(expectedTeammateNames).filter( + (name) => !confirmedArtifactNames.has(name) + ); + if (missingMembers.length === 0) { + return null; + } + return { + partialLaunchFailure: true as const, + expectedMemberCount: expectedTeammateNames.size, + confirmedMemberCount: confirmedArtifactNames.size, + missingMembers, + }; + })(); const summary: TeamSummary = { teamName, displayName, @@ -276,6 +377,7 @@ export class TeamConfigReader { ...(projectPathHistory ? { projectPathHistory } : {}), ...(sessionHistory ? { sessionHistory } : {}), ...(deletedAt ? { deletedAt } : {}), + ...(partialLaunchState ?? {}), }; return summary; } catch { diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index e3bc315f..815bf8fe 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -21,6 +21,7 @@ import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/ut import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { parseNumericSuffixName } from '@shared/utils/teamMemberName'; +import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary'; import * as agentTeamsControllerModule from 'agent-teams-controller'; import { randomUUID } from 'crypto'; @@ -589,6 +590,68 @@ export class TeamDataService { }); } + // Dedup exact message copies that can appear as both live lead_process rows and + // their persisted inbox/sent-message counterpart. If the messageId is identical, + // keep a single row so the UI does not show the same SendMessage twice + // (for example "LIVE" plus the stored copy). + const duplicateMessageIds = new Set(); + const messageIdCounts = new Map(); + for (const msg of messages) { + const id = typeof msg.messageId === 'string' ? msg.messageId.trim() : ''; + if (!id) continue; + const nextCount = (messageIdCounts.get(id) ?? 0) + 1; + messageIdCounts.set(id, nextCount); + if (nextCount > 1) duplicateMessageIds.add(id); + } + if (duplicateMessageIds.size > 0) { + const choosePreferredMessage = ( + current: InboxMessage, + candidate: InboxMessage + ): InboxMessage => { + const score = (msg: InboxMessage): number => { + let value = 0; + if (msg.source !== 'lead_process') value += 4; + if (msg.read === false) value += 2; + if (msg.relayOfMessageId) value += 1; + if (msg.summary) value += 1; + if (msg.to) value += 1; + return value; + }; + const currentScore = score(current); + const candidateScore = score(candidate); + if (candidateScore !== currentScore) { + return candidateScore > currentScore ? candidate : current; + } + const currentTs = Date.parse(current.timestamp); + const candidateTs = Date.parse(candidate.timestamp); + if ( + Number.isFinite(currentTs) && + Number.isFinite(candidateTs) && + candidateTs !== currentTs + ) { + return candidateTs > currentTs ? candidate : current; + } + return current; + }; + + const dedupedById = new Map(); + const dedupedWithoutId: InboxMessage[] = []; + for (const msg of messages) { + const id = typeof msg.messageId === 'string' ? msg.messageId.trim() : ''; + if (!id) { + dedupedWithoutId.push(msg); + continue; + } + const existing = dedupedById.get(id); + if (!existing) { + dedupedById.set(id, msg); + continue; + } + dedupedById.set(id, choosePreferredMessage(existing, msg)); + } + messages = [...dedupedWithoutId, ...dedupedById.values()]; + } + // Enrich inbox messages without leadSessionId by assigning the nearest neighbor's // session ID (by timestamp). This avoids the old forward-only propagation bug. if (config.leadSessionId || messages.some((m) => m.leadSessionId)) { @@ -1021,10 +1084,7 @@ export class TeamDataService { name, role: member.role?.trim() || undefined, workflow: member.workflow?.trim() || undefined, - providerId: - member.providerId === 'codex' || member.providerId === 'gemini' - ? member.providerId - : undefined, + providerId: normalizeOptionalTeamProviderId(member.providerId), model: member.model?.trim() || undefined, effort: member.effort === 'low' || member.effort === 'medium' || member.effort === 'high' @@ -1985,10 +2045,7 @@ export class TeamDataService { })(), role: member.role?.trim() || undefined, workflow: member.workflow?.trim() || undefined, - providerId: - member.providerId === 'codex' || member.providerId === 'gemini' - ? member.providerId - : undefined, + providerId: normalizeOptionalTeamProviderId(member.providerId), model: member.model?.trim() || undefined, effort: member.effort === 'low' || member.effort === 'medium' || member.effort === 'high' diff --git a/src/main/services/team/TeamMembersMetaStore.ts b/src/main/services/team/TeamMembersMetaStore.ts index 7bc612ef..f03cc458 100644 --- a/src/main/services/team/TeamMembersMetaStore.ts +++ b/src/main/services/team/TeamMembersMetaStore.ts @@ -1,5 +1,6 @@ import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { getTeamsBasePath } from '@main/utils/pathDecoder'; +import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName'; import * as fs from 'fs'; import * as path from 'path'; @@ -24,10 +25,7 @@ function normalizeMember(member: TeamMember): TeamMember | null { name: trimmedName, role: typeof member.role === 'string' ? member.role.trim() || undefined : undefined, workflow: typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined, - providerId: - member.providerId === 'codex' || member.providerId === 'gemini' - ? member.providerId - : undefined, + providerId: normalizeOptionalTeamProviderId(member.providerId), model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined, effort: member.effort === 'low' || member.effort === 'medium' || member.effort === 'high' diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 6cb62dc6..ff177be6 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -3,6 +3,7 @@ import { NotificationManager } from '@main/services/infrastructure/NotificationM import { getAppIconPath } from '@main/utils/appIcon'; import { killProcessTree, spawnCli } from '@main/utils/childProcess'; import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead'; +import { killProcessByPid } from '@main/utils/processKill'; import { encodePath, extractBaseDir, @@ -45,13 +46,14 @@ import { type ParsedTeammateContent, } from '@shared/utils/teammateMessageParser'; import { createCliAutoSuffixNameGuard, parseNumericSuffixName } from '@shared/utils/teamMemberName'; +import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { extractToolPreview, extractToolResultPreview, formatToolSummaryFromCalls, } from '@shared/utils/toolSummary'; import * as agentTeamsControllerModule from 'agent-teams-controller'; -import { type ChildProcess, type spawn } from 'child_process'; +import { execFileSync, type ChildProcess, type spawn } from 'child_process'; import { randomUUID } from 'crypto'; import * as fs from 'fs'; import * as os from 'os'; @@ -69,8 +71,15 @@ import { TeamMembersMetaStore } from './TeamMembersMetaStore'; import { TeamMetaStore } from './TeamMetaStore'; import { TeamSentMessagesStore } from './TeamSentMessagesStore'; import { TeamTaskReader } from './TeamTaskReader'; -import { applyProviderRuntimeEnv, resolveTeamProviderId } from '../runtime/providerRuntimeEnv'; -import { resolveGeminiRuntimeAuth } from '../runtime/geminiRuntimeAuth'; +import { + applyConfiguredRuntimeBackendsEnv, + applyProviderRuntimeEnv, + resolveTeamProviderId, +} from '../runtime/providerRuntimeEnv'; +import { + resolveGeminiRuntimeAuth, + type GeminiRuntimeAuthState, +} from '../runtime/geminiRuntimeAuth'; /** * Kill a team CLI process using SIGKILL (uncatchable). @@ -85,12 +94,20 @@ function killTeamProcess(child: ChildProcess | null | undefined): void { killProcessTree(child, 'SIGKILL'); } +interface PersistedRuntimeMemberLike { + name?: string; + agentId?: string; + tmuxPaneId?: string; + backendType?: string; +} + import type { ActiveToolCall, CrossTeamSendResult, InboxMessage, LeadContextUsage, MemberSpawnStatus, + MemberSpawnLivenessSource, MemberSpawnStatusEntry, TeamChangeEvent, TeamCreateRequest, @@ -124,7 +141,7 @@ const LOG_PROGRESS_THROTTLE_MS = 300; const UI_LOGS_TAIL_LIMIT = 128 * 1024; const PROBE_CACHE_TTL_MS = 36 * 60 * 60 * 1000; const PREFLIGHT_TIMEOUT_MS = 60000; -const PREFLIGHT_CODEX_TIMEOUT_MS = 20000; +const PREFLIGHT_CODEX_TIMEOUT_MS = 45000; const PREFLIGHT_GEMINI_TIMEOUT_MS = 15000; const PREFLIGHT_BINARY_TIMEOUT_MS = 8000; const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000; @@ -136,6 +153,7 @@ const STALL_WARNING_THRESHOLD_MS = 20_000; const TEAM_JSON_READ_TIMEOUT_MS = 5_000; const TEAM_CONFIG_MAX_BYTES = 10 * 1024 * 1024; const TEAM_INBOX_MAX_BYTES = 2 * 1024 * 1024; +const TEAM_LAUNCH_STATE_FILE = 'launch-state.json'; const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([ 'cross_team_send', 'cross_team_list_targets', @@ -213,12 +231,167 @@ function getTeamProviderLabel(providerId: TeamProviderId): string { } } +type 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 `')}\`.`; +} + +function getCanonicalSendMessageToolRule(to: string): string { + return `Use the SendMessage tool with to="${to}".`; +} + +function getConfiguredRuntimeBackend(providerId: TeamProviderId): string | null { + const runtimeConfig = ConfigManager.getInstance().getConfig().runtime.providerBackends; + switch (providerId) { + case 'gemini': + return runtimeConfig.gemini; + case 'codex': + return runtimeConfig.codex; + case 'anthropic': + default: + return null; + } +} + +function mergeProvisioningWarnings( + existing: string[] | undefined, + nextWarning: string | null +): string[] | undefined { + if (!nextWarning) return existing; + const merged = (existing ?? []).filter((warning) => warning !== nextWarning); + merged.push(nextWarning); + return merged.length > 0 ? merged : undefined; +} + +function buildRuntimeLaunchWarning( + request: Pick, + env: NodeJS.ProcessEnv, + options?: { + geminiRuntimeAuth?: GeminiRuntimeAuthState | null; + promptSize?: PromptSizeSummary | null; + expectedMembersCount?: number; + } +): string { + const providerId = resolveTeamProviderId(request.providerId); + const providerLabel = getTeamProviderLabel(providerId); + const modelLabel = request.model?.trim() || 'default'; + const effortLabel = request.effort ?? 'default'; + const backend = getConfiguredRuntimeBackend(providerId); + const flags: string[] = []; + if (env.CLAUDE_CODE_USE_GEMINI === '1') flags.push('USE_GEMINI'); + if (env.CLAUDE_CODE_USE_OPENAI === '1') flags.push('USE_OPENAI'); + if (env.CLAUDE_CODE_ENTRY_PROVIDER) { + flags.push(`ENTRY_PROVIDER=${env.CLAUDE_CODE_ENTRY_PROVIDER}`); + } + if (env.CLAUDE_CODE_GEMINI_BACKEND) { + flags.push(`GEMINI_BACKEND=${env.CLAUDE_CODE_GEMINI_BACKEND}`); + } + if (env.CLAUDE_CODE_CODEX_BACKEND) { + flags.push(`CODEX_BACKEND=${env.CLAUDE_CODE_CODEX_BACKEND}`); + } + const backendPart = backend ? `, backend ${backend}` : ''; + const flagsPart = flags.length > 0 ? `, env ${flags.join(', ')}` : ''; + const geminiAuth = options?.geminiRuntimeAuth; + const authPart = + providerId === 'gemini' && geminiAuth + ? `, auth ${geminiAuth.authMethod ?? 'none'}/${geminiAuth.resolvedBackend}` + : ''; + const promptSize = options?.promptSize; + const promptPart = promptSize + ? `, prompt ${promptSize.chars.toLocaleString('en-US')} chars/${promptSize.lines} lines` + : ''; + const membersPart = + typeof options?.expectedMembersCount === 'number' + ? `, members ${options.expectedMembersCount}` + : ''; + return `Launch runtime: ${providerLabel} · ${modelLabel} · ${effortLabel}${backendPart}${authPart}${promptPart}${membersPart}${flagsPart}`; +} + +function logRuntimeLaunchSnapshot( + teamName: string, + claudePath: string, + args: string[], + request: Pick, + env: NodeJS.ProcessEnv, + options?: { + geminiRuntimeAuth?: GeminiRuntimeAuthState | null; + promptSize?: PromptSizeSummary | null; + expectedMembersCount?: number; + } +): void { + const providerId = resolveTeamProviderId(request.providerId); + const snapshot = { + providerId, + model: request.model ?? null, + effort: request.effort ?? null, + configuredBackend: getConfiguredRuntimeBackend(providerId), + promptSize: options?.promptSize ?? null, + expectedMembersCount: options?.expectedMembersCount ?? null, + geminiRuntimeAuth: + providerId === 'gemini' + ? { + authenticated: options?.geminiRuntimeAuth?.authenticated ?? null, + authMethod: options?.geminiRuntimeAuth?.authMethod ?? null, + resolvedBackend: options?.geminiRuntimeAuth?.resolvedBackend ?? null, + projectId: options?.geminiRuntimeAuth?.projectId ?? null, + statusMessage: options?.geminiRuntimeAuth?.statusMessage ?? null, + } + : null, + env: { + CLAUDE_CODE_USE_GEMINI: env.CLAUDE_CODE_USE_GEMINI ?? null, + CLAUDE_CODE_USE_OPENAI: env.CLAUDE_CODE_USE_OPENAI ?? null, + CLAUDE_CODE_ENTRY_PROVIDER: env.CLAUDE_CODE_ENTRY_PROVIDER ?? null, + CLAUDE_CODE_GEMINI_BACKEND: env.CLAUDE_CODE_GEMINI_BACKEND ?? null, + CLAUDE_CODE_CODEX_BACKEND: env.CLAUDE_CODE_CODEX_BACKEND ?? null, + CLAUDE_CONFIG_DIR: env.CLAUDE_CONFIG_DIR ?? null, + CLAUDE_TEAM_CONTROL_URL: env.CLAUDE_TEAM_CONTROL_URL ?? null, + }, + args, + claudePath, + }; + logger.info(`[${teamName}] Launch runtime snapshot ${JSON.stringify(snapshot)}`); +} + +function getPromptSizeSummary(prompt: string): PromptSizeSummary { + return { + chars: prompt.length, + lines: prompt.length === 0 ? 0 : prompt.split(/\r?\n/g).length, + }; +} + type TeamsBaseLocation = 'configured' | 'default'; type ValidConfigProbeResult = | { ok: true; location: TeamsBaseLocation; configPath: string } | { ok: false }; +interface PartialLaunchStateFile { + version: 1; + state: 'partial_launch_failure'; + updatedAt: string; + leadSessionId?: string; + expectedMembers: string[]; + confirmedMembers: string[]; + missingMembers: string[]; +} + function getTeamsBasePathsToProbe(): { location: TeamsBaseLocation; basePath: string }[] { const configured = getTeamsBasePath(); const defaultBase = path.join(getAutoDetectedClaudeBasePath(), 'teams'); @@ -262,6 +435,10 @@ function looksLikeClaudeStdoutJsonFragment(text: string): boolean { ); } +function getTeamLaunchStatePath(teamName: string): string { + return path.join(getTeamsBasePath(), teamName, TEAM_LAUNCH_STATE_FILE); +} + interface ProvisioningRun { runId: string; teamName: string; @@ -365,6 +542,8 @@ interface ProvisioningRun { pendingInboxRelayCandidates: PendingInboxRelayCandidate[]; /** Accumulates assistant text during provisioning phase for live UI preview. */ provisioningOutputParts: string[]; + /** Stable assistant message ids -> provisioningOutputParts index for in-place updates. */ + provisioningOutputIndexByMessageId: Map; /** Session ID detected from stream-json output (result.session_id or message.session_id). */ detectedSessionId: string | null; /** Lead process activity: 'active' during turn processing, 'idle' waiting for input, 'offline' after exit. */ @@ -405,8 +584,15 @@ interface ProvisioningRun { /** Per-member spawn lifecycle statuses tracked from stream-json output. */ memberSpawnStatuses: Map< string, - { status: MemberSpawnStatus; error?: string; updatedAt: string } + { + status: MemberSpawnStatus; + error?: string; + livenessSource?: MemberSpawnLivenessSource; + updatedAt: string; + } >; + /** Agent tool_use_id -> teammate name for persistent teammate spawns. */ + memberSpawnToolUseIds: Map; } type LeadActivityState = 'active' | 'idle' | 'offline'; @@ -421,6 +607,12 @@ type ProvisioningAuthSource = interface ProvisioningEnvResolution { env: NodeJS.ProcessEnv; authSource: ProvisioningAuthSource; + geminiRuntimeAuth: GeminiRuntimeAuthState | null; +} + +interface PromptSizeSummary { + chars: number; + lines: number; } function nowIso(): string { @@ -531,6 +723,56 @@ function buildEffectiveTeamMemberSpecs( return members.map((member) => buildEffectiveTeamMemberSpec(member, defaults)); } +function shouldSkipResumeForProviderRuntimeChange( + request: Pick, + config: Record +): { skip: boolean; reason?: string } { + const providerId = normalizeTeamMemberProviderId(request.providerId); + if (providerId !== 'gemini' && providerId !== 'codex') { + return { skip: false }; + } + + const members = Array.isArray(config.members) + ? (config.members as Record[]) + : []; + const lead = + members.find((member) => isLeadMember(member)) ?? + members.find((member) => { + const name = typeof member?.name === 'string' ? member.name.trim().toLowerCase() : ''; + return name === 'team-lead'; + }); + if (!lead) { + return { skip: false }; + } + + const currentLeadProviderId = + normalizeTeamMemberProviderId( + typeof lead.providerId === 'string' + ? lead.providerId + : typeof lead.provider === 'string' + ? lead.provider + : providerId + ) ?? providerId; + const requestedModel = request.model?.trim() || ''; + const currentLeadModel = typeof lead.model === 'string' ? lead.model.trim() : ''; + + if (currentLeadProviderId !== providerId) { + return { + skip: true, + reason: `provider changed (${currentLeadProviderId} -> ${providerId})`, + }; + } + + if (requestedModel && currentLeadModel && requestedModel !== currentLeadModel) { + return { + skip: true, + reason: `model changed (${currentLeadModel} -> ${requestedModel})`, + }; + } + + return { skip: false }; +} + function buildMembersPrompt(members: TeamCreateRequest['members']): string { return members .map((member) => { @@ -571,6 +813,38 @@ function buildTeammateAgentBlockReminder(): string { ].join('\n'); } +function extractBootstrapFailureReason(text: string): string | null { + const trimmed = text.trim(); + if (!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('no such tool available: mcp__agent_teams__member_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('please check the provided tool list'); + if (!looksLikeBootstrapFailure) return null; + return trimmed.slice(0, 280); +} + +function normalizeMemberDiagnosticText(memberName: string, text: string): string { + return `${memberName}: ${text.trim()}`; +} + function buildMemberSpawnPrompt( member: TeamCreateRequest['members'][number], displayName: string, @@ -597,12 +871,19 @@ function buildMemberSpawnPrompt( ${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 member_briefing fails, send a short message to your team lead "${leadName}" explaining that bootstrap failed, then wait. +If member_briefing fails, send 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 wait. Do NOT send only "bootstrap failed". 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()} +Correct example: +${buildCanonicalSendMessageExample({ to: leadName, summary: 'short update', message: 'your message' })} After member_briefing succeeds: -- Introduce yourself briefly (name and role) and confirm you are ready. -- Then wait for task assignments. +- 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. +- 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 compact queue view. 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. @@ -658,13 +939,18 @@ ${providerArgLine}${modelArgLine}${effortArgLine} - prompt: } 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 member_briefing fails, send a short message to your team lead "${leadName}" explaining that bootstrap failed, then wait. + If member_briefing fails, send 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 wait. Do NOT send only "bootstrap failed". 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"). ${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. + - 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 compact queue view. - 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. @@ -780,7 +1066,7 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string `- Request changes: review_request_changes { teamName: "${teamName}", taskId: "", comment: "" }`, `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.`, ``, - `Process operations — use MCP tools directly:`, + `Background service operations — use MCP tools directly (dev servers, watchers, databases, etc.; NOT teammate-agent liveness):`, protocols.buildProcessProtocolText(teamName), ``, `Attachment storage modes (IMPORTANT):`, @@ -880,7 +1166,7 @@ Constraints: - 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 recipient "*" (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. +- 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). @@ -895,9 +1181,10 @@ ${teamCtlOps} ${actionModeProtocol} Communication protocol (CRITICAL — you are running headless, no one sees your text output): -- When you receive a from a teammate, ALWAYS reply using the SendMessage tool with the sender's name as recipient. +- When you receive a from a teammate, reply using the SendMessage tool ONLY when a reply changes what happens next: decision, clarification, unblock, assignment, review, correction, or explicit acknowledgement the teammate asked for. - 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(type: "message", recipient: "alice", content: "your reply"). +- Example: if you receive ..., respond with SendMessage(${buildCanonicalSendMessageExample({ to: 'alice', summary: 'short reply', message: 'your reply' })}). +- 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. - 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}". @@ -1123,6 +1410,9 @@ ${step2Block} ${step3Block} ${isSolo ? '3' : '4'}) After all steps, output a short summary. +CRITICAL: If any Agent teammate spawn returns an error, that teammate is NOT online. Do NOT claim they were spawned successfully. In your final summary, explicitly list which teammate names failed to start. +CRITICAL: Do NOT call a teammate "online", "ready", "confirmed alive", or "without launch errors" solely because Agent returned "Spawned successfully". Use that wording only after the teammate has actually completed bootstrap or sent a real post-bootstrap confirmation message. If a teammate runtime is alive but bootstrap is still pending, say exactly that. +CRITICAL: If a teammate reports that member_briefing is unavailable, do NOT tell them to skip bootstrap and do NOT improvise a workaround. Treat that as a real bootstrap error, ask them to send the exact error text, and keep that teammate in bootstrap-pending or failed state until the error is resolved. `; } @@ -1225,6 +1515,7 @@ ${step2And3Block} 4) If something about team state looks unclear or inconsistent, you MAY inspect ~/.claude/teams/${request.teamName}/config.json after teammates are restored (or immediately in solo mode). Treat it as a diagnostic cross-check, not as the first reconnect action. 5) After all steps, output a short summary of reconnected members and what happens next. +CRITICAL: If any Agent teammate spawn returns an error, that teammate is NOT online. Do NOT claim they were restored/spawned successfully. In your final summary, explicitly list which teammate names failed to start. `; } @@ -1244,7 +1535,7 @@ function updateProgress( message: string, extras?: Pick< TeamProvisioningProgress, - 'pid' | 'error' | 'warnings' | 'cliLogsTail' | 'configReady' + 'pid' | 'error' | 'warnings' | 'cliLogsTail' | 'configReady' | 'messageSeverity' > ): TeamProvisioningProgress { const assistantOutput = @@ -1262,6 +1553,7 @@ function updateProgress( cliLogsTail: extras?.cliLogsTail ?? run.progress.cliLogsTail, assistantOutput, configReady: extras?.configReady ?? run.progress.configReady, + messageSeverity: extras?.messageSeverity, }; return run.progress; } @@ -2022,8 +2314,18 @@ export class TeamProvisioningService { })(); } - // Same-team reconciliation: record fingerprints for native delivery dedup + // Same-team teammate messages are the canonical heartbeat signal: they prove the + // runtime produced a real post-spawn message, unlike writes to inboxes/.json + // which may simply be user/lead messages addressed TO the teammate. const sameTeamBlocks = blocks.filter((block) => !parseCrossTeamPrefix(block.content)); + for (const block of sameTeamBlocks) { + this.setMemberSpawnStatus(run, block.teammateId, 'online', undefined, 'heartbeat'); + } + for (const block of sameTeamBlocks) { + const bootstrapFailureReason = extractBootstrapFailureReason(block.content); + if (!bootstrapFailureReason) continue; + this.setMemberSpawnStatus(run, block.teammateId, 'error', bootstrapFailureReason); + } if (sameTeamBlocks.length > 0) { this.rememberSameTeamNativeFingerprints(run.teamName, sameTeamBlocks); const leadName = this.getRunLeadName(run); @@ -2372,6 +2674,60 @@ export class TeamProvisioningService { resultPreview: extractToolResultPreview(resultContent), isError, }); + + const spawnedMemberName = run.memberSpawnToolUseIds.get(toolUseId); + if (spawnedMemberName) { + run.memberSpawnToolUseIds.delete(toolUseId); + if (isError) { + const resultPreview = extractToolResultPreview(resultContent); + this.handleMemberSpawnFailure(run, spawnedMemberName, resultPreview); + } else { + // Agent tool_result only confirms that the runtime accepted the spawn. + // The teammate becomes truly "online" only after the first inbox heartbeat. + this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting'); + } + } + } + + private handleMemberSpawnFailure( + run: ProvisioningRun, + memberName: string, + resultPreview?: string + ): void { + const reason = + (typeof resultPreview === 'string' && resultPreview.trim().length > 0 + ? resultPreview.trim() + : 'Teammate spawn failed immediately after launch.') || 'Teammate spawn failed.'; + const message = `Teammate "${memberName}" failed to start: ${reason}`; + + this.setMemberSpawnStatus(run, memberName, 'error', message); + + const lastIndex = run.provisioningOutputParts.length - 1; + if (lastIndex < 0 || run.provisioningOutputParts[lastIndex]?.trim() !== message) { + run.provisioningOutputParts.push(message); + } + + if ( + !run.provisioningComplete && + (run.progress.state === 'assembling' || run.progress.state === 'configuring') + ) { + const progress = updateProgress(run, 'assembling', `Failed to start member ${memberName}`); + run.onProgress(progress); + } + } + + private appendMemberBootstrapDiagnostic( + run: ProvisioningRun, + memberName: string, + text: string + ): void { + const line = normalizeMemberDiagnosticText(memberName, text); + const lastIndex = run.provisioningOutputParts.length - 1; + if (lastIndex >= 0 && run.provisioningOutputParts[lastIndex]?.trim() === line) { + return; + } + run.provisioningOutputParts.push(line); + logger.info(`[${run.teamName}] [bootstrap] ${line}`); } private resetRuntimeToolActivity(run: ProvisioningRun, memberName?: string): void { @@ -2402,15 +2758,50 @@ export class TeamProvisioningService { run: ProvisioningRun, memberName: string, status: MemberSpawnStatus, - error?: string + error?: string, + livenessSource?: MemberSpawnLivenessSource ): void { const prev = run.memberSpawnStatuses.get(memberName); - if (prev?.status === status) return; + if ( + prev?.status === status && + prev?.error === error && + prev?.livenessSource === livenessSource + ) { + return; + } run.memberSpawnStatuses.set(memberName, { status, error, + livenessSource, updatedAt: nowIso(), }); + if (status === 'spawning') { + this.appendMemberBootstrapDiagnostic(run, memberName, 'Agent tool invoked'); + } else if (status === 'waiting') { + this.appendMemberBootstrapDiagnostic( + run, + memberName, + 'spawn accepted, waiting for bootstrap' + ); + } else if (status === 'online' && livenessSource === 'heartbeat') { + this.appendMemberBootstrapDiagnostic( + run, + memberName, + 'bootstrap confirmed via first heartbeat' + ); + } else if (status === 'online' && livenessSource === 'process') { + this.appendMemberBootstrapDiagnostic( + run, + memberName, + 'runtime process is alive, bootstrap not yet confirmed' + ); + } else if (status === 'error') { + this.appendMemberBootstrapDiagnostic( + run, + memberName, + error?.trim().length ? error.trim() : 'bootstrap failed' + ); + } if (!this.isCurrentTrackedRun(run)) return; this.teamChangeEmitter?.({ type: 'member-spawn', @@ -2434,7 +2825,12 @@ export class TeamProvisioningService { if (!run) return { statuses: {}, runId: null }; const result: Record = {}; for (const [name, entry] of run.memberSpawnStatuses) { - result[name] = { status: entry.status, error: entry.error, updatedAt: entry.updatedAt }; + result[name] = { + status: entry.status, + error: entry.error, + livenessSource: entry.livenessSource, + updatedAt: entry.updatedAt, + }; } return { statuses: result, runId }; } @@ -3245,7 +3641,9 @@ export class TeamProvisioningService { if (msgType === 'assistant' || msgType === 'result') { run.lastStdoutReceivedAt = Date.now(); if (run.stallWarningIndex != null) { - run.provisioningOutputParts.splice(run.stallWarningIndex, 1); + const removedIndex = run.stallWarningIndex; + run.provisioningOutputParts.splice(removedIndex, 1); + this.shiftProvisioningOutputIndexesAfterRemoval(run, removedIndex); run.stallWarningIndex = null; if (run.preStallMessage != null) { run.progress.message = run.preStallMessage; @@ -3401,6 +3799,7 @@ export class TeamProvisioningService { silentUserDmForwardClearHandle: null, pendingInboxRelayCandidates: [], provisioningOutputParts: [], + provisioningOutputIndexByMessageId: new Map(), detectedSessionId: null, leadActivityState: 'active', leadContextUsage: null, @@ -3413,8 +3812,9 @@ export class TeamProvisioningService { postCompactReminderInFlight: false, suppressPostCompactReminderOutput: false, memberSpawnStatuses: new Map( - request.members.map((m) => [m.name, { status: 'waiting' as const, updatedAt: nowIso() }]) + request.members.map((m) => [m.name, { status: 'offline' as const, updatedAt: nowIso() }]) ), + memberSpawnToolUseIds: new Map(), progress: { runId, teamName: request.teamName, @@ -3429,10 +3829,14 @@ export class TeamProvisioningService { this.runs.set(runId, run); this.provisioningRunByTeam.set(request.teamName, runId); run.onProgress(run.progress); + await this.clearPartialLaunchState(request.teamName); const prompt = buildProvisioningPrompt(request, effectiveMemberSpecs); + const promptSize = getPromptSizeSummary(prompt); let child: ReturnType; - const { env: shellEnv } = await this.buildProvisioningEnv(request.providerId); + const { env: shellEnv, geminiRuntimeAuth } = await this.buildProvisioningEnv( + request.providerId + ); let mcpConfigPath: string; try { mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd); @@ -3465,6 +3869,16 @@ export class TeamProvisioningService { ...(request.worktree ? ['--worktree', request.worktree] : []), ...parseCliArgs(request.extraCliArgs), ]; + const runtimeWarning = buildRuntimeLaunchWarning(request, shellEnv, { + geminiRuntimeAuth, + promptSize, + expectedMembersCount: effectiveMemberSpecs.length, + }); + logRuntimeLaunchSnapshot(request.teamName, claudePath, spawnArgs, request, shellEnv, { + geminiRuntimeAuth, + promptSize, + expectedMembersCount: effectiveMemberSpecs.length, + }); try { // Pre-save our meta files before spawn — CLI doesn't touch these. // If provisioning fails before TeamCreate, user can retry without re-entering config. @@ -3525,6 +3939,7 @@ export class TeamProvisioningService { updateProgress(run, 'spawning', 'Starting Claude CLI process', { pid: child.pid ?? undefined, + warnings: mergeProvisioningWarnings(run.progress.warnings, runtimeWarning), }); run.onProgress(run.progress); run.child = child; @@ -3697,7 +4112,12 @@ export class TeamProvisioningService { } else { try { const configParsed = JSON.parse(configRaw) as Record; - if ( + const resumeGuard = shouldSkipResumeForProviderRuntimeChange(request, configParsed); + if (resumeGuard.skip) { + logger.info( + `[${request.teamName}] Skipping session resume — ${resumeGuard.reason ?? 'runtime changed'}` + ); + } else if ( typeof configParsed.leadSessionId === 'string' && configParsed.leadSessionId.trim().length > 0 ) { @@ -3857,6 +4277,7 @@ export class TeamProvisioningService { silentUserDmForwardClearHandle: null, pendingInboxRelayCandidates: [], provisioningOutputParts: [], + provisioningOutputIndexByMessageId: new Map(), detectedSessionId: null, leadActivityState: 'active', leadContextUsage: null, @@ -3869,8 +4290,9 @@ export class TeamProvisioningService { postCompactReminderInFlight: false, suppressPostCompactReminderOutput: false, memberSpawnStatuses: new Map( - expectedMembers.map((name) => [name, { status: 'waiting' as const, updatedAt: nowIso() }]) + expectedMembers.map((name) => [name, { status: 'offline' as const, updatedAt: nowIso() }]) ), + memberSpawnToolUseIds: new Map(), progress: { runId, teamName: request.teamName, @@ -3909,8 +4331,11 @@ export class TeamProvisioningService { existingTasks, Boolean(previousSessionId) ); + const promptSize = getPromptSizeSummary(prompt); let child: ReturnType; - const { env: shellEnv } = await this.buildProvisioningEnv(request.providerId); + const { env: shellEnv, geminiRuntimeAuth } = await this.buildProvisioningEnv( + request.providerId + ); let mcpConfigPath: string; try { mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd); @@ -3956,6 +4381,16 @@ export class TeamProvisioningService { launchArgs.push('--worktree', request.worktree); } launchArgs.push(...parseCliArgs(request.extraCliArgs)); + const runtimeWarning = buildRuntimeLaunchWarning(request, shellEnv, { + geminiRuntimeAuth, + promptSize, + expectedMembersCount: effectiveMemberSpecs.length, + }); + logRuntimeLaunchSnapshot(request.teamName, claudePath, launchArgs, request, shellEnv, { + geminiRuntimeAuth, + promptSize, + expectedMembersCount: effectiveMemberSpecs.length, + }); // --resume is added above when a valid previous session JSONL exists. // Without it, CLI creates a fresh session ID automatically. @@ -3982,6 +4417,7 @@ export class TeamProvisioningService { const resumeHint = previousSessionId ? ' (resuming previous session)' : ''; updateProgress(run, 'spawning', `Starting Claude CLI process for team launch${resumeHint}`, { pid: child.pid ?? undefined, + warnings: mergeProvisioningWarnings(run.progress.warnings, runtimeWarning), }); run.onProgress(run.progress); run.child = child; @@ -4217,9 +4653,10 @@ export class TeamProvisioningService { const internal = wrapInAgentBlock( [ `UI relay request — forward a direct message to teammate "${teammateName}".`, - `MUST: use the SendMessage tool with recipient="${teammateName}".`, - `MUST: ask the teammate to reply back to recipient "user" (short answer).`, - `CRITICAL: Do NOT send any message to recipient "user" for this turn.`, + `MUST: ${getCanonicalSendMessageToolRule(teammateName)}`, + `MUST: if they reply to the human, the destination must be to="user" (short answer).`, + `CRITICAL: Do NOT send any message to="user" for this turn.`, + getCanonicalSendMessageFieldRule(), ].join('\n') ); const message = [ @@ -4296,8 +4733,9 @@ export class TeamProvisioningService { `Inbox relay (internal) — forward to "${memberName}".`, wrapInAgentBlock( [ - `CRITICAL: Do NOT send any message to recipient "user" for this relay turn. The ONLY valid recipient is "${memberName}".`, - `Use the SendMessage tool with recipient="${memberName}" to forward each inbox item below.`, + `CRITICAL: Do NOT send any message to="user" for this relay turn. The ONLY valid destination is to="${memberName}".`, + getCanonicalSendMessageToolRule(memberName), + getCanonicalSendMessageFieldRule(), `Preserve task IDs and critical instructions. Do NOT add extra narration outside the SendMessage calls.`, `If an inbox item is marked Source: system_notification, forward that notification exactly once without paraphrasing.`, ].join('\n') @@ -4982,6 +5420,10 @@ export class TeamProvisioningService { // Only track spawns for this team if (teamName !== run.teamName) continue; this.setMemberSpawnStatus(run, memberName, 'spawning'); + const toolUseId = typeof part.id === 'string' ? part.id.trim() : ''; + if (toolUseId) { + run.memberSpawnToolUseIds.set(toolUseId, memberName); + } // Advance stepper to "Members joining" when first member spawn is detected if ( @@ -4994,22 +5436,6 @@ export class TeamProvisioningService { } } - /** - * Mark a member as online when their first inbox message arrives. - * Called from the inbox change handler. - */ - markMemberOnlineFromInbox(teamName: string, memberName: string): void { - const runId = this.getTrackedRunId(teamName); - if (!runId) return; - const run = this.runs.get(runId); - if (!run) return; - const entry = run.memberSpawnStatuses.get(memberName); - // Only transition spawning → online (not offline → online, to avoid false positives) - if (entry?.status === 'spawning') { - this.setMemberSpawnStatus(run, memberName, 'online'); - } - } - /** * Post-provisioning audit: read config.json members and flag any expectedMember * that was NOT registered by Claude Code as a team member. @@ -5049,17 +5475,29 @@ export class TeamProvisioningService { // Flag any expected member not found in config.json (excluding the lead) for (const expected of run.expectedMembers) { - // Check exact name or CLI-suffixed variant (e.g., "alice-2" for "alice") - if (registeredNames.has(expected)) continue; - const hasSuffixed = [...registeredNames].some((name) => { + const current = run.memberSpawnStatuses.get(expected); + if (current?.status === 'error' || current?.status === 'online') continue; + + const matchedRuntimeNames = [...registeredNames].filter((name) => { + if (name === expected) return true; const parsed = parseNumericSuffixName(name); return parsed !== null && parsed.suffix >= 2 && parsed.base === expected; }); - if (hasSuffixed) continue; - // Skip if already in a terminal or positive status - const current = run.memberSpawnStatuses.get(expected); - if (current?.status === 'error' || current?.status === 'online') continue; + // A teammate may intentionally stay silent after bootstrap. If Claude Code + // registered the runtime and the OS process is still alive, treat it as + // process-confirmed running. Keep this distinct from heartbeat-confirmed online. + if ( + matchedRuntimeNames.length > 0 && + matchedRuntimeNames.some((runtimeName) => + this.hasLiveTeamAgentProcess(run.teamName, runtimeName) + ) + ) { + this.setMemberSpawnStatus(run, expected, 'online', undefined, 'process'); + continue; + } + + if (matchedRuntimeNames.length > 0) continue; logger.warn( `[${run.teamName}] Member "${expected}" not found in config.json members after provisioning` @@ -5073,6 +5511,138 @@ export class TeamProvisioningService { } } + private hasLiveTeamAgentProcess(teamName: string, memberName: string): boolean { + if (process.platform === 'win32') { + return false; + } + + let output = ''; + try { + output = execFileSync('ps', ['-ax', '-o', 'command='], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + } catch { + return false; + } + + const teamMarker = `--team-name ${teamName}`; + const memberMarker = `--agent-id ${memberName}@${teamName}`; + + return output.split('\n').some((line) => { + const trimmed = line.trim(); + return trimmed.includes(teamMarker) && trimmed.includes(memberMarker); + }); + } + + private async clearPartialLaunchState(teamName: string): Promise { + try { + await fs.promises.rm(getTeamLaunchStatePath(teamName), { force: true }); + } catch { + // best-effort + } + } + + private getFailedSpawnMembers( + run: ProvisioningRun + ): { name: string; error?: string; updatedAt: string }[] { + return [...run.memberSpawnStatuses.entries()] + .filter(([, entry]) => entry.status === 'error') + .map(([name, entry]) => ({ name, error: entry.error, updatedAt: entry.updatedAt })) + .sort((a, b) => a.name.localeCompare(b.name)); + } + + private async persistPartialLaunchState(run: ProvisioningRun): Promise { + if (!run.isLaunch || !run.expectedMembers || run.expectedMembers.length === 0) { + if (run.isLaunch) { + await this.clearPartialLaunchState(run.teamName); + } + return; + } + + const expectedMembers = Array.from( + new Set( + run.expectedMembers + .map((name) => name.trim()) + .filter((name) => name.length > 0 && name !== 'user' && !isLeadMember({ name })) + ) + ); + if (expectedMembers.length === 0) { + await this.clearPartialLaunchState(run.teamName); + return; + } + + const configPath = path.join(getTeamsBasePath(), run.teamName, 'config.json'); + let registeredMembers = new Set(); + let leadSessionId: string | undefined; + try { + const raw = await tryReadRegularFileUtf8(configPath, { + timeoutMs: TEAM_JSON_READ_TIMEOUT_MS, + maxBytes: TEAM_CONFIG_MAX_BYTES, + }); + if (!raw) { + await this.clearPartialLaunchState(run.teamName); + return; + } + const config = JSON.parse(raw) as { + leadSessionId?: unknown; + members?: { name?: unknown }[]; + }; + leadSessionId = + typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0 + ? config.leadSessionId.trim() + : undefined; + registeredMembers = new Set( + (config.members ?? []) + .map((member) => (typeof member?.name === 'string' ? member.name.trim() : '')) + .filter((name) => name.length > 0 && !isLeadMember({ name })) + ); + } catch { + await this.clearPartialLaunchState(run.teamName); + return; + } + + const inboxNames = await this.inboxReader + .listInboxNames(run.teamName) + .catch(() => [] as string[]); + const confirmedMembers = Array.from( + new Set( + [...registeredMembers, ...inboxNames] + .map((name) => name.trim()) + .filter((name) => expectedMembers.includes(name)) + ) + ); + const missingMembers = expectedMembers.filter((name) => !confirmedMembers.includes(name)); + + if (missingMembers.length === 0 || confirmedMembers.length === 0) { + await this.clearPartialLaunchState(run.teamName); + return; + } + + const payload: PartialLaunchStateFile = { + version: 1, + state: 'partial_launch_failure', + updatedAt: new Date().toISOString(), + ...(leadSessionId ? { leadSessionId } : {}), + expectedMembers, + confirmedMembers, + missingMembers, + }; + + try { + await atomicWriteAsync( + getTeamLaunchStatePath(run.teamName), + JSON.stringify(payload, null, 2) + ); + } catch (error) { + logger.warn( + `[${run.teamName}] Failed to persist partial launch state: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + private captureSendMessages(run: ProvisioningRun, content: Record[]): void { for (const part of content) { if (part.type !== 'tool_use' || typeof part.name !== 'string') continue; @@ -5349,6 +5919,47 @@ export class TeamProvisioningService { return null; } + private appendProvisioningAssistantText( + run: ProvisioningRun, + msg: Record, + text: string + ): void { + const normalized = text.trim(); + if (normalized.length === 0) { + return; + } + + const stableMessageId = this.getStableLeadThoughtMessageId(msg); + if (stableMessageId) { + const existingIndex = run.provisioningOutputIndexByMessageId.get(stableMessageId); + if (existingIndex != null) { + run.provisioningOutputParts[existingIndex] = text; + return; + } + } + + const lastIndex = run.provisioningOutputParts.length - 1; + if (lastIndex >= 0 && run.provisioningOutputParts[lastIndex]?.trim() === normalized) { + return; + } + + const newIndex = run.provisioningOutputParts.push(text) - 1; + if (stableMessageId) { + run.provisioningOutputIndexByMessageId.set(stableMessageId, newIndex); + } + } + + private shiftProvisioningOutputIndexesAfterRemoval( + run: ProvisioningRun, + removedIndex: number + ): void { + for (const [messageId, index] of run.provisioningOutputIndexByMessageId.entries()) { + if (index > removedIndex) { + run.provisioningOutputIndexByMessageId.set(messageId, index - 1); + } + } + } + private pushLiveLeadTextMessage( run: ProvisioningRun, cleanText: string, @@ -5392,6 +6003,8 @@ export class TeamProvisioningService { * Always uses SIGKILL via killTeamProcess() to prevent CLI cleanup. */ stopTeam(teamName: string): void { + this.stopPersistentTeamMembers(teamName); + const runId = this.getTrackedRunId(teamName); if (!runId) { return; @@ -5414,6 +6027,112 @@ export class TeamProvisioningService { logger.info(`[${teamName}] Process stopped (SIGKILL)`); } + private stopPersistentTeamMembers(teamName: string): void { + const members = this.readPersistedRuntimeMembers(teamName); + if (members.length > 0) { + this.killPersistedPaneMembers(teamName, members); + } + this.killOrphanedTeamAgentProcesses(teamName); + } + + private readPersistedRuntimeMembers(teamName: string): PersistedRuntimeMemberLike[] { + const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); + try { + const raw = fs.readFileSync(configPath, 'utf8'); + const parsed = JSON.parse(raw) as { members?: unknown }; + if (!Array.isArray(parsed.members)) { + return []; + } + return parsed.members.filter((member): member is PersistedRuntimeMemberLike => { + return !!member && typeof member === 'object'; + }); + } catch { + return []; + } + } + + private listPersistedTeamNames(): string[] { + try { + return fs + .readdirSync(getTeamsBasePath(), { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name.trim()) + .filter((name) => name.length > 0); + } catch { + return []; + } + } + + private killPersistedPaneMembers(teamName: string, members: PersistedRuntimeMemberLike[]): void { + for (const member of members) { + const name = typeof member.name === 'string' ? member.name.trim() : ''; + const paneId = typeof member.tmuxPaneId === 'string' ? member.tmuxPaneId.trim() : ''; + const backendType = + typeof member.backendType === 'string' ? member.backendType.trim().toLowerCase() : ''; + if (!name || name === 'team-lead' || !paneId || backendType !== 'tmux') { + continue; + } + try { + execFileSync('tmux', ['kill-pane', '-t', paneId], { stdio: 'ignore' }); + logger.info(`[${teamName}] Killed teammate pane ${name} (${paneId}) during stop`); + } catch (error) { + logger.debug( + `[${teamName}] Failed to kill teammate pane ${name} (${paneId}) during stop: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + } + + private killOrphanedTeamAgentProcesses(teamName: string): void { + if (process.platform === 'win32') { + return; + } + + let output = ''; + try { + output = execFileSync('ps', ['-ax', '-o', 'pid=,command='], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + } catch { + return; + } + + const currentRunPid = this.getTrackedRunId(teamName) + ? this.runs.get(this.getTrackedRunId(teamName)!)?.child?.pid + : undefined; + const marker = `--team-name ${teamName}`; + const pids = new Set(); + + for (const line of output.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || !trimmed.includes(marker) || !trimmed.includes('--agent-id')) { + continue; + } + const match = trimmed.match(/^(\d+)\s+(.*)$/); + if (!match) continue; + const pid = Number.parseInt(match[1], 10); + if (!Number.isFinite(pid) || pid <= 0) continue; + if (currentRunPid && pid === currentRunPid) continue; + pids.add(pid); + } + + for (const pid of pids) { + try { + killProcessByPid(pid); + logger.info(`[${teamName}] Killed orphaned teammate process pid=${pid} during stop`); + } catch (error) { + logger.debug( + `[${teamName}] Failed to kill orphaned teammate process pid=${pid}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + } + /** * Stop all running team processes. Called during app shutdown. * Uses killTeamProcess() (SIGKILL) to guarantee instant death @@ -5421,10 +6140,20 @@ export class TeamProvisioningService { */ stopAllTeams(): void { const alive = this.getAliveTeams(); - if (alive.length === 0) return; - logger.info(`Killing all team processes on shutdown (SIGKILL): ${alive.join(', ')}`); - for (const teamName of alive) { - this.stopTeam(teamName); + if (alive.length > 0) { + logger.info(`Killing all team processes on shutdown (SIGKILL): ${alive.join(', ')}`); + for (const teamName of alive) { + this.stopTeam(teamName); + } + } + + const persistedTeamNames = this.listPersistedTeamNames(); + const orphanOnly = persistedTeamNames.filter((teamName) => !alive.includes(teamName)); + if (orphanOnly.length > 0) { + logger.info(`Cleaning up persisted teammate runtimes on shutdown: ${orphanOnly.join(', ')}`); + for (const teamName of orphanOnly) { + this.stopPersistentTeamMembers(teamName); + } } } @@ -5489,7 +6218,7 @@ export class TeamProvisioningService { // During provisioning (before provisioningComplete), accumulate for live UI preview. // Emission is handled by the throttled emitLogsProgress() in the stdout data handler. if (!run.provisioningComplete) { - run.provisioningOutputParts.push(text); + this.appendProvisioningAssistantText(run, msg, text); } // Once relay capture is settled, later assistant chunks belong to the normal live @@ -6334,6 +7063,8 @@ export class TeamProvisioningService { private formatToolApprovalBody(toolName: string, toolInput: Record): string { switch (toolName) { + case 'AskUserQuestion': + return this.formatAskUserQuestionApprovalBody(toolInput); case 'Bash': return `Bash: ${typeof toolInput.command === 'string' ? toolInput.command.slice(0, 150) : 'command'}`; case 'Write': @@ -6346,6 +7077,30 @@ export class TeamProvisioningService { } } + private formatAskUserQuestionApprovalBody(toolInput: Record): string { + const rawQuestions = Array.isArray(toolInput.questions) ? toolInput.questions : []; + const questions = rawQuestions + .map((item) => { + if (!item || typeof item !== 'object') return null; + const question = + 'question' in item && typeof item.question === 'string' ? item.question.trim() : null; + return question && question.length > 0 ? question.replace(/\s+/g, ' ') : null; + }) + .filter((question): question is string => Boolean(question)); + + if (questions.length === 0) { + return 'Question: User input is required'; + } + + const firstQuestion = questions[0]!; + const truncatedQuestion = + firstQuestion.length > 140 ? `${firstQuestion.slice(0, 137)}...` : firstQuestion; + + return questions.length === 1 + ? `Question: ${truncatedQuestion}` + : `Questions (${questions.length}): ${truncatedQuestion}`; + } + /** * Immediately sends an "allow" control_response for a non-tool control_request. * Prevents CLI deadlock for hook_callback and other non-`can_use_tool` subtypes. @@ -6970,10 +7725,17 @@ export class TeamProvisioningService { // Audit: flag any expected member not registered in config.json after launch. await this.auditMemberSpawnStatuses(run); - - const readyMessage = 'Team launched — process alive and ready'; + await this.persistPartialLaunchState(run); + const failedSpawnMembers = this.getFailedSpawnMembers(run); + const hasSpawnFailures = failedSpawnMembers.length > 0; + const readyMessage = hasSpawnFailures + ? `Launch completed with teammate errors — ${failedSpawnMembers + .map((member) => member.name) + .join(', ')} failed to start` + : 'Team launched — process alive and ready'; const progress = updateProgress(run, 'ready', readyMessage, { cliLogsTail: extractCliLogsFromRun(run), + messageSeverity: hasSpawnFailures ? 'warning' : undefined, }); run.onProgress(progress); this.provisioningRunByTeam.delete(run.teamName); @@ -6989,8 +7751,25 @@ export class TeamProvisioningService { detail: 'lead-session-sync', }); - // Fire "Team Launched" notification - void this.fireTeamLaunchedNotification(run); + if (!hasSpawnFailures) { + // Fire "Team Launched" notification only for clean launches. + void this.fireTeamLaunchedNotification(run); + } + + if (hasSpawnFailures) { + const failureNotice = [ + `Системное замечание: часть команды не запустилась.`, + `Не стартовали тиммейты: ${failedSpawnMembers.map((member) => `@${member.name}`).join(', ')}.`, + `Не считай их доступными, пока их запуск не будет повторён успешно.`, + ].join(' '); + await this.sendMessageToTeam(run.teamName, failureNotice).catch((error: unknown) => + logger.warn( + `[${run.teamName}] failed to send teammate-start failure notice to lead: ${ + error instanceof Error ? error.message : String(error) + }` + ) + ); + } // Pick up any direct messages that arrived before/while reconnecting. void this.relayLeadInboxMessages(run.teamName).catch((e: unknown) => @@ -7085,10 +7864,22 @@ export class TeamProvisioningService { // Audit: flag any expected member not registered in config.json after provisioning. await this.auditMemberSpawnStatuses(run); - - const progress = updateProgress(run, 'ready', 'Team provisioned — process alive and ready', { - cliLogsTail: extractCliLogsFromRun(run), - }); + await this.clearPartialLaunchState(run.teamName); + const failedSpawnMembers = this.getFailedSpawnMembers(run); + const hasSpawnFailures = failedSpawnMembers.length > 0; + const progress = updateProgress( + run, + 'ready', + hasSpawnFailures + ? `Provisioning completed with teammate errors — ${failedSpawnMembers + .map((member) => member.name) + .join(', ')} failed to start` + : 'Team provisioned — process alive and ready', + { + cliLogsTail: extractCliLogsFromRun(run), + messageSeverity: hasSpawnFailures ? 'warning' : undefined, + } + ); run.onProgress(progress); this.provisioningRunByTeam.delete(run.teamName); this.aliveRunByTeam.set(run.teamName, run.runId); @@ -7103,8 +7894,25 @@ export class TeamProvisioningService { detail: 'lead-session-sync', }); - // Fire "Team Launched" notification - void this.fireTeamLaunchedNotification(run); + if (!hasSpawnFailures) { + // Fire "Team Launched" notification only for clean launches. + void this.fireTeamLaunchedNotification(run); + } + + if (hasSpawnFailures) { + const failureNotice = [ + `Системное замечание: часть команды не запустилась.`, + `Не стартовали тиммейты: ${failedSpawnMembers.map((member) => `@${member.name}`).join(', ')}.`, + `Не считай их доступными, пока их запуск не будет повторён успешно.`, + ].join(' '); + await this.sendMessageToTeam(run.teamName, failureNotice).catch((error: unknown) => + logger.warn( + `[${run.teamName}] failed to send teammate-start failure notice to lead: ${ + error instanceof Error ? error.message : String(error) + }` + ) + ); + } // Pick up any direct messages that arrived during provisioning. void this.relayLeadInboxMessages(run.teamName).catch((e: unknown) => @@ -7410,6 +8218,9 @@ export class TeamProvisioningService { * Remove a run from tracking maps. */ private cleanupRun(run: ProvisioningRun): void { + if (run.isLaunch && !run.provisioningComplete) { + void this.persistPartialLaunchState(run); + } this.resetRuntimeToolActivity(run); this.setLeadActivity(run, 'offline'); run.pendingDirectCrossTeamSendRefresh = false; @@ -7553,14 +8364,14 @@ export class TeamProvisioningService { const progress = updateProgress( run, 'finalizing', - `All ${inboxCount} member inboxes created, preparing workspace` + `Prepared communication channels for all ${inboxCount} members, preparing workspace` ); run.onProgress(progress); } else if (inboxCount > 0) { const progress = updateProgress( run, 'assembling', - `${inboxCount}/${request.members.length} member inboxes created` + `Prepared communication channels for ${inboxCount}/${request.members.length} members` ); run.onProgress(progress); } @@ -7940,6 +8751,7 @@ export class TeamProvisioningService { : {}), CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', }; + applyConfiguredRuntimeBackendsEnv(env); applyProviderRuntimeEnv(env, providerId); const controlApiBaseUrl = await this.resolveControlApiBaseUrl(); @@ -7968,16 +8780,20 @@ export class TeamProvisioningService { } if (resolveTeamProviderId(providerId) === 'codex') { - return { env, authSource: 'codex_runtime' }; + return { env, authSource: 'codex_runtime', geminiRuntimeAuth: null }; } if (resolveTeamProviderId(providerId) === 'gemini') { - return { env, authSource: 'gemini_runtime' }; + return { + env, + authSource: 'gemini_runtime', + geminiRuntimeAuth: await resolveGeminiRuntimeAuth(env), + }; } // 1. Explicit ANTHROPIC_API_KEY — works with `-p` mode directly if (typeof env.ANTHROPIC_API_KEY === 'string' && env.ANTHROPIC_API_KEY.trim().length > 0) { - return { env, authSource: 'anthropic_api_key' }; + return { env, authSource: 'anthropic_api_key', geminiRuntimeAuth: null }; } // 2. Proxy token (ANTHROPIC_AUTH_TOKEN) — `-p` mode does NOT read this var, @@ -7987,7 +8803,7 @@ export class TeamProvisioningService { env.ANTHROPIC_AUTH_TOKEN.trim().length > 0 ) { env.ANTHROPIC_API_KEY = env.ANTHROPIC_AUTH_TOKEN; - return { env, authSource: 'anthropic_auth_token' }; + return { env, authSource: 'anthropic_auth_token', geminiRuntimeAuth: null }; } // 3. No explicit API key — let the CLI handle its own OAuth auth. @@ -7995,7 +8811,7 @@ export class TeamProvisioningService { // tokens in-memory. Injecting CLAUDE_CODE_OAUTH_TOKEN from the // credentials file causes 401 errors because the stored token is // often stale (CLI refreshes in-memory but rarely writes back). - return { env, authSource: 'none' }; + return { env, authSource: 'none', geminiRuntimeAuth: null }; } private async resolveControlApiBaseUrl(): Promise { @@ -8742,10 +9558,7 @@ export class TeamProvisioningService { name: member.name.trim(), role: member.role?.trim() || undefined, workflow: member.workflow?.trim() || undefined, - providerId: - member.providerId === 'codex' || member.providerId === 'gemini' - ? member.providerId - : undefined, + providerId: normalizeOptionalTeamProviderId(member.providerId), model: member.model?.trim() || undefined, effort: member.effort === 'low' || member.effort === 'medium' || member.effort === 'high' @@ -8788,10 +9601,7 @@ export class TeamProvisioningService { const role = typeof member.role === 'string' ? member.role.trim() || undefined : undefined; const workflow = typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined; - const providerId = - member.providerId === 'codex' || member.providerId === 'gemini' - ? member.providerId - : undefined; + const providerId = normalizeOptionalTeamProviderId(member.providerId); const model = typeof member.model === 'string' ? member.model.trim() || undefined : undefined; const effort = diff --git a/src/main/workers/team-fs-worker.ts b/src/main/workers/team-fs-worker.ts index 266cee1b..52697cf4 100644 --- a/src/main/workers/team-fs-worker.ts +++ b/src/main/workers/team-fs-worker.ts @@ -86,6 +86,9 @@ interface TaskReadDiag { skipReasons: Record; } +const MAX_LAUNCH_STATE_BYTES = 32 * 1024; +const TEAM_LAUNCH_STATE_FILE = 'launch-state.json'; + // --------------------------------------------------------------------------- // Parsed JSON types (loose shapes from disk) // --------------------------------------------------------------------------- @@ -316,6 +319,54 @@ function dropCliProvisionerMembers( } } +async function readPartialLaunchState( + teamsDir: string, + teamName: string +): Promise<{ + partialLaunchFailure: true; + expectedMemberCount: number; + confirmedMemberCount: number; + missingMembers: string[]; +} | null> { + const launchStatePath = path.join(teamsDir, teamName, TEAM_LAUNCH_STATE_FILE); + try { + const stat = await fs.promises.stat(launchStatePath); + if (!stat.isFile() || stat.size > MAX_LAUNCH_STATE_BYTES) return null; + const raw = await fs.promises.readFile(launchStatePath, 'utf8'); + const parsed = JSON.parse(raw) as { + state?: unknown; + expectedMembers?: unknown; + confirmedMembers?: unknown; + missingMembers?: unknown; + }; + if (parsed.state !== 'partial_launch_failure') return null; + const expectedMembers = Array.isArray(parsed.expectedMembers) + ? parsed.expectedMembers.filter( + (name): name is string => typeof name === 'string' && name.trim().length > 0 + ) + : []; + const confirmedMembers = Array.isArray(parsed.confirmedMembers) + ? parsed.confirmedMembers.filter( + (name): name is string => typeof name === 'string' && name.trim().length > 0 + ) + : []; + const missingMembers = Array.isArray(parsed.missingMembers) + ? parsed.missingMembers.filter( + (name): name is string => typeof name === 'string' && name.trim().length > 0 + ) + : []; + if (expectedMembers.length === 0 || missingMembers.length === 0) return null; + return { + partialLaunchFailure: true, + expectedMemberCount: expectedMembers.length, + confirmedMemberCount: confirmedMembers.length, + missingMembers, + }; + } catch { + return null; + } +} + /** * Reads a draft team summary from team.meta.json when config.json is missing. * Returns null if team.meta.json doesn't exist or is invalid. @@ -482,6 +533,8 @@ async function listTeams( const memberMap = new Map(); const removedKeys = new Set(); + const expectedTeammateNames = new Set(); + const confirmedArtifactNames = new Set(); try { const metaPath = path.join(payload.teamsDir, teamName, 'members.meta.json'); @@ -500,6 +553,7 @@ async function listTeams( removedKeys.add(key); continue; } + expectedTeammateNames.add(name); mergeMember(member, memberMap, removedKeys); } } @@ -511,15 +565,55 @@ async function listTeams( if (config && Array.isArray(config.members)) { for (const member of config.members as unknown[]) { if (isRawMember(member)) { + const name = typeof member.name === 'string' ? member.name.trim() : ''; + if (name && name !== 'user' && !isLeadMember(member)) { + confirmedArtifactNames.add(name); + } mergeMember(member, memberMap, removedKeys); } } } + try { + const inboxDir = path.join(payload.teamsDir, teamName, 'inboxes'); + const inboxEntries = await fs.promises.readdir(inboxDir, { withFileTypes: true }); + for (const entry of inboxEntries) { + if (!entry.isFile() || !entry.name.endsWith('.json')) continue; + const inboxName = entry.name.slice(0, -'.json'.length).trim(); + if (!inboxName || inboxName === 'user' || isLeadMember({ name: inboxName })) continue; + confirmedArtifactNames.add(inboxName); + } + } catch { + // best-effort + } + dropCliAutoSuffixedMembers(memberMap); dropCliProvisionerMembers(memberMap); const members = Array.from(memberMap.values()); + const partialLaunchState = + (await readPartialLaunchState(payload.teamsDir, teamName)) ?? + (() => { + if ( + !leadSessionId || + expectedTeammateNames.size === 0 || + confirmedArtifactNames.size === 0 + ) { + return null; + } + const missingMembers = Array.from(expectedTeammateNames).filter( + (name) => !confirmedArtifactNames.has(name) + ); + if (missingMembers.length === 0) { + return null; + } + return { + partialLaunchFailure: true as const, + expectedMemberCount: expectedTeammateNames.size, + confirmedMemberCount: confirmedArtifactNames.size, + missingMembers, + }; + })(); const summary = { teamName, displayName, @@ -534,6 +628,7 @@ async function listTeams( ...(projectPathHistory ? { projectPathHistory } : {}), ...(sessionHistory ? { sessionHistory } : {}), ...(deletedAt ? { deletedAt } : {}), + ...(partialLaunchState ?? {}), }; const ms = nowMs() - t0; diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 509321c7..8a9586f2 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -417,6 +417,9 @@ export const CROSS_TEAM_GET_OUTBOX = 'crossTeam:getOutbox'; /** Get CLI installation status */ export const CLI_INSTALLER_GET_STATUS = 'cliInstaller:getStatus'; +/** Get status for a single provider */ +export const CLI_INSTALLER_GET_PROVIDER_STATUS = 'cliInstaller:getProviderStatus'; + /** Start CLI install/update */ export const CLI_INSTALLER_INSTALL = 'cliInstaller:install'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 4918726a..0ec956dc 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -9,6 +9,7 @@ import { API_KEYS_STORAGE_STATUS, APP_RELAUNCH, CLI_INSTALLER_GET_STATUS, + CLI_INSTALLER_GET_PROVIDER_STATUS, CLI_INSTALLER_INSTALL, CLI_INSTALLER_INVALIDATE_STATUS, CLI_INSTALLER_PROGRESS, @@ -1344,6 +1345,9 @@ const electronAPI: ElectronAPI = { getStatus: async (): Promise => { return invokeIpcWithResult(CLI_INSTALLER_GET_STATUS); }, + getProviderStatus: async (providerId: import('@shared/types').CliProviderId) => { + return invokeIpcWithResult(CLI_INSTALLER_GET_PROVIDER_STATUS, providerId); + }, install: async (): Promise => { return invokeIpcWithResult(CLI_INSTALLER_INSTALL); }, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 5b14ca3c..1721b303 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -1076,6 +1076,7 @@ export class HttpAPIClient implements ElectronAPI { authMethod: null, providers: [], }), + getProviderStatus: async (): Promise => null, install: async (): Promise => { console.warn('[HttpAPIClient] CLI installer not available in browser mode'); }, diff --git a/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx b/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx index c2815a01..13320b3f 100644 --- a/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx +++ b/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx @@ -13,6 +13,7 @@ import { DIFF_REMOVED_TEXT, } from '@renderer/constants/cssVariables'; import { highlightLines } from '@renderer/utils/syntaxHighlighter'; +import { getAgentToolDisplayDetails } from '@shared/utils/toolSummary'; /** * Renders the input section based on tool type with theme-aware styling. @@ -99,6 +100,71 @@ export function renderInput(toolName: string, input: Record): R ); } + // Special rendering for Agent tool - do not leak full bootstrap prompts in UI logs. + if (toolName === 'Agent') { + const details = getAgentToolDisplayDetails(input); + + return ( +
+
+
+
+ action +
+
{details.action}
+
+ + {details.teammateName && ( +
+
+ teammate +
+
{details.teammateName}
+
+ )} + + {details.teamName && ( +
+
+ team +
+
{details.teamName}
+
+ )} + + {details.runtime && ( +
+
+ runtime +
+
{details.runtime}
+
+ )} + + {details.subagentType && ( +
+
+ type +
+
{details.subagentType}
+
+ )} +
+ +
+ Startup instructions are hidden in the UI. +
+
+ ); + } + // Default: key-value format with readable string values return (
diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index fa2256ac..34d7c7c7 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -11,7 +11,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { api, isElectronMode } from '@renderer/api'; import { confirm } from '@renderer/components/common/ConfirmDialog'; +import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog'; +import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector'; import { SettingsToggle } from '@renderer/components/settings/components'; +import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice'; import { TerminalLogPanel } from '@renderer/components/terminal/TerminalLogPanel'; import { TerminalModal } from '@renderer/components/terminal/TerminalModal'; import { useCliInstaller } from '@renderer/hooks/useCliInstaller'; @@ -29,6 +32,7 @@ import { LogOut, Puzzle, RefreshCw, + SlidersHorizontal, Terminal, } from 'lucide-react'; @@ -167,6 +171,7 @@ const CliCheckingSpinner = ({ interface InstalledBannerProps { cliStatus: NonNullable['cliStatus']>; cliStatusLoading: boolean; + cliProviderStatusLoading: Partial>; cliStatusError: string | null; isBusy: boolean; multimodelEnabled: boolean; @@ -176,6 +181,8 @@ interface InstalledBannerProps { onMultimodelToggle: (enabled: boolean) => void; onProviderLogin: (providerId: CliProviderId) => void; onProviderLogout: (providerId: CliProviderId) => void; + onProviderManage: (providerId: CliProviderId) => void; + onProviderRefresh: (providerId: CliProviderId) => void; variant: BannerVariant; } @@ -190,35 +197,41 @@ function getProviderLabel(providerId: CliProviderId): string { } } -function getProviderTerminalCommand(providerId: CliProviderId): { +function getProviderTerminalCommand(provider: CliProviderStatus): { args: string[]; env?: Record; } { - if (providerId === 'gemini') { + if (provider.providerId === 'gemini') { return { args: ['login'], - env: { CLAUDE_CODE_USE_GEMINI: '1' }, + env: { + CLAUDE_CODE_ENTRY_PROVIDER: 'gemini', + CLAUDE_CODE_GEMINI_BACKEND: provider.selectedBackendId ?? 'auto', + }, }; } return { - args: ['auth', 'login', '--provider', providerId], + args: ['auth', 'login', '--provider', provider.providerId], }; } -function getProviderTerminalLogoutCommand(providerId: CliProviderId): { +function getProviderTerminalLogoutCommand(provider: CliProviderStatus): { args: string[]; env?: Record; } { - if (providerId === 'gemini') { + if (provider.providerId === 'gemini') { return { args: ['logout'], - env: { CLAUDE_CODE_USE_GEMINI: '1' }, + env: { + CLAUDE_CODE_ENTRY_PROVIDER: 'gemini', + CLAUDE_CODE_GEMINI_BACKEND: provider.selectedBackendId ?? 'auto', + }, }; } return { - args: ['auth', 'logout', '--provider', providerId], + args: ['auth', 'logout', '--provider', provider.providerId], }; } @@ -278,6 +291,40 @@ function ModelBadges({ ); } +function ProviderDetailSkeleton(): React.JSX.Element { + return ( +
+
+
+ {Array.from({ length: 4 }).map((_, index) => ( +
+ ))} +
+
+ ); +} + +function isProviderCardLoading(provider: CliProviderStatus, providerLoading: boolean): boolean { + return ( + providerLoading || + (!provider.authenticated && + provider.statusMessage === 'Checking...' && + provider.models.length === 0 && + provider.backend == null) + ); +} + function formatRuntimeLabel( cliStatus: NonNullable['cliStatus']> ): string | null { @@ -301,12 +348,8 @@ function formatRuntimeAuthSummary( ) { return 'Checking providers...'; } - const supportedProviders = cliStatus.providers.filter((provider) => provider.supported); - const denominator = - supportedProviders.length > 0 ? supportedProviders.length : cliStatus.providers.length; - const connected = ( - supportedProviders.length > 0 ? supportedProviders : cliStatus.providers - ).filter((provider) => provider.authenticated).length; + const denominator = cliStatus.providers.length; + const connected = cliStatus.providers.filter((provider) => provider.authenticated).length; return `Providers: ${connected}/${denominator} connected`; } @@ -334,48 +377,10 @@ function isCheckingMultimodelStatus( ); } -function createLoadingMultimodelStatus(): CliInstallationStatus { - const providers: CliProviderStatus[] = [ - { providerId: 'anthropic' as const, displayName: 'Anthropic' }, - { providerId: 'codex' as const, displayName: 'Codex' }, - { providerId: 'gemini' as const, displayName: 'Gemini' }, - ].map((provider) => ({ - ...provider, - supported: false, - authenticated: false, - authMethod: null, - verificationState: 'unknown' as const, - statusMessage: 'Checking...', - models: [], - canLoginFromUi: true, - capabilities: { - teamLaunch: false, - oneShot: false, - }, - backend: null, - })); - - return { - flavor: 'free-code', - displayName: 'free-code-gemini-research', - supportsSelfUpdate: false, - showVersionDetails: false, - showBinaryPath: false, - installed: true, - installedVersion: null, - binaryPath: null, - latestVersion: null, - updateAvailable: false, - authLoggedIn: false, - authStatusChecking: true, - authMethod: null, - providers, - }; -} - const InstalledBanner = ({ cliStatus, cliStatusLoading, + cliProviderStatusLoading, cliStatusError, isBusy, multimodelEnabled, @@ -385,6 +390,8 @@ const InstalledBanner = ({ onMultimodelToggle, onProviderLogin, onProviderLogout, + onProviderManage, + onProviderRefresh, variant, }: InstalledBannerProps): React.JSX.Element => { const openExtensionsTab = useStore((s) => s.openExtensionsTab); @@ -499,48 +506,56 @@ const InstalledBanner = ({ {cliStatus.providers.map((provider) => { const statusText = formatProviderStatus(provider); const actionDisabled = isBusy || !cliStatus.binaryPath; + const runtimeSummary = getProviderRuntimeBackendSummary(provider); + const providerLoading = cliProviderStatusLoading[provider.providerId] === true; + const showSkeleton = isProviderCardLoading(provider, providerLoading); return (
-
-
- - {provider.displayName} - - - {statusText} - -
-
- {provider.backend?.label && ( - - Backend: {provider.backend.label} - {provider.backend.endpointLabel - ? ` (${provider.backend.endpointLabel})` - : ''} +
+
+
+ + {provider.displayName} - )} - {provider.models.length === 0 && ( - Models unavailable for this runtime build + + {statusText} + +
+ {showSkeleton ? ( + + ) : ( +
+ {provider.backend?.label && ( + + Backend: {provider.backend.label} + {provider.backend.endpointLabel + ? ` (${provider.backend.endpointLabel})` + : ''} + + )} + {runtimeSummary ? Runtime: {runtimeSummary} : null} + {provider.models.length === 0 && ( + Models unavailable for this runtime build + )} +
)}
-
-
- {provider.authenticated ? ( +
- ) : provider.canLoginFromUi ? ( + {provider.authenticated && provider.canLoginFromUi ? ( + + ) : provider.canLoginFromUi ? ( + + ) : null} - ) : null} - +
- {provider.models.length > 0 && ( + {!showSkeleton && provider.models.length > 0 && (
@@ -605,6 +638,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => { const { cliStatus, cliStatusLoading, + cliProviderStatusLoading, cliStatusError, installerState, downloadProgress, @@ -614,7 +648,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => { installerDetail, installerRawChunks, completedVersion, + bootstrapCliStatus, fetchCliStatus, + fetchCliProviderStatus, invalidateCliStatus, installCli, isBusy, @@ -625,6 +661,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => { providerId: CliProviderId; action: 'login' | 'logout'; } | null>(null); + const [manageProviderId, setManageProviderId] = useState('gemini'); + const [manageDialogOpen, setManageDialogOpen] = useState(false); const [isVerifyingAuth, setIsVerifyingAuth] = useState(false); const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false); const [showTroubleshoot, setShowTroubleshoot] = useState(false); @@ -655,26 +693,34 @@ export const CliStatusBanner = (): React.JSX.Element | null => { }, [installCli]); const handleRefresh = useCallback(() => { + if (multimodelEnabled) { + void bootstrapCliStatus({ multimodelEnabled: true }); + return; + } void fetchCliStatus(); - }, [fetchCliStatus]); + }, [bootstrapCliStatus, fetchCliStatus, multimodelEnabled]); const handleMultimodelToggle = useCallback( async (enabled: boolean) => { setIsSwitchingFlavor(true); try { useStore.setState({ - cliStatus: enabled ? createLoadingMultimodelStatus() : null, + cliStatus: enabled ? createLoadingMultimodelCliStatus() : null, cliStatusLoading: true, cliStatusError: null, }); await updateConfig('general', { multimodelEnabled: enabled }); await invalidateCliStatus(); - await fetchCliStatus(); + if (enabled) { + await bootstrapCliStatus({ multimodelEnabled: true }); + } else { + await fetchCliStatus(); + } } finally { setIsSwitchingFlavor(false); } }, - [fetchCliStatus, invalidateCliStatus, updateConfig] + [bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, updateConfig] ); const recheckAuthState = useCallback(() => { @@ -711,6 +757,40 @@ export const CliStatusBanner = (): React.JSX.Element | null => { })(); }, []); + const handleProviderManage = useCallback((providerId: CliProviderId) => { + setManageProviderId(providerId); + setManageDialogOpen(true); + }, []); + + const handleProviderRefresh = useCallback( + (providerId: CliProviderId) => { + void fetchCliProviderStatus(providerId); + }, + [fetchCliProviderStatus] + ); + + const handleProviderBackendChange = useCallback( + async (providerId: CliProviderId, backendId: string) => { + if (providerId !== 'gemini' && providerId !== 'codex') { + return; + } + + const currentBackends = appConfig?.runtime?.providerBackends ?? { + gemini: 'auto' as const, + codex: 'auto' as const, + }; + + await updateConfig('runtime', { + providerBackends: { + ...currentBackends, + [providerId]: backendId, + }, + }); + await fetchCliProviderStatus(providerId); + }, + [appConfig?.runtime?.providerBackends, fetchCliProviderStatus, updateConfig] + ); + if (!isElectron) return null; // Determine variant for styling @@ -729,11 +809,17 @@ export const CliStatusBanner = (): React.JSX.Element | null => { const variant = getVariant(); const styles = VARIANT_STYLES[variant]; - const providerTerminalCommand = providerTerminal - ? providerTerminal.action === 'login' - ? getProviderTerminalCommand(providerTerminal.providerId) - : getProviderTerminalLogoutCommand(providerTerminal.providerId) + const activeTerminalProvider = providerTerminal + ? (cliStatus?.providers.find( + (provider) => provider.providerId === providerTerminal.providerId + ) ?? null) : null; + const providerTerminalCommand = + providerTerminal && activeTerminalProvider + ? providerTerminal.action === 'login' + ? getProviderTerminalCommand(activeTerminalProvider) + : getProviderTerminalLogoutCommand(activeTerminalProvider) + : null; // ── Loading / fetch error state ──────────────────────────────────────── if (!cliStatus && installerState === 'idle') { @@ -794,8 +880,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => { if (multimodelEnabled) { return ( { onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)} onProviderLogin={handleProviderLogin} onProviderLogout={handleProviderLogout} + onProviderManage={handleProviderManage} + onProviderRefresh={handleProviderRefresh} variant="info" /> ); @@ -1072,7 +1161,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => { setIsVerifyingAuth(true); try { await invalidateCliStatus(); - await fetchCliStatus(); + if (multimodelEnabled) { + await bootstrapCliStatus({ multimodelEnabled: true }); + } else { + await fetchCliStatus(); + } } finally { setIsVerifyingAuth(false); } @@ -1142,7 +1235,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => { void (async () => { try { await invalidateCliStatus(); - await fetchCliStatus(); + if (multimodelEnabled) { + await bootstrapCliStatus({ multimodelEnabled: true }); + } else { + await fetchCliStatus(); + } } finally { setIsVerifyingAuth(false); } @@ -1153,7 +1250,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => { void (async () => { try { await invalidateCliStatus(); - await fetchCliStatus(); + if (multimodelEnabled) { + await bootstrapCliStatus({ multimodelEnabled: true }); + } else { + await fetchCliStatus(); + } } finally { setIsVerifyingAuth(false); } @@ -1174,6 +1275,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => { { onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)} onProviderLogin={handleProviderLogin} onProviderLogout={handleProviderLogout} + onProviderManage={handleProviderManage} + onProviderRefresh={handleProviderRefresh} variant={variant} /> + {cliStatus && ( + { + void handleProviderBackendChange(providerId, backendId); + }} + onRefreshProvider={(providerId) => fetchCliProviderStatus(providerId)} + /> + )} {providerTerminal && cliStatus.binaryPath && ( void; +}; + +export function getOptionDisplayLabel( + option: NonNullable[number], + resolvedOption: NonNullable[number] | null +): string { + if (option.id !== 'auto') { + return option.label; + } + + if (resolvedOption?.label) { + return `Auto (currently: ${resolvedOption.label})`; + } + + return 'Auto'; +} + +export function getProviderRuntimeBackendSummary(provider: CliProviderStatus): string | null { + const options = provider.availableBackends ?? []; + if (options.length === 0) { + return null; + } + + const selectedBackendId = provider.selectedBackendId ?? options[0]?.id ?? ''; + const selectedOption = options.find((option) => option.id === selectedBackendId) ?? options[0]; + const resolvedOption = options.find((option) => option.id === provider.resolvedBackendId) ?? null; + + return getOptionDisplayLabel(selectedOption, resolvedOption); +} + +export function ProviderRuntimeBackendSelector({ + provider, + disabled = false, + onSelect, +}: Props): React.JSX.Element | null { + const options = provider.availableBackends ?? []; + if (options.length === 0) { + return null; + } + + const selectedBackendId = provider.selectedBackendId ?? options[0]?.id ?? ''; + const selectedOption = options.find((option) => option.id === selectedBackendId) ?? options[0]; + const resolvedOption = options.find((option) => option.id === provider.resolvedBackendId) ?? null; + const selectedLabel = getOptionDisplayLabel(selectedOption, resolvedOption); + + return ( +
+
+ + Runtime backend + + {provider.resolvedBackendId && + provider.resolvedBackendId !== provider.selectedBackendId && ( + + Resolved: {resolvedOption?.label ?? provider.resolvedBackendId} + + )} +
+ + {selectedOption && ( +
+
+ + {selectedLabel} + + {selectedOption.recommended ? ( + + Recommended + + ) : null} + {!selectedOption.available ? ( + + + + + Unavailable + + + + {selectedOption.detailMessage ?? selectedOption.statusMessage ?? 'Unavailable'} + + + + ) : null} +
+
+
{selectedOption.description}
+ {selectedOption.statusMessage ?
{selectedOption.statusMessage}
: null} + {selectedOption.detailMessage && selectedOption.available ? ( +
{selectedOption.detailMessage}
+ ) : null} +
+
+ )} +
+ ); +} diff --git a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx new file mode 100644 index 00000000..4338de51 --- /dev/null +++ b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx @@ -0,0 +1,414 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { Button } from '@renderer/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@renderer/components/ui/dialog'; +import { Input } from '@renderer/components/ui/input'; +import { Label } from '@renderer/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@renderer/components/ui/select'; +import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; +import { useStore } from '@renderer/store'; +import { AlertTriangle, Key, Trash2 } from 'lucide-react'; + +import { + ProviderRuntimeBackendSelector, + getProviderRuntimeBackendSummary, +} from './ProviderRuntimeBackendSelector'; + +import type { CliProviderId, CliProviderStatus } from '@shared/types'; +import type { ApiKeyEntry } from '@shared/types/extensions'; + +type Props = { + open: boolean; + onOpenChange: (open: boolean) => void; + providers: CliProviderStatus[]; + initialProviderId: CliProviderId; + providerStatusLoading?: Partial>; + disabled?: boolean; + onSelectBackend: (providerId: CliProviderId, backendId: string) => void; + onRefreshProvider?: (providerId: CliProviderId) => Promise | void; +}; + +export function ProviderRuntimeSettingsDialog({ + open, + onOpenChange, + providers, + initialProviderId, + providerStatusLoading = {}, + disabled = false, + onSelectBackend, + onRefreshProvider, +}: Props): React.JSX.Element { + const [selectedProviderId, setSelectedProviderId] = useState(initialProviderId); + const [showGeminiApiKeyForm, setShowGeminiApiKeyForm] = useState(false); + const [geminiApiKeyValue, setGeminiApiKeyValue] = useState(''); + const [geminiApiKeyScope, setGeminiApiKeyScope] = useState<'user' | 'project'>('user'); + const [geminiApiKeyError, setGeminiApiKeyError] = useState(null); + + const apiKeys = useStore((s) => s.apiKeys); + const apiKeysLoading = useStore((s) => s.apiKeysLoading); + const apiKeysError = useStore((s) => s.apiKeysError); + const apiKeySaving = useStore((s) => s.apiKeySaving); + const apiKeyStorageStatus = useStore((s) => s.apiKeyStorageStatus); + const fetchApiKeys = useStore((s) => s.fetchApiKeys); + const fetchApiKeyStorageStatus = useStore((s) => s.fetchApiKeyStorageStatus); + const saveApiKey = useStore((s) => s.saveApiKey); + const deleteApiKey = useStore((s) => s.deleteApiKey); + + useEffect(() => { + if (open) { + setSelectedProviderId(initialProviderId); + void fetchApiKeys(); + void fetchApiKeyStorageStatus(); + } + }, [fetchApiKeyStorageStatus, fetchApiKeys, initialProviderId, open]); + + useEffect(() => { + if (!open) { + setShowGeminiApiKeyForm(false); + setGeminiApiKeyValue(''); + setGeminiApiKeyError(null); + } + }, [open]); + + const selectedProvider = useMemo(() => { + return ( + providers.find((provider) => provider.providerId === selectedProviderId) ?? + providers.find( + (provider) => provider.availableBackends && provider.availableBackends.length > 0 + ) ?? + providers[0] ?? + null + ); + }, [providers, selectedProviderId]); + + const summary = selectedProvider ? getProviderRuntimeBackendSummary(selectedProvider) : null; + const canConfigure = (selectedProvider?.availableBackends?.length ?? 0) > 0; + const geminiApiKey = useMemo(() => { + const matches = apiKeys.filter((entry) => entry.envVarName === 'GEMINI_API_KEY'); + const preferred = matches.find((entry) => entry.scope === 'user') ?? matches[0] ?? null; + return preferred; + }, [apiKeys]); + + const handleSaveGeminiApiKey = async (): Promise => { + if (!geminiApiKeyValue.trim()) { + setGeminiApiKeyError('API key is required'); + return; + } + + setGeminiApiKeyError(null); + try { + await saveApiKey({ + id: geminiApiKey?.id, + name: 'Gemini API Key', + envVarName: 'GEMINI_API_KEY', + value: geminiApiKeyValue.trim(), + scope: geminiApiKeyScope, + }); + setShowGeminiApiKeyForm(false); + setGeminiApiKeyValue(''); + await onRefreshProvider?.('gemini'); + } catch (error) { + setGeminiApiKeyError(error instanceof Error ? error.message : 'Failed to save API key'); + } + }; + + const handleDeleteGeminiApiKey = async (entry: ApiKeyEntry): Promise => { + setGeminiApiKeyError(null); + await deleteApiKey(entry.id); + await fetchApiKeys(); + await onRefreshProvider?.('gemini'); + }; + + return ( + + + + Provider Runtime Settings + + Choose a provider and adjust which internal runtime backend `free-code` should use. + + + +
+
+
+ Provider +
+ setSelectedProviderId(value as CliProviderId)} + > +
+ + {providers.map((provider) => ( + + {provider.displayName} + + ))} + +
+
+
+ + {selectedProvider ? ( +
+
+ + {selectedProvider.displayName} + + + {selectedProvider.authenticated + ? selectedProvider.authMethod + ? `Authenticated via ${selectedProvider.authMethod}` + : 'Authenticated' + : selectedProvider.statusMessage || 'Not connected'} + + {summary ? ( + + Runtime: {summary} + + ) : null} +
+
+ ) : null} + + {selectedProvider && canConfigure ? ( + + ) : ( +
+ Runtime backend is not configurable for this provider in the current version. +
+ )} + + {selectedProvider?.providerId === 'gemini' && ( +
+
+
+
+
+ +
+
+
+ API access +
+
+ Use `GEMINI_API_KEY` for the Gemini API backend. CLI SDK does not require + it. +
+
+
+
+ {!showGeminiApiKeyForm ? ( + + ) : null} +
+ +
+ + {geminiApiKey ? 'Configured' : 'Not configured'} + + {geminiApiKey ? ( + + {geminiApiKey.maskedValue} · {geminiApiKey.scope} + + ) : null} + {apiKeyStorageStatus ? ( + + Stored in {apiKeyStorageStatus.backend} + + ) : null} +
+ + {selectedProvider.availableBackends?.some( + (option) => option.id === 'api' && !option.available + ) ? ( +
+ + + Gemini API is currently unavailable. Configure `GEMINI_API_KEY` here or use + valid Google ADC credentials. + +
+ ) : null} + + {showGeminiApiKeyForm ? ( +
+
+ + setGeminiApiKeyValue(e.target.value)} + placeholder="AIza..." + className="h-9 text-sm" + autoFocus + /> +
+ +
+ + +
+ + {(geminiApiKeyError || apiKeysError) && ( +
+ {geminiApiKeyError ?? apiKeysError} +
+ )} + +
+ {geminiApiKey ? ( + + ) : ( + + )} +
+ + +
+
+
+ ) : null} + + {apiKeysLoading && !geminiApiKey ? ( +
+ Loading stored credentials... +
+ ) : null} +
+ )} +
+
+
+ ); +} diff --git a/src/renderer/components/settings/hooks/useSettingsHandlers.ts b/src/renderer/components/settings/hooks/useSettingsHandlers.ts index 18e964e5..d42bdccb 100644 --- a/src/renderer/components/settings/hooks/useSettingsHandlers.ts +++ b/src/renderer/components/settings/hooks/useSettingsHandlers.ts @@ -321,6 +321,12 @@ export function useSettingsHandlers({ useNativeTitleBar: false, telemetryEnabled: true, }, + runtime: { + providerBackends: { + gemini: 'auto', + codex: 'auto', + }, + }, display: { showTimestamps: true, compactMode: false, @@ -334,6 +340,7 @@ export function useSettingsHandlers({ await api.config.update('notifications', defaultConfig.notifications); await api.config.update('general', defaultConfig.general); + await api.config.update('runtime', defaultConfig.runtime); const updatedConfig = await api.config.update('display', defaultConfig.display); setConfig(updatedConfig); setOptimisticConfig(updatedConfig); diff --git a/src/renderer/components/settings/sections/CliStatusSection.tsx b/src/renderer/components/settings/sections/CliStatusSection.tsx index c278d5b3..e32e1c9d 100644 --- a/src/renderer/components/settings/sections/CliStatusSection.tsx +++ b/src/renderer/components/settings/sections/CliStatusSection.tsx @@ -9,10 +9,13 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { isElectronMode } from '@renderer/api'; import { confirm } from '@renderer/components/common/ConfirmDialog'; +import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog'; +import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector'; import { SettingsToggle } from '@renderer/components/settings/components'; import { TerminalModal } from '@renderer/components/terminal/TerminalModal'; import { useCliInstaller } from '@renderer/hooks/useCliInstaller'; import { useStore } from '@renderer/store'; +import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice'; import { formatBytes } from '@renderer/utils/formatters'; import { AlertTriangle, @@ -23,12 +26,13 @@ import { LogOut, Puzzle, RefreshCw, + SlidersHorizontal, Terminal, } from 'lucide-react'; import { SettingsSectionHeader } from '../components'; -import type { CliInstallationStatus, CliProviderId } from '@shared/types'; +import type { CliProviderId, CliProviderStatus } from '@shared/types'; function formatModelBadgeLabel(providerId: CliProviderId, model: string): string { if (providerId === 'anthropic') { @@ -69,6 +73,40 @@ function ModelBadges({ ); } +function ProviderDetailSkeleton(): React.JSX.Element { + return ( +
+
+
+ {Array.from({ length: 4 }).map((_, index) => ( +
+ ))} +
+
+ ); +} + +function isProviderCardLoading(provider: CliProviderStatus, providerLoading: boolean): boolean { + return ( + providerLoading || + (!provider.authenticated && + provider.statusMessage === 'Checking...' && + provider.models.length === 0 && + provider.backend == null) + ); +} + function getProviderLabel(providerId: CliProviderId): string { switch (providerId) { case 'anthropic': @@ -80,74 +118,41 @@ function getProviderLabel(providerId: CliProviderId): string { } } -function getProviderTerminalCommand(providerId: CliProviderId): { +function getProviderTerminalCommand(provider: CliProviderStatus): { args: string[]; env?: Record; } { - if (providerId === 'gemini') { + if (provider.providerId === 'gemini') { return { args: ['login'], - env: { CLAUDE_CODE_USE_GEMINI: '1' }, + env: { + CLAUDE_CODE_ENTRY_PROVIDER: 'gemini', + CLAUDE_CODE_GEMINI_BACKEND: provider.selectedBackendId ?? 'auto', + }, }; } return { - args: ['auth', 'login', '--provider', providerId], + args: ['auth', 'login', '--provider', provider.providerId], }; } -function getProviderTerminalLogoutCommand(providerId: CliProviderId): { +function getProviderTerminalLogoutCommand(provider: CliProviderStatus): { args: string[]; env?: Record; } { - if (providerId === 'gemini') { + if (provider.providerId === 'gemini') { return { args: ['logout'], - env: { CLAUDE_CODE_USE_GEMINI: '1' }, + env: { + CLAUDE_CODE_ENTRY_PROVIDER: 'gemini', + CLAUDE_CODE_GEMINI_BACKEND: provider.selectedBackendId ?? 'auto', + }, }; } return { - args: ['auth', 'logout', '--provider', providerId], - }; -} - -function createLoadingMultimodelStatus(): CliInstallationStatus { - const providers: Array<{ providerId: CliProviderId; displayName: string }> = [ - { providerId: 'anthropic', displayName: 'Anthropic' }, - { providerId: 'codex', displayName: 'Codex' }, - { providerId: 'gemini', displayName: 'Gemini' }, - ]; - - return { - flavor: 'free-code', - displayName: 'free-code-gemini-research', - supportsSelfUpdate: false, - showVersionDetails: false, - showBinaryPath: false, - installed: true, - installedVersion: null, - binaryPath: null, - latestVersion: null, - updateAvailable: false, - authLoggedIn: false, - authStatusChecking: true, - authMethod: null, - providers: providers.map((provider) => ({ - ...provider, - supported: false, - authenticated: false, - authMethod: null, - verificationState: 'unknown' as const, - statusMessage: 'Checking...', - models: [], - canLoginFromUi: true, - capabilities: { - teamLaunch: false, - oneShot: false, - }, - backend: null, - })), + args: ['auth', 'logout', '--provider', provider.providerId], }; } @@ -163,28 +168,39 @@ export const CliStatusSection = (): React.JSX.Element | null => { downloadTotal, installerError, completedVersion, + bootstrapCliStatus, fetchCliStatus, + fetchCliProviderStatus, installCli, isBusy, cliStatusLoading, + cliProviderStatusLoading, invalidateCliStatus, } = useCliInstaller(); const [providerTerminal, setProviderTerminal] = useState<{ providerId: CliProviderId; action: 'login' | 'logout'; } | null>(null); + const [manageProviderId, setManageProviderId] = useState('gemini'); + const [manageDialogOpen, setManageDialogOpen] = useState(false); const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false); const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true; const effectiveCliStatus = !cliStatus && cliStatusLoading && multimodelEnabled - ? createLoadingMultimodelStatus() + ? createLoadingMultimodelCliStatus() : cliStatus; useEffect(() => { if (isElectron) { - void fetchCliStatus(); + if (!cliStatus) { + if (multimodelEnabled) { + void bootstrapCliStatus({ multimodelEnabled: true }); + } else { + void fetchCliStatus(); + } + } } - }, [isElectron, fetchCliStatus]); + }, [bootstrapCliStatus, cliStatus, fetchCliStatus, isElectron, multimodelEnabled]); const handleInstall = useCallback(() => { installCli(); @@ -213,6 +229,11 @@ export const CliStatusSection = (): React.JSX.Element | null => { }); }, []); + const handleProviderManage = useCallback((providerId: CliProviderId) => { + setManageProviderId(providerId); + setManageDialogOpen(true); + }, []); + const recheckStatus = useCallback(() => { void (async () => { await invalidateCliStatus(); @@ -225,18 +246,22 @@ export const CliStatusSection = (): React.JSX.Element | null => { setIsSwitchingFlavor(true); try { useStore.setState({ - cliStatus: enabled ? createLoadingMultimodelStatus() : null, + cliStatus: enabled ? createLoadingMultimodelCliStatus() : null, cliStatusLoading: true, cliStatusError: null, }); await updateConfig('general', { multimodelEnabled: enabled }); await invalidateCliStatus(); - await fetchCliStatus(); + if (enabled) { + await bootstrapCliStatus({ multimodelEnabled: true }); + } else { + await fetchCliStatus(); + } } finally { setIsSwitchingFlavor(false); } }, - [fetchCliStatus, invalidateCliStatus, updateConfig] + [bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, updateConfig] ); if (!isElectron) return null; @@ -250,11 +275,39 @@ export const CliStatusSection = (): React.JSX.Element | null => { ? `${effectiveCliStatus.displayName} v${effectiveCliStatus.installedVersion ?? 'unknown'}` : (effectiveCliStatus?.displayName ?? 'Claude CLI'); - const providerTerminalCommand = providerTerminal - ? providerTerminal.action === 'login' - ? getProviderTerminalCommand(providerTerminal.providerId) - : getProviderTerminalLogoutCommand(providerTerminal.providerId) + const activeTerminalProvider = providerTerminal + ? (effectiveCliStatus?.providers.find( + (provider) => provider.providerId === providerTerminal.providerId + ) ?? null) : null; + const providerTerminalCommand = + providerTerminal && activeTerminalProvider + ? providerTerminal.action === 'login' + ? getProviderTerminalCommand(activeTerminalProvider) + : getProviderTerminalLogoutCommand(activeTerminalProvider) + : null; + + const handleRuntimeBackendChange = useCallback( + async (providerId: CliProviderId, backendId: string) => { + const currentBackends = appConfig?.runtime?.providerBackends ?? { + gemini: 'auto' as const, + codex: 'auto' as const, + }; + + if (providerId !== 'gemini' && providerId !== 'codex') { + return; + } + + await updateConfig('runtime', { + providerBackends: { + ...currentBackends, + [providerId]: backendId, + }, + }); + await fetchCliProviderStatus(providerId); + }, + [appConfig?.runtime?.providerBackends, fetchCliProviderStatus, updateConfig] + ); return (
@@ -381,94 +434,141 @@ export const CliStatusSection = (): React.JSX.Element | null => { {effectiveCliStatus.providers.map((provider) => (
-
-
- - {provider.displayName} - - - {provider.authenticated - ? provider.authMethod - ? `Authenticated via ${provider.authMethod}` - : 'Authenticated' - : provider.statusMessage || 'Not connected'} - -
-
- {provider.backend?.label && ( - Backend: {provider.backend.label} - )} - {provider.models.length === 0 && ( - Models unavailable for this runtime build - )} -
-
-
- {provider.authenticated ? ( - - ) : provider.canLoginFromUi ? ( - - ) : null} -
- {provider.models.length > 0 && ( -
- -
- )} + {(() => { + const providerLoading = + cliProviderStatusLoading[provider.providerId] === true; + const showSkeleton = isProviderCardLoading(provider, providerLoading); + const runtimeSummary = getProviderRuntimeBackendSummary(provider); + + return ( + <> +
+
+
+ + {provider.displayName} + + + {provider.authenticated + ? provider.authMethod + ? `Authenticated via ${provider.authMethod}` + : 'Authenticated' + : provider.statusMessage || 'Not connected'} + +
+ {showSkeleton ? ( + + ) : ( +
+ {provider.backend?.label && ( + Backend: {provider.backend.label} + )} + {runtimeSummary ? ( + Runtime: {runtimeSummary} + ) : null} + {provider.models.length === 0 && ( + Models unavailable for this runtime build + )} +
+ )} +
+
+ + {provider.authenticated && provider.canLoginFromUi ? ( + + ) : provider.canLoginFromUi ? ( + + ) : null} +
+
+ {!showSkeleton && provider.models.length > 0 && ( +
+ +
+ )} + + ); + })()}
))}
)} + { + void handleRuntimeBackendChange(providerId, backendId); + }} + onRefreshProvider={(providerId) => fetchCliProviderStatus(providerId)} + />
) : (
{ const { @@ -130,6 +132,7 @@ export const ClaudeLogsPanel = ({ order="newest-first" searchQueryOverride={searchQuery.trim() ? searchQuery : undefined} className={cn('p-2', viewerClassName)} + style={viewerMaxHeight ? { maxHeight: `${viewerMaxHeight}px` } : undefined} containerRefCallback={containerRefCallback} onScroll={handleScroll} viewerState={viewerState} diff --git a/src/renderer/components/team/ClaudeLogsSection.tsx b/src/renderer/components/team/ClaudeLogsSection.tsx index 1c8b56c5..f476b175 100644 --- a/src/renderer/components/team/ClaudeLogsSection.tsx +++ b/src/renderer/components/team/ClaudeLogsSection.tsx @@ -29,6 +29,8 @@ const PREVIEW_ICONS = { interface ClaudeLogsSectionProps { teamName: string; position?: 'sidebar' | 'inline'; + sidebarViewerMaxHeight?: number; + onOpenChange?: (isOpen: boolean) => void; } /** @@ -70,6 +72,8 @@ const LogPreviewInline = ({ preview }: { preview: LastLogPreview }): React.JSX.E export const ClaudeLogsSection = ({ teamName, position = 'inline', + sidebarViewerMaxHeight, + onOpenChange, }: ClaudeLogsSectionProps): React.JSX.Element => { const ctrl = useClaudeLogsController(teamName); const [dialogOpen, setDialogOpen] = useState(false); @@ -95,7 +99,7 @@ export const ClaudeLogsSection = ({ <> ) : undefined } + headerClassName={isSidebar ? '-mx-3 w-[calc(100%+1.5rem)] py-0' : undefined} + headerSurfaceClassName={isSidebar ? '!rounded-none' : undefined} headerContentClassName={isSidebar ? 'flex-wrap items-center gap-y-1 py-1 pr-1' : 'pr-1'} headerExtra={sectionHeaderExtra} defaultOpen={false} + onOpenChange={onOpenChange} + contentWrapperClassName={isSidebar ? 'mt-0 pb-0' : undefined} contentClassName="pt-0 [overflow-anchor:none]" > {/* When dialog is open, hide the compact log viewer to avoid two competing scroll containers */} @@ -131,7 +139,11 @@ export const ClaudeLogsSection = ({ Viewing in fullscreen mode
) : ( - + )} diff --git a/src/renderer/components/team/CliLogsRichView.tsx b/src/renderer/components/team/CliLogsRichView.tsx index 993cfa15..58ad832c 100644 --- a/src/renderer/components/team/CliLogsRichView.tsx +++ b/src/renderer/components/team/CliLogsRichView.tsx @@ -49,6 +49,7 @@ interface CliLogsRichViewProps { /** Optional local search query override for inline highlighting */ searchQueryOverride?: string; className?: string; + style?: React.CSSProperties; /** Content rendered at the very bottom of the scroll container (e.g. "Show more" button). */ footer?: React.ReactNode; @@ -341,6 +342,7 @@ export const CliLogsRichView = ({ containerRefCallback, searchQueryOverride, className, + style, footer, viewerState: controlledState, onViewerStateChange, @@ -557,6 +559,7 @@ export const CliLogsRichView = ({ 'max-h-[400px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)]', className )} + style={style} onScroll={(e) => handleScrollEvent(e.currentTarget)} >
@@ -582,6 +585,7 @@ export const CliLogsRichView = ({ containerRefCallback?.(el); }} className={cn('cli-logs-compact max-h-[400px] space-y-1 overflow-y-auto', className)} + style={style} onScroll={(e) => handleScrollEvent(e.currentTarget)} > {visibleEntries.map((entry) => diff --git a/src/renderer/components/team/CollapsibleTeamSection.tsx b/src/renderer/components/team/CollapsibleTeamSection.tsx index 485567e1..3782434e 100644 --- a/src/renderer/components/team/CollapsibleTeamSection.tsx +++ b/src/renderer/components/team/CollapsibleTeamSection.tsx @@ -31,10 +31,14 @@ interface CollapsibleTeamSectionProps { sectionId?: string; /** Extra classes applied to the content wrapper (e.g. padding). */ contentClassName?: string; + /** Extra classes for the outer content wrapper (e.g. remove default top/bottom gaps). */ + contentWrapperClassName?: string; /** Extra classes for the header bar (e.g. "-mx-6 w-[calc(100%+3rem)]" to match parent padding). */ headerClassName?: string; /** Extra classes for the inner header content (e.g. "pl-6" to match parent padding). */ headerContentClassName?: string; + /** Extra classes for the clickable header surface itself (e.g. override rounded corners). */ + headerSurfaceClassName?: string; /** When true, children stay mounted (hidden via CSS) when collapsed. Useful when children drive header state (e.g. online indicators). */ keepMounted?: boolean; children: React.ReactNode; @@ -53,8 +57,10 @@ export const CollapsibleTeamSection = ({ action, sectionId, contentClassName, + contentWrapperClassName, headerClassName, headerContentClassName, + headerSurfaceClassName, keepMounted, children, }: CollapsibleTeamSectionProps): React.JSX.Element => { @@ -88,7 +94,13 @@ export const CollapsibleTeamSection = ({ >
{keepMounted ? (
{children}
) : ( isOpen && ( -
+
{children}
) diff --git a/src/renderer/components/team/ProvisioningProgressBlock.tsx b/src/renderer/components/team/ProvisioningProgressBlock.tsx index 54bcd338..a76f6b1f 100644 --- a/src/renderer/components/team/ProvisioningProgressBlock.tsx +++ b/src/renderer/components/team/ProvisioningProgressBlock.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import { Button } from '@renderer/components/ui/button'; import { cn } from '@renderer/lib/utils'; -import { CheckCircle2, ChevronDown, ChevronRight, Loader2, X } from 'lucide-react'; +import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Loader2, X } from 'lucide-react'; import { MarkdownViewer } from '../chat/viewers/MarkdownViewer'; @@ -39,6 +39,8 @@ export interface ProvisioningProgressBlockProps { onCancel?: (() => void) | null; /** Success message shown inside the block header (e.g. "Team launched — all N teammates online") */ successMessage?: string | null; + /** Visual tone for the status banner above the block. */ + successMessageSeverity?: 'success' | 'warning'; /** Dismiss handler — renders an X button in the block header top-right */ onDismiss?: (() => void) | null; /** ISO timestamp when provisioning started */ @@ -132,6 +134,7 @@ export const ProvisioningProgressBlock = ({ loading = false, onCancel, successMessage, + successMessageSeverity = 'success', onDismiss, startedAt, pid, @@ -199,8 +202,21 @@ export const ProvisioningProgressBlock = ({ > {successMessage ? (
- -

{successMessage}

+ {successMessageSeverity === 'warning' ? ( + + ) : ( + + )} +

+ {successMessage} +

{onDismiss ? (
@@ -1542,7 +1551,11 @@ export const TeamDetailView = ({ > - Team is offline + {currentTeamSummary?.partialLaunchFailure + ? currentTeamSummary.missingMembers?.length + ? `Last launch failed partway — ${currentTeamSummary.missingMembers.length}/${currentTeamSummary.expectedMemberCount ?? currentTeamSummary.missingMembers.length} teammates did not join` + : 'Last launch failed partway' + : 'Team is offline'} - - - {launchingTeamName === team.teamName ? 'Launching…' : 'Launch team'} - - - )} + {(status === 'offline' || status === 'partial_failure') && + team.projectPath && ( + + + + + + {launchingTeamName === team.teamName ? 'Launching…' : 'Launch team'} + + + )} {(status === 'active' || status === 'idle') && ( @@ -908,6 +924,13 @@ export const TeamListView = (): React.JSX.Element => { {team.description || 'No description'}

+ {team.partialLaunchFailure ? ( +

+ {team.missingMembers?.length + ? `Last launch stopped before ${team.missingMembers.length}/${team.expectedMemberCount ?? team.missingMembers.length} teammate${team.missingMembers.length === 1 ? '' : 's'} joined.` + : 'Last launch stopped before all teammates joined.'} +

+ ) : null}
{team.members && team.members.length > 0 ? ( renderMemberChips(team.members, isLight) diff --git a/src/renderer/components/team/TeamProvisioningBanner.tsx b/src/renderer/components/team/TeamProvisioningBanner.tsx index 90ef0a5a..67dab2b2 100644 --- a/src/renderer/components/team/TeamProvisioningBanner.tsx +++ b/src/renderer/components/team/TeamProvisioningBanner.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { Button } from '@renderer/components/ui/button'; import { useStore } from '@renderer/store'; import { getCurrentProvisioningProgressForTeam } from '@renderer/store/slices/teamSlice'; +import { isLeadMember } from '@shared/utils/leadDetection'; import { X } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; @@ -16,11 +17,12 @@ interface TeamProvisioningBannerProps { export const TeamProvisioningBanner = ({ teamName, }: TeamProvisioningBannerProps): React.JSX.Element | null => { - const { progress, cancelProvisioning, teamMembers } = useStore( + const { progress, cancelProvisioning, teamMembers, memberSpawnStatuses } = useStore( useShallow((s) => ({ progress: getCurrentProvisioningProgressForTeam(s, teamName), cancelProvisioning: s.cancelProvisioning, teamMembers: s.selectedTeamData?.members, + memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName], })) ); const [dismissed, setDismissed] = useState(false); @@ -102,15 +104,37 @@ export const TeamProvisioningBanner = ({ ); } - const allTeammatesOnline = - teamMembers != null && - teamMembers.length > 0 && - teamMembers.every((m) => m.status === 'active' || m.status === 'idle'); + const teammates = (teamMembers ?? []).filter((member) => !isLeadMember(member)); + const failedSpawnEntries = Object.entries(memberSpawnStatuses ?? {}).filter( + ([, entry]) => entry.status === 'error' + ); + const failedSpawnCount = failedSpawnEntries.length; + const heartbeatConfirmedCount = teammates.filter((member) => { + const entry = memberSpawnStatuses?.[member.name]; + return entry?.status === 'online' && entry.livenessSource === 'heartbeat'; + }).length; + const processOnlyAliveCount = teammates.filter((member) => { + const entry = memberSpawnStatuses?.[member.name]; + return entry?.status === 'online' && entry.livenessSource === 'process'; + }).length; + const awaitingHeartbeatCount = teammates.filter((member) => { + const entry = memberSpawnStatuses?.[member.name]; + return entry?.status === 'waiting'; + }).length; + const allTeammatesConfirmedAlive = + teammates.length > 0 && failedSpawnCount === 0 && heartbeatConfirmedCount === teammates.length; if (isReady) { - const readyMessage = allTeammatesOnline - ? `Team launched — all ${teamMembers.length} teammates online` - : 'Team launched — teammates may still be starting'; + const readyMessage = + failedSpawnCount > 0 + ? `Launch finished with errors — ${failedSpawnCount}/${Math.max(teammates.length, failedSpawnCount)} teammates failed to start` + : teammates.length === 0 + ? 'Team launched — lead online' + : allTeammatesConfirmedAlive + ? `Team launched — all ${teammates.length} teammates confirmed alive` + : processOnlyAliveCount > 0 || awaitingHeartbeatCount > 0 + ? `Team launched — ${heartbeatConfirmedCount}/${teammates.length} teammates confirmed alive${processOnlyAliveCount > 0 ? `, ${processOnlyAliveCount} runtime${processOnlyAliveCount === 1 ? '' : 's'} alive but bootstrap still pending` : ''}${awaitingHeartbeatCount > 0 ? `${processOnlyAliveCount > 0 ? ', ' : ', '}${awaitingHeartbeatCount} awaiting first heartbeat` : ''}` + : 'Team launched — teammate liveness is still being confirmed'; return (
@@ -127,6 +151,7 @@ export const TeamProvisioningBanner = ({ defaultLiveOutputOpen={false} onCancel={null} successMessage={readyMessage} + successMessageSeverity={failedSpawnCount > 0 ? 'warning' : 'success'} onDismiss={() => setDismissed(true)} />
diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 79b39aef..89570814 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -23,6 +23,12 @@ import { parseMessageReply, parseStructuredAgentMessage, } from '@renderer/utils/agentMessageFormatting'; +import { + getBootstrapAcknowledgementDisplay, + getBootstrapPromptDisplay, + getSanitizedInboxMessageSummary, + getSanitizedInboxMessageText, +} from '@renderer/utils/bootstrapPromptSanitizer'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; import { @@ -291,6 +297,80 @@ const NoiseRow = ({
); +const BootstrapSystemRow = ({ + senderName, + recipientName, + runtime, + senderColor, + recipientColor, + timestamp, + onMemberNameClick, +}: { + senderName: string; + recipientName: string; + runtime?: string; + senderColor?: string; + recipientColor?: string; + timestamp: string; + onMemberNameClick?: (memberName: string) => void; +}): React.JSX.Element => ( +
+ + start + + + + + + {runtime || 'Starting teammate'} + + + {timestamp} + +
+); + +const BootstrapAcknowledgementRow = ({ + senderName, + recipientName, + senderColor, + recipientColor, + timestamp, + onMemberNameClick, +}: { + senderName: string; + recipientName: string; + senderColor?: string; + recipientColor?: string; + timestamp: string; + onMemberNameClick?: (memberName: string) => void; +}): React.JSX.Element => ( +
+ + bootstrap + + + + + + Bootstrap acknowledged + + + {timestamp} + +
+); + // --------------------------------------------------------------------------- // Detect historical system/automated messages that should be collapsed by default. // These patterns are kept only for legacy compatibility with old inbox/session rows; @@ -452,6 +532,11 @@ export const ActivityItem = memo( }, [message.timestamp]); const structured = parseStructuredAgentMessage(message.text); + const bootstrapDisplay = useMemo(() => getBootstrapPromptDisplay(message), [message]); + const bootstrapAcknowledgement = useMemo( + () => getBootstrapAcknowledgementDisplay(message), + [message] + ); // Only flag agent messages as rate-limited, not user's own quotes const rateLimited = message.from !== 'user' && isRateLimitMessage(message.text); // Highlight messages containing API errors @@ -510,7 +595,10 @@ export const ActivityItem = memo( // Strip agent-only blocks + normalize escape sequences (before linkification) const strippedText = useMemo(() => { if (structured) return null; - let stripped = stripAgentBlocks(message.text).trim(); + let stripped = getSanitizedInboxMessageText(message).trim(); + if (!bootstrapDisplay) { + stripped = stripAgentBlocks(stripped).trim(); + } if (!stripped) return null; // All content was agent-only blocks → show summary instead // Strip cross-team metadata tag (e.g. `\n`) // — kept in stored text for CLI agents / durable artifacts. @@ -519,7 +607,7 @@ export const ActivityItem = memo( } // Normalize literal \n from historical CLI-produced text to real newlines return stripped.replace(/\\n/g, '\n').replace(/\\t/g, '\t'); - }, [structured, message.text, isCrossTeamAny]); + }, [structured, message, bootstrapDisplay, isCrossTeamAny]); const standaloneSlashCommand = useMemo( () => (strippedText ? parseStandaloneSlashCommand(strippedText) : null), [strippedText] @@ -580,10 +668,12 @@ export const ActivityItem = memo( } if (crossTeamPreview) return crossTeamPreview; const s = - message.summary || (structured ? getStructuredMessageSummary(structured) : '') || ''; + getSanitizedInboxMessageSummary(message) || + (structured ? getStructuredMessageSummary(structured) : '') || + ''; if (s) return s; // Fallback: use the beginning of message text as preview for plain-text messages - const plain = stripAgentBlocks(message.text).trim(); + const plain = getSanitizedInboxMessageText(message).trim(); if (!plain) return ''; const oneLine = plain.replace(/\n+/g, ' '); return oneLine.length > 80 ? oneLine.slice(0, 80) + '…' : oneLine; @@ -592,10 +682,8 @@ export const ActivityItem = memo( isSlashCommandMessage, isSlashCommandResult, message.commandOutput, - message.summary, - message.text, + message, slashCommandMeta, - standaloneSlashCommand, structured, ]); const summaryText = useMemo(() => extractMarkdownPlainText(rawSummary), [rawSummary]); @@ -632,6 +720,33 @@ export const ActivityItem = memo( ); } + if (bootstrapDisplay) { + return ( + + ); + } + + if (bootstrapAcknowledgement) { + return ( + + ); + } + const messageType = structured && typeof structured.type === 'string' ? getMessageTypeLabel(structured.type) @@ -642,18 +757,10 @@ export const ActivityItem = memo( const subject = message.summary || autoSummary || `Task from ${message.from}`; const plainText = structured ? JSON.stringify(structured, null, 2) - : stripAgentBlocks(message.text); + : getSanitizedInboxMessageText(message); const description = `From: ${message.from}\nAt: ${timestamp}\n\n${plainText}`.slice(0, 2000); onCreateTask?.(subject, description); - }, [ - autoSummary, - message.from, - message.summary, - message.text, - onCreateTask, - structured, - timestamp, - ]); + }, [autoSummary, message.from, message.summary, message, onCreateTask, structured, timestamp]); const isHeaderClickable = isManaged && canToggleCollapse; const showChevron = isHeaderClickable && !compactHeader; diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index faf44f48..6c4c1e6f 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -37,6 +37,7 @@ import { useTheme } from '@renderer/hooks/useTheme'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; import { normalizePath } from '@renderer/utils/pathNormalize'; +import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react'; import { AdvancedCliSection } from './AdvancedCliSection'; @@ -44,6 +45,7 @@ import { OptionalSettingsSection } from './OptionalSettingsSection'; import { createInitialProviderChecks, failIncompleteProviderChecks, + getProvisioningProviderBackendSummary, getProvisioningFailureHint, ProvisioningProviderStatusList, shouldHideProvisioningProviderStatusList, @@ -270,6 +272,9 @@ export const CreateTeamDialog = ({ }: CreateTeamDialogProps): React.JSX.Element => { const { isLight } = useTheme(); const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true); + const cliStatus = useStore((s) => s.cliStatus); + const cliStatusLoading = useStore((s) => s.cliStatusLoading); + const fetchCliStatus = useStore((s) => s.fetchCliStatus); // ── Persisted draft state (survives tab navigation) ────────────────── const { @@ -460,12 +465,25 @@ export const CreateTeamDialog = ({ new Set([ selectedProviderId, ...members.flatMap((member) => - member.providerId === 'codex' || member.providerId === 'gemini' ? [member.providerId] : [] + isTeamProviderId(member.providerId) ? [member.providerId] : [] ), ]) ); }, [members, multimodelEnabled, selectedProviderId, soloTeam, syncModelsWithLead]); + const runtimeBackendSummaryByProvider = useMemo(() => { + const entries: Array = ( + cliStatus?.providers ?? [] + ).map( + (provider) => + [ + provider.providerId as TeamProviderId, + getProvisioningProviderBackendSummary(provider), + ] as const + ); + return new Map(entries); + }, [cliStatus?.providers]); + useEffect(() => { if (multimodelEnabled) { return; @@ -481,6 +499,13 @@ export const CreateTeamDialog = ({ } }, [members, multimodelEnabled, selectedProviderId, setMembers]); + useEffect(() => { + if (!open || cliStatus || cliStatusLoading) { + return; + } + void fetchCliStatus(); + }, [open, cliStatus, cliStatusLoading, fetchCliStatus]); + useEffect(() => { if (!open || !canCreate || !launchTeam) { return; @@ -523,6 +548,7 @@ export const CreateTeamDialog = ({ for (const providerId of selectedMemberProviders) { checks = updateProviderCheck(checks, providerId, { status: 'checking', + backendSummary: runtimeBackendSummaryByProvider.get(providerId) ?? null, details: [], }); if (!cancelled && prepareRequestSeqRef.current === requestSeq) { @@ -552,6 +578,7 @@ export const CreateTeamDialog = ({ } checks = updateProviderCheck(checks, providerId, { status: !prepResult.ready ? 'failed' : detailLines.length > 0 ? 'notes' : 'ready', + backendSummary: runtimeBackendSummaryByProvider.get(providerId) ?? null, details: detailLines, }); if (!cancelled && prepareRequestSeqRef.current === requestSeq) { @@ -584,7 +611,15 @@ export const CreateTeamDialog = ({ cancelled = true; clearTimeout(timer); }; - }, [open, canCreate, launchTeam, effectiveCwd, selectedProviderId, selectedMemberProviders]); + }, [ + open, + canCreate, + launchTeam, + effectiveCwd, + selectedProviderId, + selectedMemberProviders, + runtimeBackendSummaryByProvider, + ]); useEffect(() => { if (!open) { @@ -660,12 +695,8 @@ export const CreateTeamDialog = ({ roleSelection: isCustom ? CUSTOM_ROLE : (m.role ?? ''), customRole: isCustom ? m.role : '', workflow: m.workflow, - providerId: normalizeProviderForMode(m.providerId, multimodelEnabled), - model: - normalizeProviderForMode(m.providerId, multimodelEnabled) === - normalizeProviderForMode(m.providerId, true) - ? (m.model ?? '') - : '', + providerId: normalizeOptionalTeamProviderId(m.providerId), + model: m.model ?? '', effort: m.effort, }), multimodelEnabled @@ -777,9 +808,10 @@ export const CreateTeamDialog = ({ ); const sanitizedTeamName = sanitizeTeamName(teamName.trim()); + const teamNameInlineError = validateTeamNameInline(teamName); + const isNameTakenByExistingTeam = existingTeamNames.includes(sanitizedTeamName); const isNameProvisioning = - provisioningTeamNames.includes(sanitizedTeamName) && - !existingTeamNames.includes(sanitizedTeamName); + provisioningTeamNames.includes(sanitizedTeamName) && !isNameTakenByExistingTeam; const request = useMemo( () => ({ @@ -815,6 +847,15 @@ export const CreateTeamDialog = ({ customArgs, ] ); + const requestValidation = useMemo( + () => validateRequest(request, { requireCwd: launchTeam }), + [request, launchTeam] + ); + const hasCreateFormErrors = + !!teamNameInlineError || + isNameTakenByExistingTeam || + isNameProvisioning || + !requestValidation.valid; const internalArgs = useMemo(() => { const args: string[] = []; @@ -1055,20 +1096,24 @@ export const CreateTeamDialog = ({ id="team-name" className={cn( 'h-8 text-xs', - (fieldErrors.teamName || allTakenTeamNames.includes(sanitizedTeamName)) && + (fieldErrors.teamName || teamNameInlineError || isNameTakenByExistingTeam) && 'border-[var(--field-error-border)] bg-[var(--field-error-bg)] focus-visible:ring-[var(--field-error-border)]' )} value={teamName} onChange={(event) => handleTeamNameChange(event.target.value)} placeholder={suggestedTeamName} /> - {allTakenTeamNames.includes(sanitizedTeamName) ? ( + {isNameTakenByExistingTeam ? (

- {isNameProvisioning ? 'Team is currently launching' : 'Team name already exists'} + Team name already exists

- ) : validateTeamNameInline(teamName) ? ( + ) : teamNameInlineError ? (

- {validateTeamNameInline(teamName)} + {teamNameInlineError} +

+ ) : isNameProvisioning ? ( +

+ A team with this name is currently launching

) : fieldErrors.teamName ? (

@@ -1391,7 +1436,7 @@ export const CreateTeamDialog = ({

+ {providerChangeForcesFreshLeadContext ? ( +
+
+ +

+ Provider changed from {getProviderLabel(previousProviderId!)} to{' '} + {getProviderLabel(selectedProviderId)}. The previous lead session will not + be resumed, and the lead will start with fresh context so the new runtime + is applied correctly. +

+
+
+ ) : null}
({ providerId, status: 'pending', + backendSummary: null, details: [], })); } +export function getProvisioningProviderBackendSummary( + provider: + | Pick< + CliProviderStatus, + 'selectedBackendId' | 'resolvedBackendId' | 'availableBackends' | 'backend' + > + | null + | undefined +): string | null { + if (!provider) { + return null; + } + + const options = provider.availableBackends ?? []; + const optionById = new Map(options.map((option) => [option.id, option.label])); + const effectiveBackendId = provider.resolvedBackendId ?? provider.selectedBackendId; + + if (effectiveBackendId) { + return optionById.get(effectiveBackendId) ?? provider.backend?.label ?? effectiveBackendId; + } + + return provider.backend?.label ?? null; +} + export function updateProviderCheck( checks: ProvisioningProviderCheck[], providerId: TeamProviderId, @@ -207,7 +234,9 @@ export function ProvisioningProviderStatusList({ > - {getProvisioningProviderLabel(check.providerId)}: {getDisplayStatusText(check)} + {getProvisioningProviderLabel(check.providerId)} + {check.backendSummary ? ` (${check.backendSummary})` : ''}:{' '} + {getDisplayStatusText(check)}
{visibleDetails.length > 0 ? ( diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx index 654c4385..f8ae46ac 100644 --- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx +++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx @@ -123,7 +123,22 @@ export function formatTeamModelSummary( const providerLabel = getTeamProviderLabel(providerId); const modelLabel = model.trim() ? getTeamModelLabel(model.trim()) : 'Default'; const effortLabel = effort?.trim() ? getTeamEffortLabel(effort) : ''; - return [providerLabel, modelLabel, effortLabel].filter(Boolean).join(' · '); + + const normalizedProvider = providerLabel.trim().toLowerCase(); + const normalizedModel = modelLabel.trim().toLowerCase(); + const modelAlreadyCarriesProviderBrand = + modelLabel !== 'Default' && + (normalizedModel.startsWith(normalizedProvider) || + (providerId === 'anthropic' && normalizedModel.startsWith('claude')) || + (providerId === 'codex' && normalizedModel.startsWith('codex')) || + (providerId === 'codex' && normalizedModel.startsWith('gpt')) || + (providerId === 'gemini' && normalizedModel.startsWith('gemini'))); + + const parts = modelAlreadyCarriesProviderBrand + ? [modelLabel, effortLabel] + : [providerLabel, modelLabel, effortLabel]; + + return parts.filter(Boolean).join(' · '); } /** diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index 1e6290a1..0c8b8855 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -250,7 +250,8 @@ export const KanbanTaskCard = memo( [taskChangeRequestOptions, onViewChanges] ); - const isReviewManual = columnId === 'review' && !hasReviewers && !kanbanTaskState?.reviewer; + const effectiveReviewer = (kanbanTaskState?.reviewer ?? task.reviewer ?? '').trim(); + const isReviewManual = columnId === 'review' && !hasReviewers && effectiveReviewer.length === 0; const metaActions = ( <> {canDisplay && task.changePresence === 'has_changes' ? ( diff --git a/src/renderer/components/team/members/LeadModelRow.tsx b/src/renderer/components/team/members/LeadModelRow.tsx index 2bc85aea..df2aec13 100644 --- a/src/renderer/components/team/members/LeadModelRow.tsx +++ b/src/renderer/components/team/members/LeadModelRow.tsx @@ -28,6 +28,7 @@ interface LeadModelRowProps { onLimitContextChange: (value: boolean) => void; syncModelsWithTeammates: boolean; onSyncModelsWithTeammatesChange: (value: boolean) => void; + warningText?: string | null; } export const LeadModelRow = ({ @@ -41,6 +42,7 @@ export const LeadModelRow = ({ onLimitContextChange, syncModelsWithTeammates, onSyncModelsWithTeammatesChange, + warningText, }: LeadModelRowProps): React.JSX.Element => { const { isLight } = useTheme(); const [modelExpanded, setModelExpanded] = useState(false); @@ -102,6 +104,14 @@ export const LeadModelRow = ({
+ {warningText ? ( +
+
+ +

{warningText}

+
+
+ ) : null} {modelExpanded ? (
void; onOpenReviewTask?: () => void; onClick?: () => void; @@ -58,6 +60,7 @@ export const MemberCard = ({ isRemoved, spawnStatus, spawnError, + spawnLivenessSource, onOpenTask, onOpenReviewTask, onClick, @@ -79,6 +82,7 @@ export const MemberCard = ({ const presenceLabel = getSpawnAwarePresenceLabel( member, spawnStatus, + spawnLivenessSource, isTeamAlive, isTeamProvisioning, leadActivity diff --git a/src/renderer/components/team/members/MemberDraftRow.tsx b/src/renderer/components/team/members/MemberDraftRow.tsx index 29d4c588..ec55711e 100644 --- a/src/renderer/components/team/members/MemberDraftRow.tsx +++ b/src/renderer/components/team/members/MemberDraftRow.tsx @@ -51,6 +51,7 @@ interface MemberDraftRowProps { modelLockReason?: string; isRemoved?: boolean; onRestore?: (id: string) => void; + warningText?: string | null; } export const MemberDraftRow = ({ @@ -81,6 +82,7 @@ export const MemberDraftRow = ({ modelLockReason, isRemoved = false, onRestore, + warningText, }: MemberDraftRowProps): React.JSX.Element => { const { isLight } = useTheme(); const memberColorSet = getTeamColorSet( @@ -289,6 +291,14 @@ export const MemberDraftRow = ({
Removed
) : null}
+ {!isRemoved && warningText ? ( +
+
+ +

{warningText}

+
+
+ ) : null} {showWorkflow && onWorkflowChange && workflowExpanded ? (
diff --git a/src/renderer/components/team/members/TeamRosterEditorSection.tsx b/src/renderer/components/team/members/TeamRosterEditorSection.tsx index 30b79d02..9326fa9c 100644 --- a/src/renderer/components/team/members/TeamRosterEditorSection.tsx +++ b/src/renderer/components/team/members/TeamRosterEditorSection.tsx @@ -41,6 +41,8 @@ interface TeamRosterEditorSectionProps { headerTop?: React.ReactNode; headerBottom?: React.ReactNode; softDeleteMembers?: boolean; + leadWarningText?: string | null; + memberWarningById?: Record; } export const TeamRosterEditorSection = ({ @@ -77,6 +79,8 @@ export const TeamRosterEditorSection = ({ headerTop, headerBottom, softDeleteMembers = false, + leadWarningText, + memberWarningById, }: TeamRosterEditorSectionProps): React.JSX.Element => { return ( {headerBottom}
} + memberWarningById={memberWarningById} /> ); }; diff --git a/src/renderer/components/team/members/membersEditorUtils.ts b/src/renderer/components/team/members/membersEditorUtils.ts index 5f3f8c91..1d19cb49 100644 --- a/src/renderer/components/team/members/membersEditorUtils.ts +++ b/src/renderer/components/team/members/membersEditorUtils.ts @@ -1,6 +1,7 @@ import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles'; import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; +import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import type { MemberDraft } from './membersEditorTypes'; import type { MentionSuggestion } from '@renderer/types/mention'; @@ -62,10 +63,7 @@ export function createMemberDraftsFromInputs( roleSelection: role ? (isPreset ? role : CUSTOM_ROLE) : '', customRole: role && !isPreset ? role : '', workflow: member.workflow, - providerId: - member.providerId === 'codex' || member.providerId === 'gemini' - ? member.providerId - : 'anthropic', + providerId: normalizeOptionalTeamProviderId(member.providerId), model: member.model ?? '', effort: normalizeDraftEffort(member.effort), removedAt: member.removedAt, @@ -196,11 +194,8 @@ export function buildMembersFromDrafts(members: MemberDraft[]): TeamProvisioning const result: TeamProvisioningMemberInput = { name, role }; const workflow = getWorkflowForExport(member); if (workflow) result.workflow = workflow; - const providerId: TeamProviderId = - member.providerId === 'codex' || member.providerId === 'gemini' - ? member.providerId - : 'anthropic'; - if (providerId !== 'anthropic') { + const providerId = normalizeOptionalTeamProviderId(member.providerId); + if (providerId) { result.providerId = providerId; } const model = member.model?.trim(); diff --git a/src/renderer/components/team/sidebar/TeamSidebarRail.tsx b/src/renderer/components/team/sidebar/TeamSidebarRail.tsx index 73331d28..a55a00ad 100644 --- a/src/renderer/components/team/sidebar/TeamSidebarRail.tsx +++ b/src/renderer/components/team/sidebar/TeamSidebarRail.tsx @@ -1,6 +1,7 @@ import { ClaudeLogsSection } from '../ClaudeLogsSection'; import { MessagesPanel } from '../messages/MessagesPanel'; +import { useState } from 'react'; import type { MouseEventHandler } from 'react'; import type { ComponentProps } from 'react'; @@ -11,6 +12,9 @@ interface TeamSidebarRailProps { messagesPanelProps: SharedMessagesPanelProps; isResizing: boolean; onResizeMouseDown: MouseEventHandler; + logsHeight: number; + isLogsResizing: boolean; + onLogsResizeMouseDown: MouseEventHandler; } export const TeamSidebarRail = ({ @@ -18,13 +22,39 @@ export const TeamSidebarRail = ({ messagesPanelProps, isResizing, onResizeMouseDown, + logsHeight, + isLogsResizing, + onLogsResizeMouseDown, }: TeamSidebarRailProps): React.JSX.Element => { + const [logsOpen, setLogsOpen] = useState(false); + const logsSeparator = logsOpen ? ( +
+
+
+ ) : ( +
+ ); + return (
- +
-
+ {logsSeparator}
diff --git a/src/renderer/hooks/useCliInstaller.ts b/src/renderer/hooks/useCliInstaller.ts index a7831d6d..7fbc374e 100644 --- a/src/renderer/hooks/useCliInstaller.ts +++ b/src/renderer/hooks/useCliInstaller.ts @@ -7,11 +7,12 @@ import { useStore } from '@renderer/store'; -import type { CliInstallationStatus } from '@shared/types'; +import type { CliInstallationStatus, CliProviderId } from '@shared/types'; export function useCliInstaller(): { cliStatus: CliInstallationStatus | null; cliStatusLoading: boolean; + cliProviderStatusLoading: Partial>; cliStatusError: string | null; installerState: | 'idle' @@ -28,13 +29,19 @@ export function useCliInstaller(): { installerDetail: string | null; installerRawChunks: string[]; completedVersion: string | null; + bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise; fetchCliStatus: () => Promise; + fetchCliProviderStatus: ( + providerId: CliProviderId, + options?: { silent?: boolean; epoch?: number } + ) => Promise; invalidateCliStatus: () => Promise; installCli: () => void; isBusy: boolean; } { const cliStatus = useStore((s) => s.cliStatus); const cliStatusLoading = useStore((s) => s.cliStatusLoading); + const cliProviderStatusLoading = useStore((s) => s.cliProviderStatusLoading); const cliStatusError = useStore((s) => s.cliStatusError); const installerState = useStore((s) => s.cliInstallerState); const downloadProgress = useStore((s) => s.cliDownloadProgress); @@ -44,7 +51,9 @@ export function useCliInstaller(): { const installerDetail = useStore((s) => s.cliInstallerDetail); const installerRawChunks = useStore((s) => s.cliInstallerRawChunks); const completedVersion = useStore((s) => s.cliCompletedVersion); + const bootstrapCliStatus = useStore((s) => s.bootstrapCliStatus); const fetchCliStatus = useStore((s) => s.fetchCliStatus); + const fetchCliProviderStatus = useStore((s) => s.fetchCliProviderStatus); const invalidateCliStatus = useStore((s) => s.invalidateCliStatus); const installCli = useStore((s) => s.installCli); @@ -53,6 +62,7 @@ export function useCliInstaller(): { return { cliStatus, cliStatusLoading, + cliProviderStatusLoading, cliStatusError, installerState, downloadProgress, @@ -62,7 +72,9 @@ export function useCliInstaller(): { installerDetail, installerRawChunks, completedVersion, + bootstrapCliStatus, fetchCliStatus, + fetchCliProviderStatus, invalidateCliStatus, installCli, isBusy, diff --git a/src/renderer/hooks/useResizablePanel.ts b/src/renderer/hooks/useResizablePanel.ts index 29f518af..c88cf47b 100644 --- a/src/renderer/hooks/useResizablePanel.ts +++ b/src/renderer/hooks/useResizablePanel.ts @@ -1,30 +1,35 @@ /** * useResizablePanel - Reusable hook for mouse-based panel resizing. * - * Extracted from the resize pattern in Sidebar.tsx. - * Handles mousedown/mousemove/mouseup on document, cursor and userSelect overrides. - * - * @param options.width Current panel width (controlled) - * @param options.onWidthChange Callback when width changes during drag - * @param options.minWidth Minimum allowed width (default 280) - * @param options.maxWidth Maximum allowed width (default 500) - * @param options.side Which side the panel is on: - * 'left' → panel is on the left, resize handle on right edge - * 'right' → panel is on the right, resize handle on left edge + * Supports both: + * - horizontal resizing for left/right side panels + * - vertical resizing for top/bottom stacked panels */ import { useCallback, useEffect, useRef, useState } from 'react'; const DEFAULT_MIN_WIDTH = 280; const DEFAULT_MAX_WIDTH = 500; +const DEFAULT_MIN_HEIGHT = 120; +const DEFAULT_MAX_HEIGHT = 520; -interface UseResizablePanelOptions { +type HorizontalResizeOptions = { width: number; onWidthChange: (width: number) => void; minWidth?: number; maxWidth?: number; side: 'left' | 'right'; -} +}; + +type VerticalResizeOptions = { + height: number; + onHeightChange: (height: number) => void; + minHeight?: number; + maxHeight?: number; + side: 'top' | 'bottom'; +}; + +type UseResizablePanelOptions = HorizontalResizeOptions | VerticalResizeOptions; interface ResizeHandleProps { onMouseDown: (e: React.MouseEvent) => void; @@ -35,47 +40,61 @@ interface UseResizablePanelReturn { handleProps: ResizeHandleProps; } -export function useResizablePanel({ - width, - onWidthChange, - minWidth = DEFAULT_MIN_WIDTH, - maxWidth = DEFAULT_MAX_WIDTH, - side, -}: UseResizablePanelOptions): UseResizablePanelReturn { +function isVerticalOptions(options: UseResizablePanelOptions): options is VerticalResizeOptions { + return options.side === 'top' || options.side === 'bottom'; +} + +export function useResizablePanel(options: UseResizablePanelOptions): UseResizablePanelReturn { const [isResizing, setIsResizing] = useState(false); + const originRef = useRef(0); + const isVertical = isVerticalOptions(options); - // Store the panel's left offset for 'left' side panels. - // Updated on resize start so the formula stays correct if layout shifts. - const panelLeftRef = useRef(0); - - // Keep callbacks in refs to avoid stale closures in mousemove listener - const onWidthChangeRef = useRef(onWidthChange); - const minWidthRef = useRef(minWidth); - const maxWidthRef = useRef(maxWidth); - const sideRef = useRef(side); + const onSizeChangeRef = useRef<(size: number) => void>( + isVertical ? options.onHeightChange : options.onWidthChange + ); + const minSizeRef = useRef( + isVertical ? (options.minHeight ?? DEFAULT_MIN_HEIGHT) : (options.minWidth ?? DEFAULT_MIN_WIDTH) + ); + const maxSizeRef = useRef( + isVertical ? (options.maxHeight ?? DEFAULT_MAX_HEIGHT) : (options.maxWidth ?? DEFAULT_MAX_WIDTH) + ); + const sideRef = useRef(options.side); useEffect(() => { - onWidthChangeRef.current = onWidthChange; - minWidthRef.current = minWidth; - maxWidthRef.current = maxWidth; - sideRef.current = side; - }); + sideRef.current = options.side; + if (isVerticalOptions(options)) { + onSizeChangeRef.current = options.onHeightChange; + minSizeRef.current = options.minHeight ?? DEFAULT_MIN_HEIGHT; + maxSizeRef.current = options.maxHeight ?? DEFAULT_MAX_HEIGHT; + } else { + onSizeChangeRef.current = options.onWidthChange; + minSizeRef.current = options.minWidth ?? DEFAULT_MIN_WIDTH; + maxSizeRef.current = options.maxWidth ?? DEFAULT_MAX_WIDTH; + } + }, [options]); const handleMouseMove = useCallback( (e: MouseEvent) => { if (!isResizing) return; - let newWidth: number; - if (sideRef.current === 'left') { - // Panel on the left: width = cursor position - panel left edge - newWidth = e.clientX - panelLeftRef.current; - } else { - // Panel on the right: width = viewport width - cursor position - newWidth = window.innerWidth - e.clientX; + let newSize: number; + switch (sideRef.current) { + case 'left': + newSize = e.clientX - originRef.current; + break; + case 'right': + newSize = window.innerWidth - e.clientX; + break; + case 'top': + newSize = e.clientY - originRef.current; + break; + case 'bottom': + newSize = window.innerHeight - e.clientY; + break; } - if (newWidth >= minWidthRef.current && newWidth <= maxWidthRef.current) { - onWidthChangeRef.current(newWidth); + if (newSize >= minSizeRef.current && newSize <= maxSizeRef.current) { + onSizeChangeRef.current(newSize); } }, [isResizing] @@ -89,7 +108,7 @@ export function useResizablePanel({ if (isResizing) { document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); - document.body.style.cursor = 'col-resize'; + document.body.style.cursor = isVertical ? 'row-resize' : 'col-resize'; document.body.style.userSelect = 'none'; } @@ -99,20 +118,23 @@ export function useResizablePanel({ document.body.style.cursor = ''; document.body.style.userSelect = ''; }; - }, [isResizing, handleMouseMove, handleMouseUp]); + }, [isResizing, handleMouseMove, handleMouseUp, isVertical]); const onMouseDown = useCallback( (e: React.MouseEvent) => { e.preventDefault(); - if (side === 'left') { - // Calculate the left edge of the panel from cursor position minus current width - panelLeftRef.current = e.clientX - width; + if (isVerticalOptions(options)) { + if (options.side === 'top') { + originRef.current = e.clientY - options.height; + } + } else if (options.side === 'left') { + originRef.current = e.clientX - options.width; } setIsResizing(true); }, - [side, width] + [options] ); return { diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 1eff394c..7237d3da 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -144,7 +144,12 @@ export function initializeNotificationListeners(): () => void { const isWindows = platform.toLowerCase().includes('win'); const delayMs = isWindows ? 3000 : 0; cliStatusTimer = setTimeout(() => { - void useStore.getState().fetchCliStatus(); + const multimodelEnabled = useStore.getState().appConfig?.general?.multimodelEnabled ?? true; + if (multimodelEnabled) { + void useStore.getState().bootstrapCliStatus({ multimodelEnabled: true }); + } else { + void useStore.getState().fetchCliStatus(); + } cliStatusTimer = null; }, delayMs); } diff --git a/src/renderer/store/slices/cliInstallerSlice.ts b/src/renderer/store/slices/cliInstallerSlice.ts index 929b5938..2622aaa2 100644 --- a/src/renderer/store/slices/cliInstallerSlice.ts +++ b/src/renderer/store/slices/cliInstallerSlice.ts @@ -6,13 +6,55 @@ import { api } from '@renderer/api'; import { createLogger } from '@shared/utils/logger'; import type { AppState } from '../types'; -import type { CliInstallationStatus } from '@shared/types'; +import type { CliInstallationStatus, CliProviderId, CliProviderStatus } from '@shared/types'; import type { StateCreator } from 'zustand'; const logger = createLogger('Store:cliInstaller'); /** Max log lines to keep in UI (reserved for future use) */ const _MAX_LOG_LINES = 50; +export const MULTIMODEL_PROVIDER_IDS: CliProviderId[] = ['anthropic', 'codex', 'gemini']; + +export function createLoadingMultimodelCliStatus(): CliInstallationStatus { + const providers: CliProviderStatus[] = ( + [ + { providerId: 'anthropic', displayName: 'Anthropic' }, + { providerId: 'codex', displayName: 'Codex' }, + { providerId: 'gemini', displayName: 'Gemini' }, + ] as const + ).map((provider) => ({ + ...provider, + supported: false, + authenticated: false, + authMethod: null, + verificationState: 'unknown' as const, + statusMessage: 'Checking...', + models: [], + canLoginFromUi: true, + capabilities: { + teamLaunch: false, + oneShot: false, + }, + backend: null, + })); + + return { + flavor: 'free-code', + displayName: 'free-code-gemini-research', + supportsSelfUpdate: false, + showVersionDetails: false, + showBinaryPath: false, + installed: true, + installedVersion: null, + binaryPath: null, + latestVersion: null, + updateAvailable: false, + authLoggedIn: false, + authStatusChecking: true, + authMethod: null, + providers, + }; +} // ============================================================================= // Slice Interface @@ -22,6 +64,7 @@ export interface CliInstallerSlice { // State cliStatus: CliInstallationStatus | null; cliStatusLoading: boolean; + cliProviderStatusLoading: Partial>; cliStatusError: string | null; cliInstallerState: | 'idle' @@ -41,23 +84,33 @@ export interface CliInstallerSlice { cliCompletedVersion: string | null; // Actions + bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise; fetchCliStatus: () => Promise; + fetchCliProviderStatus: ( + providerId: CliProviderId, + options?: { silent?: boolean; epoch?: number } + ) => Promise; invalidateCliStatus: () => Promise; installCli: () => void; } let cliStatusInFlight: Promise | null = null; +const cliProviderStatusInFlight = new Map>(); +let cliStatusEpoch = 0; +const cliProviderStatusSeq = new Map(); // ============================================================================= // Slice Creator // ============================================================================= export const createCliInstallerSlice: StateCreator = ( - set + set, + get ) => ({ // Initial state cliStatus: null, cliStatusLoading: false, + cliProviderStatusLoading: {}, cliStatusError: null, cliInstallerState: 'idle', cliDownloadProgress: 0, @@ -69,15 +122,92 @@ export const createCliInstallerSlice: StateCreator { + if (!api.cliInstaller) return; + const multimodelEnabled = options?.multimodelEnabled ?? true; + if (!multimodelEnabled) { + return get().fetchCliStatus(); + } + + const epoch = ++cliStatusEpoch; + const providerLoading = Object.fromEntries( + MULTIMODEL_PROVIDER_IDS.map((providerId) => [providerId, true]) + ) as Partial>; + + set({ + cliStatus: createLoadingMultimodelCliStatus(), + cliStatusLoading: true, + cliProviderStatusLoading: providerLoading, + cliStatusError: null, + }); + + void (async () => { + try { + const metadata = await api.cliInstaller.getStatus(); + set((state) => { + if (epoch !== cliStatusEpoch || !state.cliStatus) { + return {}; + } + + return { + cliStatus: { + ...state.cliStatus, + flavor: metadata.flavor, + displayName: metadata.displayName, + supportsSelfUpdate: metadata.supportsSelfUpdate, + showVersionDetails: metadata.showVersionDetails, + showBinaryPath: metadata.showBinaryPath, + installed: metadata.installed, + installedVersion: metadata.installedVersion, + binaryPath: metadata.binaryPath, + latestVersion: metadata.latestVersion, + updateAvailable: metadata.updateAvailable, + authStatusChecking: state.cliStatus.providers.some( + (provider) => provider.statusMessage === 'Checking...' + ), + }, + }; + }); + } catch (error) { + logger.warn('Failed to hydrate CLI metadata during provider-first bootstrap:', error); + } + })(); + + try { + await Promise.allSettled( + MULTIMODEL_PROVIDER_IDS.map((providerId) => + get().fetchCliProviderStatus(providerId, { + silent: false, + epoch, + }) + ) + ); + } finally { + if (epoch === cliStatusEpoch) { + set({ cliStatusLoading: false }); + } + } + }, + fetchCliStatus: async () => { if (!api.cliInstaller) return; if (cliStatusInFlight) return cliStatusInFlight; + const epoch = ++cliStatusEpoch; cliStatusInFlight = (async () => { set({ cliStatusLoading: true, cliStatusError: null }); try { const status = await api.cliInstaller.getStatus(); - set({ cliStatus: status }); + if (epoch !== cliStatusEpoch) { + return; + } + set({ cliStatus: status, cliProviderStatusLoading: {} }); + for (const provider of status.providers) { + void get().fetchCliProviderStatus(provider.providerId, { + silent: true, + epoch, + }); + } } catch (error) { const message = error instanceof Error ? error.message : 'Failed to check CLI status'; logger.error('Failed to fetch CLI status:', error); @@ -91,6 +221,102 @@ export const createCliInstallerSlice: StateCreator { + if (!api.cliInstaller) return; + const inFlight = cliProviderStatusInFlight.get(providerId); + if (inFlight) return inFlight; + + const requestEpoch = options?.epoch ?? cliStatusEpoch; + const requestSeq = (cliProviderStatusSeq.get(providerId) ?? 0) + 1; + const silent = options?.silent === true; + cliProviderStatusSeq.set(providerId, requestSeq); + + const request = (async () => { + if (!silent) { + set((state) => ({ + cliStatusError: null, + cliProviderStatusLoading: { + ...state.cliProviderStatusLoading, + [providerId]: true, + }, + })); + } + + try { + const providerStatus = await api.cliInstaller.getProviderStatus(providerId); + set((state) => { + const nextLoading = silent + ? state.cliProviderStatusLoading + : { + ...state.cliProviderStatusLoading, + [providerId]: false, + }; + + if ( + requestEpoch !== cliStatusEpoch || + cliProviderStatusSeq.get(providerId) !== requestSeq + ) { + return { cliProviderStatusLoading: nextLoading }; + } + + if (!providerStatus || !state.cliStatus) { + return { cliProviderStatusLoading: nextLoading }; + } + + const hasProvider = state.cliStatus.providers.some( + (provider) => provider.providerId === providerId + ); + const nextProviders = hasProvider + ? state.cliStatus.providers.map((provider) => + provider.providerId === providerId ? providerStatus : provider + ) + : [...state.cliStatus.providers, providerStatus]; + const authenticatedProvider = + nextProviders.find((provider) => provider.authenticated) ?? null; + + return { + cliStatus: { + ...state.cliStatus, + providers: nextProviders, + authLoggedIn: nextProviders.some((provider) => provider.authenticated), + authMethod: authenticatedProvider?.authMethod ?? null, + }, + cliProviderStatusLoading: nextLoading, + }; + }); + } catch (error) { + const message = + error instanceof Error ? error.message : `Failed to refresh ${providerId} status`; + logger.error(`Failed to fetch ${providerId} CLI status:`, error); + set((state) => { + const nextLoading = silent + ? state.cliProviderStatusLoading + : { + ...state.cliProviderStatusLoading, + [providerId]: false, + }; + + if ( + requestEpoch !== cliStatusEpoch || + cliProviderStatusSeq.get(providerId) !== requestSeq + ) { + return { cliProviderStatusLoading: nextLoading }; + } + + return { + cliStatusError: message, + cliProviderStatusLoading: nextLoading, + }; + }); + } finally { + cliProviderStatusInFlight.delete(providerId); + } + })(); + + cliProviderStatusInFlight.set(providerId, request); + return request; + }, + invalidateCliStatus: async () => { await api.cliInstaller?.invalidateStatus(); }, diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 71818524..d839a536 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -762,8 +762,10 @@ export interface TeamSlice { // Messages panel UI state messagesPanelMode: 'sidebar' | 'inline'; messagesPanelWidth: number; + sidebarLogsHeight: number; setMessagesPanelMode: (mode: 'sidebar' | 'inline') => void; setMessagesPanelWidth: (width: number) => void; + setSidebarLogsHeight: (height: number) => void; } // --- Per-team launch params persistence --- @@ -998,8 +1000,10 @@ export const createTeamSlice: StateCreator = (set, // Messages panel UI state messagesPanelMode: 'sidebar' as const, messagesPanelWidth: 340, + sidebarLogsHeight: 213, setMessagesPanelMode: (mode: 'sidebar' | 'inline') => set({ messagesPanelMode: mode }), setMessagesPanelWidth: (width: number) => set({ messagesPanelWidth: width }), + setSidebarLogsHeight: (height: number) => set({ sidebarLogsHeight: height }), fetchBranches: async (paths: string[]) => { const entries = await Promise.all( @@ -2381,10 +2385,24 @@ export const createTeamSlice: StateCreator = (set, } if (isCanonicalRun && TERMINAL_PROVISIONING_STATES.has(progress.state)) { - // Clear spawn statuses — provisioning is complete, members now tracked via normal status set((prev) => { const next = { ...prev.memberSpawnStatusesByTeam }; - delete next[progress.teamName]; + const currentStatuses = next[progress.teamName]; + if (!currentStatuses) { + return { memberSpawnStatusesByTeam: next }; + } + if (progress.state === 'ready') { + next[progress.teamName] = currentStatuses; + return { memberSpawnStatusesByTeam: next }; + } + const retainedStatuses = Object.fromEntries( + Object.entries(currentStatuses).filter(([, entry]) => entry.status === 'error') + ); + if (Object.keys(retainedStatuses).length > 0) { + next[progress.teamName] = retainedStatuses; + } else { + delete next[progress.teamName]; + } return { memberSpawnStatusesByTeam: next }; }); } diff --git a/src/renderer/utils/bootstrapPromptSanitizer.ts b/src/renderer/utils/bootstrapPromptSanitizer.ts new file mode 100644 index 00000000..2d70260b --- /dev/null +++ b/src/renderer/utils/bootstrapPromptSanitizer.ts @@ -0,0 +1,229 @@ +import { displayMemberName } from '@renderer/utils/memberHelpers'; + +import type { InboxMessage } from '@shared/types'; + +const BOOTSTRAP_REQUIRED_MARKERS = [ + 'Your FIRST action: call MCP tool member_briefing', + 'Do NOT start work, claim tasks, or improvise workflow/task/process rules before member_briefing succeeds.', +] as const; + +const BOOTSTRAP_SUPPORTING_MARKERS = [ + 'If member_briefing fails, send', + 'member_briefing is expected to be available in your initial MCP tool list.', + 'IMPORTANT: When sending messages to the team lead', +] as const; + +type TeamProviderId = 'anthropic' | 'codex' | 'gemini'; + +function parseProviderId(value: string | undefined): TeamProviderId | null { + const normalized = value?.trim().toLowerCase(); + if (normalized === 'anthropic' || normalized === 'codex' || normalized === 'gemini') { + return normalized; + } + return null; +} + +function getTeamModelLabel(model: string): string { + const trimmed = model.trim(); + switch (trimmed) { + case 'gemini-2.5-pro': + return 'Gemini 2.5 Pro'; + case 'gemini-2.5-flash': + return 'Gemini 2.5 Flash'; + case 'gemini-2.5-flash-lite': + return 'Gemini 2.5 Flash Lite'; + case 'gpt-5.4': + return 'GPT-5.4'; + case 'gpt-5.4-mini': + return 'GPT-5.4 Mini'; + case 'gpt-5.3-codex': + return 'GPT-5.3 Codex'; + case 'gpt-5.3-codex-spark': + return 'GPT-5.3 Codex Spark'; + case 'gpt-5.2': + return 'GPT-5.2'; + case 'gpt-5.2-codex': + return 'GPT-5.2 Codex'; + case 'gpt-5.1-codex-mini': + return 'GPT-5.1 Codex Mini'; + case 'gpt-5.1-codex-max': + return 'GPT-5.1 Codex Max'; + case 'claude-sonnet-4-6': + return 'Sonnet 4.6'; + case 'claude-sonnet-4-6[1m]': + return 'Sonnet 4.6 (1M)'; + case 'claude-opus-4-6': + return 'Opus 4.6'; + case 'claude-opus-4-6[1m]': + return 'Opus 4.6 (1M)'; + case 'claude-haiku-4-5-20251001': + return 'Haiku 4.5'; + default: + return trimmed || 'Default'; + } +} + +function getTeamProviderLabel(providerId: TeamProviderId): string { + switch (providerId) { + case 'codex': + return 'Codex'; + case 'gemini': + return 'Gemini'; + case 'anthropic': + default: + return 'Anthropic'; + } +} + +function getTeamEffortLabel(effort: string | undefined): string { + const trimmed = effort?.trim() ?? ''; + return trimmed ? trimmed.charAt(0).toUpperCase() + trimmed.slice(1) : 'Default'; +} + +function matchField(text: string, pattern: RegExp): string | undefined { + const match = pattern.exec(text); + const value = match?.[1]?.trim(); + return value ? value : undefined; +} + +function buildRuntimeSummary( + providerId: TeamProviderId | null, + model: string | undefined, + effort: string | undefined +): string | undefined { + if (providerId) { + const providerLabel = getTeamProviderLabel(providerId); + const modelLabel = model ? getTeamModelLabel(model) : 'Default'; + const effortLabel = getTeamEffortLabel(effort); + const normalizedProvider = providerLabel.trim().toLowerCase(); + const normalizedModel = modelLabel.trim().toLowerCase(); + const modelAlreadyCarriesProviderBrand = + modelLabel !== 'Default' && + (normalizedModel.startsWith(normalizedProvider) || + (providerId === 'anthropic' && normalizedModel.startsWith('claude')) || + (providerId === 'codex' && + (normalizedModel.startsWith('codex') || normalizedModel.startsWith('gpt'))) || + (providerId === 'gemini' && normalizedModel.startsWith('gemini'))); + + const parts = modelAlreadyCarriesProviderBrand + ? [modelLabel, effortLabel] + : [providerLabel, modelLabel, effortLabel]; + return parts.filter(Boolean).join(' · '); + } + + const modelLabel = model ? getTeamModelLabel(model) : ''; + const effortLabel = effort ? getTeamEffortLabel(effort) : ''; + const providerLabel = providerId ? getTeamProviderLabel(providerId) : ''; + return [providerLabel, modelLabel, effortLabel].filter(Boolean).join(' · ') || undefined; +} + +export interface BootstrapPromptDisplay { + teammateName?: string; + teamName?: string; + runtime?: string; + summary: string; + body: string; +} + +export interface BootstrapAcknowledgementDisplay { + teammateName?: string; + teamName?: string; + summary: string; + body: string; +} + +export function getBootstrapPromptDisplay( + message: Pick +): BootstrapPromptDisplay | null { + const text = typeof message.text === 'string' ? message.text.trim() : ''; + const hasRequiredMarkers = BOOTSTRAP_REQUIRED_MARKERS.every((marker) => text.includes(marker)); + const hasSupportingMarker = BOOTSTRAP_SUPPORTING_MARKERS.some((marker) => text.includes(marker)); + if (!text.startsWith('You are ') || !hasRequiredMarkers || !hasSupportingMarker) { + return null; + } + + const teammateName = + matchField(text, /^You are\s+([^,\n]+),/m) ?? + (typeof message.to === 'string' ? message.to.trim() : undefined); + const teamName = matchField(text, /on team "([^"]+)"/); + const providerId = parseProviderId( + matchField(text, /Provider override for this teammate:\s*([^\.\n]+)/i) + ); + const model = matchField(text, /Model override for this teammate:\s*([^\.\n]+)/i); + const effort = matchField(text, /Effort override for this teammate:\s*([^\.\n]+)/i); + const runtime = buildRuntimeSummary(providerId, model, effort); + const displayName = teammateName ? displayMemberName(teammateName) : 'teammate'; + const summary = `Starting ${displayName}`; + const bodyLines = [`Lead is starting \`${displayName}\` as a teammate.`]; + + if (runtime) { + bodyLines.push(`Runtime: ${runtime}`); + } else if (teamName) { + bodyLines.push(`Team: \`${teamName}\``); + } + + bodyLines.push('Startup instructions are hidden in the UI.'); + + return { + teammateName, + teamName, + runtime, + summary, + body: bodyLines.join('\n\n'), + }; +} + +export function getBootstrapAcknowledgementDisplay( + message: Pick +): BootstrapAcknowledgementDisplay | null { + const text = typeof message.text === 'string' ? message.text.trim() : ''; + if (!text.startsWith('{') || !text.endsWith('}')) { + return null; + } + + const markers = [ + "'concerns':", + "'existingRelationships':", + "'hasBootstrapGuidance':", + "'hasAcknowledged':", + "'isTeamLead':", + "'memberName':", + "'teamName':", + ]; + if (!markers.every((marker) => text.includes(marker))) { + return null; + } + + const teammateName = + matchField(text, /'memberName':\s*'([^']+)'/) ?? + (typeof message.from === 'string' ? message.from.trim() : undefined); + const teamName = matchField(text, /'teamName':\s*'([^']+)'/); + const displayName = teammateName ? displayMemberName(teammateName) : 'teammate'; + + return { + teammateName, + teamName, + summary: `${displayName} acknowledged bootstrap`, + body: `${displayName} acknowledged bootstrap.`, + }; +} + +export function getSanitizedInboxMessageText(message: Pick): string { + return ( + getBootstrapPromptDisplay(message)?.body ?? + getBootstrapAcknowledgementDisplay(message as Pick)?.body ?? + message.text ?? + '' + ); +} + +export function getSanitizedInboxMessageSummary( + message: Pick +): string { + return ( + getBootstrapPromptDisplay(message)?.summary ?? + getBootstrapAcknowledgementDisplay(message)?.summary ?? + message.summary ?? + '' + ); +} diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index e7d5b419..1aa194ba 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -7,6 +7,7 @@ import { isLeadMember } from '@shared/utils/leadDetection'; import type { LeadActivityState, + MemberSpawnLivenessSource, MemberSpawnStatus, MemberStatus, ResolvedTeamMember, @@ -98,9 +99,9 @@ export const SPAWN_DOT_COLORS: Record = { export const SPAWN_PRESENCE_LABELS: Record = { offline: 'offline', - waiting: 'waiting', - spawning: 'spawning', - online: 'online', + waiting: 'awaiting heartbeat', + spawning: 'starting', + online: 'ready', error: 'spawn failed', }; @@ -115,8 +116,20 @@ export function getSpawnAwareDotClass( isTeamProvisioning?: boolean, leadActivity?: LeadActivityState ): string { - if (spawnStatus && isTeamProvisioning) { - return SPAWN_DOT_COLORS[spawnStatus]; + if (spawnStatus === 'error') { + return SPAWN_DOT_COLORS.error; + } + if (spawnStatus === 'waiting') { + return SPAWN_DOT_COLORS.waiting; + } + if (spawnStatus === 'online') { + return SPAWN_DOT_COLORS.online; + } + if (spawnStatus === 'offline' && isTeamProvisioning) { + return SPAWN_DOT_COLORS.offline; + } + if (spawnStatus === 'spawning' && isTeamProvisioning) { + return SPAWN_DOT_COLORS.spawning; } return getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity); } @@ -127,10 +140,23 @@ export function getSpawnAwareDotClass( export function getSpawnAwarePresenceLabel( member: ResolvedTeamMember, spawnStatus: MemberSpawnStatus | undefined, + livenessSource: MemberSpawnLivenessSource | undefined, isTeamAlive?: boolean, isTeamProvisioning?: boolean, leadActivity?: LeadActivityState ): string { + if (spawnStatus === 'error') { + return SPAWN_PRESENCE_LABELS.error; + } + if (spawnStatus === 'offline' && isTeamProvisioning) { + return 'waiting for Agent'; + } + if (spawnStatus === 'waiting') { + return SPAWN_PRESENCE_LABELS.waiting; + } + if (spawnStatus === 'online' && livenessSource === 'process') { + return 'running'; + } if (spawnStatus && isTeamProvisioning) { return SPAWN_PRESENCE_LABELS[spawnStatus]; } diff --git a/src/renderer/utils/streamJsonParser.ts b/src/renderer/utils/streamJsonParser.ts index 7a154b64..54b3645a 100644 --- a/src/renderer/utils/streamJsonParser.ts +++ b/src/renderer/utils/streamJsonParser.ts @@ -6,6 +6,7 @@ */ import { getToolSummary } from '@renderer/utils/toolRendering/toolSummaryHelpers'; +import { summarizeAgentToolInput } from '@shared/utils/toolSummary'; import type { AIGroupDisplayItem, LinkedToolItem } from '@renderer/types/groups'; @@ -363,8 +364,8 @@ export function groupBySubagent(groups: StreamJsonGroup[]): StreamJsonEntry[] { if (item.type === 'tool' && (item.tool.name === 'Agent' || item.tool.name === 'Task')) { const input = item.tool.input as Record | undefined; const desc = + (item.tool.name === 'Agent' && input ? summarizeAgentToolInput(input, 80) : null) || (typeof input?.description === 'string' && input.description) || - (typeof input?.prompt === 'string' && input.prompt.slice(0, 80)) || 'Subagent'; pendingDescriptions.push(desc); } diff --git a/src/renderer/utils/teamMessageFiltering.ts b/src/renderer/utils/teamMessageFiltering.ts index c5925605..4b7b5327 100644 --- a/src/renderer/utils/teamMessageFiltering.ts +++ b/src/renderer/utils/teamMessageFiltering.ts @@ -1,3 +1,7 @@ +import { + getSanitizedInboxMessageSummary, + getSanitizedInboxMessageText, +} from '@renderer/utils/bootstrapPromptSanitizer'; import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; import type { InboxMessage } from '@shared/types'; @@ -48,8 +52,8 @@ export function filterTeamMessages( const q = searchQuery.trim().toLowerCase(); if (q) { list = list.filter((m) => { - const text = (m.text ?? '').toLowerCase(); - const summary = (m.summary ?? '').toLowerCase(); + const text = getSanitizedInboxMessageText(m).toLowerCase(); + const summary = getSanitizedInboxMessageSummary(m).toLowerCase(); const from = (m.from ?? '').toLowerCase(); const to = (m.to ?? '').toLowerCase(); return text.includes(q) || summary.includes(q) || from.includes(q) || to.includes(q); diff --git a/src/renderer/utils/toolRendering/toolSummaryHelpers.ts b/src/renderer/utils/toolRendering/toolSummaryHelpers.ts index 2b06119b..fe9aa778 100644 --- a/src/renderer/utils/toolRendering/toolSummaryHelpers.ts +++ b/src/renderer/utils/toolRendering/toolSummaryHelpers.ts @@ -5,6 +5,7 @@ */ import { getBaseName } from '@renderer/utils/pathUtils'; +import { summarizeAgentToolInput } from '@shared/utils/toolSummary'; /** * Truncates a string to a maximum length with ellipsis. @@ -249,8 +250,7 @@ export function getToolSummary(toolName: string, input: Record) return 'Delete team'; case 'Agent': { - const desc = input.description ?? input.prompt; - return typeof desc === 'string' ? truncate(desc, 60) : 'Subagent'; + return summarizeAgentToolInput(input, 60); } default: { diff --git a/src/shared/types/cliInstaller.ts b/src/shared/types/cliInstaller.ts index 6d3aad66..326cc8c7 100644 --- a/src/shared/types/cliInstaller.ts +++ b/src/shared/types/cliInstaller.ts @@ -25,6 +25,25 @@ export type CliFlavor = 'claude' | 'free-code'; export type CliProviderId = 'anthropic' | 'codex' | 'gemini'; +export interface CliProviderBackendOption { + id: string; + label: string; + description: string; + selectable: boolean; + recommended: boolean; + available: boolean; + statusMessage?: string | null; + detailMessage?: string | null; +} + +export interface CliExternalRuntimeDiagnostic { + id: string; + label: string; + detected: boolean; + statusMessage?: string | null; + detailMessage?: string | null; +} + export interface CliProviderStatus { providerId: CliProviderId; displayName: string; @@ -39,6 +58,10 @@ export interface CliProviderStatus { teamLaunch: boolean; oneShot: boolean; }; + selectedBackendId?: string | null; + resolvedBackendId?: string | null; + availableBackends?: CliProviderBackendOption[]; + externalRuntimeDiagnostics?: CliExternalRuntimeDiagnostic[]; backend?: { kind: string; label: string; @@ -131,6 +154,8 @@ export interface CliInstallerProgress { export interface CliInstallerAPI { /** Get current CLI installation status */ getStatus: () => Promise; + /** Get current runtime/auth status for a single provider */ + getProviderStatus: (providerId: CliProviderId) => Promise; /** Start install/update flow. Progress sent via onProgress events. */ install: () => Promise; /** Invalidate cached status (forces fresh check on next getStatus) */ diff --git a/src/shared/types/notifications.ts b/src/shared/types/notifications.ts index f273bf23..f208b889 100644 --- a/src/shared/types/notifications.ts +++ b/src/shared/types/notifications.ts @@ -321,6 +321,13 @@ export interface AppConfig { /** Send anonymous crash & performance telemetry (requires SENTRY_DSN at build time) */ telemetryEnabled: boolean; }; + /** Runtime backend preferences for app-launched free-code sessions */ + runtime: { + providerBackends: { + gemini: 'auto' | 'api' | 'cli-sdk'; + codex: 'auto' | 'adapter'; + }; + }; /** Display and UI settings */ display: { /** Whether to show timestamps in message views */ diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index c833eec0..2a8708f2 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -58,6 +58,14 @@ export interface TeamSummary { deletedAt?: string; /** True when team.meta.json exists but config.json doesn't — provisioning failed before TeamCreate. */ pendingCreate?: boolean; + /** True when the last launch partially succeeded (e.g. lead started, but not all teammates joined). */ + partialLaunchFailure?: boolean; + /** Planned teammate count for the last persisted partial launch marker. */ + expectedMemberCount?: number; + /** Confirmed teammate count from runtime artifacts/config for the last partial launch marker. */ + confirmedMemberCount?: number; + /** Missing teammate names from the last partial launch marker. */ + missingMembers?: string[]; } export type TeamTaskStatus = 'pending' | 'in_progress' | 'completed' | 'deleted'; @@ -434,9 +442,10 @@ export type MemberStatus = 'active' | 'idle' | 'terminated' | 'unknown'; /** * Spawn lifecycle status for a team member during team launch/reconnect. - * - offline: not yet spawned (no Agent tool_use seen) + * - offline: queued, Agent tool_use not sent yet * - spawning: Agent tool_use sent, awaiting tool_result - * - online: tool_result received, agent is active + * - waiting: teammate process accepted by runtime, awaiting first heartbeat/inbox signal + * - online: first heartbeat/inbox signal received * - error: spawn failed (tool_result with error) */ export type MemberSpawnStatus = 'offline' | 'waiting' | 'spawning' | 'online' | 'error'; @@ -574,6 +583,8 @@ export interface MemberSpawnStatusesSnapshot { runId: string | null; } +export type MemberSpawnLivenessSource = 'heartbeat' | 'process'; + export interface TeamChangeEvent { type: | 'config' @@ -601,6 +612,12 @@ export interface MemberSpawnStatusEntry { status: MemberSpawnStatus; /** Error message when status === 'error'. */ error?: string; + /** + * Optional provenance for `online`. + * - heartbeat: teammate sent a real inbox/native message after bootstrap + * - process: runtime process is alive, but bootstrap/first reply is not yet confirmed + */ + livenessSource?: MemberSpawnLivenessSource; /** ISO timestamp of the last status change. */ updatedAt: string; } diff --git a/src/shared/utils/teamProvider.ts b/src/shared/utils/teamProvider.ts new file mode 100644 index 00000000..eb77bf67 --- /dev/null +++ b/src/shared/utils/teamProvider.ts @@ -0,0 +1,16 @@ +import type { TeamProviderId } from '@shared/types'; + +export function isTeamProviderId(value: unknown): value is TeamProviderId { + return value === 'anthropic' || value === 'codex' || value === 'gemini'; +} + +export function normalizeOptionalTeamProviderId(value: unknown): TeamProviderId | undefined { + return isTeamProviderId(value) ? value : undefined; +} + +export function normalizeTeamProviderId( + value: unknown, + fallback: TeamProviderId = 'anthropic' +): TeamProviderId { + return normalizeOptionalTeamProviderId(value) ?? fallback; +} diff --git a/src/shared/utils/toolSummary.ts b/src/shared/utils/toolSummary.ts index 546fd5ba..fae7681f 100644 --- a/src/shared/utils/toolSummary.ts +++ b/src/shared/utils/toolSummary.ts @@ -73,6 +73,75 @@ function truncateStr(str: string, max: number): string { return str.length <= max ? str : str.slice(0, max) + '...'; } +function formatProviderName(providerId: string): string { + switch (providerId.trim().toLowerCase()) { + case 'anthropic': + return 'Anthropic'; + case 'codex': + return 'Codex'; + case 'gemini': + return 'Gemini'; + default: + return providerId; + } +} + +function formatEffortName(effort: string): string { + const trimmed = effort.trim(); + return trimmed ? trimmed.charAt(0).toUpperCase() + trimmed.slice(1) : trimmed; +} + +export interface AgentToolDisplayDetails { + action: string; + teammateName?: string; + teamName?: string; + runtime?: string; + subagentType?: string; +} + +export function getAgentToolDisplayDetails( + input: Record +): AgentToolDisplayDetails { + const teammateName = typeof input.name === 'string' ? input.name.trim() || undefined : undefined; + const teamName = + typeof input.team_name === 'string' ? input.team_name.trim() || undefined : undefined; + const description = + typeof input.description === 'string' ? input.description.trim() || undefined : undefined; + const provider = + typeof input.provider === 'string' + ? formatProviderName(input.provider) + : typeof input.providerId === 'string' + ? formatProviderName(input.providerId) + : undefined; + const model = typeof input.model === 'string' ? input.model.trim() || undefined : undefined; + const effort = typeof input.effort === 'string' ? formatEffortName(input.effort) : undefined; + const subagentType = + typeof input.subagent_type === 'string' + ? input.subagent_type.trim() || undefined + : typeof input.subagentType === 'string' + ? input.subagentType.trim() || undefined + : undefined; + + const runtimeParts = [provider, model, effort].filter( + (part): part is string => typeof part === 'string' && part.length > 0 + ); + const runtime = runtimeParts.length > 0 ? runtimeParts.join(' · ') : undefined; + + return { + action: description ?? (teammateName ? `Spawn teammate ${teammateName}` : 'Spawn subagent'), + teammateName, + teamName, + runtime, + subagentType, + }; +} + +export function summarizeAgentToolInput(input: Record, max = 60): string { + const details = getAgentToolDisplayDetails(input); + const text = details.runtime ? `${details.action} · ${details.runtime}` : details.action; + return truncateStr(text, max); +} + /** Extract a short human-readable preview from tool_use input arguments. */ export function extractToolPreview( name: string, @@ -95,10 +164,13 @@ export function extractToolPreview( case 'Agent': case 'Task': case 'TaskCreate': - return typeof input.prompt === 'string' - ? input.prompt - : typeof input.description === 'string' - ? input.description + if (name === 'Agent') { + return summarizeAgentToolInput(input, 80); + } + return typeof input.description === 'string' + ? input.description + : typeof input.prompt === 'string' + ? truncateStr(input.prompt, 80) : undefined; case 'WebFetch': if (typeof input.url === 'string') { diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 10bd96dc..b67b2b7b 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -481,4 +481,34 @@ describe('TeamProvisioningService', () => { }) ).toContain('but no logs for 2m is already unusual.'); }); + + it('formats AskUserQuestion approvals with readable question text', () => { + const svc = new TeamProvisioningService(); + + expect( + (svc as any).formatToolApprovalBody('AskUserQuestion', { + questions: [ + { + question: + 'Я испытываю технические трудности с отправкой сообщений с помощью инструмента `SendMessage`.', + }, + ], + }) + ).toBe( + 'Question: Я испытываю технические трудности с отправкой сообщений с помощью инструмента `SendMessage`.' + ); + }); + + it('formats AskUserQuestion approvals with a compact multi-question summary', () => { + const svc = new TeamProvisioningService(); + + expect( + (svc as any).formatToolApprovalBody('AskUserQuestion', { + questions: [ + { question: ' First question with extra spacing. ' }, + { question: 'Second question.' }, + ], + }) + ).toBe('Questions (2): First question with extra spacing.'); + }); });