From bae3609561204fb73b0296030729a806b093dc53 Mon Sep 17 00:00:00 2001 From: iliya Date: Thu, 2 Apr 2026 10:22:11 +0300 Subject: [PATCH 01/67] feat(multimodel): add free-code runtime and provider status UI --- docs/claude-multimodel-integration-plan.md | 825 ++++++++++++++++++ pnpm-lock.yaml | 17 +- src/main/ipc/cliInstaller.ts | 2 + src/main/ipc/configValidation.ts | 7 + .../infrastructure/CliInstallerService.ts | 116 ++- .../services/infrastructure/ConfigManager.ts | 2 + .../runtime/ClaudeMultimodelBridgeService.ts | 299 +++++++ .../services/runtime/geminiRuntimeAuth.ts | 113 +++ .../services/runtime/providerRuntimeEnv.ts | 33 + .../schedule/ScheduledTaskExecutor.ts | 8 +- .../services/team/ClaudeBinaryResolver.ts | 38 + src/main/services/team/cliFlavor.ts | 43 + src/preload/index.ts | 13 +- .../components/dashboard/CliStatusBanner.tsx | 545 ++++++++++-- .../settings/hooks/useSettingsConfig.ts | 2 + .../settings/hooks/useSettingsHandlers.ts | 1 + .../settings/sections/CliStatusSection.tsx | 388 +++++++- .../components/terminal/EmbeddedTerminal.tsx | 4 + .../components/terminal/TerminalModal.tsx | 5 +- src/renderer/store/index.ts | 5 + src/shared/types/cliInstaller.ts | 50 +- src/shared/types/notifications.ts | 2 + .../ClaudeMultimodelBridgeService.test.ts | 160 ++++ .../runtime/providerRuntimeEnv.test.ts | 29 + .../team/ClaudeBinaryResolver.test.ts | 94 ++ test/main/services/team/cliFlavor.test.ts | 55 ++ test/renderer/store/cliInstallerSlice.test.ts | 12 + 27 files changed, 2780 insertions(+), 88 deletions(-) create mode 100644 docs/claude-multimodel-integration-plan.md create mode 100644 src/main/services/runtime/ClaudeMultimodelBridgeService.ts create mode 100644 src/main/services/runtime/geminiRuntimeAuth.ts create mode 100644 src/main/services/runtime/providerRuntimeEnv.ts create mode 100644 src/main/services/team/cliFlavor.ts create mode 100644 test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts create mode 100644 test/main/services/runtime/providerRuntimeEnv.test.ts create mode 100644 test/main/services/team/ClaudeBinaryResolver.test.ts create mode 100644 test/main/services/team/cliFlavor.test.ts diff --git a/docs/claude-multimodel-integration-plan.md b/docs/claude-multimodel-integration-plan.md new file mode 100644 index 00000000..e4d983bd --- /dev/null +++ b/docs/claude-multimodel-integration-plan.md @@ -0,0 +1,825 @@ +# Claude Multimodel Integration Plan + +## Summary + +`claude_team` will integrate with a single CLI runtime: `claude-multimodel`. + +Inside that runtime, the app will track multiple provider states independently: + +- `anthropic` +- `codex` + +The dashboard will show one grouped status banner for the runtime and its providers. +That grouped banner should replace the current single-runtime dashboard banner, not sit beside a second provider banner. + +Provider and model selection will happen at process launch time, not in the banner. + +This avoids duplicating auth logic in `claude_team` and keeps `claude-multimodel` responsible +for provider login, token storage, and provider capability reporting. + +## Core Decisions + +### 1. Runtime model + +There is one runtime: + +- `claude-multimodel` + +There are not multiple runtime binaries for this feature. +`Anthropic` and `Codex` are providers inside the same runtime. + +Important consequence: + +- there is no separate `Codex` binary to install +- there is no separate `Codex` version to display +- there is no separate `Codex` updater flow + +### 2. Provider model + +Provider status must be tracked independently. + +Supported initial providers: + +- `anthropic` +- `codex` + +The user may be authenticated with both at the same time. +The UI must show both statuses independently without trying to choose a global active provider. + +This provider model must also remain compatible with future providers that expose +multiple internal backend paths under one public provider id. + +Example: + +- public provider: `gemini` +- internal runtime backend: `api` or `cli` + +In that scenario, `claude_team` should still treat `gemini` as one provider row. +Backend choice is diagnostic/runtime metadata inside the provider, not a second provider id. + +Provider support must be treated per execution mode, not just globally. +At minimum, the integration needs to distinguish: + +- interactive team launch / resume +- scheduled one-shot execution + +Internal naming rule: + +- app-level provider id: `codex` +- runtime/provider label in UI: `Codex` +- this maps to the OpenAI/Codex path inside `claude-multimodel` +- during migration, runtime-internal naming may still use `openai`; the bridge layer must normalize that to app-level `codex` + +Backward-compatibility rule: + +- existing persisted launches/schedules/teams that do not yet have `providerId` must default safely to `anthropic` +- missing `providerId` in old metadata must never block resume, launch, or schedule execution + +### 3. Launch-time selection + +Banner state is informational only. + +Provider selection happens when launching a task/team/process: + +- selected provider +- selected model + +Future extension: + +- per-teammate provider +- per-teammate model + +Defaulting rule: + +- for backward compatibility, flows with no explicit provider should default to `anthropic` +- provider defaulting must be explicit in code and persistence, not inferred from whichever UI tab/control happened to be last open +- model defaults must be provider-aware rather than using a single global default such as `opus` +- if a provider later adds internal backend selection, backend resolution must not silently change the app-level `providerId` + +## Goals + +- Show runtime status for `claude-multimodel` +- Show separate provider status for `Anthropic` and `Codex` +- Support login/logout/status checks through the runtime CLI +- Reuse the existing binary resolver and terminal-based login modal flow where possible +- Avoid reading runtime token/config internals directly from `claude_team` +- Keep current team launch flow extensible for provider/model selection + +## Non-Goals + +- No separate `Codex` installer +- No fake `Codex` download flow in the UI +- No separate `Codex` runtime version +- No full runtime abstraction layer right now +- No per-teammate model/provider implementation in this phase + +## Architecture + +### Runtime ownership + +`claude-multimodel` owns: + +- provider auth flows +- provider token storage +- provider auth verification +- provider model availability reporting + +`claude_team` owns: + +- UI rendering +- caching and refreshing status +- launch-time provider/model selection +- mapping runtime status into app-friendly DTOs +- reusing shared CLI probing helpers instead of duplicating binary/version logic +- ensuring per-process provider env/args are isolated and do not leak across parallel launches +- validating provider support for the concrete execution mode before spawn + +### Service boundary in `claude_team` + +Add a new main-process service: + +- `src/main/services/runtime/ClaudeMultimodelBridgeService.ts` + +Responsibilities: + +- resolve runtime binary path +- get runtime version +- get provider auth status +- get provider model lists +- aggregate banner DTO +- trigger provider login/logout commands + +This service should be separate from `CliInstallerService`. +Installer/update concerns and provider/auth concerns are different responsibilities. + +However, the implementation should reuse existing low-level pieces where possible: + +- `ClaudeBinaryResolver` +- existing terminal modal login pattern +- existing CLI version probing helpers, if they are extracted into a shared utility + +But existing Claude-specific auth/model logic must not be reused as-is for provider rows: + +- current installer auth probing is Anthropic-oriented +- current model parsing and display utilities are Claude-oriented +- provider rows must be driven by runtime provider data, not inferred from Claude-only helpers + +The service should also enforce bounded status-check behavior: + +- use timeouts for each runtime CLI command +- keep partial results if one command fails +- cache recent successful results briefly to avoid noisy repeated probes + +## Required CLI Contract in `claude-multimodel` + +The runtime should expose machine-readable commands. + +### 1. Runtime version + +Already available: + +```bash +claude-multimodel --version +``` + +Used for: + +- runtime version display in banner header + +Version display rule: + +- the app should normalize dev/build suffixes before rendering the banner header +- example: + - raw: `2.1.87-dev.20260401.t145625.sha38c09970 (Claude Code)` + - shown: `Claude 2.1.87` + +### 2. Provider auth status + +Add: + +```bash +claude-multimodel auth status --json --provider all +``` + +Contract requirements: + +- non-interactive only +- must never open a browser or prompt for user input +- must be side-effect free: + - no provider switching + - no token mutation unless the runtime explicitly performs a safe read-only refresh internally +- should return partial provider results when possible instead of failing the entire command on one provider-specific check + +Example response: + +```json +{ + "schemaVersion": 1, + "providers": { + "anthropic": { + "supported": true, + "authenticated": true, + "authMethod": "oauth", + "verificationState": "verified", + "canLoginFromUi": true, + "capabilities": { + "teamLaunch": true, + "oneShot": true + } + }, + "codex": { + "supported": true, + "authenticated": false, + "authMethod": null, + "verificationState": "verified", + "canLoginFromUi": true, + "capabilities": { + "teamLaunch": true, + "oneShot": true + } + } + } +} +``` + +Notes: + +- no global `active` provider state belongs in this contract +- provider choice happens per launch request, not in global status +- `authenticated` means the runtime considers usable credentials/session state present for that provider +- `verificationState` must distinguish `verified` from `unknown` / `offline` / `error` +- `supported` means the current runtime build knows how to use that provider +- `capabilities` means the current runtime build knows which execution modes can use that provider + +### 3. Provider model lists + +Add: + +```bash +claude-multimodel model list --json --provider all +``` + +Example response: + +```json +{ + "schemaVersion": 1, + "providers": { + "anthropic": { + "models": ["opus", "sonnet", "haiku"] + }, + "codex": { + "models": ["gpt-5.3-codex", "gpt-5.4", "gpt-5.4-mini"] + } + } +} +``` + +This is intentionally separate from auth status so auth and capability failures can be handled independently. + +Model listing should be available even when the provider is not currently authenticated, whenever the runtime can determine the model set statically. + +### 4. Provider login + +Add: + +```bash +claude-multimodel auth login --provider anthropic +claude-multimodel auth login --provider codex +``` + +```bash +claude-multimodel auth logout --provider anthropic +claude-multimodel auth logout --provider codex +``` + +This allows `claude_team` to reuse runtime-managed login flows instead of implementing OAuth directly. + +## Status Model in `claude_team` + +Recommended DTO: + +```ts +type ProviderId = 'anthropic' | 'codex'; + +interface ProviderStatus { + providerId: ProviderId; + displayName: string; + supported: boolean; + authenticated: boolean; + authMethod: 'oauth' | 'api_key' | 'external' | 'unknown' | null; + verificationState: 'verified' | 'unknown' | 'offline' | 'error'; + statusMessage?: string | null; + models: string[]; + canLoginFromUi: boolean; + capabilities: { + teamLaunch: boolean; + oneShot: boolean; + }; + backend?: { + kind: string; + label: string; + endpointLabel?: string | null; + projectId?: string | null; + authMethodDetail?: string | null; + } | null; +} + +interface ClaudeMultimodelDashboardStatus { + runtime: { + id: 'claude-multimodel'; + installed: boolean; + version: string | null; + }; + providers: ProviderStatus[]; +} +``` + +## Dashboard Banner + +One grouped banner should be shown. +It should replace the current dashboard runtime banner, not add a second top-level banner. + +### Header + +- runtime name/version, for example: `Claude 2.1.87` +- runtime auth-independent actions like `Extensions` + +For `claude-multimodel` mode: + +- keep the runtime version in the header +- hide runtime self-update UI +- hide binary path UI + +### Provider rows + +One row per provider: + +- `Anthropic` +- `Codex` + +Each row may show: + +- authenticated state +- auth method +- verification state when status is uncertain +- model list if available +- backend diagnostics when the provider has internal backend resolution +- actions: + - `Login` when unauthenticated + - `Logout` when authenticated + - `Re-check` always + +### Important UI rules + +- Do not show a fake `Codex version` +- Do not show a separate `Codex installer` +- Do not show a fake `Download Codex` action +- Do not force a global provider choice from the banner +- If a provider has multiple internal backends, show them as provider diagnostics, not as separate provider rows +- Partial success must be visible: + - Anthropic ok, Codex missing + - Codex ok, Anthropic missing + +## Launch Flow Changes + +Current launch flow already supports a team-level `model`. +Current UI also already has a provider affordance in [TeamModelSelector.tsx](../../src/renderer/components/team/dialogs/TeamModelSelector.tsx), but its logic is still placeholder/coming-soon. That placeholder currently uses `openai`; it should be normalized or mapped to the app-level provider id `codex` during implementation. + +Current code paths that must be kept in sync: + +- shared types in [team.ts](../../src/shared/types/team.ts) +- HTTP launch parsing in [teams.ts](../../src/main/http/teams.ts) +- IPC launch/create validation in [teams.ts](../../src/main/ipc/teams.ts) +- persisted draft metadata in [TeamMetaStore.ts](../../src/main/services/team/TeamMetaStore.ts) +- scheduled one-shot config in [schedule.ts](../../src/shared/types/schedule.ts) +- scheduled execution in [ScheduledTaskExecutor.ts](../../src/main/services/schedule/ScheduledTaskExecutor.ts) +- provider/model UI helpers in [TeamModelSelector.tsx](../../src/renderer/components/team/dialogs/TeamModelSelector.tsx) +- model display parsing in [modelParser.ts](../../src/shared/utils/modelParser.ts) +- launch dialog state persistence in [LaunchTeamDialog.tsx](../../src/renderer/components/team/dialogs/LaunchTeamDialog.tsx) +- create dialog state persistence in [CreateTeamDialog.tsx](../../src/renderer/components/team/dialogs/CreateTeamDialog.tsx) +- saved draft restore in [teams.ts](../../src/main/ipc/teams.ts) +- launch param persistence in the renderer store +- slash-command metadata in [slashCommands.ts](../../src/shared/utils/slashCommands.ts) + +Next step is to extend launch requests to include provider selection: + +```ts +interface TeamLaunchRequest { + providerId?: 'anthropic' | 'codex'; + model?: string; +} +``` + +Future-compatible extension for providers with internal backend preference: + +```ts +interface TeamLaunchRequest { + providerId?: 'anthropic' | 'codex' | 'gemini'; + model?: string; + providerOptions?: { + geminiBackendPreference?: 'auto' | 'api' | 'cli'; + }; +} +``` + +Provider selection must be applied only to the spawned child process environment/args. +It must not mutate a global app-wide runtime provider state. + +This launch-time provider/model selection must be honored consistently across all spawn paths: + +- manual team launch +- draft team launch that redirects into `createTeam` +- scheduled/background execution +- resume/retry/restart flows +- any other non-interactive process launch path that currently inherits team/model settings + +Implementation rule: + +- provider choice must be expressed via child-process-scoped args/env only +- conflicting provider env vars must be cleared for that child process +- one run using `codex` must not affect another run using `anthropic` + +Future-compatible shape: + +```ts +interface TeamLaunchRequest { + providerId?: 'anthropic' | 'codex'; + model?: string; + memberProviders?: Record; + memberModels?: Record; +} +``` + +This allows future per-teammate routing without redesigning the API again. + +Launch-time preflight validation should fail fast before spawning the child process when possible: + +- selected provider is unsupported by the current runtime build +- selected provider is clearly unauthenticated +- selected model is clearly incompatible with the selected provider + +These checks should produce actionable errors for the user, while still treating cached banner model lists as advisory rather than authoritative. + +The same provider/model fields must also be added to the data that currently persists launch intent: + +- `TeamLaunchRequest` +- `TeamCreateRequest` for draft-team fallback +- `TeamMetaFile` in `team.meta.json` +- `ScheduleLaunchConfig` + +Migration rule: + +- when old persisted data has no `providerId`, treat it as `anthropic` +- new writes should persist `providerId` explicitly + +UI persistence rule: + +- provider selection must be persisted independently from model selection +- remembered model state must be namespaced by provider, or reset safely on provider switch +- a previously remembered Anthropic model must not be auto-applied to Codex, and vice versa + +Launch source-of-truth rule: + +- explicit values from the current launch request win +- persisted metadata is fallback only +- banner/provider status is informational and must never silently override a launch request +- provider-internal backend resolution may inform diagnostics, but it must not overwrite explicit launch-time provider options + +Runtime observation rule: + +- launch-time `providerId` / `model` are the requested starting configuration +- observed runtime message metadata may later reflect a different actual model after in-session commands such as `/model` +- the app must not conflate "requested at launch" with "currently observed in session" + +## Edge Cases + +### Provider auth combinations + +Must support all of these without breaking the banner: + +- Anthropic authenticated, Codex unauthenticated +- Codex authenticated, Anthropic unauthenticated +- both authenticated +- neither authenticated + +All of these must remain first-class supported states in the banner. + +The same rule extends to future providers with internal backends: + +- one provider row may be authenticated while one backend path is degraded +- the banner should show provider-level health plus backend diagnostics without splitting into two pseudo-providers + +### Provider supported, but not for this execution mode + +A provider may be authenticated and generally supported, but still unavailable for a specific path: + +- available for one-shot scheduler runs, but not Agent Teams +- available for Agent Teams, but not one-shot + +The banner and preflight validation must not flatten these into a single boolean. +Capability checks must use the concrete execution mode being launched. + +For providers with internal backends, capability checks may also depend on the resolved backend: + +- provider supported, backend `api` unsupported for this path +- provider supported, backend `cli` supported for this path + +That still remains one provider decision surface in `claude_team`. + +### Runtime ok, provider verify uncertain + +If network verification fails: + +- do not downgrade directly to `authenticated = false` +- prefer `verificationState = unknown` or `offline` + +### No global active provider + +Because provider choice happens per launch: + +- the status contract must not imply one provider is globally selected +- a user can keep both `Anthropic` and `Codex` signed in simultaneously +- the banner is informational, not a provider selector + +### Provider selection must not leak between runs + +If one team/process launches with `codex` and another launches with `anthropic`: + +- this must not mutate the banner into a single globally active provider state +- launch-specific provider state should stay attached to the launched process/team metadata +- dashboard status should continue to reflect provider availability/auth only +- child process env must explicitly clear incompatible provider-selection variables before spawn + +### Managed flags vs custom CLI args + +`claude_team` already supports raw `extraCliArgs` / custom CLI args. + +Those args must not be allowed to silently override app-managed launch intent such as: + +- provider selection +- model selection +- permission mode defaults owned by the app +- any future provider-routing flags/env + +The implementation should either: + +- reject conflicting custom args with a validation error, or +- define a strict precedence rule where app-managed provider/model flags always win + +but it must not allow ambiguous mixed configuration. + +### Provider-specific model normalization + +Current UI and launch code contain Claude-specific model assumptions, including: + +- `limitContext` +- automatic `[1m]` suffixing +- defaulting to Claude-family model ids such as `opus[1m]` + +These rules must become provider-aware: + +- Anthropic may keep Claude-specific context/model normalization +- Codex must not inherit Claude-only suffix rules +- switching provider must not silently rewrite a selected model into an invalid format for that provider + +### Provider-specific selector state + +Current selector state is Anthropic-biased: + +- provider UI is currently hardcoded to Anthropic +- model options are static Anthropic options +- launch/create dialogs remember one global last-selected model + +The implementation must make selector state provider-aware: + +- provider list must come from supported runtime providers, with unsupported placeholders still disabled +- model options must come from the selected provider, not a shared static list +- switching provider must either preserve a valid provider-local previous choice or clear to that provider's default +- the UI must not display a stale Anthropic label/model when `codex` is selected + +### Resume and restart semantics + +If a team/run is resumed, retried, or restarted later: + +- the persisted launch-time `providerId` and `model` must remain the default for that resumed run +- changing banner status or current UI defaults must not silently rewrite old run configuration +- explicit user overrides during a new launch are allowed, but implicit fallback to a different provider is not + +The same rule applies to edited schedules and stored drafts: + +- opening an old schedule/draft in a newer UI must not silently rewrite provider/model until the user explicitly saves changes + +If a running session changes model interactively after launch: + +- launch metadata should remain the historical requested starting state +- observed session model should come from runtime output/log parsing +- resume should continue from the actual session state instead of blindly reimposing old defaults unless the user explicitly requests a fresh launch + +### Draft-team launch fallback + +`claude_team` can redirect a launch request into `createTeam` when only `team.meta.json` exists. + +That fallback must preserve the same provider/model intent: + +- `providerId` must survive the launch -> create redirect +- draft metadata must not drop provider selection +- create-time validation must match launch-time validation + +### Models unavailable + +If model listing fails but auth status succeeds: + +- keep provider visible +- show auth state +- omit or gray out model list + +If model listing succeeds for an unauthenticated provider: + +- still show the model list +- do not block model visibility on login state + +### Provider-specific model display parsing + +Current shared model display utilities are Claude-oriented. + +The integration must not assume: + +- every model id starts with `claude-` +- every provider model can be parsed into Claude family/version semantics + +For non-Claude providers, the UI should fall back to generic provider/model labels unless a provider-specific parser is added intentionally. + +This also applies to in-session command output: + +- `/model` command output or equivalent runtime messages may describe state transitions differently per provider +- the UI should not assume Anthropic-specific phrasing when interpreting provider/model changes + +If model lists differ by execution mode in a future runtime build: + +- the contract should evolve via `schemaVersion` +- old app versions should continue to degrade safely instead of assuming one global model set + +If model lists are temporarily unavailable for the selected provider in the launch dialog: + +- the UI should still allow safe fallback behavior for backward-compatible Anthropic flows +- Codex flows should avoid inventing a default model id that did not come from the runtime or explicit user choice + +### Model list is advisory, not launch authority + +Provider model lists in the banner are helpful capability hints only: + +- the actual launch path must still validate provider/model compatibility at spawn time +- cached model lists must not be treated as proof that a launch will succeed +- auth changes, subscription changes, or runtime updates may change model availability between refresh and launch + +### Older runtime build + +If the runtime does not yet support the new JSON commands: + +- show runtime status normally +- show provider section as unavailable +- include a message like `Provider status not supported by current claude-multimodel build` + +### Login/logout refresh behavior + +After a login or logout flow in the terminal modal: + +- the app must invalidate cached provider status +- the banner must refresh automatically +- a canceled login must not be treated as auth failure + +Refresh handling must also be race-safe: + +- older async refresh results must not overwrite newer ones +- concurrent login/logout/re-check actions must not leave the banner in a stale mixed state +- provider rows should expose a temporary loading/pending state while a provider-specific action is in flight + +### Running teams during auth changes + +If the user logs in, logs out, or changes provider credentials while teams are already running: + +- the banner should refresh to reflect the new provider status +- already-running teams must not be implicitly reconfigured by the dashboard refresh itself +- launch-time provider/model metadata for existing runs should remain attached to those runs + +Provider status changes should also avoid leaking sensitive details into UI logs: + +- no token values +- no raw OAuth payloads +- only high-level auth method / verification state / actionable error text + +### Runtime missing + +If `claude-multimodel` is missing or not executable: + +- show runtime-level failure in the banner header +- provider rows should be disabled or shown as unavailable +- do not render stale provider state as if it were fresh + +### Slow or hanging status commands + +If one or more runtime CLI status commands are slow or hang: + +- the dashboard must not block indefinitely +- show the last good known state when available +- mark uncertain sections as stale/unknown instead of replacing them with false negatives + +### Mixed command support during rollout + +During migration, some runtime builds may support: + +- version probing +- auth status JSON +- but not model list JSON + +The app should degrade gracefully per capability instead of requiring all commands to work at once. + +### Provider-specific status degradation + +If one provider status check succeeds and another degrades: + +- keep the successful provider row fully usable +- mark only the failing provider row as `unknown` / `error` +- do not collapse the whole banner into a global failure state + +## Implementation Phases + +### Phase 1. Runtime CLI contract + +In `claude-multimodel`: + +- add `auth status --json --provider all` +- add `model list --json --provider all` +- add `auth login --provider anthropic|codex` +- add `auth logout --provider anthropic|codex` + +### Phase 2. Bridge service in `claude_team` + +Add: + +- `src/main/services/runtime/ClaudeMultimodelBridgeService.ts` + +Implement: + +- `getRuntimeVersion()` +- `getProviderAuthStatus()` +- `getProviderModels()` +- `getDashboardStatus()` +- `login(providerId)` +- `logout(providerId)` + +The service should aggregate the banner DTO from multiple runtime CLI calls while keeping partial results usable. + +### Phase 3. Banner integration + +Refactor the dashboard CLI banner into a grouped runtime/provider banner: + +- runtime header +- Anthropic provider row +- Codex provider row + +This phase should also explicitly hide runtime path/update controls for `claude-multimodel` mode. + +### Phase 4. Launch integration + +Extend launch/create dialogs and IPC payloads: + +- add `providerId` +- wire provider choice into spawned CLI args/env +- reuse the existing provider affordance in `TeamModelSelector` instead of creating a second provider selector + +This phase must explicitly define the child-process env mapping for provider selection, including clearing conflicting provider flags. +It must also persist launch-time `providerId` alongside `model` in team/run metadata so existing runs remain inspectable after refresh. +It must update both the interactive team runtime path and the scheduler one-shot path. + +Phase 4 should also explicitly hide or keep disabled unsupported placeholder providers in `TeamModelSelector` until their runtime support actually exists. In Phase 1/2/3, only `Anthropic` and `Codex` should move out of placeholder behavior. + +### Phase 5. Future teammate-level routing + +Later extension: + +- `memberProviders` +- `memberModels` + +This phase should happen only after team-level provider/model selection is stable. +It will likely require both `claude_team` changes and runtime support in `claude-multimodel` for teammate-level routing. + +## Why This Plan + +This plan keeps the ownership boundary clean: + +- `claude-multimodel` remains the source of truth for auth and provider capabilities +- `claude_team` remains a UI/orchestration layer + +It also avoids the two bad extremes: + +- no duplicated OAuth/token logic in `claude_team` +- no premature heavy runtime abstraction layer + +This is the smallest architecture that is still robust enough for: + +- multi-provider status +- launch-time provider selection +- future per-agent provider/model routing +- shared runtime ownership without duplicating auth logic in `claude_team` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f81f1a5b..972d9968 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -523,6 +523,9 @@ importers: d3-force: specifier: ^3.0.0 version: 3.0.0 + lucide-react: + specifier: '>=0.300.0' + version: 0.577.0(react@18.3.1) react: specifier: ^18.0.0 version: 18.3.1 @@ -15340,6 +15343,14 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 + '@vitest/mocker@3.2.4(vite@5.4.21(@types/node@22.19.15)(sass@1.98.0)(terser@5.46.0))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@22.19.15)(sass@1.98.0)(terser@5.46.0) + '@vitest/mocker@3.2.4(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))': dependencies: '@vitest/spy': 3.2.4 @@ -19287,6 +19298,10 @@ snapshots: dependencies: yallist: 4.0.0 + lucide-react@0.577.0(react@18.3.1): + dependencies: + react: 18.3.1 + lucide-react@0.577.0(react@19.2.4): dependencies: react: 19.2.4 @@ -22802,7 +22817,7 @@ snapshots: dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0)) + '@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@22.19.15)(sass@1.98.0)(terser@5.46.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 diff --git a/src/main/ipc/cliInstaller.ts b/src/main/ipc/cliInstaller.ts index de4d5fda..8511140d 100644 --- a/src/main/ipc/cliInstaller.ts +++ b/src/main/ipc/cliInstaller.ts @@ -17,6 +17,7 @@ import { getErrorMessage } from '@shared/utils/errorHandling'; 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 { IpcMain, IpcMainInvokeEvent } from 'electron'; @@ -111,5 +112,6 @@ async function handleInstall(_event: IpcMainInvokeEvent): Promise { cachedStatus = null; + ClaudeBinaryResolver.clearCache(); return { success: true, data: undefined }; } diff --git a/src/main/ipc/configValidation.ts b/src/main/ipc/configValidation.ts index 9d322564..d9a5104e 100644 --- a/src/main/ipc/configValidation.ts +++ b/src/main/ipc/configValidation.ts @@ -286,6 +286,7 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V 'showDockIcon', 'theme', 'defaultTab', + 'multimodelEnabled', 'claudeRootPath', 'agentLanguage', 'autoExpandAIGroups', @@ -328,6 +329,12 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V } result.defaultTab = value; break; + case 'multimodelEnabled': + if (typeof value !== 'boolean') { + return { valid: false, error: 'general.multimodelEnabled must be a boolean' }; + } + result.multimodelEnabled = value; + break; case 'claudeRootPath': if (value === null) { result.claudeRootPath = null; diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index 4e4b0d8f..70c9cfe2 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -37,7 +37,9 @@ import https from 'https'; import { tmpdir } from 'os'; import { join, posix as pathPosix, win32 as pathWin32 } from 'path'; +import { ClaudeMultimodelBridgeService } from '../runtime/ClaudeMultimodelBridgeService'; import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver'; +import { getConfiguredCliFlavor, getCliFlavorUiOptions } from '../team/cliFlavor'; import type { CliInstallationStatus, CliInstallerProgress, CliPlatform } from '@shared/types'; import type { BrowserWindow } from 'electron'; @@ -123,6 +125,18 @@ const DIAG_PATH_HEAD = 400; const DIAG_HOME_PREVIEW = 120; const DIAG_AUTH_STDOUT_TAIL = 160; +function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallationStatus { + return { + ...status, + providers: status.providers.map((provider) => ({ + ...provider, + capabilities: { ...provider.capabilities }, + backend: provider.backend ? { ...provider.backend } : null, + models: [...provider.models], + })), + }; +} + // ============================================================================= // Helpers // ============================================================================= @@ -297,6 +311,7 @@ function resetGatherDiag(diag: CliInstallerStatusRunDiag): void { export class CliInstallerService { private mainWindow: BrowserWindow | null = null; private installing = false; + private readonly multimodelBridgeService = new ClaudeMultimodelBridgeService(); private electronMetaForDiag(): Record { try { @@ -370,12 +385,48 @@ export class CliInstallerService { return buildEnrichedEnv(binaryPath); } - // --------------------------------------------------------------------------- - // Public: getStatus - // --------------------------------------------------------------------------- - - async getStatus(): Promise { - const result: CliInstallationStatus = { + private createInitialStatus(): CliInstallationStatus { + const flavor = getConfiguredCliFlavor(); + const ui = getCliFlavorUiOptions(flavor); + const providers = + flavor === 'free-code' + ? ( + [ + { + 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, + displayName: ui.displayName, + supportsSelfUpdate: ui.supportsSelfUpdate, + showVersionDetails: ui.showVersionDetails, + showBinaryPath: ui.showBinaryPath, installed: false, installedVersion: null, binaryPath: null, @@ -383,7 +434,16 @@ export class CliInstallerService { updateAvailable: false, authLoggedIn: false, authMethod: null, + providers, }; + } + + // --------------------------------------------------------------------------- + // Public: getStatus + // --------------------------------------------------------------------------- + + async getStatus(): Promise { + const result = this.createInitialStatus(); // Run the actual status gathering with an overall timeout. // On timeout, return whatever partial result was collected so far. @@ -437,6 +497,7 @@ export class CliInstallerService { if (binaryPath) { r.installed = true; r.binaryPath = binaryPath; + this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(r) }); try { const { stdout } = await execCli(binaryPath, ['--version'], { @@ -451,13 +512,20 @@ export class CliInstallerService { diag.versionError = getErrorMessage(err); logger.warn('Failed to get CLI version:', diag.versionError); } + this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(r) }); // Auth and GCS version check are independent — run in parallel. // Both mutate `r` directly so partial results survive the outer timeout. - await Promise.all([this.checkAuthStatus(binaryPath, r, diag), this.fetchLatestVersion(r)]); + await Promise.all([ + this.checkAuthStatus(binaryPath, r, diag), + r.supportsSelfUpdate ? this.fetchLatestVersion(r) : Promise.resolve(), + ]); } else { // No binary — still check latest version for "install" prompt - await this.fetchLatestVersion(r); + if (r.supportsSelfUpdate) { + await this.fetchLatestVersion(r); + } + this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(r) }); } } @@ -472,6 +540,31 @@ export class CliInstallerService { result: CliInstallationStatus, diag: CliInstallerStatusRunDiag ): Promise { + if (result.flavor === 'free-code') { + try { + const providers = await this.multimodelBridgeService.getProviderStatuses( + binaryPath, + (providersSnapshot) => { + result.providers = providersSnapshot; + result.authLoggedIn = providersSnapshot.some((provider) => provider.authenticated); + result.authMethod = + providersSnapshot.find((provider) => provider.authenticated)?.authMethod ?? null; + this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(result) }); + } + ); + result.providers = providers; + result.authLoggedIn = providers.some((provider) => provider.authenticated); + result.authMethod = + providers.find((provider) => provider.authenticated)?.authMethod ?? null; + this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(result) }); + } catch (error) { + const msg = getErrorMessage(error); + diag.authLastError = msg; + logger.warn(`Provider status check failed for claude-multimodel: ${msg}`); + } + return; + } + const doCheck = async (): Promise => { for (let authAttempt = 1; authAttempt <= AUTH_STATUS_MAX_RETRIES; authAttempt++) { diag.authAttempts = authAttempt; @@ -559,6 +652,13 @@ export class CliInstallerService { // --------------------------------------------------------------------------- async install(): Promise { + if (!getCliFlavorUiOptions(getConfiguredCliFlavor()).supportsSelfUpdate) { + const error = 'Updates are disabled for the configured free-code runtime.'; + logger.warn(error); + this.sendProgress({ type: 'error', error }); + return; + } + if (this.installing) { this.sendProgress({ type: 'error', error: 'Installation already in progress' }); return; diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts index a1d09b79..f6aa1d3f 100644 --- a/src/main/services/infrastructure/ConfigManager.ts +++ b/src/main/services/infrastructure/ConfigManager.ts @@ -204,6 +204,7 @@ export interface GeneralConfig { showDockIcon: boolean; theme: 'dark' | 'light' | 'system'; defaultTab: 'dashboard' | 'last-session'; + multimodelEnabled: boolean; claudeRootPath: string | null; agentLanguage: string; autoExpandAIGroups: boolean; @@ -290,6 +291,7 @@ const DEFAULT_CONFIG: AppConfig = { showDockIcon: true, theme: 'dark', defaultTab: 'dashboard', + multimodelEnabled: true, claudeRootPath: null, agentLanguage: 'system', autoExpandAIGroups: false, diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts new file mode 100644 index 00000000..5dc5638e --- /dev/null +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -0,0 +1,299 @@ +import { execCli } from '@main/utils/childProcess'; +import { buildEnrichedEnv } from '@main/utils/cliEnv'; +import { + getCachedShellEnv, + getShellPreferredHome, + resolveInteractiveShellEnv, +} from '@main/utils/shellEnv'; +import { createLogger } from '@shared/utils/logger'; + +import type { CliProviderId, CliProviderStatus } from '@shared/types'; +import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth'; + +const logger = createLogger('ClaudeMultimodelBridgeService'); + +const PROVIDER_STATUS_TIMEOUT_MS = 10_000; +const PROVIDER_MODELS_TIMEOUT_MS = 10_000; + +interface ProviderStatusCommandResponse { + schemaVersion?: number; + providers?: Record< + string, + { + supported?: boolean; + authenticated?: boolean; + authMethod?: string | null; + verificationState?: 'verified' | 'unknown' | 'offline' | 'error'; + canLoginFromUi?: boolean; + statusMessage?: string | null; + capabilities?: { + teamLaunch?: boolean; + oneShot?: boolean; + }; + backend?: { + kind?: string; + label?: string; + endpointLabel?: string | null; + projectId?: string | null; + authMethodDetail?: string | null; + } | null; + } + >; +} + +interface ProviderModelsCommandResponse { + schemaVersion?: number; + providers?: Record< + string, + { + models?: Array; + } + >; +} + +const ORDERED_PROVIDER_IDS: CliProviderId[] = ['anthropic', 'codex', 'gemini']; + +function extractJsonObject(raw: string): T { + const trimmed = raw.trim(); + try { + return JSON.parse(trimmed) as T; + } catch { + const start = trimmed.indexOf('{'); + const end = trimmed.lastIndexOf('}'); + if (start >= 0 && end > start) { + return JSON.parse(trimmed.slice(start, end + 1)) as T; + } + throw new Error('No JSON object found in CLI output'); + } +} + +function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStatus { + return { + providerId, + displayName: + providerId === 'anthropic' ? 'Anthropic' : providerId === 'codex' ? 'Codex' : 'Gemini', + supported: false, + authenticated: false, + authMethod: null, + verificationState: 'unknown', + statusMessage: null, + models: [], + canLoginFromUi: true, + capabilities: { + teamLaunch: false, + oneShot: false, + }, + backend: null, + }; +} + +function extractModelIds( + models: Array | undefined +): string[] { + if (!models) { + return []; + } + + return models.flatMap((model) => { + if (typeof model === 'string') { + return model; + } + if (typeof model?.id === 'string' && model.id.trim().length > 0) { + return model.id.trim(); + } + return []; + }); +} + +export class ClaudeMultimodelBridgeService { + private buildCliEnv(binaryPath: string): NodeJS.ProcessEnv { + const shellEnv = getCachedShellEnv() ?? {}; + const home = + getShellPreferredHome() || shellEnv.HOME || process.env.HOME || process.env.USERPROFILE; + const env = { + ...buildEnrichedEnv(binaryPath), + ...shellEnv, + }; + if (home) { + env.HOME = home; + } + return env; + } + + private buildProviderCliEnv(binaryPath: string, providerId: CliProviderId): NodeJS.ProcessEnv { + const env = { ...this.buildCliEnv(binaryPath) }; + delete env.CLAUDE_CODE_USE_OPENAI; + delete env.CLAUDE_CODE_USE_BEDROCK; + delete env.CLAUDE_CODE_USE_VERTEX; + delete env.CLAUDE_CODE_USE_FOUNDRY; + delete env.CLAUDE_CODE_USE_GEMINI; + + if (providerId === 'codex') { + env.CLAUDE_CODE_USE_OPENAI = '1'; + } else if (providerId === 'gemini') { + env.CLAUDE_CODE_USE_GEMINI = '1'; + } + + return env; + } + + private async buildGeminiStatus(binaryPath: string): Promise { + const provider = createDefaultProviderStatus('gemini'); + const env = this.buildProviderCliEnv(binaryPath, 'gemini'); + + try { + const { stdout } = await execCli( + binaryPath, + ['model', 'list', '--json', '--provider', 'all'], + { + timeout: PROVIDER_MODELS_TIMEOUT_MS, + env, + } + ); + const parsed = extractJsonObject(stdout); + const models = extractModelIds(parsed.providers?.gemini?.models); + if (models.length > 0) { + provider.supported = true; + provider.models = models; + provider.capabilities = { + teamLaunch: true, + oneShot: true, + }; + } + } catch (error) { + logger.warn( + `Gemini model list unavailable: ${error instanceof Error ? error.message : String(error)}` + ); + } + + const authState = await resolveGeminiRuntimeAuth(env); + if (authState.authenticated) { + provider.authenticated = true; + provider.authMethod = + authState.authMethod === 'adc_authorized_user' || + authState.authMethod === 'adc_service_account' + ? `gemini_${authState.authMethod}` + : authState.authMethod; + provider.verificationState = 'verified'; + provider.statusMessage = null; + if (authState.authMethod === 'cli_oauth_personal') { + provider.backend = { + kind: 'cli', + label: 'Gemini CLI', + endpointLabel: 'Code Assist (cloudcode-pa.googleapis.com/v1internal)', + projectId: authState.projectId, + authMethodDetail: authState.authMethod, + }; + } + return provider; + } + + provider.statusMessage = + authState.statusMessage ?? 'Set GEMINI_API_KEY or Google ADC to use Gemini.'; + return provider; + } + + async getProviderStatuses( + binaryPath: string, + onUpdate?: (providers: CliProviderStatus[]) => void + ): Promise { + await resolveInteractiveShellEnv(); + const env = this.buildCliEnv(binaryPath); + + const [statusResult, modelsResult] = await Promise.allSettled([ + execCli(binaryPath, ['auth', 'status', '--json', '--provider', 'all'], { + timeout: PROVIDER_STATUS_TIMEOUT_MS, + env, + }), + execCli(binaryPath, ['model', 'list', '--json', '--provider', 'all'], { + timeout: PROVIDER_MODELS_TIMEOUT_MS, + env, + }), + ]); + + const providers = new Map( + ORDERED_PROVIDER_IDS.map((providerId) => [ + providerId, + createDefaultProviderStatus(providerId), + ]) + ); + + if (statusResult.status === 'fulfilled') { + try { + const parsed = extractJsonObject(statusResult.value.stdout); + for (const providerId of ORDERED_PROVIDER_IDS.filter((id) => id !== 'gemini')) { + const runtimeStatus = parsed.providers?.[providerId]; + if (!runtimeStatus) continue; + providers.set(providerId, { + ...providers.get(providerId)!, + 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, + }, + 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, + }); + onUpdate?.(ORDERED_PROVIDER_IDS.map((id) => providers.get(id)!)); + } + } catch (error) { + logger.warn( + `Failed to parse provider auth status JSON: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } else { + const message = + statusResult.reason instanceof Error + ? statusResult.reason.message + : String(statusResult.reason); + logger.warn(`Provider auth status unavailable: ${message}`); + for (const providerId of ORDERED_PROVIDER_IDS) { + providers.set(providerId, { + ...providers.get(providerId)!, + statusMessage: 'Provider status not supported by current claude-multimodel build', + }); + onUpdate?.(ORDERED_PROVIDER_IDS.map((id) => providers.get(id)!)); + } + } + + if (modelsResult.status === 'fulfilled') { + try { + const parsed = extractJsonObject(modelsResult.value.stdout); + for (const providerId of ORDERED_PROVIDER_IDS.filter((id) => id !== 'gemini')) { + const runtimeModels = extractModelIds(parsed.providers?.[providerId]?.models); + if (runtimeModels.length === 0) continue; + providers.set(providerId, { + ...providers.get(providerId)!, + models: runtimeModels, + }); + onUpdate?.(ORDERED_PROVIDER_IDS.map((id) => providers.get(id)!)); + } + } catch (error) { + logger.warn( + `Failed to parse provider models JSON: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + + providers.set('gemini', await this.buildGeminiStatus(binaryPath)); + onUpdate?.(ORDERED_PROVIDER_IDS.map((id) => providers.get(id)!)); + + return ORDERED_PROVIDER_IDS.map((providerId) => providers.get(providerId)!); + } +} diff --git a/src/main/services/runtime/geminiRuntimeAuth.ts b/src/main/services/runtime/geminiRuntimeAuth.ts new file mode 100644 index 00000000..0eb81a47 --- /dev/null +++ b/src/main/services/runtime/geminiRuntimeAuth.ts @@ -0,0 +1,113 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +export type GeminiGlobalConfig = { + geminiBackendPreference?: 'auto' | 'api' | 'cli'; + geminiResolvedBackend?: 'api' | 'cli'; + geminiLastAuthMethod?: string; + geminiProjectId?: string; +}; + +export type GeminiRuntimeAuthState = { + authenticated: boolean; + authMethod: string | null; + resolvedBackend: 'auto' | 'api' | 'cli'; + projectId: string | null; + statusMessage: string | null; +}; + +export async function readGeminiGlobalConfig( + env: NodeJS.ProcessEnv +): Promise { + const home = env.HOME?.trim() || env.USERPROFILE?.trim(); + const configDir = env.CLAUDE_CONFIG_DIR?.trim(); + const candidates = configDir + ? [path.join(configDir, '.config.json')] + : home + ? [path.join(home, '.claude', '.config.json'), path.join(home, '.claude.json')] + : []; + + for (const candidate of candidates) { + try { + const raw = await fs.promises.readFile(candidate, 'utf8'); + return JSON.parse(raw) as GeminiGlobalConfig; + } catch { + continue; + } + } + + return null; +} + +export async function resolveGeminiRuntimeAuth( + env: NodeJS.ProcessEnv +): Promise { + const config = await readGeminiGlobalConfig(env); + const resolvedBackend = + env.CLAUDE_CODE_GEMINI_BACKEND?.trim() || + config?.geminiResolvedBackend?.trim() || + config?.geminiBackendPreference?.trim() || + 'auto'; + const authMethod = config?.geminiLastAuthMethod?.trim() ?? null; + const projectId = + env.GOOGLE_CLOUD_PROJECT?.trim() || + env.GOOGLE_CLOUD_PROJECT_ID?.trim() || + env.GCLOUD_PROJECT?.trim() || + config?.geminiProjectId?.trim() || + null; + const hasGeminiApiKey = Boolean(env.GEMINI_API_KEY?.trim()); + + if (hasGeminiApiKey) { + return { + authenticated: true, + authMethod: 'api_key', + resolvedBackend: + resolvedBackend === 'api' || resolvedBackend === 'cli' ? resolvedBackend : 'auto', + projectId, + statusMessage: null, + }; + } + + if ((authMethod === 'adc_authorized_user' || authMethod === 'adc_service_account') && projectId) { + return { + authenticated: true, + authMethod, + resolvedBackend: + resolvedBackend === 'api' || resolvedBackend === 'cli' ? resolvedBackend : 'auto', + projectId, + statusMessage: null, + }; + } + + if (authMethod === 'cli_oauth_personal' && resolvedBackend === 'cli') { + return { + authenticated: true, + authMethod, + resolvedBackend: 'cli', + projectId, + statusMessage: null, + }; + } + + if (authMethod === 'cli_oauth_personal') { + return { + authenticated: false, + authMethod, + resolvedBackend: + resolvedBackend === 'api' || resolvedBackend === 'cli' ? resolvedBackend : 'auto', + projectId, + statusMessage: + 'Gemini CLI OAuth was detected, but the active Gemini backend is not set to cli.', + }; + } + + return { + authenticated: false, + authMethod, + resolvedBackend: + resolvedBackend === 'api' || resolvedBackend === 'cli' ? resolvedBackend : 'auto', + 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 new file mode 100644 index 00000000..2739c2d6 --- /dev/null +++ b/src/main/services/runtime/providerRuntimeEnv.ts @@ -0,0 +1,33 @@ +import type { TeamProviderId } from '@shared/types'; + +const THIRD_PARTY_PROVIDER_ENV_KEYS = [ + 'CLAUDE_CODE_USE_OPENAI', + 'CLAUDE_CODE_USE_BEDROCK', + 'CLAUDE_CODE_USE_VERTEX', + 'CLAUDE_CODE_USE_FOUNDRY', + 'CLAUDE_CODE_USE_GEMINI', +] as const; + +export function applyProviderRuntimeEnv( + env: NodeJS.ProcessEnv, + providerId: TeamProviderId | undefined +): NodeJS.ProcessEnv { + const resolvedProvider: TeamProviderId = + providerId === 'codex' || providerId === 'gemini' ? providerId : 'anthropic'; + + for (const key of THIRD_PARTY_PROVIDER_ENV_KEYS) { + env[key] = undefined; + } + + if (resolvedProvider === 'codex') { + env.CLAUDE_CODE_USE_OPENAI = '1'; + } else if (resolvedProvider === 'gemini') { + env.CLAUDE_CODE_USE_GEMINI = '1'; + } + + return env; +} + +export function resolveTeamProviderId(providerId: TeamProviderId | undefined): TeamProviderId { + return providerId === 'codex' || providerId === 'gemini' ? providerId : 'anthropic'; +} diff --git a/src/main/services/schedule/ScheduledTaskExecutor.ts b/src/main/services/schedule/ScheduledTaskExecutor.ts index a16cb57a..00c04d39 100644 --- a/src/main/services/schedule/ScheduledTaskExecutor.ts +++ b/src/main/services/schedule/ScheduledTaskExecutor.ts @@ -13,6 +13,7 @@ import { buildEnrichedEnv } from '@main/utils/cliEnv'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { createLogger } from '@shared/utils/logger'; +import { applyProviderRuntimeEnv } from '../runtime/providerRuntimeEnv'; import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver'; import type { ScheduleLaunchConfig, ScheduleRun } from '@shared/types'; @@ -101,11 +102,16 @@ export class ScheduledTaskExecutor { logger.info(`[${request.runId}] Spawning: ${binaryPath} ${args.join(' ')}`); + const env = applyProviderRuntimeEnv( + { ...buildEnrichedEnv(binaryPath), ...shellEnv, CLAUDECODE: undefined }, + request.config.providerId + ); + const child = spawnCli(binaryPath, args, { cwd: request.config.cwd, // shellEnv spread after buildEnrichedEnv ensures freshly-resolved values // take precedence over the cached snapshot inside buildEnrichedEnv. - env: { ...buildEnrichedEnv(binaryPath), ...shellEnv, CLAUDECODE: undefined }, + env, stdio: ['ignore', 'pipe', 'pipe'], }); diff --git a/src/main/services/team/ClaudeBinaryResolver.ts b/src/main/services/team/ClaudeBinaryResolver.ts index 644354cf..13a75782 100644 --- a/src/main/services/team/ClaudeBinaryResolver.ts +++ b/src/main/services/team/ClaudeBinaryResolver.ts @@ -3,6 +3,8 @@ import { getShellPreferredHome, resolveInteractiveShellEnv } from '@main/utils/s import * as fs from 'fs'; import * as path from 'path'; +import { getConfiguredCliFlavor } from './cliFlavor'; + async function isExecutable(filePath: string): Promise { if (process.platform === 'win32') { try { @@ -165,6 +167,28 @@ async function resolveFromExplicitPath(inputPath: string): Promise { + for (const candidate of candidates) { + if (await isExecutable(candidate)) { + return candidate; + } + } + return null; +} + +function getRepoLocalCliCandidates(): string[] { + if (process.platform === 'win32') { + return []; + } + + const repoRoot = process.cwd(); + return [ + 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'), + ]; +} + let cachedPath: string | null | undefined; /** Timestamp of last successful cache verification (ms). */ @@ -213,6 +237,7 @@ export class ClaudeBinaryResolver { private static async runResolve(): Promise { await resolveInteractiveShellEnv(); const enrichedPath = buildMergedCliPath(null); + const flavor = getConfiguredCliFlavor(); const overrideRaw = process.env.CLAUDE_CLI_PATH?.trim(); if (overrideRaw) { @@ -229,6 +254,19 @@ export class ClaudeBinaryResolver { } } + if (flavor === 'free-code') { + const repoLocalCli = await resolveFromCandidateList(getRepoLocalCliCandidates()); + if (repoLocalCli) { + cachedPath = repoLocalCli; + cacheVerifiedAt = Date.now(); + return cachedPath; + } + + // Free-code mode is explicit. If the configured local runtime is missing, + // fail closed instead of silently falling back to a different CLI. + return null; + } + const baseBinaryName = 'claude'; const fromPath = await resolveFromPathEnv(baseBinaryName, enrichedPath); if (fromPath) { diff --git a/src/main/services/team/cliFlavor.ts b/src/main/services/team/cliFlavor.ts new file mode 100644 index 00000000..98d47306 --- /dev/null +++ b/src/main/services/team/cliFlavor.ts @@ -0,0 +1,43 @@ +import type { CliFlavor, CliFlavorUiOptions } from '@shared/types'; + +import { configManager } from '../infrastructure/ConfigManager'; + +export const DEFAULT_CLI_FLAVOR: CliFlavor = 'free-code'; + +function parseFlavorOverride(raw: string | undefined): CliFlavor | null { + const trimmed = raw?.trim(); + if (trimmed === 'claude' || trimmed === 'free-code') { + return trimmed; + } + return null; +} + +export function getConfiguredCliFlavor(): CliFlavor { + const envOverride = parseFlavorOverride(process.env.CLAUDE_TEAM_CLI_FLAVOR); + if (envOverride) { + return envOverride; + } + + const multimodelEnabled = configManager.getConfig().general.multimodelEnabled; + return multimodelEnabled ? 'free-code' : 'claude'; +} + +export function getCliFlavorUiOptions(flavor: CliFlavor): CliFlavorUiOptions { + switch (flavor) { + case 'free-code': + return { + displayName: 'free-code-gemini-research', + supportsSelfUpdate: false, + showVersionDetails: false, + showBinaryPath: false, + }; + case 'claude': + default: + return { + displayName: 'Claude CLI', + supportsSelfUpdate: true, + showVersionDetails: true, + showBinaryPath: true, + }; + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index f5ab238a..4918726a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -837,8 +837,17 @@ const electronAPI: ElectronAPI = { deleteDraft: async (teamName: string) => { return invokeIpcWithResult(TEAM_DELETE_DRAFT, teamName); }, - prepareProvisioning: async (cwd?: string) => { - return invokeIpcWithResult(TEAM_PREPARE_PROVISIONING, cwd); + prepareProvisioning: async ( + cwd?: string, + providerId?: TeamLaunchRequest['providerId'], + providerIds?: TeamLaunchRequest['providerId'][] + ) => { + return invokeIpcWithResult( + TEAM_PREPARE_PROVISIONING, + cwd, + providerId, + providerIds + ); }, createTeam: async (request: TeamCreateRequest) => { return invokeIpcWithResult(TEAM_CREATE, request); diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index e19d3fe7..23ee9564 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -10,6 +10,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { api, isElectronMode } from '@renderer/api'; +import { confirm } from '@renderer/components/common/ConfirmDialog'; +import { SettingsToggle } from '@renderer/components/settings/components'; import { TerminalLogPanel } from '@renderer/components/terminal/TerminalLogPanel'; import { TerminalModal } from '@renderer/components/terminal/TerminalModal'; import { useCliInstaller } from '@renderer/hooks/useCliInstaller'; @@ -24,11 +26,14 @@ import { HelpCircle, Loader2, LogIn, + LogOut, Puzzle, RefreshCw, Terminal, } from 'lucide-react'; +import type { CliInstallationStatus, CliProviderId, CliProviderStatus } from '@shared/types'; + // ============================================================================= // Border color by state // ============================================================================= @@ -141,7 +146,7 @@ const CliCheckingSpinner = ({ />
- Checking Claude CLI... + Checking AI Providers... {showHint && (

@@ -162,22 +167,211 @@ interface InstalledBannerProps { cliStatusLoading: boolean; cliStatusError: string | null; isBusy: boolean; + multimodelEnabled: boolean; + multimodelBusy: boolean; onInstall: () => void; onRefresh: () => void; + onMultimodelToggle: (enabled: boolean) => void; + onProviderLogin: (providerId: CliProviderId) => void; + onProviderLogout: (providerId: CliProviderId) => void; variant: BannerVariant; } +function getProviderLabel(providerId: CliProviderId): string { + switch (providerId) { + case 'anthropic': + return 'Anthropic'; + case 'codex': + return 'Codex'; + case 'gemini': + return 'Gemini'; + } +} + +function getProviderTerminalCommand(providerId: CliProviderId): { + args: string[]; + env?: Record; +} { + if (providerId === 'gemini') { + return { + args: ['login'], + env: { CLAUDE_CODE_USE_GEMINI: '1' }, + }; + } + + return { + args: ['auth', 'login', '--provider', providerId], + }; +} + +function getProviderTerminalLogoutCommand(providerId: CliProviderId): { + args: string[]; + env?: Record; +} { + if (providerId === 'gemini') { + return { + args: ['logout'], + env: { CLAUDE_CODE_USE_GEMINI: '1' }, + }; + } + + return { + args: ['auth', 'logout', '--provider', providerId], + }; +} + +function formatProviderStatus(provider: CliProviderStatus): string { + if (!provider.supported) { + return provider.statusMessage ?? 'Unavailable in current runtime'; + } + if (provider.authenticated) { + return provider.authMethod ? `Authenticated via ${provider.authMethod}` : 'Authenticated'; + } + if (provider.verificationState === 'offline') { + return provider.statusMessage ?? 'Unable to verify'; + } + return provider.statusMessage ?? 'Not connected'; +} + +function formatProviderModels(provider: CliProviderStatus): string | null { + return provider.models.length > 0 ? provider.models.join(', ') : null; +} + +function formatModelBadgeLabel(providerId: CliProviderId, model: string): string { + if (providerId === 'anthropic') { + return model.replace(/^claude-/, ''); + } + if (providerId === 'codex') { + return model.replace(/^gpt-/, ''); + } + if (providerId === 'gemini') { + return model.replace(/^gemini-/, ''); + } + return model; +} + +function ModelBadges({ + providerId, + models, +}: { + providerId: CliProviderId; + models: string[]; +}): React.JSX.Element { + return ( +

+ {models.map((model) => ( + + {formatModelBadgeLabel(providerId, model)} + + ))} +
+ ); +} + +function formatRuntimeLabel( + cliStatus: NonNullable['cliStatus']> +): string | null { + if (cliStatus.flavor === 'free-code') { + return null; + } + + return cliStatus.showVersionDetails && cliStatus.installedVersion + ? `${cliStatus.displayName} v${cliStatus.installedVersion ?? 'unknown'}` + : cliStatus.displayName; +} + +function formatRuntimeAuthSummary( + cliStatus: NonNullable['cliStatus']> +): string | null { + if (cliStatus.flavor === 'free-code' && cliStatus.providers.length > 0) { + if ( + cliStatus.providers.every( + (provider) => provider.statusMessage === 'Checking...' && !provider.authenticated + ) + ) { + 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; + + return `Providers: ${connected}/${denominator} connected`; + } + + if (cliStatus.authLoggedIn) { + return 'Authenticated'; + } + + return null; +} + +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, + authMethod: null, + providers, + }; +} + const InstalledBanner = ({ cliStatus, cliStatusLoading, cliStatusError, isBusy, + multimodelEnabled, + multimodelBusy, onInstall, onRefresh, + onMultimodelToggle, + onProviderLogin, + onProviderLogout, variant, }: InstalledBannerProps): React.JSX.Element => { const openExtensionsTab = useStore((s) => s.openExtensionsTab); const styles = VARIANT_STYLES[variant]; + const runtimeLabel = formatRuntimeLabel(cliStatus); + const runtimeAuthSummary = formatRuntimeAuthSummary(cliStatus); return (
- - Claude CLI v{cliStatus.installedVersion ?? 'unknown'} - + {runtimeLabel && ( + + {runtimeLabel} + + )} {/* Update / Check for Updates — inline next to version */} - {cliStatus.updateAvailable ? ( + {cliStatus.supportsSelfUpdate && cliStatus.updateAvailable ? ( - ) : ( + ) : cliStatus.supportsSelfUpdate ? ( - )} + ) : null} - {cliStatus.authLoggedIn && ( + {runtimeAuthSummary && ( - Authenticated + {runtimeAuthSummary} )}
- {cliStatus.binaryPath && ( + {cliStatus.showBinaryPath && cliStatus.binaryPath && (
- {/* Extensions button — only when installed + authenticated */} - {cliStatus.authLoggedIn && ( - - )} +
+
+ + Multimodel + + {multimodelEnabled && ( + + Beta + + )} + +
+ {/* Extensions button — only when installed + authenticated */} + {cliStatus.authLoggedIn && ( + + )} +
{cliStatusError && !cliStatusLoading && (

Failed to check for updates. Check your network connection and try again.

)} + {cliStatus.providers.length > 0 && ( +
+ {cliStatus.providers.map((provider) => { + const statusText = formatProviderStatus(provider); + const actionDisabled = isBusy || !cliStatus.binaryPath; + + return ( +
+
+
+ + {provider.displayName} + + + {statusText} + +
+
+ {provider.backend?.label && ( + + Backend: {provider.backend.label} + {provider.backend.endpointLabel + ? ` (${provider.backend.endpointLabel})` + : ''} + + )} + {provider.models.length === 0 && ( + Models unavailable for this runtime build + )} +
+
+
+ {provider.authenticated ? ( + + ) : provider.canLoginFromUi ? ( + + ) : null} + +
+ {provider.models.length > 0 && ( +
+ +
+ )} +
+ ); + })} +
+ )}
); }; @@ -262,6 +581,8 @@ const InstalledBanner = ({ export const CliStatusBanner = (): React.JSX.Element | null => { const isElectron = useMemo(() => isElectronMode(), []); + const appConfig = useStore((s) => s.appConfig); + const updateConfig = useStore((s) => s.updateConfig); const { cliStatus, cliStatusLoading, @@ -281,8 +602,14 @@ export const CliStatusBanner = (): React.JSX.Element | null => { } = useCliInstaller(); const [showLoginTerminal, setShowLoginTerminal] = useState(false); + const [providerTerminal, setProviderTerminal] = useState<{ + providerId: CliProviderId; + action: 'login' | 'logout'; + } | null>(null); const [isVerifyingAuth, setIsVerifyingAuth] = useState(false); + const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false); const [showTroubleshoot, setShowTroubleshoot] = useState(false); + const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true; useEffect(() => { if (!isElectron) return; @@ -312,6 +639,54 @@ export const CliStatusBanner = (): React.JSX.Element | null => { void fetchCliStatus(); }, [fetchCliStatus]); + const handleMultimodelToggle = useCallback( + async (enabled: boolean) => { + setIsSwitchingFlavor(true); + try { + await updateConfig('general', { multimodelEnabled: enabled }); + await invalidateCliStatus(); + await fetchCliStatus(); + } finally { + setIsSwitchingFlavor(false); + } + }, + [fetchCliStatus, invalidateCliStatus, updateConfig] + ); + + const recheckAuthState = useCallback(() => { + setIsVerifyingAuth(true); + void (async () => { + try { + await invalidateCliStatus(); + await fetchCliStatus(); + } finally { + setIsVerifyingAuth(false); + } + })(); + }, [fetchCliStatus, invalidateCliStatus]); + + const handleProviderLogin = useCallback((providerId: CliProviderId) => { + setProviderTerminal({ providerId, action: 'login' }); + }, []); + + const handleProviderLogout = useCallback((providerId: CliProviderId) => { + void (async () => { + const confirmed = await confirm({ + title: `Logout from ${getProviderLabel(providerId)}?`, + message: 'This will remove the current provider session from the local Claude CLI runtime.', + confirmLabel: 'Logout', + cancelLabel: 'Cancel', + variant: 'danger', + }); + + if (!confirmed) { + return; + } + + setProviderTerminal({ providerId, action: 'logout' }); + })(); + }, []); + if (!isElectron) return null; // Determine variant for styling @@ -328,6 +703,11 @@ 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) + : null; // ── Loading / fetch error state ──────────────────────────────────────── if (!cliStatus && installerState === 'idle') { @@ -384,7 +764,27 @@ export const CliStatusBanner = (): React.JSX.Element | null => { ); } - // Loading state: show spinner only while an actual request is in-flight. + // Multimodel: render provider cards immediately instead of a generic intermediate block. + if (multimodelEnabled) { + return ( + void handleMultimodelToggle(enabled)} + onProviderLogin={handleProviderLogin} + onProviderLogout={handleProviderLogout} + variant="info" + /> + ); + } + + // Claude-only mode: keep the generic loading spinner. return ; } @@ -516,22 +916,28 @@ export const CliStatusBanner = (): React.JSX.Element | null => {

- + {cliStatus.supportsSelfUpdate ? ( + + ) : ( +

+ The configured free-code runtime was not found. +

+ )} ); } // Installed but not logged in — yellow warning banner - if (cliStatus.installed && !cliStatus.authLoggedIn) { + if (cliStatus.installed && cliStatus.flavor !== 'free-code' && !cliStatus.authLoggedIn) { if (isVerifyingAuth) { return (
{ Not logged in

- Claude CLI is installed but you are not authenticated. Login is required for team - provisioning and AI features. + {cliStatus.displayName} is installed but you are not authenticated. Login is + required for team provisioning and AI features.

@@ -642,28 +1048,30 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
  • Open your terminal and run:{' '} - claude auth status + {cliStatus.showBinaryPath && cliStatus.binaryPath + ? `"${cliStatus.binaryPath}" auth status` + : 'your configured CLI auth status command'} {' '} — check if it shows "Logged in"
  • If it says logged in but the app doesn't see it, try:{' '} - claude auth logout + {cliStatus.showBinaryPath && cliStatus.binaryPath + ? `"${cliStatus.binaryPath}" auth logout` + : 'the runtime logout command'} {' '} then{' '} - claude auth login + {cliStatus.showBinaryPath && cliStatus.binaryPath + ? `"${cliStatus.binaryPath}" auth login` + : 'the runtime login command'} {' '} again
  • - Make sure{' '} - - claude - {' '} - in your terminal is the same binary the app uses - {cliStatus.binaryPath && ( + Make sure the CLI in your terminal is the same runtime the app uses + {cliStatus.showBinaryPath && cliStatus.binaryPath && ( :{' '} @@ -682,7 +1090,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => { {showLoginTerminal && cliStatus.binaryPath && ( { @@ -719,14 +1127,45 @@ export const CliStatusBanner = (): React.JSX.Element | null => { // Installed — show version, path, update info return ( - + <> + void handleMultimodelToggle(enabled)} + onProviderLogin={handleProviderLogin} + onProviderLogout={handleProviderLogout} + variant={variant} + /> + {providerTerminal && cliStatus.binaryPath && ( + { + setProviderTerminal(null); + recheckAuthState(); + }} + onExit={() => { + recheckAuthState(); + }} + autoCloseOnSuccessMs={3000} + successMessage={ + providerTerminal.action === 'login' ? 'Authentication updated' : 'Provider logged out' + } + failureMessage={ + providerTerminal.action === 'login' ? 'Authentication failed' : 'Logout failed' + } + /> + )} + ); }; diff --git a/src/renderer/components/settings/hooks/useSettingsConfig.ts b/src/renderer/components/settings/hooks/useSettingsConfig.ts index bc6b84f8..0ab735d2 100644 --- a/src/renderer/components/settings/hooks/useSettingsConfig.ts +++ b/src/renderer/components/settings/hooks/useSettingsConfig.ts @@ -29,6 +29,7 @@ export interface SafeConfig { showDockIcon: boolean; theme: 'dark' | 'light' | 'system'; defaultTab: 'dashboard' | 'last-session'; + multimodelEnabled: boolean; claudeRootPath: string | null; agentLanguage: string; autoExpandAIGroups: boolean; @@ -169,6 +170,7 @@ export function useSettingsConfig(): UseSettingsConfigReturn { showDockIcon: displayConfig?.general?.showDockIcon ?? true, theme: displayConfig?.general?.theme ?? 'dark', defaultTab: displayConfig?.general?.defaultTab ?? 'dashboard', + multimodelEnabled: displayConfig?.general?.multimodelEnabled ?? true, claudeRootPath: displayConfig?.general?.claudeRootPath ?? null, agentLanguage: displayConfig?.general?.agentLanguage ?? 'system', autoExpandAIGroups: displayConfig?.general?.autoExpandAIGroups ?? false, diff --git a/src/renderer/components/settings/hooks/useSettingsHandlers.ts b/src/renderer/components/settings/hooks/useSettingsHandlers.ts index 76af24e2..18e964e5 100644 --- a/src/renderer/components/settings/hooks/useSettingsHandlers.ts +++ b/src/renderer/components/settings/hooks/useSettingsHandlers.ts @@ -314,6 +314,7 @@ export function useSettingsHandlers({ showDockIcon: true, theme: 'dark', defaultTab: 'dashboard', + multimodelEnabled: true, claudeRootPath: null, agentLanguage: 'system', autoExpandAIGroups: false, diff --git a/src/renderer/components/settings/sections/CliStatusSection.tsx b/src/renderer/components/settings/sections/CliStatusSection.tsx index c351c371..8c00fabe 100644 --- a/src/renderer/components/settings/sections/CliStatusSection.tsx +++ b/src/renderer/components/settings/sections/CliStatusSection.tsx @@ -5,16 +5,22 @@ * Shows detection status, version info, download progress, and error states. */ -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { isElectronMode } from '@renderer/api'; +import { confirm } from '@renderer/components/common/ConfirmDialog'; +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 { formatBytes } from '@renderer/utils/formatters'; import { AlertTriangle, CheckCircle, Download, Loader2, + LogIn, + LogOut, Puzzle, RefreshCw, Terminal, @@ -22,8 +28,132 @@ import { import { SettingsSectionHeader } from '../components'; +import type { CliInstallationStatus, CliProviderId } from '@shared/types'; + +function formatModelBadgeLabel(providerId: CliProviderId, model: string): string { + if (providerId === 'anthropic') { + return model.replace(/^claude-/, ''); + } + if (providerId === 'codex') { + return model.replace(/^gpt-/, ''); + } + if (providerId === 'gemini') { + return model.replace(/^gemini-/, ''); + } + return model; +} + +function ModelBadges({ + providerId, + models, +}: { + providerId: CliProviderId; + models: string[]; +}): React.JSX.Element { + return ( +
    + {models.map((model) => ( + + {formatModelBadgeLabel(providerId, model)} + + ))} +
    + ); +} + +function getProviderLabel(providerId: CliProviderId): string { + switch (providerId) { + case 'anthropic': + return 'Anthropic'; + case 'codex': + return 'Codex'; + case 'gemini': + return 'Gemini'; + } +} + +function getProviderTerminalCommand(providerId: CliProviderId): { + args: string[]; + env?: Record; +} { + if (providerId === 'gemini') { + return { + args: ['login'], + env: { CLAUDE_CODE_USE_GEMINI: '1' }, + }; + } + + return { + args: ['auth', 'login', '--provider', providerId], + }; +} + +function getProviderTerminalLogoutCommand(providerId: CliProviderId): { + args: string[]; + env?: Record; +} { + if (providerId === 'gemini') { + return { + args: ['logout'], + env: { CLAUDE_CODE_USE_GEMINI: '1' }, + }; + } + + 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, + 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, + })), + }; +} + export const CliStatusSection = (): React.JSX.Element | null => { const isElectron = useMemo(() => isElectronMode(), []); + const appConfig = useStore((s) => s.appConfig); + const updateConfig = useStore((s) => s.updateConfig); const { cliStatus, installerState, @@ -36,7 +166,18 @@ export const CliStatusSection = (): React.JSX.Element | null => { installCli, isBusy, cliStatusLoading, + invalidateCliStatus, } = useCliInstaller(); + const [providerTerminal, setProviderTerminal] = useState<{ + providerId: CliProviderId; + action: 'login' | 'logout'; + } | null>(null); + const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false); + const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true; + const effectiveCliStatus = + !cliStatus && cliStatusLoading && multimodelEnabled + ? createLoadingMultimodelStatus() + : cliStatus; useEffect(() => { if (isElectron) { @@ -52,38 +193,118 @@ export const CliStatusSection = (): React.JSX.Element | null => { void fetchCliStatus(); }, [fetchCliStatus]); + const handleProviderLogout = useCallback(async (providerId: CliProviderId) => { + const confirmed = await confirm({ + title: `Logout from ${getProviderLabel(providerId)}?`, + message: 'This will remove the current provider session from the local Claude CLI runtime.', + confirmLabel: 'Logout', + cancelLabel: 'Cancel', + variant: 'danger', + }); + + if (!confirmed) { + return; + } + + setProviderTerminal({ + providerId, + action: 'logout', + }); + }, []); + + const recheckStatus = useCallback(() => { + void (async () => { + await invalidateCliStatus(); + await fetchCliStatus(); + })(); + }, [fetchCliStatus, invalidateCliStatus]); + + const handleMultimodelToggle = useCallback( + async (enabled: boolean) => { + setIsSwitchingFlavor(true); + try { + await updateConfig('general', { multimodelEnabled: enabled }); + await invalidateCliStatus(); + await fetchCliStatus(); + } finally { + setIsSwitchingFlavor(false); + } + }, + [fetchCliStatus, invalidateCliStatus, updateConfig] + ); + if (!isElectron) return null; + const runtimeLabel = + effectiveCliStatus?.flavor === 'free-code' + ? null + : effectiveCliStatus && + effectiveCliStatus.showVersionDetails && + effectiveCliStatus.installedVersion + ? `${effectiveCliStatus.displayName} v${effectiveCliStatus.installedVersion ?? 'unknown'}` + : (effectiveCliStatus?.displayName ?? 'Claude CLI'); + + const providerTerminalCommand = providerTerminal + ? providerTerminal.action === 'login' + ? getProviderTerminalCommand(providerTerminal.providerId) + : getProviderTerminalLogoutCommand(providerTerminal.providerId) + : null; + return (
    - +
    {/* Loading status */} - {!cliStatus && installerState === 'idle' && ( + {!effectiveCliStatus && installerState === 'idle' && (
    - Checking CLI... + Checking AI Providers...
    )} {/* Status display */} - {cliStatus && installerState === 'idle' && ( + {effectiveCliStatus && installerState === 'idle' && (
    - {cliStatus.installed ? ( + {effectiveCliStatus.installed ? (
    - - Claude CLI v{cliStatus.installedVersion ?? 'unknown'} - + {runtimeLabel && ( + {runtimeLabel} + )} +
    + + Multimodel + + {multimodelEnabled && ( + + Beta + + )} + void handleMultimodelToggle(value)} + disabled={isBusy || cliStatusLoading || isSwitchingFlavor} + /> +
    {/* Inline action buttons */} - {cliStatus.updateAvailable ? ( + {effectiveCliStatus.supportsSelfUpdate && effectiveCliStatus.updateAvailable ? ( - ) : ( + ) : effectiveCliStatus.supportsSelfUpdate ? ( - )} + ) : null} {/* Extensions button — right-aligned */}
    - {cliStatus.binaryPath && ( + {effectiveCliStatus.showBinaryPath && effectiveCliStatus.binaryPath && (

    - {cliStatus.binaryPath} + {effectiveCliStatus.binaryPath}

    )} - {cliStatus.updateAvailable && cliStatus.latestVersion && ( -
    - - v{cliStatus.installedVersion} → v{cliStatus.latestVersion} - + {effectiveCliStatus.supportsSelfUpdate && + effectiveCliStatus.updateAvailable && + effectiveCliStatus.latestVersion && ( +
    + + v{effectiveCliStatus.installedVersion} → v + {effectiveCliStatus.latestVersion} + +
    + )} + {effectiveCliStatus.providers.length > 0 && ( +
    + {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 && ( +
    + +
    + )} +
    + ))}
    )}
    @@ -158,7 +475,7 @@ export const CliStatusSection = (): React.JSX.Element | null => { )} {/* Install button (CLI not installed) */} - {!cliStatus.installed && ( + {!effectiveCliStatus.installed && effectiveCliStatus.supportsSelfUpdate && ( )} + {!effectiveCliStatus.installed && !effectiveCliStatus.supportsSelfUpdate && ( +

    + The configured free-code runtime was not found. +

    + )}
    )} @@ -270,6 +592,30 @@ export const CliStatusSection = (): React.JSX.Element | null => {
    )}
    + {providerTerminal && cliStatus?.binaryPath && ( + { + setProviderTerminal(null); + recheckStatus(); + }} + onExit={() => { + recheckStatus(); + }} + autoCloseOnSuccessMs={3000} + successMessage={ + providerTerminal.action === 'login' ? 'Authentication updated' : 'Provider logged out' + } + failureMessage={ + providerTerminal.action === 'login' ? 'Authentication failed' : 'Logout failed' + } + /> + )}
    ); }; diff --git a/src/renderer/components/terminal/EmbeddedTerminal.tsx b/src/renderer/components/terminal/EmbeddedTerminal.tsx index 895cf062..78acc77d 100644 --- a/src/renderer/components/terminal/EmbeddedTerminal.tsx +++ b/src/renderer/components/terminal/EmbeddedTerminal.tsx @@ -16,6 +16,8 @@ interface EmbeddedTerminalProps { args?: string[]; /** Working directory */ cwd?: string; + /** Environment variables merged into the PTY process env */ + env?: Record; /** Callback when PTY process exits */ onExit?: (exitCode: number) => void; /** CSS class for container */ @@ -26,6 +28,7 @@ export const EmbeddedTerminal = ({ command, args, cwd, + env, onExit, className, }: EmbeddedTerminalProps): React.JSX.Element => { @@ -99,6 +102,7 @@ export const EmbeddedTerminal = ({ ...(command ? { command } : {}), ...(args ? { args } : {}), ...(cwd ? { cwd } : {}), + ...(env ? { env } : {}), cols: term.cols, rows: term.rows, }; diff --git a/src/renderer/components/terminal/TerminalModal.tsx b/src/renderer/components/terminal/TerminalModal.tsx index a7c7f4e7..88c8dc37 100644 --- a/src/renderer/components/terminal/TerminalModal.tsx +++ b/src/renderer/components/terminal/TerminalModal.tsx @@ -14,6 +14,8 @@ interface TerminalModalProps { args?: string[]; /** Working directory */ cwd?: string; + /** Environment variables merged into the PTY process env */ + env?: Record; /** Called when the modal should close */ onClose: () => void; /** Called when the PTY process exits */ @@ -31,6 +33,7 @@ export function TerminalModal({ command, args, cwd, + env, onClose, onExit, autoCloseOnSuccessMs = 0, @@ -116,7 +119,7 @@ export function TerminalModal({ {/* Terminal area — always visible, status bar overlaid at bottom */}
    - + {exited !== null && (
    void { cliInstallerError: progress.error ?? 'Unknown error', }); break; + case 'status': + if (progress.status) { + useStore.setState({ cliStatus: progress.status }); + } + break; } }); if (typeof cleanup === 'function') { diff --git a/src/shared/types/cliInstaller.ts b/src/shared/types/cliInstaller.ts index 9149ead1..00433e24 100644 --- a/src/shared/types/cliInstaller.ts +++ b/src/shared/types/cliInstaller.ts @@ -21,6 +21,40 @@ export type CliPlatform = | 'win32-x64' | 'win32-arm64'; +export type CliFlavor = 'claude' | 'free-code'; + +export type CliProviderId = 'anthropic' | 'codex' | 'gemini'; + +export interface CliProviderStatus { + providerId: CliProviderId; + displayName: string; + supported: boolean; + authenticated: boolean; + authMethod: string | null; + verificationState: 'verified' | 'unknown' | 'offline' | 'error'; + statusMessage?: string | null; + models: string[]; + canLoginFromUi: boolean; + capabilities: { + teamLaunch: boolean; + oneShot: boolean; + }; + backend?: { + kind: string; + label: string; + endpointLabel?: string | null; + projectId?: string | null; + authMethodDetail?: string | null; + } | null; +} + +export interface CliFlavorUiOptions { + displayName: string; + supportsSelfUpdate: boolean; + showVersionDetails: boolean; + showBinaryPath: boolean; +} + // ============================================================================= // Installation Status // ============================================================================= @@ -29,6 +63,16 @@ export type CliPlatform = * Current CLI installation status returned by getStatus(). */ export interface CliInstallationStatus { + /** Selected CLI runtime flavor */ + flavor: CliFlavor; + /** Display label for the configured runtime */ + displayName: string; + /** Whether this runtime should expose self-update/install actions in the UI */ + supportsSelfUpdate: boolean; + /** Whether version text should be shown in the UI */ + showVersionDetails: boolean; + /** Whether binary path should be shown in the UI */ + showBinaryPath: boolean; /** Whether CLI binary is found on the system */ installed: boolean; /** Installed version string (e.g. "2.1.59"), null if not installed */ @@ -43,6 +87,8 @@ export interface CliInstallationStatus { authLoggedIn: boolean; /** Auth method if logged in (e.g. "oauth_token", "api_key"), null otherwise */ authMethod: string | null; + /** Provider-level runtime status when supported by the configured runtime */ + providers: CliProviderStatus[]; } // ============================================================================= @@ -54,7 +100,7 @@ export interface CliInstallationStatus { */ export interface CliInstallerProgress { /** Current phase of the installation process */ - type: 'checking' | 'downloading' | 'verifying' | 'installing' | 'completed' | 'error'; + type: 'checking' | 'downloading' | 'verifying' | 'installing' | 'completed' | 'error' | 'status'; /** Download progress 0-100, only present for 'downloading' */ percent?: number; /** Bytes downloaded so far */ @@ -69,6 +115,8 @@ export interface CliInstallerProgress { detail?: string; /** Raw terminal output chunk (with ANSI codes), only for 'installing' */ rawChunk?: string; + /** Partial or full CLI status snapshot during status gathering. */ + status?: CliInstallationStatus; } // ============================================================================= diff --git a/src/shared/types/notifications.ts b/src/shared/types/notifications.ts index cd36ca7b..f273bf23 100644 --- a/src/shared/types/notifications.ts +++ b/src/shared/types/notifications.ts @@ -308,6 +308,8 @@ export interface AppConfig { theme: 'dark' | 'light' | 'system'; /** Default tab to show on app launch */ defaultTab: 'dashboard' | 'last-session'; + /** Whether to use the multimodel runtime instead of the stock Claude CLI */ + multimodelEnabled: boolean; /** Optional custom Claude root folder (auto-detected when null) */ claudeRootPath: string | null; /** Agent communication language ('system' = use OS locale) */ diff --git a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts new file mode 100644 index 00000000..6a45cd38 --- /dev/null +++ b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts @@ -0,0 +1,160 @@ +// @vitest-environment node +import type { PathLike } from 'fs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const execCliMock = vi.fn(); +const buildEnrichedEnvMock = vi.fn<(binaryPath: string) => NodeJS.ProcessEnv>(); +const getCachedShellEnvMock = vi.fn<() => NodeJS.ProcessEnv | null>(); +const getShellPreferredHomeMock = vi.fn<() => string>(); +const resolveInteractiveShellEnvMock = vi.fn<() => Promise>(); +const readFileMock = vi.fn<(path: PathLike, encoding: BufferEncoding) => Promise>(); + +vi.mock('@main/utils/childProcess', () => ({ + execCli: (...args: Parameters) => execCliMock(...args), +})); + +vi.mock('@main/utils/cliEnv', () => ({ + buildEnrichedEnv: (binaryPath: string) => buildEnrichedEnvMock(binaryPath), +})); + +vi.mock('@main/utils/shellEnv', () => ({ + getCachedShellEnv: () => getCachedShellEnvMock(), + getShellPreferredHome: () => getShellPreferredHomeMock(), + resolveInteractiveShellEnv: () => resolveInteractiveShellEnvMock(), +})); + +vi.mock('fs', () => ({ + default: { + promises: { + readFile: (filePath: PathLike, encoding: BufferEncoding) => readFileMock(filePath, encoding), + }, + }, + promises: { + readFile: (filePath: PathLike, encoding: BufferEncoding) => readFileMock(filePath, encoding), + }, +})); + +describe('ClaudeMultimodelBridgeService', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + buildEnrichedEnvMock.mockReturnValue({}); + getCachedShellEnvMock.mockReturnValue({}); + getShellPreferredHomeMock.mockReturnValue('/Users/tester'); + resolveInteractiveShellEnvMock.mockResolvedValue({}); + readFileMock.mockImplementation(async (filePath) => { + if (String(filePath) === '/Users/tester/.claude.json') { + return JSON.stringify({ + geminiResolvedBackend: 'cli', + geminiLastAuthMethod: 'cli_oauth_personal', + geminiProjectId: 'demo-project', + }); + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + }); + + it('parses object-based model lists and exposes Gemini runtime status', async () => { + execCliMock.mockImplementation(async (_binaryPath, args, options) => { + const normalizedArgs = Array.isArray(args) ? args.join(' ') : ''; + const env = options?.env ?? {}; + + if (normalizedArgs === 'auth status --json --provider all') { + return { + stdout: JSON.stringify({ + providers: { + anthropic: { + supported: true, + authenticated: true, + authMethod: 'oauth_token', + verificationState: 'verified', + canLoginFromUi: true, + capabilities: { teamLaunch: true, oneShot: true }, + backend: { kind: 'anthropic', label: 'Anthropic' }, + }, + codex: { + supported: true, + authenticated: false, + verificationState: 'verified', + canLoginFromUi: true, + statusMessage: 'Not connected', + capabilities: { teamLaunch: true, oneShot: true }, + backend: { kind: 'openai', label: 'OpenAI' }, + }, + }, + }), + stderr: '', + exitCode: 0, + }; + } + + if (normalizedArgs === 'model list --json --provider all' && env.CLAUDE_CODE_USE_GEMINI === '1') { + return { + stdout: JSON.stringify({ + providers: { + gemini: { + models: [{ id: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' }], + }, + }, + }), + stderr: '', + exitCode: 0, + }; + } + + if (normalizedArgs === 'model list --json --provider all') { + return { + stdout: JSON.stringify({ + providers: { + anthropic: { + models: [{ id: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5' }], + }, + codex: { + models: [{ id: 'gpt-5-codex', label: 'GPT-5 Codex' }], + }, + }, + }), + stderr: '', + exitCode: 0, + }; + } + + throw new Error(`Unexpected execCli call: ${normalizedArgs}`); + }); + + const { ClaudeMultimodelBridgeService } = await import( + '@main/services/runtime/ClaudeMultimodelBridgeService' + ); + const service = new ClaudeMultimodelBridgeService(); + + const providers = await service.getProviderStatuses('/mock/free-code'); + + expect(providers).toHaveLength(3); + expect(providers[0]).toMatchObject({ + providerId: 'anthropic', + authenticated: true, + models: ['claude-sonnet-4-5'], + }); + expect(providers[1]).toMatchObject({ + providerId: 'codex', + authenticated: false, + models: ['gpt-5-codex'], + statusMessage: 'Not connected', + }); + expect(providers[2]).toMatchObject({ + providerId: 'gemini', + displayName: 'Gemini', + supported: true, + authenticated: true, + models: ['gemini-2.5-pro'], + canLoginFromUi: true, + authMethod: 'cli_oauth_personal', + backend: { + kind: 'cli', + label: 'Gemini CLI', + endpointLabel: 'Code Assist (cloudcode-pa.googleapis.com/v1internal)', + projectId: 'demo-project', + }, + }); + }); +}); diff --git a/test/main/services/runtime/providerRuntimeEnv.test.ts b/test/main/services/runtime/providerRuntimeEnv.test.ts new file mode 100644 index 00000000..e7f4d92a --- /dev/null +++ b/test/main/services/runtime/providerRuntimeEnv.test.ts @@ -0,0 +1,29 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; + +import { + applyProviderRuntimeEnv, + resolveTeamProviderId, +} from '@main/services/runtime/providerRuntimeEnv'; + +describe('providerRuntimeEnv', () => { + it('enables Gemini runtime mode and clears other third-party provider flags', () => { + const env: NodeJS.ProcessEnv = { + CLAUDE_CODE_USE_OPENAI: '1', + CLAUDE_CODE_USE_GEMINI: undefined, + CLAUDE_CODE_USE_BEDROCK: '1', + }; + + const result = applyProviderRuntimeEnv(env, 'gemini'); + + expect(result.CLAUDE_CODE_USE_GEMINI).toBe('1'); + expect(result.CLAUDE_CODE_USE_OPENAI).toBeUndefined(); + expect(result.CLAUDE_CODE_USE_BEDROCK).toBeUndefined(); + }); + + it('preserves gemini as a valid team provider id', () => { + expect(resolveTeamProviderId('gemini')).toBe('gemini'); + expect(resolveTeamProviderId('codex')).toBe('codex'); + expect(resolveTeamProviderId(undefined)).toBe('anthropic'); + }); +}); diff --git a/test/main/services/team/ClaudeBinaryResolver.test.ts b/test/main/services/team/ClaudeBinaryResolver.test.ts new file mode 100644 index 00000000..b65a6098 --- /dev/null +++ b/test/main/services/team/ClaudeBinaryResolver.test.ts @@ -0,0 +1,94 @@ +// @vitest-environment node +import type { PathLike } from 'fs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockBuildMergedCliPath = vi.fn<(binaryPath: string | null) => string>(); +const mockGetShellPreferredHome = vi.fn<() => string>(); +const mockResolveInteractiveShellEnv = vi.fn<() => Promise>(); +const mockGetConfiguredCliFlavor = vi.fn<() => 'claude' | 'free-code'>(); + +const accessMock = vi.fn<(filePath: PathLike, mode?: number) => Promise>(); +const statMock = vi.fn< + (filePath: PathLike) => Promise<{ isFile: () => boolean }> +>(); +const readdirMock = vi.fn<(filePath: PathLike) => Promise>(); + +vi.mock('@main/utils/cliPathMerge', () => ({ + buildMergedCliPath: (binaryPath: string | null) => mockBuildMergedCliPath(binaryPath), +})); + +vi.mock('@main/utils/shellEnv', () => ({ + getShellPreferredHome: () => mockGetShellPreferredHome(), + resolveInteractiveShellEnv: () => mockResolveInteractiveShellEnv(), +})); + +vi.mock('@main/services/team/cliFlavor', () => ({ + getConfiguredCliFlavor: () => mockGetConfiguredCliFlavor(), +})); + +vi.mock('fs', () => ({ + default: { + constants: { X_OK: 1 }, + promises: { + access: (filePath: PathLike, mode?: number) => accessMock(filePath, mode), + stat: (filePath: PathLike) => statMock(filePath), + readdir: (filePath: PathLike) => readdirMock(filePath), + }, + }, + constants: { X_OK: 1 }, + promises: { + access: (filePath: PathLike, mode?: number) => accessMock(filePath, mode), + stat: (filePath: PathLike) => statMock(filePath), + readdir: (filePath: PathLike) => readdirMock(filePath), + }, +})); + +describe('ClaudeBinaryResolver', () => { + const originalPlatform = process.platform; + const originalCwd = process.cwd; + const workspaceRoot = '/Users/belief/dev/projects/claude/claude_team_freecode'; + + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + mockBuildMergedCliPath.mockReturnValue('/usr/local/bin:/usr/bin'); + mockGetShellPreferredHome.mockReturnValue('/Users/tester'); + mockResolveInteractiveShellEnv.mockResolvedValue({}); + mockGetConfiguredCliFlavor.mockReturnValue('free-code'); + readdirMock.mockResolvedValue([]); + Object.defineProperty(process, 'platform', { + value: 'darwin', + configurable: true, + writable: true, + }); + process.cwd = vi.fn(() => workspaceRoot); + delete process.env.CLAUDE_CLI_PATH; + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + writable: true, + }); + process.cwd = originalCwd; + vi.unstubAllEnvs(); + }); + + it('resolves free-code runtime from the free-code-gemini-research sibling repo', async () => { + const expectedBinary = '/Users/belief/dev/projects/claude/free-code-gemini-research/cli'; + + accessMock.mockImplementation(async (filePath) => { + if (filePath === expectedBinary) { + return; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const { ClaudeBinaryResolver } = await import('@main/services/team/ClaudeBinaryResolver'); + ClaudeBinaryResolver.clearCache(); + + await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary); + expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1); + }); +}); diff --git a/test/main/services/team/cliFlavor.test.ts b/test/main/services/team/cliFlavor.test.ts new file mode 100644 index 00000000..a4612c84 --- /dev/null +++ b/test/main/services/team/cliFlavor.test.ts @@ -0,0 +1,55 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const getConfigMock = vi.fn(); + +vi.mock('@main/services/infrastructure/ConfigManager', () => ({ + configManager: { + getConfig: () => getConfigMock(), + }, +})); + +describe('cliFlavor', () => { + afterEach(() => { + delete process.env.CLAUDE_TEAM_CLI_FLAVOR; + vi.resetModules(); + vi.clearAllMocks(); + }); + + it('uses multimodel runtime by default when config enables it', async () => { + getConfigMock.mockReturnValue({ + general: { + multimodelEnabled: true, + }, + }); + + const { getConfiguredCliFlavor } = await import('@main/services/team/cliFlavor'); + + expect(getConfiguredCliFlavor()).toBe('free-code'); + }); + + it('uses claude runtime when multimodel is disabled in config', async () => { + getConfigMock.mockReturnValue({ + general: { + multimodelEnabled: false, + }, + }); + + const { getConfiguredCliFlavor } = await import('@main/services/team/cliFlavor'); + + expect(getConfiguredCliFlavor()).toBe('claude'); + }); + + it('lets env override the persisted config', async () => { + process.env.CLAUDE_TEAM_CLI_FLAVOR = 'claude'; + getConfigMock.mockReturnValue({ + general: { + multimodelEnabled: true, + }, + }); + + const { getConfiguredCliFlavor } = await import('@main/services/team/cliFlavor'); + + expect(getConfiguredCliFlavor()).toBe('claude'); + }); +}); diff --git a/test/renderer/store/cliInstallerSlice.test.ts b/test/renderer/store/cliInstallerSlice.test.ts index d9dd9e92..7177eee6 100644 --- a/test/renderer/store/cliInstallerSlice.test.ts +++ b/test/renderer/store/cliInstallerSlice.test.ts @@ -83,6 +83,11 @@ describe('cliInstallerSlice', () => { describe('fetchCliStatus', () => { it('updates cliStatus from API', async () => { const mockStatus: CliInstallationStatus = { + flavor: 'claude', + displayName: 'Claude CLI', + supportsSelfUpdate: true, + showVersionDetails: true, + showBinaryPath: true, installed: true, installedVersion: '2.1.59', binaryPath: '/usr/local/bin/claude', @@ -90,6 +95,7 @@ describe('cliInstallerSlice', () => { updateAvailable: false, authLoggedIn: false, authMethod: null, + providers: [], }; vi.mocked(api.cliInstaller.getStatus).mockResolvedValue(mockStatus); @@ -109,6 +115,11 @@ describe('cliInstallerSlice', () => { it('detects update available', async () => { const mockStatus: CliInstallationStatus = { + flavor: 'claude', + displayName: 'Claude CLI', + supportsSelfUpdate: true, + showVersionDetails: true, + showBinaryPath: true, installed: true, installedVersion: '2.1.34', binaryPath: '/usr/local/bin/claude', @@ -116,6 +127,7 @@ describe('cliInstallerSlice', () => { updateAvailable: true, authLoggedIn: true, authMethod: 'oauth_token', + providers: [], }; vi.mocked(api.cliInstaller.getStatus).mockResolvedValue(mockStatus); From 3ac46e2861333f95a213ea8cc2b35dc36cfe6477 Mon Sep 17 00:00:00 2001 From: iliya Date: Thu, 2 Apr 2026 10:23:14 +0300 Subject: [PATCH 02/67] feat(teams): unify provider-aware create and launch flows --- src/main/http/teams.ts | 11 + src/main/ipc/teams.ts | 263 +++++++- .../infrastructure/EventLoopLagMonitor.ts | 5 + src/main/services/team/TeamDataService.ts | 39 +- src/main/services/team/TeamMemberResolver.ts | 39 +- .../services/team/TeamMembersMetaStore.ts | 9 + src/main/services/team/TeamMetaStore.ts | 8 + .../services/team/TeamProvisioningService.ts | 597 ++++++++++++++---- .../team/memberUpdateNotifications.ts | 154 +++++ src/renderer/api/httpClient.ts | 12 +- .../components/team/TeamDetailView.tsx | 8 + .../team/dialogs/AddMemberDialog.tsx | 7 + .../team/dialogs/CreateTeamDialog.tsx | 402 ++++++++---- .../team/dialogs/EditTeamDialog.tsx | 27 +- .../team/dialogs/EffortLevelSelector.tsx | 4 +- .../team/dialogs/LaunchTeamDialog.tsx | 437 ++++++++++--- .../ProvisioningProviderStatusList.tsx | 258 ++++++++ .../team/dialogs/TeamModelSelector.tsx | 329 ++++++---- .../components/team/members/LeadModelRow.tsx | 138 ++++ .../components/team/members/MemberCard.tsx | 77 ++- .../team/members/MemberDraftRow.tsx | 153 +++-- .../components/team/members/MemberList.tsx | 48 ++ .../team/members/MembersEditorSection.tsx | 84 ++- .../team/members/TeamRosterEditorSection.tsx | 121 ++++ .../team/members/membersEditorTypes.ts | 4 + .../team/members/membersEditorUtils.ts | 70 +- src/renderer/hooks/useCreateTeamDraft.ts | 43 +- .../services/createTeamDraftStorage.ts | 17 +- src/renderer/store/slices/teamSlice.ts | 3 + src/shared/types/api.ts | 6 +- src/shared/types/schedule.ts | 3 +- src/shared/types/team.ts | 15 + src/shared/utils/cliArgsParser.ts | 6 + .../services/team/TeamMemberResolver.test.ts | 22 + .../TeamProvisioningServicePrepare.test.ts | 32 + .../TeamProvisioningServiceRoster.test.ts | 21 + 36 files changed, 2898 insertions(+), 574 deletions(-) create mode 100644 src/main/services/team/memberUpdateNotifications.ts create mode 100644 src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx create mode 100644 src/renderer/components/team/members/LeadModelRow.tsx create mode 100644 src/renderer/components/team/members/TeamRosterEditorSection.tsx diff --git a/src/main/http/teams.ts b/src/main/http/teams.ts index ed177e9c..77d9feb9 100644 --- a/src/main/http/teams.ts +++ b/src/main/http/teams.ts @@ -89,6 +89,16 @@ function assertOptionalEffort(value: unknown): EffortLevel | undefined { function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest { const payload = body && typeof body === 'object' ? (body as Record) : {}; + const providerId = + payload.providerId === 'codex' + ? 'codex' + : payload.providerId === 'gemini' + ? 'gemini' + : payload.providerId == null || payload.providerId === 'anthropic' + ? 'anthropic' + : (() => { + throw new HttpBadRequestError('providerId must be anthropic, codex, or gemini'); + })(); const prompt = assertOptionalString(payload.prompt, 'prompt'); const model = assertOptionalString(payload.model, 'model'); const effort = assertOptionalEffort(payload.effort); @@ -100,6 +110,7 @@ function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest return { teamName, cwd: assertAbsoluteCwd(payload.cwd), + providerId, ...(prompt && { prompt, }), diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 4848902b..e0e17945 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -101,6 +101,10 @@ import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore'; import { TeamMetaStore } from '../services/team/TeamMetaStore'; import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService'; import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore'; +import { + buildReplaceMembersDiff, + buildReplaceMembersSummaryMessage, +} from '../services/team/memberUpdateNotifications'; import { validateFromField, @@ -534,25 +538,30 @@ async function handleGetData( const tn = validated.value!; const startedAt = Date.now(); let data: TeamData; + setCurrentMainOp('team:getData'); try { - data = await getTeamDataService().getTeamData(tn); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if ( - message === `Team not found: ${tn}` && - getTeamProvisioningService().hasProvisioningRun(tn) - ) { - return { success: false, error: 'TEAM_PROVISIONING' }; - } - // Draft team: team.meta.json exists but config.json doesn't (provisioning failed before TeamCreate) - if (message === `Team not found: ${tn}`) { - const meta = await teamMetaStore.getMeta(tn); - if (meta) { - return { success: false, error: 'TEAM_DRAFT' }; + try { + data = await getTeamDataService().getTeamData(tn); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if ( + message === `Team not found: ${tn}` && + getTeamProvisioningService().hasProvisioningRun(tn) + ) { + return { success: false, error: 'TEAM_PROVISIONING' }; } + // Draft team: team.meta.json exists but config.json doesn't (provisioning failed before TeamCreate) + if (message === `Team not found: ${tn}`) { + const meta = await teamMetaStore.getMeta(tn); + if (meta) { + return { success: false, error: 'TEAM_DRAFT' }; + } + } + logger.error(`[teams:getData] ${message}`); + return { success: false, error: message }; } - logger.error(`[teams:getData] ${message}`); - return { success: false, error: message }; + } finally { + setCurrentMainOp(null); } const getDataMs = Date.now() - startedAt; if (getDataMs >= 1500) { @@ -833,6 +842,32 @@ function isValidEffort(value: unknown): value is EffortLevel { return typeof value === 'string' && VALID_EFFORT_LEVELS.includes(value); } +function parseOptionalMemberProviderId( + value: unknown +): + | { valid: true; value: 'anthropic' | 'codex' | 'gemini' | undefined } + | { valid: false; error: string } { + if (value === undefined || value === null || value === '') { + return { valid: true, value: undefined }; + } + if (value === 'anthropic' || value === 'codex' || value === 'gemini') { + return { valid: true, value }; + } + return { valid: false, error: 'member providerId must be anthropic, codex, or gemini' }; +} + +function parseOptionalMemberEffort( + value: unknown +): { valid: true; value: EffortLevel | undefined } | { valid: false; error: string } { + if (value === undefined || value === null || value === '') { + return { valid: true, value: undefined }; + } + if (isValidEffort(value)) { + return { valid: true, value }; + } + return { valid: false, error: 'member effort must be low, medium, or high' }; +} + async function validateProvisioningRequest( request: unknown ): Promise<{ valid: true; value: TeamCreateRequest } | { valid: false; error: string }> { @@ -884,10 +919,22 @@ async function validateProvisioningRequest( if (workflow !== undefined && typeof workflow !== 'string') { return { valid: false, error: 'member workflow must be string' }; } + const providerValidation = parseOptionalMemberProviderId( + (member as { providerId?: unknown }).providerId + ); + if (!providerValidation.valid) { + return { valid: false, error: providerValidation.error }; + } + const model = (member as { model?: unknown }).model; + if (model !== undefined && typeof model !== 'string') { + return { valid: false, error: 'member model must be string' }; + } members.push({ name: memberName, role: typeof role === 'string' ? role.trim() : undefined, workflow: typeof workflow === 'string' ? workflow.trim() : undefined, + providerId: providerValidation.value, + model: typeof model === 'string' ? model.trim() || undefined : undefined, }); } @@ -953,6 +1000,12 @@ async function validateProvisioningRequest( members, cwd, prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined, + providerId: + payload.providerId === 'codex' + ? 'codex' + : payload.providerId === 'gemini' + ? 'gemini' + : 'anthropic', model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined, effort: isValidEffort(payload.effort) ? payload.effort : undefined, skipPermissions: @@ -1089,6 +1142,16 @@ async function handleLaunchTeam( color: meta?.color, cwd, prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined, + providerId: + payload.providerId === 'codex' + ? 'codex' + : payload.providerId === 'gemini' + ? 'gemini' + : meta?.providerId === 'codex' + ? 'codex' + : meta?.providerId === 'gemini' + ? 'gemini' + : 'anthropic', model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined, effort: isValidEffort(payload.effort) ? payload.effort : undefined, limitContext: typeof payload.limitContext === 'boolean' ? payload.limitContext : undefined, @@ -1100,7 +1163,14 @@ async function handleLaunchTeam( typeof payload.extraCliArgs === 'string' ? payload.extraCliArgs.trim() || undefined : undefined, - members: members.map((m) => ({ name: m.name, role: m.role, workflow: m.workflow })), + members: members.map((m) => ({ + name: m.name, + role: m.role, + workflow: m.workflow, + providerId: m.providerId, + model: m.model, + effort: m.effort, + })), }; return wrapTeamHandler('create', () => @@ -1122,6 +1192,12 @@ async function handleLaunchTeam( teamName: validatedTeamName.value!, cwd, prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined, + providerId: + payload.providerId === 'codex' + ? 'codex' + : payload.providerId === 'gemini' + ? 'gemini' + : 'anthropic', model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined, effort: isValidEffort(payload.effort) ? payload.effort : undefined, clearContext: payload.clearContext === true ? true : undefined, @@ -1174,9 +1250,13 @@ async function handleValidateCliArgs( async function handlePrepareProvisioning( _event: IpcMainInvokeEvent, - cwd: unknown + cwd: unknown, + providerId: unknown, + providerIds: unknown ): Promise> { let validatedCwd: string | undefined; + let validatedProviderId: TeamLaunchRequest['providerId']; + let validatedProviderIds: Array<'anthropic' | 'codex' | 'gemini'> | undefined; if (cwd !== undefined) { if (typeof cwd !== 'string' || cwd.trim().length === 0) { return { success: false, error: 'cwd must be a non-empty string' }; @@ -1186,8 +1266,32 @@ async function handlePrepareProvisioning( return { success: false, error: 'cwd must be an absolute path' }; } } + if (providerId !== undefined) { + if (providerId !== 'anthropic' && providerId !== 'codex' && providerId !== 'gemini') { + return { success: false, error: 'providerId must be anthropic, codex, or gemini' }; + } + validatedProviderId = providerId; + } + if (providerIds !== undefined) { + if (!Array.isArray(providerIds)) { + return { success: false, error: 'providerIds must be an array when provided' }; + } + const normalized: Array<'anthropic' | 'codex' | 'gemini'> = []; + for (const entry of providerIds) { + if (entry !== 'anthropic' && entry !== 'codex' && entry !== 'gemini') { + return { success: false, error: 'providerIds entries must be anthropic, codex, or gemini' }; + } + if (!normalized.includes(entry)) { + normalized.push(entry); + } + } + validatedProviderIds = normalized; + } return wrapTeamHandler('prepareProvisioning', () => - getTeamProvisioningService().prepareForProvisioning(validatedCwd) + getTeamProvisioningService().prepareForProvisioning(validatedCwd, { + providerId: validatedProviderId, + providerIds: validatedProviderIds, + }) ); } @@ -2057,10 +2161,27 @@ async function handleCreateConfig( if (workflow !== undefined && typeof workflow !== 'string') { return { success: false, error: 'member workflow must be string' }; } + const providerValidation = parseOptionalMemberProviderId( + (member as { providerId?: unknown }).providerId + ); + if (!providerValidation.valid) { + return { success: false, error: providerValidation.error }; + } + const model = (member as { model?: unknown }).model; + if (model !== undefined && typeof model !== 'string') { + return { success: false, error: 'member model must be string' }; + } + const effortValidation = parseOptionalMemberEffort((member as { effort?: unknown }).effort); + if (!effortValidation.valid) { + return { success: false, error: effortValidation.error }; + } members.push({ name: memberName, role: typeof role === 'string' ? role.trim() : undefined, workflow: typeof workflow === 'string' ? workflow.trim() : undefined, + providerId: providerValidation.value, + model: typeof model === 'string' ? model.trim() || undefined : undefined, + effort: effortValidation.value, }); } @@ -2286,10 +2407,13 @@ async function handleAddMember( if (!payload || typeof payload !== 'object') { return { success: false, error: 'Invalid payload' }; } - const { name, role, workflow } = payload as { + const { name, role, workflow, providerId, model } = payload as { name?: unknown; role?: unknown; workflow?: unknown; + providerId?: unknown; + model?: unknown; + effort?: unknown; }; const vName = validateTeammateName(name); if (!vName.valid) return { success: false, error: vName.error ?? 'Invalid member name' }; @@ -2299,6 +2423,17 @@ async function handleAddMember( if (workflow !== undefined && typeof workflow !== 'string') { return { success: false, error: 'workflow must be a string' }; } + const providerValidation = parseOptionalMemberProviderId(providerId); + if (!providerValidation.valid) { + return { success: false, error: providerValidation.error }; + } + if (model !== undefined && typeof model !== 'string') { + return { success: false, error: 'model must be a string' }; + } + const effortValidation = parseOptionalMemberEffort((payload as { effort?: unknown }).effort); + if (!effortValidation.valid) { + return { success: false, error: effortValidation.error }; + } return wrapTeamHandler('addMember', async () => { const tn = vTeam.value!; @@ -2307,6 +2442,9 @@ async function handleAddMember( name: memberName, role: role, workflow: typeof workflow === 'string' ? workflow.trim() || undefined : undefined, + providerId: providerValidation.value, + model: typeof model === 'string' ? model.trim() || undefined : undefined, + effort: effortValidation.value, }); // If team is alive, notify the lead to spawn the new teammate @@ -2329,6 +2467,9 @@ async function handleAddMember( name: memberName, ...(typeof role === 'string' ? { role } : {}), ...(typeof workflow === 'string' ? { workflow } : {}), + ...(providerValidation.value ? { providerId: providerValidation.value } : {}), + ...(typeof model === 'string' && model.trim() ? { model: model.trim() } : {}), + ...(effortValidation.value ? { effort: effortValidation.value } : {}), }); try { await provisioning.sendMessageToTeam(tn, spawnMessage); @@ -2355,12 +2496,26 @@ async function handleReplaceMembers( return { success: false, error: 'members must be an array' }; } const seenNames = new Set(); - const members: { name: string; role?: string; workflow?: string }[] = []; + const members: { + name: string; + role?: string; + workflow?: string; + providerId?: 'anthropic' | 'codex' | 'gemini'; + model?: string; + effort?: 'low' | 'medium' | 'high'; + }[] = []; for (const item of payload.members) { if (!item || typeof item !== 'object') { return { success: false, error: 'member must be object' }; } - const m = item as { name?: unknown; role?: unknown; workflow?: unknown }; + const m = item as { + name?: unknown; + role?: unknown; + workflow?: unknown; + providerId?: unknown; + model?: unknown; + effort?: unknown; + }; const vName = validateTeammateName(m.name); if (!vName.valid) return { success: false, error: vName.error ?? 'Invalid member name' }; const name = vName.value!; @@ -2372,15 +2527,73 @@ async function handleReplaceMembers( if (m.workflow !== undefined && typeof m.workflow !== 'string') { return { success: false, error: 'member workflow must be string' }; } + const providerValidation = parseOptionalMemberProviderId( + (m as { providerId?: unknown }).providerId + ); + if (!providerValidation.valid) { + return { success: false, error: providerValidation.error }; + } + if (m.model !== undefined && typeof m.model !== 'string') { + return { success: false, error: 'member model must be string' }; + } + const effortValidation = parseOptionalMemberEffort((m as { effort?: unknown }).effort); + if (!effortValidation.valid) { + return { success: false, error: effortValidation.error }; + } members.push({ name, role: typeof m.role === 'string' ? m.role.trim() : undefined, workflow: typeof m.workflow === 'string' ? m.workflow.trim() : undefined, + providerId: providerValidation.value, + model: typeof m.model === 'string' ? m.model.trim() || undefined : undefined, + effort: effortValidation.value, }); } return wrapTeamHandler('replaceMembers', async () => { - await getTeamDataService().replaceMembers(vTeam.value!, { members }); + const tn = vTeam.value!; + const teamDataService = getTeamDataService(); + const previousMembers = (await teamDataService.getTeamData(tn)).members; + const diff = buildReplaceMembersDiff(previousMembers, members); + + await teamDataService.replaceMembers(tn, { members }); + + const provisioning = getTeamProvisioningService(); + if (!provisioning.isTeamAlive(tn)) { + return; + } + + let leadName = 'team-lead'; + let displayName = tn; + try { + const [resolvedLeadName, resolvedDisplayName] = await Promise.all([ + teamDataService.getLeadMemberName(tn), + teamDataService.getTeamDisplayName(tn), + ]); + leadName = resolvedLeadName || 'team-lead'; + displayName = resolvedDisplayName || tn; + } catch { + // Best-effort: fall back to default lead and team names + } + + for (const addedMember of diff.added) { + const spawnMessage = buildAddMemberSpawnMessage(tn, displayName, leadName, addedMember); + try { + await provisioning.sendMessageToTeam(tn, spawnMessage); + } catch { + logger.warn(`Failed to notify lead about new member "${addedMember.name}" in ${tn}`); + } + } + + const summaryMessage = buildReplaceMembersSummaryMessage(diff); + if (!summaryMessage) { + return; + } + try { + await provisioning.sendMessageToTeam(tn, summaryMessage); + } catch { + logger.warn(`Failed to notify lead about member updates in ${tn}`); + } }); } @@ -3088,6 +3301,7 @@ async function handleGetSavedRequest( color: meta.color, cwd: meta.cwd, prompt: meta.prompt, + providerId: meta.providerId ?? 'anthropic', model: meta.model, effort: meta.effort as TeamCreateRequest['effort'], skipPermissions: meta.skipPermissions, @@ -3098,6 +3312,9 @@ async function handleGetSavedRequest( name: m.name, role: m.role, workflow: m.workflow, + providerId: m.providerId, + model: m.model, + effort: m.effort, })), }, }; diff --git a/src/main/services/infrastructure/EventLoopLagMonitor.ts b/src/main/services/infrastructure/EventLoopLagMonitor.ts index 7af62b24..c47fe9b8 100644 --- a/src/main/services/infrastructure/EventLoopLagMonitor.ts +++ b/src/main/services/infrastructure/EventLoopLagMonitor.ts @@ -27,6 +27,11 @@ export function startEventLoopLagMonitor(): void { // Only report meaningful stalls if (maxMs < 250) return; + // For known IPC/main-thread operations we already emit operation-specific + // timing diagnostics. Suppress the generic event-loop warning to avoid + // duplicate noisy logs that do not add new debugging value. + if (currentOp) return; + logger.warn( `Event loop stall detected: p95=${p95Ms.toFixed(1)}ms max=${maxMs.toFixed(1)}ms` + (currentOp ? ` op=${currentOp}` : '') diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 92bdbf5e..e3bc315f 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -948,6 +948,15 @@ export class TeamDataService { name, role: request.role?.trim() || undefined, workflow: request.workflow?.trim() || undefined, + providerId: + request.providerId === 'codex' || request.providerId === 'gemini' + ? request.providerId + : undefined, + model: request.model?.trim() || undefined, + effort: + request.effort === 'low' || request.effort === 'medium' || request.effort === 'high' + ? request.effort + : undefined, agentType: 'general-purpose', color: getMemberColorByName(name), joinedAt: Date.now(), @@ -977,7 +986,16 @@ export class TeamDataService { async replaceMembers( teamName: string, - request: { members: { name: string; role?: string; workflow?: string }[] } + request: { + members: { + name: string; + role?: string; + workflow?: string; + providerId?: 'anthropic' | 'codex' | 'gemini'; + model?: string; + effort?: 'low' | 'medium' | 'high'; + }[]; + } ): Promise { const existing = await this.membersMetaStore.getMembers(teamName); const existingLead = existing.find(isLeadMember) ?? null; @@ -1003,6 +1021,15 @@ export class TeamDataService { name, role: member.role?.trim() || undefined, workflow: member.workflow?.trim() || undefined, + providerId: + member.providerId === 'codex' || member.providerId === 'gemini' + ? member.providerId + : undefined, + model: member.model?.trim() || undefined, + effort: + member.effort === 'low' || member.effort === 'medium' || member.effort === 'high' + ? member.effort + : undefined, agentType: prev?.agentType ?? 'general-purpose', color: prev?.color ?? getMemberColorByName(name), joinedAt: prev?.joinedAt ?? joinedAt, @@ -1957,6 +1984,16 @@ export class TeamDataService { return name; })(), role: member.role?.trim() || undefined, + workflow: member.workflow?.trim() || undefined, + providerId: + member.providerId === 'codex' || member.providerId === 'gemini' + ? member.providerId + : undefined, + model: member.model?.trim() || undefined, + effort: + member.effort === 'low' || member.effort === 'medium' || member.effort === 'high' + ? member.effort + : undefined, agentType: 'general-purpose', color: getMemberColorByName(member.name.trim()), joinedAt, diff --git a/src/main/services/team/TeamMemberResolver.ts b/src/main/services/team/TeamMemberResolver.ts index c5368981..92832d0e 100644 --- a/src/main/services/team/TeamMemberResolver.ts +++ b/src/main/services/team/TeamMemberResolver.ts @@ -17,6 +17,7 @@ const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([ 'cross_team_list_targets', 'cross_team_get_outbox', ]); +const GENERATED_AGENT_ID_PATTERN = /^a[0-9a-f]{16}$/i; function looksLikeQualifiedExternalRecipient(name: string): boolean { const trimmed = name.trim(); @@ -51,6 +52,10 @@ function looksLikeCrossTeamToolRecipient(name: string): boolean { return CROSS_TEAM_TOOL_RECIPIENT_NAMES.has(name.trim()); } +function looksLikeGeneratedAgentId(name: string): boolean { + return GENERATED_AGENT_ID_PATTERN.test(name.trim()); +} + export class TeamMemberResolver { resolveMembers( config: TeamConfig, @@ -106,13 +111,25 @@ export class TeamMemberResolver { ) { continue; } + if (!explicitNames.has(trimmed.toLowerCase()) && looksLikeGeneratedAgentId(trimmed)) { + continue; + } addName(trimmed); } } const configMemberMap = new Map< string, - { agentType?: string; role?: string; workflow?: string; color?: string; cwd?: string } + { + agentType?: string; + role?: string; + workflow?: string; + providerId?: 'anthropic' | 'codex' | 'gemini'; + model?: string; + effort?: 'low' | 'medium' | 'high'; + color?: string; + cwd?: string; + } >(); if (Array.isArray(config.members)) { for (const m of config.members) { @@ -121,6 +138,9 @@ export class TeamMemberResolver { agentType: m.agentType, role: m.role, workflow: m.workflow, + providerId: m.providerId, + model: m.model, + effort: m.effort, color: m.color, cwd: m.cwd, }); @@ -130,7 +150,16 @@ export class TeamMemberResolver { const metaMemberMap = new Map< string, - { agentType?: string; role?: string; workflow?: string; color?: string; removedAt?: number } + { + agentType?: string; + role?: string; + workflow?: string; + providerId?: 'anthropic' | 'codex' | 'gemini'; + model?: string; + effort?: 'low' | 'medium' | 'high'; + color?: string; + removedAt?: number; + } >(); if (Array.isArray(metaMembers)) { for (const member of metaMembers) { @@ -139,6 +168,9 @@ export class TeamMemberResolver { agentType: member.agentType, role: member.role, workflow: member.workflow, + providerId: member.providerId, + model: member.model, + effort: member.effort, color: member.color, removedAt: member.removedAt, }); @@ -193,6 +225,9 @@ export class TeamMemberResolver { agentType: configMember?.agentType ?? metaMember?.agentType, role: configMember?.role ?? metaMember?.role, workflow: configMember?.workflow ?? metaMember?.workflow, + providerId: configMember?.providerId ?? metaMember?.providerId, + model: configMember?.model ?? metaMember?.model, + effort: configMember?.effort ?? metaMember?.effort, cwd: configMember?.cwd, removedAt: metaMember?.removedAt, }); diff --git a/src/main/services/team/TeamMembersMetaStore.ts b/src/main/services/team/TeamMembersMetaStore.ts index 78492e35..7bc612ef 100644 --- a/src/main/services/team/TeamMembersMetaStore.ts +++ b/src/main/services/team/TeamMembersMetaStore.ts @@ -24,6 +24,15 @@ 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, + model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined, + effort: + member.effort === 'low' || member.effort === 'medium' || member.effort === 'high' + ? member.effort + : undefined, agentType: typeof member.agentType === 'string' ? member.agentType.trim() || undefined : undefined, color: typeof member.color === 'string' ? member.color.trim() || undefined : undefined, diff --git a/src/main/services/team/TeamMetaStore.ts b/src/main/services/team/TeamMetaStore.ts index 3cc7aaa6..a8bce4dc 100644 --- a/src/main/services/team/TeamMetaStore.ts +++ b/src/main/services/team/TeamMetaStore.ts @@ -18,6 +18,7 @@ export interface TeamMetaFile { color?: string; cwd: string; prompt?: string; + providerId?: 'anthropic' | 'codex' | 'gemini'; model?: string; effort?: string; skipPermissions?: boolean; @@ -82,6 +83,12 @@ export class TeamMetaStore { color: typeof file.color === 'string' ? file.color.trim() || undefined : undefined, cwd: file.cwd.trim(), prompt: typeof file.prompt === 'string' ? file.prompt.trim() || undefined : undefined, + providerId: + file.providerId === 'anthropic' || + file.providerId === 'codex' || + file.providerId === 'gemini' + ? file.providerId + : undefined, model: typeof file.model === 'string' ? file.model.trim() || undefined : undefined, effort: typeof file.effort === 'string' ? file.effort.trim() || undefined : undefined, skipPermissions: typeof file.skipPermissions === 'boolean' ? file.skipPermissions : undefined, @@ -101,6 +108,7 @@ export class TeamMetaStore { color: data.color?.trim() || undefined, cwd: data.cwd.trim(), prompt: data.prompt?.trim() || undefined, + providerId: data.providerId, model: data.model?.trim() || undefined, effort: data.effort?.trim() || undefined, skipPermissions: data.skipPermissions, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 3306ad81..9841a248 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -69,6 +69,8 @@ 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'; /** * Kill a team CLI process using SIGKILL (uncatchable). @@ -106,6 +108,7 @@ import type { ToolApprovalRequest, ToolApprovalSettings, ToolCallMeta, + TeamProviderId, } from '@shared/types'; const logger = createLogger('Service:TeamProvisioning'); @@ -121,6 +124,9 @@ 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_GEMINI_TIMEOUT_MS = 15000; +const PREFLIGHT_BINARY_TIMEOUT_MS = 8000; const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000; const PREFLIGHT_AUTH_MAX_RETRIES = 2; const FS_MONITOR_POLL_MS = 2000; @@ -143,18 +149,69 @@ const HANDLED_STREAM_JSON_TYPES = new Set([ 'system', ]); const PREFLIGHT_PING_PROMPT = 'Output only the single word PONG.'; -const PREFLIGHT_PING_ARGS = [ - '-p', - PREFLIGHT_PING_PROMPT, - '--output-format', - 'text', - '--model', - 'haiku', - '--max-turns', - '1', - '--no-session-persistence', -] as const; const PREFLIGHT_EXPECTED = 'PONG'; +const PREFLIGHT_CODEX_MODEL = 'gpt-5.4-mini'; +const PREFLIGHT_GEMINI_MODEL = 'gemini-2.5-flash-lite'; + +function getPreflightPingModel(providerId: TeamProviderId | undefined): string { + switch (resolveTeamProviderId(providerId)) { + case 'codex': + return PREFLIGHT_CODEX_MODEL; + case 'gemini': + return PREFLIGHT_GEMINI_MODEL; + case 'anthropic': + default: + return 'haiku'; + } +} + +function getPreflightPingArgs(providerId: TeamProviderId | undefined): string[] { + return [ + '-p', + PREFLIGHT_PING_PROMPT, + '--output-format', + 'text', + '--model', + getPreflightPingModel(providerId), + '--max-turns', + '1', + '--no-session-persistence', + ]; +} + +function getPreflightTimeoutMs(providerId: TeamProviderId | undefined): number { + switch (resolveTeamProviderId(providerId)) { + case 'codex': + return PREFLIGHT_CODEX_TIMEOUT_MS; + case 'gemini': + return PREFLIGHT_GEMINI_TIMEOUT_MS; + case 'anthropic': + default: + return PREFLIGHT_TIMEOUT_MS; + } +} + +function isProbeTimeoutMessage(message: string): boolean { + const lower = message.toLowerCase(); + return ( + lower.includes('timeout running:') || + lower.includes('timed out') || + lower.includes('did not complete') || + lower.includes('etimedout') + ); +} + +function getTeamProviderLabel(providerId: TeamProviderId): string { + switch (providerId) { + case 'codex': + return 'Codex'; + case 'gemini': + return 'Gemini'; + case 'anthropic': + default: + return 'Anthropic'; + } +} type TeamsBaseLocation = 'configured' | 'default'; @@ -257,6 +314,8 @@ interface ProvisioningRun { * watchdog defers to retry messages for progress.message (retries are * more informative than the generic "CLI not responding" stall text). */ lastRetryAt: number; + /** Index of the latest api_retry warning block in provisioningOutputParts. */ + apiRetryWarningIndex: number | null; /** True after emitApiErrorWarning() fires once — prevents duplicate warnings and pre-complete false positives. */ apiErrorWarningEmitted: boolean; fsPhase: 'waiting_config' | 'waiting_members' | 'waiting_tasks' | 'all_files_found'; @@ -351,7 +410,12 @@ interface ProvisioningRun { type LeadActivityState = 'active' | 'idle' | 'offline'; -type ProvisioningAuthSource = 'anthropic_api_key' | 'anthropic_auth_token' | 'none'; +type ProvisioningAuthSource = + | 'anthropic_api_key' + | 'anthropic_auth_token' + | 'codex_runtime' + | 'gemini_runtime' + | 'none'; interface ProvisioningEnvResolution { env: NodeJS.ProcessEnv; @@ -405,6 +469,11 @@ async function ensureCwdExists(cwd: string): Promise { } } +function isMissingCwdSpawnError(message: string): boolean { + const lower = message.toLowerCase(); + return lower.includes('spawn ') && lower.includes(' enoent'); +} + /** @deprecated Use wrapAgentBlock from @shared/constants/agentBlocks instead. */ const wrapInAgentBlock = wrapAgentBlock; @@ -426,10 +495,16 @@ function buildMembersPrompt(members: TeamCreateRequest['members']): string { return members .map((member) => { const rolePart = member.role?.trim() ? ` (role: ${member.role.trim()})` : ''; + const providerPart = + member.providerId && member.providerId !== 'anthropic' + ? ` [provider: ${member.providerId}]` + : ''; + const modelPart = member.model?.trim() ? ` [model: ${member.model.trim()}]` : ''; + const effortPart = member.effort ? ` [effort: ${member.effort}]` : ''; const workflowPart = member.workflow?.trim() ? `\n Workflow/instructions:${formatWorkflowBlock(member.workflow, ' ')}` : ''; - return `- ${member.name}${rolePart}${workflowPart}`; + return `- ${member.name}${rolePart}${providerPart}${modelPart}${effortPart}${workflowPart}`; }) .join('\n'); } @@ -463,13 +538,21 @@ function buildMemberSpawnPrompt( leadName: string ): string { const role = member.role?.trim() || 'team member'; + const providerLine = + member.providerId && member.providerId !== 'anthropic' + ? `\nProvider override for this teammate: ${member.providerId}.` + : ''; + const modelLine = member.model?.trim() + ? `\nModel override for this teammate: ${member.model.trim()}.` + : ''; + const effortLine = member.effort ? `\nEffort override for this teammate: ${member.effort}.` : ''; const workflowBlock = member.workflow?.trim() ? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(member.workflow, '')}` : ''; const actionModeProtocol = protocols.buildActionModeProtocolText( protocols.MEMBER_DELEGATE_DESCRIPTION ); - return `You are ${member.name}, a ${role} on team "${displayName}" (${teamName}).${workflowBlock} + return `You are ${member.name}, a ${role} on team "${displayName}" (${teamName}).${providerLine}${modelLine}${effortLine}${workflowBlock} ${getAgentLanguageInstruction()} Your FIRST action: call MCP tool member_briefing with: @@ -499,6 +582,16 @@ function buildReconnectMemberSpawnPrompt( hasTasks: boolean ): string { const role = member.role?.trim() || 'team member'; + const providerLine = + member.providerId && member.providerId !== 'anthropic' + ? `\n Provider override for this teammate: ${member.providerId}.` + : ''; + const modelLine = member.model?.trim() + ? `\n Model override for this teammate: ${member.model.trim()}.` + : ''; + const effortLine = member.effort + ? `\n Effort override for this teammate: ${member.effort}.` + : ''; const workflowBlock = member.workflow?.trim() ? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(member.workflow, ' ')}` : ''; @@ -506,9 +599,15 @@ function buildReconnectMemberSpawnPrompt( protocols.buildActionModeProtocolText(protocols.MEMBER_DELEGATE_DESCRIPTION), ' ' ); + const providerArgLine = + member.providerId && member.providerId !== 'anthropic' + ? ` - provider: "${member.providerId}"\n` + : ''; + const modelArgLine = member.model?.trim() ? ` - model: "${member.model.trim()}"\n` : ''; + const effortArgLine = member.effort ? ` - effort: "${member.effort}"\n` : ''; return ` For "${member.name}": - - prompt: - You are ${member.name}, a ${role} on team "${teamName}" (${teamName}).${workflowBlock} +${providerArgLine}${modelArgLine}${effortArgLine} - prompt: + You are ${member.name}, a ${role} on team "${teamName}" (${teamName}).${providerLine}${modelLine}${effortLine}${workflowBlock} ${getAgentLanguageInstruction()} The team has been reconnected after a restart. @@ -546,7 +645,10 @@ export function buildAddMemberSpawnMessage( teamName: string, displayName: string, leadName: string, - member: Pick + member: Pick< + TeamCreateRequest['members'][number], + 'name' | 'role' | 'workflow' | 'providerId' | 'model' | 'effort' + > ): string { const roleHint = typeof member.role === 'string' && member.role.trim() @@ -562,15 +664,24 @@ export function buildAddMemberSpawnMessage( name: member.name, ...(member.role ? { role: member.role } : {}), ...(member.workflow ? { workflow: member.workflow } : {}), + ...(member.providerId ? { providerId: member.providerId } : {}), + ...(member.model ? { model: member.model } : {}), + ...(member.effort ? { effort: member.effort } : {}), }, displayName, teamName, leadName ); + const providerPart = + member.providerId && member.providerId !== 'anthropic' + ? `, provider="${member.providerId}"` + : ''; + const modelPart = member.model?.trim() ? `, model="${member.model.trim()}"` : ''; + const effortPart = member.effort ? `, effort="${member.effort}"` : ''; return ( `A new teammate "${member.name}"${roleHint} has been added to the team. ` + - `Please spawn them immediately using the **Agent** tool with team_name="${teamName}", name="${member.name}", subagent_type="general-purpose", and the exact prompt below:${workflowHint}\n\n` + + `Please spawn them immediately using the **Agent** tool with team_name="${teamName}", name="${member.name}", subagent_type="general-purpose"${providerPart}${modelPart}${effortPart}, and the exact prompt below:${workflowHint}\n\n` + indentMultiline(prompt, ' ') ); } @@ -926,15 +1037,18 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string { Per-member spawn instructions: ${request.members - .map( - (m) => ` For “${m.name}”: + .map((m) => { + const providerLine = + m.providerId && m.providerId !== 'anthropic' ? ` - provider: “${m.providerId}”\n` : ''; + const modelLine = m.model?.trim() ? ` - model: “${m.model.trim()}”\n` : ''; + return ` For “${m.name}”: - name: “${m.name}” - - prompt: +${providerLine}${modelLine} - prompt: ${buildMemberSpawnPrompt(m, displayName, request.teamName, leadName) .split('\n') .map((line) => ` ${line}`) - .join('\n')}` - ) + .join('\n')}`; + }) .join('\n\n')}`; const persistentContext = buildPersistentLeadContext({ @@ -1212,8 +1326,8 @@ type AuthWarningSource = 'probe' | 'stdout' | 'stderr' | 'assistant' | 'pre-comp const cachedProbeResults = new Map(); const probeInFlightByKey = new Map>(); -function createProbeCacheKey(cwd: string): string { - return `${path.resolve(cwd)}::${getClaudeBasePath()}`; +function createProbeCacheKey(cwd: string, providerId: TeamProviderId | undefined): string { + return `${path.resolve(cwd)}::${getClaudeBasePath()}::${resolveTeamProviderId(providerId)}`; } function isTransientProbeWarning(warning: string): boolean { @@ -1228,6 +1342,17 @@ function isTransientProbeWarning(warning: string): boolean { ); } +function isBinaryProbeWarning(warning: string): boolean { + const lower = warning.toLowerCase(); + return ( + (lower.includes('spawn ') && lower.includes(' enoent')) || + lower.includes('eacces') || + lower.includes('enoexec') || + lower.includes('bad cpu type in executable') || + lower.includes('image not found') + ); +} + interface PendingInboxRelayCandidate { recipient: string; sourceMessageId: string; @@ -2304,8 +2429,8 @@ export class TeamProvisioningService { async warmup(): Promise { try { const cwd = process.cwd(); - if (this.getFreshCachedProbeResult(cwd)) return; - const result = await this.getCachedOrProbeResult(cwd); + if (this.getFreshCachedProbeResult(cwd, 'anthropic')) return; + const result = await this.getCachedOrProbeResult(cwd, 'anthropic'); if (!result) return; logger.info('CLI warmup completed'); } catch (error) { @@ -2315,32 +2440,26 @@ export class TeamProvisioningService { async prepareForProvisioning( cwd?: string, - opts?: { forceFresh?: boolean } + opts?: { forceFresh?: boolean; providerId?: TeamProviderId; providerIds?: TeamProviderId[] } ): Promise { const targetCwdForValidation = cwd?.trim() || process.cwd(); await this.validatePrepareCwd(targetCwdForValidation); + const providerIds = Array.from( + new Set( + [opts?.providerId, ...(opts?.providerIds ?? [])] + .map((providerId) => resolveTeamProviderId(providerId)) + .filter((providerId): providerId is TeamProviderId => Boolean(providerId)) + ) + ); + if (providerIds.length === 0) { + providerIds.push('anthropic'); + } // Allow callers (e.g. scheduler warm-up) to bypass the 36h probe cache if (opts?.forceFresh) { - this.clearProbeCache(targetCwdForValidation); - } - - const cached = this.getFreshCachedProbeResult(targetCwdForValidation); - if (cached) { - const { warning, authSource } = cached; - const warnings: string[] = []; - if (warning) warnings.push(warning); - const isAuthFailure = warning ? this.isAuthFailureWarning(warning, 'probe') : false; - const ready = !warning || authSource !== 'none' || !isAuthFailure; - return { - ready, - message: ready - ? warnings.length > 0 - ? 'CLI is ready to launch (see notes)' - : 'CLI is warmed up and ready to launch' - : warning || 'CLI is not ready', - warnings: warnings.length > 0 ? warnings : undefined, - }; + for (const providerId of providerIds) { + this.clearProbeCache(targetCwdForValidation, providerId); + } } const targetCwd = cwd?.trim() || process.cwd(); @@ -2349,45 +2468,77 @@ export class TeamProvisioningService { } const warnings: string[] = []; + const blockingMessages: string[] = []; - const probeResult = await this.getCachedOrProbeResult(targetCwd); - if (!probeResult?.claudePath) { - throw new Error('Claude CLI not found; install it or provide a valid path'); - } - - const { authSource } = probeResult; - if (authSource === 'anthropic_api_key') { - logger.info('Auth: using explicit ANTHROPIC_API_KEY'); - } else if (authSource === 'anthropic_auth_token') { - logger.info('Auth: using ANTHROPIC_AUTH_TOKEN mapped to ANTHROPIC_API_KEY'); - } - - if (probeResult.warning) { - const isAuthFailure = this.isAuthFailureWarning(probeResult.warning, 'probe'); - if (authSource === 'none' && isAuthFailure) { - // No auth source + preflight indicates auth failure — block to avoid a confusing hang later. - return { - ready: false, - message: probeResult.warning, - warnings: warnings.length > 0 ? warnings : undefined, - }; + for (const providerId of providerIds) { + const cached = this.getFreshCachedProbeResult(targetCwdForValidation, providerId); + const probeResult = cached ?? (await this.getCachedOrProbeResult(targetCwd, providerId)); + if (!probeResult?.claudePath) { + throw new Error('Claude CLI not found; install it or provide a valid path'); } - // Preflight warnings (including timeouts) should not block provisioning. - warnings.push(probeResult.warning); + + const providerLabel = getTeamProviderLabel(providerId); + const { authSource } = probeResult; + if (authSource === 'anthropic_api_key') { + logger.info(`Auth: using explicit ANTHROPIC_API_KEY for ${providerLabel}`); + } else if (authSource === 'anthropic_auth_token') { + logger.info( + `Auth: using ANTHROPIC_AUTH_TOKEN mapped to ANTHROPIC_API_KEY for ${providerLabel}` + ); + } + + if (!probeResult.warning) { + continue; + } + + const prefixedWarning = + providerIds.length > 1 ? `${providerLabel}: ${probeResult.warning}` : probeResult.warning; + const isAuthFailure = this.isAuthFailureWarning(probeResult.warning, 'probe'); + if ( + (authSource === 'none' || + authSource === 'codex_runtime' || + authSource === 'gemini_runtime') && + isAuthFailure + ) { + blockingMessages.push(prefixedWarning); + } else if (isBinaryProbeWarning(probeResult.warning)) { + blockingMessages.push(prefixedWarning); + } else { + // Preflight warnings (including timeouts) should not block provisioning. + warnings.push(prefixedWarning); + } + } + + if (blockingMessages.length > 0) { + return { + ready: false, + message: + blockingMessages.length === 1 + ? blockingMessages[0]! + : 'Some provider runtimes are not ready', + warnings: blockingMessages.length > 1 ? blockingMessages : undefined, + }; } return { ready: true, message: - warnings.length > 0 - ? 'CLI is ready to launch (see notes)' - : 'CLI is warmed up and ready to launch', + providerIds.length > 1 + ? warnings.length > 0 + ? `Validated ${providerIds.length}/${providerIds.length} provider runtimes (see notes)` + : `Validated ${providerIds.length}/${providerIds.length} provider runtimes` + : warnings.length > 0 + ? 'CLI is ready to launch (see notes)' + : 'CLI is warmed up and ready to launch', warnings: warnings.length > 0 ? warnings : undefined, }; } - private getFreshCachedProbeResult(cwd: string): CachedProbeResult | null { - const cacheKey = createProbeCacheKey(cwd); + private getFreshCachedProbeResult( + cwd: string, + providerId: TeamProviderId | undefined + ): CachedProbeResult | null { + const cacheKey = createProbeCacheKey(cwd, providerId); const cached = cachedProbeResults.get(cacheKey); if (!cached) return null; const ageMs = Date.now() - cached.cachedAtMs; @@ -2398,8 +2549,8 @@ export class TeamProvisioningService { return cached; } - private clearProbeCache(cwd: string): void { - cachedProbeResults.delete(createProbeCacheKey(cwd)); + private clearProbeCache(cwd: string, providerId: TeamProviderId | undefined): void { + cachedProbeResults.delete(createProbeCacheKey(cwd, providerId)); } private async validatePrepareCwd(cwd: string): Promise { @@ -2414,15 +2565,18 @@ export class TeamProvisioningService { } } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return; + throw new Error(`Working directory does not exist: ${cwd}`); } throw error; } } - private async getCachedOrProbeResult(cwd: string): Promise { - const cacheKey = createProbeCacheKey(cwd); - const cached = this.getFreshCachedProbeResult(cwd); + private async getCachedOrProbeResult( + cwd: string, + providerId: TeamProviderId | undefined + ): Promise { + const cacheKey = createProbeCacheKey(cwd, providerId); + const cached = this.getFreshCachedProbeResult(cwd, providerId); if (cached) { return { claudePath: cached.claudePath, @@ -2440,8 +2594,8 @@ export class TeamProvisioningService { const claudePath = await ClaudeBinaryResolver.resolve(); if (!claudePath) return null; - const { env, authSource } = await this.buildProvisioningEnv(); - const probe = await this.probeClaudeRuntime(claudePath, cwd, env); + const { env, authSource } = await this.buildProvisioningEnv(providerId); + const probe = await this.probeClaudeRuntime(claudePath, cwd, env, providerId); const result = { claudePath, authSource, @@ -2451,7 +2605,8 @@ export class TeamProvisioningService { const shouldCache = !probe.warning || (!this.isAuthFailureWarning(probe.warning, 'probe') && - !isTransientProbeWarning(probe.warning)); + !isTransientProbeWarning(probe.warning) && + !isBinaryProbeWarning(probe.warning)); if (shouldCache) { cachedProbeResults.set(cacheKey, { cacheKey, ...result, cachedAtMs: Date.now() }); @@ -2480,8 +2635,14 @@ export class TeamProvisioningService { lower.includes('missing api key') || lower.includes('invalid api key') || lower.includes('authentication failed') || + lower.includes('not configured for runtime use') || + lower.includes('set gemini_api_key') || + lower.includes('google adc credentials') || + lower.includes('google_cloud_project') || + lower.includes('codex provider is not authenticated') || lower.includes('run `claude auth login`') || - lower.includes('claude auth login'); + lower.includes('claude auth login') || + lower.includes('claude-multimodel auth login'); if (hasExplicitCliAuthSignal) { return true; @@ -2515,6 +2676,56 @@ export class TeamProvisioningService { return text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); } + private normalizeApiRetryErrorMessage(text: string): string { + const sanitized = this.sanitizeCliSnippet(text).trim(); + if (!sanitized) { + return sanitized; + } + + const jsonMatch = sanitized.match(/^\d{3}\s+(\{[\s\S]*\})$/); + const jsonCandidate = jsonMatch?.[1] ?? (sanitized.startsWith('{') ? sanitized : null); + if (jsonCandidate) { + try { + const parsed = JSON.parse(jsonCandidate) as { + error?: { message?: unknown }; + message?: unknown; + }; + const nestedMessage = + typeof parsed.error?.message === 'string' + ? parsed.error.message + : typeof parsed.message === 'string' + ? parsed.message + : null; + if (nestedMessage) { + return this.normalizeApiRetryErrorMessage(nestedMessage); + } + } catch { + // Fall through to raw sanitized text. + } + } + + return sanitized + .replace(/^gemini cli backend error:\s*/i, '') + .replace(/^gemini api backend error:\s*/i, '') + .replace(/^api error:\s*\d+\s*/i, '') + .trim(); + } + + private isQuotaRetryMessage(text: string | undefined): boolean { + const lower = (text ?? '').toLowerCase(); + return ( + lower.includes('quota will reset after') || + lower.includes('exhausted your capacity on this model') || + lower.includes('resource exhausted') || + lower.includes('rate limit') || + lower.includes('rate_limit') + ); + } + + private toMarkdownCodeSafe(text: string): string { + return this.sanitizeCliSnippet(text).replace(/```/g, '``\\`'); + } + private extractApiErrorSnippet(text: string): string | null { const match = /api error:\s*\d{3}\b/i.exec(text) ?? /invalid_request_error/i.exec(text); if (match?.index === undefined) return null; @@ -3114,6 +3325,7 @@ export class TeamProvisioningService { stallWarningIndex: null, preStallMessage: null, lastRetryAt: 0, + apiRetryWarningIndex: null, apiErrorWarningEmitted: false, waitingTasksSince: null, provisioningComplete: false, @@ -3162,7 +3374,7 @@ export class TeamProvisioningService { const prompt = buildProvisioningPrompt(request); let child: ReturnType; - const { env: shellEnv } = await this.buildProvisioningEnv(); + const { env: shellEnv } = await this.buildProvisioningEnv(request.providerId); let mcpConfigPath: string; try { mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd); @@ -3207,6 +3419,7 @@ export class TeamProvisioningService { color: request.color, cwd: request.cwd, prompt: request.prompt, + providerId: request.providerId, model: request.model, effort: request.effort, skipPermissions: request.skipPermissions, @@ -3510,6 +3723,7 @@ export class TeamProvisioningService { teamName: request.teamName, members: expectedMemberSpecs, cwd: request.cwd, + providerId: request.providerId, skipPermissions: request.skipPermissions, }; @@ -3557,6 +3771,7 @@ export class TeamProvisioningService { stallWarningIndex: null, preStallMessage: null, lastRetryAt: 0, + apiRetryWarningIndex: null, apiErrorWarningEmitted: false, waitingTasksSince: null, provisioningComplete: false, @@ -3627,7 +3842,7 @@ export class TeamProvisioningService { Boolean(previousSessionId) ); let child: ReturnType; - const { env: shellEnv } = await this.buildProvisioningEnv(); + const { env: shellEnv } = await this.buildProvisioningEnv(request.providerId); let mcpConfigPath: string; try { mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd); @@ -4674,6 +4889,13 @@ export class TeamProvisioningService { const inp = input as Record; const teamName = typeof inp.team_name === 'string' ? inp.team_name.trim() : ''; const memberName = typeof inp.name === 'string' ? inp.name.trim() : ''; + if (teamName && !memberName) { + logger.warn( + `[captureTeamSpawnEvents] Agent call for team "${run.teamName}" is missing name — ` + + `runtime will spawn an ephemeral subagent instead of a persistent teammate` + ); + continue; + } if (!memberName) continue; if (!teamName) { logger.warn( @@ -5577,21 +5799,45 @@ export class TeamProvisioningService { const errorStatus = typeof msg.error_status === 'number' ? msg.error_status : undefined; const errorLabel = typeof msg.error === 'string' ? msg.error.replace(/_/g, ' ') : undefined; const retryDelay = typeof msg.retry_delay_ms === 'number' ? msg.retry_delay_ms : undefined; + const errorMessage = + typeof msg.error_message === 'string' && msg.error_message.trim().length > 0 + ? this.normalizeApiRetryErrorMessage(msg.error_message.trim()) + : undefined; + const looksLikeQuotaRetry = + errorLabel === 'rate limit' || this.isQuotaRetryMessage(errorMessage); - // Use CLI's own error label (e.g. "rate limit") with status code - const statusLabel = errorLabel - ? `${errorLabel}${errorStatus ? ` (${errorStatus})` : ''}` - : `error ${errorStatus ?? 'unknown'}`; + // Use a human label for known quota/rate-limit retries instead of a misleading 500 bucket. + const statusLabel = looksLikeQuotaRetry + ? 'rate limited' + : errorLabel + ? `${errorLabel}${errorStatus ? ` (${errorStatus})` : ''}` + : `error ${errorStatus ?? 'unknown'}`; const delayLabel = retryDelay ? ` — next retry in ${Math.round(retryDelay / 1000)}s` : ''; - const retryText = `API retry ${attempt}/${maxRetries}: ${statusLabel}${delayLabel}`; + const retryText = `API retry ${attempt}/${maxRetries}: ${statusLabel}${ + errorMessage ? ` — ${errorMessage}` : '' + }${delayLabel}`; if (!run.provisioningComplete) { + const warningText = errorMessage + ? `**API retry ${attempt}/${maxRetries}: ${statusLabel}**\n\n\`\`\`\n${this.toMarkdownCodeSafe( + errorMessage + )}\n\`\`\`\n\n${retryDelay ? `Next retry in ${Math.round(retryDelay / 1000)}s.` : 'Retrying...'}` + : `**API retry ${attempt}/${maxRetries}: ${statusLabel}**\n\n${ + retryDelay ? `Next retry in ${Math.round(retryDelay / 1000)}s.` : 'Retrying...' + }`; + if (run.apiRetryWarningIndex != null) { + run.provisioningOutputParts[run.apiRetryWarningIndex] = warningText; + } else { + run.apiRetryWarningIndex = run.provisioningOutputParts.length; + run.provisioningOutputParts.push(warningText); + } run.lastRetryAt = Date.now(); run.progress = { ...run.progress, updatedAt: nowIso(), message: retryText, messageSeverity: 'error' as const, + assistantOutput: run.provisioningOutputParts.join('\n\n'), }; run.onProgress(run.progress); } @@ -7563,7 +7809,9 @@ export class TeamProvisioningService { } } - private async buildProvisioningEnv(): Promise { + private async buildProvisioningEnv( + providerId: TeamProviderId | undefined = 'anthropic' + ): Promise { const shellEnv = await resolveInteractiveShellEnv(); // getHomeDir() uses Electron's app.getPath('home') which handles Unicode // correctly on Windows. Prefer it over process.env which may be garbled. @@ -7605,6 +7853,7 @@ export class TeamProvisioningService { : {}), CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', }; + applyProviderRuntimeEnv(env, providerId); const controlApiBaseUrl = await this.resolveControlApiBaseUrl(); if (controlApiBaseUrl) { @@ -7631,6 +7880,14 @@ export class TeamProvisioningService { env.XDG_STATE_HOME = xdgStateHome; } + if (resolveTeamProviderId(providerId) === 'codex') { + return { env, authSource: 'codex_runtime' }; + } + + if (resolveTeamProviderId(providerId) === 'gemini') { + return { env, authSource: 'gemini_runtime' }; + } + // 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' }; @@ -8300,6 +8557,15 @@ 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, + model: member.model?.trim() || undefined, + effort: + member.effort === 'low' || member.effort === 'medium' || member.effort === 'high' + ? member.effort + : undefined, agentType: 'general-purpose', color: getMemberColorByName(member.name.trim()), joinedAt, @@ -8337,14 +8603,27 @@ 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 model = + typeof member.model === 'string' ? member.model.trim() || undefined : undefined; + const effort = + member.effort === 'low' || member.effort === 'medium' || member.effort === 'high' + ? member.effort + : undefined; const prev = byName.get(name); if (!prev) { - byName.set(name, { name, role, workflow }); + byName.set(name, { name, role, workflow, providerId, model, effort }); } else { byName.set(name, { ...prev, role: prev.role || role, workflow: prev.workflow || workflow, + providerId: prev.providerId || providerId, + model: prev.model || model, + effort: prev.effort || effort, }); } } @@ -8392,8 +8671,35 @@ export class TeamProvisioningService { return !inboxNameSetLower.has(match[1].toLowerCase()); }); if (inboxNames.length > 0) { - const members = inboxNames.map((name) => ({ name })); - return { members, source: 'inboxes' }; + const configMembers = this.extractTeammateSpecsFromConfig(teamName, configRaw); + const configMembersByName = new Map( + configMembers.map((member) => [member.name.toLowerCase(), member] as const) + ); + const members = inboxNames.map((name) => { + const configMember = configMembersByName.get(name.toLowerCase()); + return { + name, + role: configMember?.role, + workflow: configMember?.workflow, + providerId: configMember?.providerId, + model: configMember?.model, + effort: configMember?.effort, + }; + }); + const memberOverridesUsed = members.some( + (member) => member.providerId || member.model || member.effort + ); + return { + members, + source: 'inboxes', + ...(memberOverridesUsed + ? { + warning: + 'Launch roster was recovered from inboxes and merged with config.json provider/model/effort overrides. ' + + 'Multimodel reconnect is best-effort in this fallback path.', + } + : {}), + }; } } catch (error) { logger.warn( @@ -8439,7 +8745,17 @@ export class TeamProvisioningService { configRaw: string ): TeamCreateRequest['members'] { try { - const parsed = JSON.parse(configRaw) as { members?: { name?: string; agentType?: string }[] }; + const parsed = JSON.parse(configRaw) as { + members?: { + name?: string; + role?: string; + workflow?: string; + agentType?: string; + provider?: string; + model?: string; + effort?: string; + }[]; + }; if (!Array.isArray(parsed.members)) { return []; } @@ -8450,7 +8766,21 @@ export class TeamProvisioningService { if (!member || isLeadMember(member) || lower === 'user') continue; const name = rawName; if (!name) continue; - byName.set(name, { name }); + byName.set(name, { + name, + role: typeof member.role === 'string' ? member.role.trim() || undefined : undefined, + workflow: + typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined, + providerId: + member.provider === 'codex' || member.provider === 'gemini' + ? member.provider + : undefined, + model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined, + effort: + member.effort === 'low' || member.effort === 'medium' || member.effort === 'high' + ? member.effort + : undefined, + }); } // Defense: ignore CLI auto-suffixed duplicates (alice-2) when base name exists. const allNames = Array.from(byName.keys()); @@ -8478,8 +8808,50 @@ export class TeamProvisioningService { private async probeClaudeRuntime( claudePath: string, cwd: string, - env: NodeJS.ProcessEnv + env: NodeJS.ProcessEnv, + providerId: TeamProviderId | undefined = 'anthropic' ): Promise<{ warning?: string }> { + const resolvedProviderId = resolveTeamProviderId(providerId); + try { + const versionProbe = await this.spawnProbe( + claudePath, + ['--version'], + cwd, + env, + PREFLIGHT_BINARY_TIMEOUT_MS + ); + if (versionProbe.exitCode !== 0) { + const errorText = + buildCombinedLogs(versionProbe.stdout, versionProbe.stderr) || + `Claude CLI exited with code ${versionProbe.exitCode ?? 'unknown'} during warm-up`; + return { + warning: `Claude CLI binary failed to start correctly. Details: ${errorText}`, + }; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (isMissingCwdSpawnError(message)) { + return { + warning: `Working directory does not exist: ${cwd}`, + }; + } + return { + warning: `Claude CLI binary failed to start. Details: ${message}`, + }; + } + + if (resolvedProviderId === 'gemini') { + const authState = await resolveGeminiRuntimeAuth(env); + if (authState.authenticated) { + return {}; + } + return { + warning: + authState.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.', + }; + } + // Stage 1: verify binary works (awaited first for clearer errors) // Important: keep this sequential with Stage 2 to avoid auth/credential-store races // when multiple `claude` processes start simultaneously (most visible on Windows). @@ -8503,10 +8875,10 @@ export class TeamProvisioningService { try { pingProbe = await this.spawnProbe( claudePath, - [...PREFLIGHT_PING_ARGS], + getPreflightPingArgs(providerId), cwd, env, - PREFLIGHT_TIMEOUT_MS, + getPreflightTimeoutMs(providerId), { resolveOnOutputMatch: ({ stdout, stderr }) => { const combined = `${stdout}\n${stderr}`.trim(); @@ -8516,7 +8888,7 @@ export class TeamProvisioningService { ); } catch (error) { const message = error instanceof Error ? error.message : String(error); - if (attempt < PREFLIGHT_AUTH_MAX_RETRIES) { + if (!isProbeTimeoutMessage(message) && attempt < PREFLIGHT_AUTH_MAX_RETRIES) { logger.warn( `Preflight ping failed (attempt ${attempt}/${PREFLIGHT_AUTH_MAX_RETRIES}), ` + `retrying in ${PREFLIGHT_AUTH_RETRY_DELAY_MS}ms: ${message}` @@ -8532,12 +8904,7 @@ export class TeamProvisioningService { } const combinedOutput = buildCombinedLogs(pingProbe.stdout, pingProbe.stderr); - const lowerOutput = combinedOutput.toLowerCase(); - const isAuthFailure = - lowerOutput.includes('not logged in') || - lowerOutput.includes('please run /login') || - lowerOutput.includes('missing api key') || - lowerOutput.includes('invalid api key'); + const isAuthFailure = this.isAuthFailureWarning(combinedOutput, 'probe'); if (isAuthFailure && attempt < PREFLIGHT_AUTH_MAX_RETRIES) { logger.warn( @@ -8550,10 +8917,14 @@ export class TeamProvisioningService { if (isAuthFailure || pingProbe.exitCode !== 0) { const hint = isAuthFailure - ? 'Claude CLI `-p` mode is not authenticated. ' + - 'Run `claude auth login` (or start `claude` and run `/login`) to authenticate. ' + - 'For automation/headless use, set ANTHROPIC_API_KEY.' + - (attempt > 1 ? ` (failed after ${attempt} attempts)` : '') + ? resolvedProviderId === 'codex' + ? 'Codex provider is not authenticated for `-p` mode. ' + + 'Run `claude-multimodel auth login --provider codex` and retry.' + + (attempt > 1 ? ` (failed after ${attempt} attempts)` : '') + : 'Claude CLI `-p` mode is not authenticated. ' + + 'Run `claude auth login` (or start `claude` and run `/login`) to authenticate. ' + + 'For automation/headless use, set ANTHROPIC_API_KEY.' + + (attempt > 1 ? ` (failed after ${attempt} attempts)` : '') : `Claude CLI preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}).`; return { warning: hint }; } @@ -8591,7 +8962,7 @@ export class TeamProvisioningService { return this.helpOutputCache; } const targetCwd = cwd ?? process.cwd(); - const probeResult = await this.getCachedOrProbeResult(targetCwd); + const probeResult = await this.getCachedOrProbeResult(targetCwd, 'anthropic'); if (!probeResult?.claudePath) { throw new Error('Claude CLI not found'); } diff --git a/src/main/services/team/memberUpdateNotifications.ts b/src/main/services/team/memberUpdateNotifications.ts new file mode 100644 index 00000000..c9cbbf77 --- /dev/null +++ b/src/main/services/team/memberUpdateNotifications.ts @@ -0,0 +1,154 @@ +export type MemberDiffInput = { + name: string; + role?: string; + workflow?: string; + providerId?: 'anthropic' | 'codex' | 'gemini'; + model?: string; + removedAt?: number | string | null; +}; + +export type ReplaceMembersDiff = { + added: Array<{ + name: string; + role?: string; + workflow?: string; + providerId?: 'anthropic' | 'codex' | 'gemini'; + model?: string; + }>; + removed: string[]; + updated: Array<{ + name: string; + changes: string[]; + }>; +}; + +function normalizeOptionalText(value: string | undefined): string | undefined { + const normalized = value?.trim(); + return normalized ? normalized : undefined; +} + +function describeRoleChange( + previousRole: string | undefined, + nextRole: string | undefined +): string | null { + if (previousRole === nextRole) { + return null; + } + if (previousRole && nextRole) { + return `role changed from "${previousRole}" to "${nextRole}"`; + } + if (nextRole) { + return `role set to "${nextRole}"`; + } + return 'role cleared'; +} + +function describeWorkflowChange( + previousWorkflow: string | undefined, + nextWorkflow: string | undefined +): string | null { + if (previousWorkflow === nextWorkflow) { + return null; + } + if (previousWorkflow && nextWorkflow) { + return 'workflow instructions were updated'; + } + if (nextWorkflow) { + return 'workflow instructions were added'; + } + return 'workflow instructions were cleared'; +} + +export function buildReplaceMembersDiff( + previousMembers: MemberDiffInput[], + nextMembers: Array<{ + name: string; + role?: string; + workflow?: string; + providerId?: 'anthropic' | 'codex' | 'gemini'; + model?: string; + }> +): ReplaceMembersDiff { + const previousByName = new Map( + previousMembers + .filter((member) => !member.removedAt && member.name.trim().toLowerCase() !== 'team-lead') + .map((member) => [ + member.name.trim().toLowerCase(), + { + name: member.name.trim(), + role: normalizeOptionalText(member.role), + workflow: normalizeOptionalText(member.workflow), + providerId: member.providerId, + model: normalizeOptionalText(member.model), + }, + ]) + ); + const nextByName = new Map( + nextMembers + .filter((member) => member.name.trim().toLowerCase() !== 'team-lead') + .map((member) => [ + member.name.trim().toLowerCase(), + { + name: member.name.trim(), + role: normalizeOptionalText(member.role), + workflow: normalizeOptionalText(member.workflow), + providerId: member.providerId, + model: normalizeOptionalText(member.model), + }, + ]) + ); + + const added = Array.from(nextByName.entries()) + .filter(([name]) => !previousByName.has(name)) + .map(([, member]) => member); + + const removed = Array.from(previousByName.entries()) + .filter(([name]) => !nextByName.has(name)) + .map(([, member]) => member.name) + .sort((a, b) => a.localeCompare(b)); + + const updated = Array.from(nextByName.entries()) + .flatMap(([name, nextMember]) => { + const previousMember = previousByName.get(name); + if (!previousMember) { + return []; + } + const changes = [ + describeRoleChange(previousMember.role, nextMember.role), + describeWorkflowChange(previousMember.workflow, nextMember.workflow), + ].filter((value): value is string => value !== null); + if (changes.length === 0) { + return []; + } + return [{ name: nextMember.name, changes }]; + }) + .sort((a, b) => a.name.localeCompare(b.name)); + + return { added, removed, updated }; +} + +export function buildReplaceMembersSummaryMessage(diff: ReplaceMembersDiff): string | null { + const lines: string[] = []; + + for (const name of diff.removed) { + lines.push( + `- Teammate "${name}" was removed from the team. Stop assigning them new work and reassign any active tasks if needed.` + ); + } + + for (const update of diff.updated) { + lines.push( + `- Teammate "${update.name}" was updated: ${update.changes.join('; ')}. Please send them refreshed instructions so their live behavior matches the new config.` + ); + } + + if (lines.length === 0) { + return null; + } + + return ( + 'The user updated the live team roster.\n' + + 'Apply these changes to the running team now:\n' + + lines.join('\n') + ); +} diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index d24b976c..0c74c587 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -702,7 +702,11 @@ export class HttpAPIClient implements ElectronAPI { deleteDraft: async (_teamName: string): Promise => { throw new Error('Draft team deletion is not available in browser mode'); }, - prepareProvisioning: async (_cwd?: string): Promise => { + prepareProvisioning: async ( + _cwd?: string, + _providerId?: TeamLaunchRequest['providerId'], + _providerIds?: TeamLaunchRequest['providerId'][] + ): Promise => { throw new Error('Team provisioning is not available in browser mode'); }, createTeam: async (_request: TeamCreateRequest): Promise => { @@ -1057,6 +1061,11 @@ export class HttpAPIClient implements ElectronAPI { cliInstaller: CliInstallerAPI = { getStatus: async () => ({ + flavor: 'claude', + displayName: 'Claude CLI', + supportsSelfUpdate: true, + showVersionDetails: true, + showBinaryPath: true, installed: false, installedVersion: null, binaryPath: null, @@ -1064,6 +1073,7 @@ export class HttpAPIClient implements ElectronAPI { updateAvailable: false, authLoggedIn: false, authMethod: null, + providers: [], }), install: async (): Promise => { console.warn('[HttpAPIClient] CLI installer not available in browser mode'); diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index da7dec5a..89369941 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -135,6 +135,9 @@ function areResolvedMembersEqual( prevMember.agentType !== nextMember.agentType || prevMember.role !== nextMember.role || prevMember.workflow !== nextMember.workflow || + prevMember.providerId !== nextMember.providerId || + prevMember.model !== nextMember.model || + prevMember.effort !== nextMember.effort || prevMember.cwd !== nextMember.cwd || prevMember.gitBranch !== nextMember.gitBranch || prevMember.removedAt !== nextMember.removedAt @@ -1616,6 +1619,7 @@ export const TeamDetailView = ({ isTeamAlive={data.isAlive} isTeamProvisioning={isTeamProvisioning} leadActivity={leadActivityByTeam[teamName]} + launchParams={launchParams} onMemberClick={setSelectedMember} onSendMessage={(member) => { setSendDialogRecipient(member.name); @@ -1976,6 +1980,7 @@ export const TeamDetailView = ({ currentDescription={data.config.description ?? ''} currentColor={data.config.color ?? ''} currentMembers={data.members.filter((m) => !isLeadMember(m))} + isTeamAlive={data.isAlive && !isTeamProvisioning} projectPath={data.config.projectPath} onClose={() => setEditDialogOpen(false)} onSaved={() => void selectTeam(teamName)} @@ -1998,6 +2003,9 @@ export const TeamDetailView = ({ name: entry.name, role: entry.role, workflow: entry.workflow, + providerId: entry.providerId, + model: entry.model, + effort: entry.effort, }); } setAddMemberDialogOpen(false); diff --git a/src/renderer/components/team/dialogs/AddMemberDialog.tsx b/src/renderer/components/team/dialogs/AddMemberDialog.tsx index 3fab05b1..26eb4187 100644 --- a/src/renderer/components/team/dialogs/AddMemberDialog.tsx +++ b/src/renderer/components/team/dialogs/AddMemberDialog.tsx @@ -19,11 +19,15 @@ import { import { Loader2 } from 'lucide-react'; import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes'; +import type { EffortLevel, TeamProviderId } from '@shared/types'; export interface AddMemberEntry { name: string; role?: string; workflow?: string; + providerId?: TeamProviderId; + model?: string; + effort?: EffortLevel; } interface AddMemberDialogProps { @@ -116,6 +120,9 @@ export const AddMemberDialog = ({ name: m.name, role: m.role, workflow: m.workflow, + providerId: m.providerId, + model: m.model, + effort: m.effort, })) ); }; diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 1d2d7550..a6689fb0 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -1,14 +1,15 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { buildMemberDraftColorMap, buildMemberDraftSuggestions, buildMembersFromDrafts, + clearMemberModelOverrides, createMemberDraft, - MembersEditorSection, validateMemberNameInline, } from '@renderer/components/team/members/MembersEditorSection'; +import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection'; import { AutoResizeTextarea } from '@renderer/components/ui/auto-resize-textarea'; import { Button } from '@renderer/components/ui/button'; import { Checkbox } from '@renderer/components/ui/checkbox'; @@ -36,12 +37,19 @@ import { normalizePath } from '@renderer/utils/pathNormalize'; import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react'; import { AdvancedCliSection } from './AdvancedCliSection'; -import { EffortLevelSelector } from './EffortLevelSelector'; -import { LimitContextCheckbox } from './LimitContextCheckbox'; import { OptionalSettingsSection } from './OptionalSettingsSection'; +import { + createInitialProviderChecks, + failIncompleteProviderChecks, + getProvisioningFailureHint, + ProvisioningProviderStatusList, + shouldHideProvisioningProviderStatusList, + updateProviderCheck, + type ProvisioningProviderCheck, +} from './ProvisioningProviderStatusList'; import { ProjectPathSelector } from './ProjectPathSelector'; import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox'; -import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector'; +import { computeEffectiveTeamModel } from './TeamModelSelector'; import { getNextSuggestedTeamName } from './teamNameSets'; const TEAM_COLOR_NAMES = [ @@ -59,10 +67,45 @@ import type { EffortLevel, Project, TeamCreateRequest, + TeamProviderId, TeamProvisioningMemberInput, TeamProvisioningPrepareResult, } from '@shared/types'; +function getStoredTeamProvider(): TeamProviderId { + const stored = localStorage.getItem('team:lastSelectedProvider'); + return stored === 'codex' || stored === 'gemini' ? stored : 'anthropic'; +} + +function getStoredTeamModel(providerId: TeamProviderId): string { + const stored = localStorage.getItem(`team:lastSelectedModel:${providerId}`); + if (stored === null) { + return providerId === 'anthropic' ? 'opus' : ''; + } + return stored === '__default__' ? '' : stored; +} + +function isEphemeralRenderedProjectPath(projectPath: string | null | undefined): boolean { + const normalized = normalizePath(projectPath ?? '').toLowerCase(); + return ( + normalized.includes('rendered_mcp_') || + normalized.includes('rendered_mcp_config') || + normalized.includes('/portable-mcp-live') + ); +} + +function getProviderLabel(providerId: TeamProviderId): string { + switch (providerId) { + case 'codex': + return 'Codex'; + case 'gemini': + return 'Gemini'; + case 'anthropic': + default: + return 'Anthropic'; + } +} + export interface TeamCopyData { teamName: string; description?: string; @@ -230,6 +273,8 @@ export const CreateTeamDialog = ({ setTeamName, members, setMembers, + syncModelsWithLead, + setSyncModelsWithLead, cwdMode, setCwdMode, selectedProjectPath, @@ -258,6 +303,7 @@ export const CreateTeamDialog = ({ const [prepareState, setPrepareState] = useState<'idle' | 'loading' | 'ready' | 'failed'>('idle'); const [prepareMessage, setPrepareMessage] = useState(null); const [prepareWarnings, setPrepareWarnings] = useState([]); + const [prepareChecks, setPrepareChecks] = useState([]); const prepareRequestSeqRef = useRef(0); const lastAutoDescriptionRef = useRef(null); const [fieldErrors, setFieldErrors] = useState<{ @@ -267,11 +313,11 @@ export const CreateTeamDialog = ({ }>({}); const [isSubmitting, setIsSubmitting] = useState(false); const [conflictDismissed, setConflictDismissed] = useState(false); - const [selectedModel, setSelectedModelRaw] = useState(() => { - const stored = localStorage.getItem('team:lastSelectedModel'); - if (stored === null) return 'opus'; - return stored === '__default__' ? '' : stored; - }); + const [selectedProviderId, setSelectedProviderIdRaw] = + useState(getStoredTeamProvider); + const [selectedModel, setSelectedModelRaw] = useState(() => + getStoredTeamModel(getStoredTeamProvider()) + ); const [limitContext, setLimitContextRaw] = useState( () => localStorage.getItem('team:lastLimitContext') === 'true' ); @@ -289,6 +335,17 @@ export const CreateTeamDialog = ({ const [worktreeName, setWorktreeNameRaw] = useState(''); const [customArgs, setCustomArgsRaw] = useState(''); + useEffect(() => { + const legacyTeamModel = localStorage.getItem('team:lastSelectedModel'); + if ( + legacyTeamModel != null && + localStorage.getItem('team:lastSelectedModel:anthropic') == null + ) { + localStorage.setItem('team:lastSelectedModel:anthropic', legacyTeamModel); + } + localStorage.removeItem('team:lastSelectedModel'); + }, []); + // Re-read localStorage when advancedKey changes useEffect(() => { const storedEnabled = @@ -301,7 +358,17 @@ export const CreateTeamDialog = ({ const setSelectedModel = (value: string): void => { setSelectedModelRaw(value); - localStorage.setItem('team:lastSelectedModel', value); + localStorage.setItem(`team:lastSelectedModel:${selectedProviderId}`, value); + }; + + const setSelectedProviderId = (value: TeamProviderId): void => { + setSelectedProviderIdRaw(value); + localStorage.setItem('team:lastSelectedProvider', value); + if (value !== 'anthropic') { + setLimitContextRaw(false); + localStorage.setItem('team:lastLimitContext', 'false'); + } + setSelectedModelRaw(getStoredTeamModel(value)); }; const setLimitContext = (value: boolean): void => { @@ -343,6 +410,7 @@ export const CreateTeamDialog = ({ setPrepareState('idle'); setPrepareMessage(null); setPrepareWarnings([]); + setPrepareChecks([]); setConflictDismissed(false); }; @@ -371,6 +439,25 @@ export const CreateTeamDialog = ({ } }, [open, clearProvisioningError, dialogTeamNameKey]); + const effectiveMemberDrafts = useMemo( + () => (syncModelsWithLead ? members.map(clearMemberModelOverrides) : members), + [members, syncModelsWithLead] + ); + + const selectedMemberProviders = useMemo(() => { + if (soloTeam || syncModelsWithLead) { + return [selectedProviderId]; + } + return Array.from( + new Set([ + selectedProviderId, + ...members.flatMap((member) => + member.providerId === 'codex' || member.providerId === 'gemini' ? [member.providerId] : [] + ), + ]) + ); + }, [members, selectedProviderId, soloTeam, syncModelsWithLead]); + useEffect(() => { if (!open || !canCreate || !launchTeam) { return; @@ -379,6 +466,7 @@ export const CreateTeamDialog = ({ if (typeof api.teams.prepareProvisioning !== 'function') { setPrepareState('failed'); setPrepareWarnings([]); + setPrepareChecks([]); setPrepareMessage( 'Current preload version does not support team:prepareProvisioning. Restart the dev app.' ); @@ -388,6 +476,7 @@ export const CreateTeamDialog = ({ if (!effectiveCwd) { setPrepareState('idle'); setPrepareWarnings([]); + setPrepareChecks([]); setPrepareMessage('Select a working directory to validate the launch environment.'); return; } @@ -395,26 +484,75 @@ export const CreateTeamDialog = ({ let cancelled = false; const requestSeq = ++prepareRequestSeqRef.current; setPrepareState('loading'); - setPrepareMessage('Warming up CLI environment...'); + setPrepareMessage('Checking selected providers...'); setPrepareWarnings([]); + setPrepareChecks(createInitialProviderChecks(selectedMemberProviders)); // Defer so file list fetch (triggered by project select) can run first const timer = setTimeout(() => { void (async () => { + let checks = createInitialProviderChecks(selectedMemberProviders); + let anyFailure = false; + let anyNotes = false; + const collectedWarnings: string[] = []; + try { - const prepResult: TeamProvisioningPrepareResult = - await api.teams.prepareProvisioning(effectiveCwd); + for (const providerId of selectedMemberProviders) { + checks = updateProviderCheck(checks, providerId, { + status: 'checking', + details: [], + }); + if (!cancelled && prepareRequestSeqRef.current === requestSeq) { + setPrepareChecks(checks); + setPrepareMessage(`Checking ${getProviderLabel(providerId)} runtime...`); + } + + const prepResult: TeamProvisioningPrepareResult = await api.teams.prepareProvisioning( + effectiveCwd, + providerId, + [providerId] + ); + const detailLines = [ + ...(prepResult.warnings ?? []).filter(Boolean), + ...(!prepResult.ready && prepResult.message ? [prepResult.message] : []), + ]; + if (prepResult.warnings?.length) { + anyNotes = true; + collectedWarnings.push( + ...prepResult.warnings.map( + (warning) => `${getProviderLabel(providerId)}: ${warning}` + ) + ); + } + if (!prepResult.ready) { + anyFailure = true; + } + checks = updateProviderCheck(checks, providerId, { + status: !prepResult.ready ? 'failed' : detailLines.length > 0 ? 'notes' : 'ready', + details: detailLines, + }); + if (!cancelled && prepareRequestSeqRef.current === requestSeq) { + setPrepareChecks(checks); + } + } if (cancelled || prepareRequestSeqRef.current !== requestSeq) return; - setPrepareState(prepResult.ready ? 'ready' : 'failed'); - setPrepareMessage(prepResult.message); - setPrepareWarnings(prepResult.warnings ?? []); + setPrepareState(anyFailure ? 'failed' : 'ready'); + setPrepareMessage( + anyFailure + ? 'Some selected providers need attention.' + : anyNotes + ? 'Selected providers are ready with notes.' + : 'Selected providers are ready.' + ); + setPrepareWarnings(collectedWarnings); } catch (error) { if (cancelled || prepareRequestSeqRef.current !== requestSeq) return; + const failureMessage = + error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment'; setPrepareState('failed'); setPrepareWarnings([]); - setPrepareMessage( - error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment' - ); + setPrepareChecks(failIncompleteProviderChecks(checks, failureMessage)); + setPrepareMessage(failureMessage); } })(); }, 250); @@ -423,7 +561,7 @@ export const CreateTeamDialog = ({ cancelled = true; clearTimeout(timer); }; - }, [open, canCreate, launchTeam, effectiveCwd]); + }, [open, canCreate, launchTeam, effectiveCwd, selectedProviderId, selectedMemberProviders]); useEffect(() => { if (!open) { @@ -446,6 +584,7 @@ export const CreateTeamDialog = ({ // display and select it. if ( defaultProjectPath && + !isEphemeralRenderedProjectPath(defaultProjectPath) && !nextProjects.some((p) => normalizePath(p.path) === defaultProjectPath) ) { const folderName = @@ -497,9 +636,15 @@ export const CreateTeamDialog = ({ roleSelection: isCustom ? CUSTOM_ROLE : (m.role ?? ''), customRole: isCustom ? m.role : '', workflow: m.workflow, + providerId: m.providerId, + model: m.model ?? '', + effort: m.effort, }); }) ); + setSyncModelsWithLead( + !initialData.members.some((member) => member.providerId || member.model || member.effort) + ); return; } @@ -559,7 +704,7 @@ export const CreateTeamDialog = ({ if (selectedProjectPath || projects.length === 0) { return; } - if (defaultProjectPath) { + if (defaultProjectPath && !isEphemeralRenderedProjectPath(defaultProjectPath)) { const match = projects.find((p) => normalizePath(p.path) === defaultProjectPath); if (match) { setSelectedProjectPath(match.path); @@ -569,6 +714,16 @@ export const CreateTeamDialog = ({ setSelectedProjectPath(projects[0].path); }, [open, cwdMode, projects, selectedProjectPath, defaultProjectPath]); + useEffect(() => { + if (!open || cwdMode !== 'project' || !selectedProjectPath) { + return; + } + if (!isEphemeralRenderedProjectPath(selectedProjectPath)) { + return; + } + setSelectedProjectPath(''); + }, [open, cwdMode, selectedProjectPath, setSelectedProjectPath]); + useFileListCacheWarmer(effectiveCwd || null); const { suggestions: taskSuggestions } = useTaskSuggestions(null); @@ -587,8 +742,8 @@ export const CreateTeamDialog = ({ ); const effectiveModel = useMemo( - () => computeEffectiveTeamModel(selectedModel, limitContext), - [selectedModel, limitContext] + () => computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId), + [selectedModel, limitContext, selectedProviderId] ); const sanitizedTeamName = sanitizeTeamName(teamName.trim()); @@ -601,9 +756,10 @@ export const CreateTeamDialog = ({ teamName: sanitizedTeamName, description: description.trim() || undefined, color: teamColor || undefined, - members: soloTeam ? [] : buildMembersFromDrafts(members), + members: soloTeam ? [] : buildMembersFromDrafts(effectiveMemberDrafts), cwd: effectiveCwd, prompt: prompt.trim() || undefined, + providerId: selectedProviderId, model: effectiveModel, effort: (selectedEffort as EffortLevel) || undefined, limitContext, @@ -616,9 +772,10 @@ export const CreateTeamDialog = ({ description, teamColor, soloTeam, - members, + effectiveMemberDrafts, effectiveCwd, prompt, + selectedProviderId, effectiveModel, selectedEffort, limitContext, @@ -643,23 +800,11 @@ export const CreateTeamDialog = ({ const launchOptionalSummary = useMemo(() => { const summary: string[] = []; if (prompt.trim()) summary.push('Lead prompt'); - if (selectedModel) summary.push(`Model: ${selectedModel}`); - if (selectedEffort) summary.push(`Effort: ${selectedEffort}`); - if (limitContext) summary.push('Limited to 200K context'); if (skipPermissions) summary.push('Auto-approve tools'); if (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`); if (customArgs.trim()) summary.push('Custom CLI args'); return summary; - }, [ - prompt, - selectedModel, - selectedEffort, - limitContext, - skipPermissions, - worktreeEnabled, - worktreeName, - customArgs, - ]); + }, [prompt, skipPermissions, worktreeEnabled, worktreeName, customArgs]); const teamDetailsSummary = useMemo(() => { const summary: string[] = []; @@ -668,6 +813,16 @@ export const CreateTeamDialog = ({ return summary; }, [description, teamColor]); + const handleSyncModelsWithLeadChange = useCallback( + (checked: boolean): void => { + setSyncModelsWithLead(checked); + if (checked) { + setMembers(members.map(clearMemberModelOverrides)); + } + }, + [members, setMembers, setSyncModelsWithLead] + ); + const activeError = localError ?? provisioningErrorsByTeam[request.teamName] ?? null; const canOpenExistingTeam = activeError?.includes('Team already exists') === true && request.teamName.length > 0; @@ -822,7 +977,14 @@ export const CreateTeamDialog = ({

    {prepareMessage ?? 'Failed to prepare environment'}

    - {prepareWarnings.length > 0 ? ( + {!shouldHideProvisioningProviderStatusList(prepareChecks, prepareMessage) ? ( + + ) : null} + {prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
    {prepareWarnings.map((warning) => (

    ) : null}

    - Make sure claude CLI is installed and available - in PATH, then reopen this dialog. + {getProvisioningFailureHint(prepareMessage, prepareChecks)}

    @@ -892,9 +1053,9 @@ export const CreateTeamDialog = ({
    - -
    - setSoloTeam(checked === true)} - /> - -
    - {soloTeam && ( -
    - -

    - Only the team lead (main process) will be started — no teammates will - be spawned. Works like a regular Claude session but with access to the task - board for planning. Saves tokens by avoiding teammate coordination overhead. - You can add members later from the team settings. -

    -
    - )} + defaultProviderId={selectedProviderId} + inheritedProviderId={selectedProviderId} + inheritedModel={selectedModel} + inheritedEffort={(selectedEffort as EffortLevel) || undefined} + inheritModelSettingsByDefault + lockProviderModel={syncModelsWithLead} + forceInheritedModelSettings={syncModelsWithLead} + modelLockReason="This teammate is synced with the lead model. Turn off sync to set a custom provider, model, or effort." + hideMembersContent={soloTeam} + providerId={selectedProviderId} + model={selectedModel} + effort={(selectedEffort as EffortLevel) || undefined} + limitContext={limitContext} + onProviderChange={setSelectedProviderId} + onModelChange={setSelectedModel} + onEffortChange={setSelectedEffort} + onLimitContextChange={setLimitContext} + syncModelsWithTeammates={syncModelsWithLead} + onSyncModelsWithTeammatesChange={handleSyncModelsWithLeadChange} + headerTop={ +
    + setSoloTeam(checked === true)} + /> +
    } + headerBottom={ + soloTeam ? ( +
    + +

    + Only the team lead (main process) will be started — no teammates will be + spawned. Works like a regular Claude session but with access to the task board + for planning. Saves tokens by avoiding teammate coordination overhead. You can + add members later from the team settings. +

    +
    + ) : null + } />
    @@ -984,7 +1163,7 @@ export const CreateTeamDialog = ({
    @@ -1017,29 +1196,11 @@ export const CreateTeamDialog = ({ />
    -
    - - - - -
    +
    {canCreate && launchTeam && (prepareState === 'idle' || prepareState === 'loading') ? ( -
    - -
    - - {prepareMessage ?? - (prepareState === 'idle' - ? 'Warming up CLI environment...' - : 'Preparing environment...')} - -

    - Pre-flight check to catch errors before launch - -

    + <> +
    + +
    + + {prepareMessage ?? + (prepareState === 'idle' + ? 'Warming up CLI environment...' + : 'Preparing environment...')} + +

    + Pre-flight check to catch errors before launch +

    +
    -
    + + ) : null} {canCreate && launchTeam && prepareState === 'ready' ? ( @@ -1161,7 +1318,8 @@ export const CreateTeamDialog = ({
    - {prepareWarnings.length > 0 + {prepareChecks.some((check) => check.status === 'notes') || + prepareWarnings.length > 0 ? 'CLI environment ready (with notes)' : 'CLI environment ready'} @@ -1171,7 +1329,8 @@ export const CreateTeamDialog = ({ {prepareMessage}

    ) : null} - {prepareWarnings.length > 0 ? ( + + {prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
    {prepareWarnings.map((warning) => (

    @@ -1202,12 +1361,7 @@ export const CreateTeamDialog = ({

    + {isTeamAlive ? ( +

    + Provider and model changes are locked while the team is live. Reconnect the team to + change them safely. +

    + ) : null}
    {/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- Color picker is a group of buttons, not a single input */}

    - Controls how much reasoning Claude invests before responding. Default uses Claude's - standard behavior. + Controls how much reasoning the selected provider invests before responding. Default uses the + provider's standard behavior for the selected model.

    ); diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 78ab5652..ed22b46b 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -1,7 +1,15 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; -import { LimitContextCheckbox } from '@renderer/components/team/dialogs/LimitContextCheckbox'; +import { + buildMemberDraftColorMap, + buildMemberDraftSuggestions, + buildMembersFromDrafts, + clearMemberModelOverrides, + createMemberDraftsFromInputs, + validateMemberNameInline, +} from '@renderer/components/team/members/MembersEditorSection'; +import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection'; import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox'; import { Button } from '@renderer/components/ui/button'; import { Checkbox } from '@renderer/components/ui/checkbox'; @@ -21,10 +29,10 @@ import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer'; +import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; +import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; -import { formatAgentRole } from '@renderer/utils/formatAgentRole'; -import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { normalizePath } from '@renderer/utils/pathNormalize'; import { nameColorSet } from '@renderer/utils/projectColor'; import { @@ -43,10 +51,20 @@ import { CronScheduleInput } from '../schedule/CronScheduleInput'; import { AdvancedCliSection } from './AdvancedCliSection'; import { EffortLevelSelector } from './EffortLevelSelector'; import { OptionalSettingsSection } from './OptionalSettingsSection'; +import { + createInitialProviderChecks, + failIncompleteProviderChecks, + getProvisioningFailureHint, + ProvisioningProviderStatusList, + shouldHideProvisioningProviderStatusList, + updateProviderCheck, + type ProvisioningProviderCheck, +} from './ProvisioningProviderStatusList'; import { ProjectPathSelector } from './ProjectPathSelector'; import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector'; import type { ActiveTeamRef } from './CreateTeamDialog'; +import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { CreateScheduleInput, @@ -56,6 +74,7 @@ import type { Schedule, ScheduleLaunchConfig, TeamLaunchRequest, + TeamProviderId, TeamProvisioningPrepareResult, UpdateSchedulePatch, } from '@shared/types'; @@ -104,6 +123,31 @@ function getLocalTimezone(): string { } } +function getStoredTeamProvider(): TeamProviderId { + const stored = localStorage.getItem('team:lastSelectedProvider'); + return stored === 'codex' || stored === 'gemini' ? stored : 'anthropic'; +} + +function getStoredTeamModel(providerId: TeamProviderId): string { + const stored = localStorage.getItem(`team:lastSelectedModel:${providerId}`); + if (stored === null) { + return providerId === 'anthropic' ? 'opus' : ''; + } + return stored === '__default__' ? '' : stored; +} + +function getProviderLabel(providerId: TeamProviderId): string { + switch (providerId) { + case 'codex': + return 'Codex'; + case 'gemini': + return 'Gemini'; + case 'anthropic': + default: + return 'Anthropic'; + } +} + // ============================================================================= // Component // ============================================================================= @@ -157,11 +201,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const [localError, setLocalError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); - const [selectedModel, setSelectedModelRaw] = useState(() => { - const stored = localStorage.getItem('team:lastSelectedModel'); - if (stored === null) return 'opus'; - return stored === '__default__' ? '' : stored; - }); + const [selectedProviderId, setSelectedProviderIdRaw] = + useState(getStoredTeamProvider); + const [selectedModel, setSelectedModelRaw] = useState(() => + getStoredTeamModel(getStoredTeamProvider()) + ); + const [membersDrafts, setMembersDrafts] = useState([]); + const [syncModelsWithLead, setSyncModelsWithLead] = useState(false); const [skipPermissions, setSkipPermissionsRaw] = useState( () => localStorage.getItem('team:lastSkipPermissions') !== 'false' ); @@ -182,7 +228,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const [prepareState, setPrepareState] = useState<'idle' | 'loading' | 'ready' | 'failed'>('idle'); const [prepareMessage, setPrepareMessage] = useState(null); const [prepareWarnings, setPrepareWarnings] = useState([]); + const [prepareChecks, setPrepareChecks] = useState([]); const prepareRequestSeqRef = useRef(0); + const storeMembers = useStore((s) => s.selectedTeamData?.members ?? []); + const members = isLaunch ? props.members : storeMembers; // Advanced CLI section state (with localStorage persistence) const [worktreeEnabled, setWorktreeEnabledRaw] = useState( @@ -208,6 +257,24 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const [warmUpMinutes, setWarmUpMinutes] = useState(15); const [maxTurns, setMaxTurns] = useState(50); const [maxBudgetUsd, setMaxBudgetUsd] = useState(''); + const effectiveMemberDrafts = useMemo( + () => (syncModelsWithLead ? membersDrafts.map(clearMemberModelOverrides) : membersDrafts), + [membersDrafts, syncModelsWithLead] + ); + const selectedMemberProviders = useMemo( + () => + Array.from( + new Set([ + selectedProviderId, + ...effectiveMemberDrafts.flatMap((member) => + member.providerId === 'codex' || member.providerId === 'gemini' + ? [member.providerId] + : [] + ), + ]) + ), + [effectiveMemberDrafts, selectedProviderId] + ); // Schedule store actions const createSchedule = useStore((s) => s.createSchedule); @@ -234,9 +301,19 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen localStorage.setItem(`team:lastCustomArgs:${effectiveTeamName}`, value); }; + const setSelectedProviderId = (value: TeamProviderId): void => { + setSelectedProviderIdRaw(value); + localStorage.setItem('team:lastSelectedProvider', value); + if (value !== 'anthropic') { + setLimitContextRaw(false); + localStorage.setItem('team:lastLimitContext', 'false'); + } + setSelectedModelRaw(getStoredTeamModel(value)); + }; + const setSelectedModel = (value: string): void => { setSelectedModelRaw(value); - localStorage.setItem('team:lastSelectedModel', value); + localStorage.setItem(`team:lastSelectedModel:${selectedProviderId}`, value); }; const setLimitContext = (value: boolean): void => { @@ -259,9 +336,19 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen // --------------------------------------------------------------------------- useEffect(() => { + const legacyTeamModel = localStorage.getItem('team:lastSelectedModel'); + if ( + legacyTeamModel != null && + localStorage.getItem('team:lastSelectedModel:anthropic') == null + ) { + localStorage.setItem('team:lastSelectedModel:anthropic', legacyTeamModel); + } + localStorage.removeItem('team:lastSelectedModel'); + for (const suffix of ['lastSelectedModel', 'lastSelectedEffort']) { const schedKey = `schedule:${suffix}`; - const teamKey = `team:${suffix}`; + const teamKey = + suffix === 'lastSelectedModel' ? 'team:lastSelectedModel:anthropic' : `team:${suffix}`; const schedVal = localStorage.getItem(schedKey); if (schedVal != null && localStorage.getItem(teamKey) == null) { localStorage.setItem(teamKey, schedVal); @@ -280,11 +367,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen setPrepareState('idle'); setPrepareMessage(null); setPrepareWarnings([]); + setPrepareChecks([]); setCwdMode('project'); setSelectedProjectPath(''); setCustomCwd(''); setClearContext(false); setConflictDismissed(false); + setMembersDrafts([]); + setSyncModelsWithLead(false); chipDraft.clearChipDraft(); // Schedule fields setSelectedTeamName(''); @@ -311,6 +401,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen promptDraft.setValue(schedule.launchConfig.prompt); setCustomCwd(schedule.launchConfig.cwd); setCwdMode('custom'); + setSelectedProviderIdRaw(schedule.launchConfig.providerId ?? 'anthropic'); setSelectedModelRaw(schedule.launchConfig.model ?? ''); setSkipPermissionsRaw(schedule.launchConfig.skipPermissions !== false); setSelectedEffortRaw(schedule.launchConfig.effort ?? ''); @@ -326,7 +417,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen setCwdMode('project'); setSelectedProjectPath(''); setCustomCwd(''); - setSelectedModelRaw('opus'); + setSelectedProviderIdRaw(getStoredTeamProvider()); + setSelectedModelRaw(getStoredTeamModel(getStoredTeamProvider())); setSelectedEffortRaw('medium'); } @@ -335,6 +427,62 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, isSchedule, schedule?.id]); + useEffect(() => { + if (!open || !isLaunch) return; + + let cancelled = false; + void (async () => { + let savedRequest = null; + try { + savedRequest = effectiveTeamName + ? await api.teams.getSavedRequest(effectiveTeamName) + : null; + } catch { + savedRequest = null; + } + if (cancelled) return; + + const nextProviderId = + savedRequest?.providerId === 'codex' || savedRequest?.providerId === 'gemini' + ? savedRequest.providerId + : 'anthropic'; + const providerFromSaved = Boolean(savedRequest?.providerId); + const nextMembersSource = + members.length > 0 + ? members + : savedRequest?.members && savedRequest.members.length > 0 + ? savedRequest.members + : []; + const storedEffort = localStorage.getItem('team:lastSelectedEffort'); + + setMembersDrafts(createMemberDraftsFromInputs(nextMembersSource)); + setSyncModelsWithLead( + !nextMembersSource.some((member) => member.providerId || member.model || member.effort) + ); + setSelectedProviderIdRaw(providerFromSaved ? nextProviderId : getStoredTeamProvider()); + setSelectedModelRaw( + typeof savedRequest?.model === 'string' + ? savedRequest.model + : getStoredTeamModel(providerFromSaved ? nextProviderId : getStoredTeamProvider()) + ); + setSelectedEffortRaw( + savedRequest?.effort ?? (storedEffort === null ? 'medium' : storedEffort) + ); + setLimitContextRaw( + savedRequest?.limitContext === true || + localStorage.getItem('team:lastLimitContext') === 'true' + ); + setSkipPermissionsRaw( + savedRequest?.skipPermissions ?? + localStorage.getItem('team:lastSkipPermissions') !== 'false' + ); + })(); + + return () => { + cancelled = true; + }; + }, [open, isLaunch, effectiveTeamName, members]); + // --------------------------------------------------------------------------- // Launch-only effects // --------------------------------------------------------------------------- @@ -355,6 +503,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen if (typeof api.teams.prepareProvisioning !== 'function') { setPrepareState('failed'); setPrepareWarnings([]); + setPrepareChecks([]); setPrepareMessage( 'Current preload version does not support team:prepareProvisioning. Restart the dev app.' ); @@ -364,6 +513,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen if (!effectiveCwd) { setPrepareState('idle'); setPrepareWarnings([]); + setPrepareChecks([]); setPrepareMessage('Select a working directory to validate the launch environment.'); return; } @@ -371,31 +521,78 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen let cancelled = false; const requestSeq = ++prepareRequestSeqRef.current; setPrepareState('loading'); - setPrepareMessage('Warming up CLI environment...'); + setPrepareMessage('Checking selected providers...'); setPrepareWarnings([]); + setPrepareChecks(createInitialProviderChecks(selectedMemberProviders)); void (async () => { + let checks = createInitialProviderChecks(selectedMemberProviders); + let anyFailure = false; + let anyNotes = false; + const collectedWarnings: string[] = []; + try { - const prepResult: TeamProvisioningPrepareResult = - await api.teams.prepareProvisioning(effectiveCwd); + for (const providerId of selectedMemberProviders) { + checks = updateProviderCheck(checks, providerId, { + status: 'checking', + details: [], + }); + if (!cancelled && prepareRequestSeqRef.current === requestSeq) { + setPrepareChecks(checks); + setPrepareMessage(`Checking ${getProviderLabel(providerId)} runtime...`); + } + + const prepResult: TeamProvisioningPrepareResult = await api.teams.prepareProvisioning( + effectiveCwd, + providerId, + [providerId] + ); + const detailLines = [ + ...(prepResult.warnings ?? []).filter(Boolean), + ...(!prepResult.ready && prepResult.message ? [prepResult.message] : []), + ]; + if (prepResult.warnings?.length) { + anyNotes = true; + collectedWarnings.push( + ...prepResult.warnings.map((warning) => `${getProviderLabel(providerId)}: ${warning}`) + ); + } + if (!prepResult.ready) { + anyFailure = true; + } + checks = updateProviderCheck(checks, providerId, { + status: !prepResult.ready ? 'failed' : detailLines.length > 0 ? 'notes' : 'ready', + details: detailLines, + }); + if (!cancelled && prepareRequestSeqRef.current === requestSeq) { + setPrepareChecks(checks); + } + } if (cancelled || prepareRequestSeqRef.current !== requestSeq) return; - setPrepareState(prepResult.ready ? 'ready' : 'failed'); - setPrepareMessage(prepResult.message); - setPrepareWarnings(prepResult.warnings ?? []); + setPrepareState(anyFailure ? 'failed' : 'ready'); + setPrepareMessage( + anyFailure + ? 'Some selected providers need attention.' + : anyNotes + ? 'Selected providers are ready with notes.' + : 'Selected providers are ready.' + ); + setPrepareWarnings(collectedWarnings); } catch (error) { if (cancelled || prepareRequestSeqRef.current !== requestSeq) return; + const failureMessage = + error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment'; setPrepareState('failed'); setPrepareWarnings([]); - setPrepareMessage( - error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment' - ); + setPrepareChecks(failIncompleteProviderChecks(checks, failureMessage)); + setPrepareMessage(failureMessage); } })(); return () => { cancelled = true; }; - }, [open, isLaunch, effectiveCwd]); + }, [open, isLaunch, effectiveCwd, selectedProviderId, selectedMemberProviders]); // --------------------------------------------------------------------------- // Shared effects: projects @@ -490,19 +687,15 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen // Mention suggestions (shared — from props in launch, from store in schedule) // --------------------------------------------------------------------------- - const storeMembers = useStore((s) => s.selectedTeamData?.members ?? []); - const members = isLaunch ? props.members : storeMembers; - - const colorMap = useMemo(() => buildMemberColorMap(members), [members]); + const { suggestions: taskSuggestions } = useTaskSuggestions(null); + const { suggestions: teamMentionSuggestions } = useTeamSuggestions(null); + const memberColorMap = useMemo( + () => buildMemberDraftColorMap(membersDrafts, members), + [membersDrafts, members] + ); const mentionSuggestions = useMemo( - () => - members.map((m) => ({ - id: m.name, - name: m.name, - subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined, - color: colorMap.get(m.name), - })), - [members, colorMap] + () => buildMemberDraftSuggestions(membersDrafts, memberColorMap), + [memberColorMap, membersDrafts] ); // --------------------------------------------------------------------------- @@ -516,21 +709,30 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen args.push('--verbose', '--setting-sources', 'user,project,local'); args.push('--mcp-config', '', '--disallowedTools', 'TeamDelete,TodoWrite'); if (skipPermissions) args.push('--dangerously-skip-permissions'); - const model = computeEffectiveTeamModel(selectedModel, limitContext); + const model = computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId); if (model) args.push('--model', model); if (selectedEffort) args.push('--effort', selectedEffort); if (!clearContext) args.push('--resume', ''); return args; - }, [isLaunch, skipPermissions, selectedModel, limitContext, selectedEffort, clearContext]); + }, [ + isLaunch, + skipPermissions, + selectedModel, + limitContext, + selectedEffort, + clearContext, + selectedProviderId, + ]); const launchOptionalSummary = useMemo(() => { if (!isLaunch) return []; const summary: string[] = []; if (promptDraft.value.trim()) summary.push('Lead prompt'); + summary.push(`Provider: ${getProviderLabel(selectedProviderId)}`); if (selectedModel) summary.push(`Model: ${selectedModel}`); if (selectedEffort) summary.push(`Effort: ${selectedEffort}`); - if (limitContext) summary.push('Limited to 200K context'); + if (selectedProviderId === 'anthropic' && limitContext) summary.push('Limited to 200K context'); if (skipPermissions) summary.push('Auto-approve tools'); if (clearContext) summary.push('Fresh session'); if (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`); @@ -540,6 +742,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen isLaunch, promptDraft.value, selectedModel, + selectedProviderId, selectedEffort, limitContext, skipPermissions, @@ -584,17 +787,39 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen setLocalError('Select working directory (cwd)'); return; } + if ( + isLaunch && + membersDrafts.some( + (member) => !member.name.trim() || validateMemberNameInline(member.name.trim()) !== null + ) + ) { + setLocalError('Fix member names before launch'); + return; + } + if (isLaunch) { + const activeNames = membersDrafts + .map((member) => member.name.trim().toLowerCase()) + .filter(Boolean); + if (new Set(activeNames).size !== activeNames.length) { + setLocalError('Member names must be unique before launch'); + return; + } + } setLocalError(null); setIsSubmitting(true); void (async () => { try { if (isLaunch) { + await api.teams.replaceMembers(effectiveTeamName, { + members: buildMembersFromDrafts(effectiveMemberDrafts), + }); await props.onLaunch({ teamName: effectiveTeamName, cwd: effectiveCwd, prompt: promptDraft.value.trim() || undefined, - model: computeEffectiveTeamModel(selectedModel, limitContext), + providerId: selectedProviderId, + model: computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId), effort: (selectedEffort as EffortLevel) || undefined, limitContext, clearContext: clearContext || undefined, @@ -610,6 +835,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const launchConfig: ScheduleLaunchConfig = { cwd: effectiveCwd, prompt: promptDraft.value.trim(), + providerId: selectedProviderId, model: selectedModel || undefined, effort: (selectedEffort as EffortLevel) || undefined, skipPermissions, @@ -748,12 +974,19 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen

    - Claude CLI is not installed — launch is blocked + CLI environment is not available — launch is blocked

    {prepareMessage ?? 'Failed to prepare environment'}

    - {prepareWarnings.length > 0 ? ( + {!shouldHideProvisioningProviderStatusList(prepareChecks, prepareMessage) ? ( + + ) : null} + {prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
    {prepareWarnings.map((warning) => (

    - Install Claude CLI from the Dashboard, then reopen this dialog. + {getProvisioningFailureHint(prepareMessage, prepareChecks)}

    - + {(prepareMessage ?? '').toLowerCase().includes('spawn ') || + prepareChecks.some((check) => + check.details.some((detail) => detail.toLowerCase().includes('spawn ')) + ) ? ( + + ) : null}
    @@ -922,6 +1160,38 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen projectsError={projectsError} /> + {isLaunch ? ( + + ) : null} + {/* ═══════════════════════════════════════════════════════════════════ Launch: optional settings Schedule: prompt + execution defaults @@ -959,22 +1229,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
    - - - {prepareState === 'idle' || prepareState === 'loading' ? ( -
    - -
    - - {prepareMessage ?? - (prepareState === 'idle' - ? 'Warming up CLI environment...' - : 'Preparing environment...')} - -

    - Pre-flight check to catch errors before launch - -

    + <> +
    + +
    + + {prepareMessage ?? + (prepareState === 'idle' + ? 'Warming up CLI environment...' + : 'Preparing environment...')} + +

    + Pre-flight check to catch errors before launch + +

    +
    -
    + + ) : null} {prepareState === 'ready' ? ( @@ -1165,7 +1424,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
    - {prepareWarnings.length > 0 + {prepareChecks.some((check) => check.status === 'notes') || + prepareWarnings.length > 0 ? 'CLI environment ready (with notes)' : 'CLI environment ready'} @@ -1175,7 +1435,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen {prepareMessage}

    ) : null} - {prepareWarnings.length > 0 ? ( + + {prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
    {prepareWarnings.map((warning) => (

    diff --git a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx new file mode 100644 index 00000000..8c4ca9eb --- /dev/null +++ b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx @@ -0,0 +1,258 @@ +import React from 'react'; + +import type { TeamProviderId } from '@shared/types'; +import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react'; + +export type ProvisioningProviderCheckStatus = 'pending' | 'checking' | 'ready' | 'notes' | 'failed'; + +export interface ProvisioningProviderCheck { + providerId: TeamProviderId; + status: ProvisioningProviderCheckStatus; + details: string[]; +} + +export function getProvisioningProviderLabel(providerId: TeamProviderId): string { + switch (providerId) { + case 'codex': + return 'Codex'; + case 'gemini': + return 'Gemini'; + case 'anthropic': + default: + return 'Anthropic'; + } +} + +export function createInitialProviderChecks( + providerIds: TeamProviderId[] +): ProvisioningProviderCheck[] { + return providerIds.map((providerId) => ({ + providerId, + status: 'pending', + details: [], + })); +} + +export function updateProviderCheck( + checks: ProvisioningProviderCheck[], + providerId: TeamProviderId, + patch: Partial +): ProvisioningProviderCheck[] { + return checks.map((check) => + check.providerId === providerId + ? { + ...check, + ...patch, + } + : check + ); +} + +export function failIncompleteProviderChecks( + checks: ProvisioningProviderCheck[], + detail: string +): ProvisioningProviderCheck[] { + return checks.map((check) => + check.status === 'ready' || check.status === 'notes' || check.status === 'failed' + ? check + : { + ...check, + status: 'failed', + details: check.details.length > 0 ? check.details : [detail], + } + ); +} + +function getStatusLabel(status: ProvisioningProviderCheckStatus): string { + switch (status) { + case 'checking': + return 'checking...'; + case 'ready': + return 'OK'; + case 'notes': + return 'OK (notes)'; + case 'failed': + return 'ERR'; + case 'pending': + default: + return 'queued'; + } +} + +function summarizeDetail(detail: string, status: ProvisioningProviderCheckStatus): string | null { + const lower = detail.toLowerCase(); + + if (lower.includes('spawn ') && lower.includes(' enoent')) { + return 'CLI binary missing'; + } + if (lower.includes('working directory does not exist:')) { + return 'Working directory missing'; + } + if ( + lower.includes('eacces') || + lower.includes('enoexec') || + lower.includes('bad cpu type in executable') || + lower.includes('image not found') + ) { + return 'CLI binary could not be started'; + } + if (lower.includes('preflight check for `claude -p` did not complete')) { + return 'CLI preflight did not complete'; + } + if (lower.includes('not authenticated') || lower.includes('not logged in')) { + return 'Authentication required'; + } + if (lower.includes('provider is not configured for runtime use')) { + return 'Runtime provider is not configured'; + } + if (lower.includes('claude cli binary failed to start')) { + return 'CLI binary could not be started'; + } + if (lower.includes('claude cli preflight check failed')) { + return 'CLI preflight failed'; + } + + if (status === 'notes') { + return 'Ready with notes'; + } + if (status === 'failed') { + return 'Needs attention'; + } + return null; +} + +function getDisplayStatusText(check: ProvisioningProviderCheck): string { + const summary = check.details.find(Boolean) + ? summarizeDetail(check.details[0]!, check.status) + : null; + return summary ?? getStatusLabel(check.status); +} + +export function shouldHideProvisioningProviderStatusList( + checks: ProvisioningProviderCheck[], + message: string | null | undefined +): boolean { + const normalizedMessage = (message ?? '').trim().toLowerCase(); + if (!normalizedMessage || checks.length === 0) { + return false; + } + + return checks.every((check) => { + if (check.status !== 'failed') { + return false; + } + + const summary = getDisplayStatusText(check).toLowerCase(); + const visibleDetails = check.details.filter( + (detail) => detail.trim().toLowerCase() !== normalizedMessage + ); + + return summary === 'working directory missing' && visibleDetails.length === 0; + }); +} + +function getStatusColor(status: ProvisioningProviderCheckStatus): string { + switch (status) { + case 'ready': + return 'text-emerald-400'; + case 'notes': + return 'text-sky-300'; + case 'failed': + return 'text-red-300'; + case 'checking': + return 'text-[var(--color-text-secondary)]'; + case 'pending': + default: + return 'text-[var(--color-text-muted)]'; + } +} + +function StatusIcon({ status }: { status: ProvisioningProviderCheckStatus }): React.JSX.Element { + if (status === 'checking') { + return ; + } + if (status === 'ready') { + return ; + } + if (status === 'notes' || status === 'failed') { + return ; + } + return ; +} + +export function ProvisioningProviderStatusList({ + checks, + className = '', + suppressDetailsMatching, +}: { + checks: ProvisioningProviderCheck[]; + className?: string; + suppressDetailsMatching?: string | null; +}): React.JSX.Element | null { + if (checks.length === 0) { + return null; + } + + return ( +

    + {checks.map((check) => { + const visibleDetails = check.details.filter( + (detail) => detail.trim() !== (suppressDetailsMatching ?? '').trim() + ); + + return ( +
    +
    + + + {getProvisioningProviderLabel(check.providerId)}: {getDisplayStatusText(check)} + +
    + {visibleDetails.length > 0 ? ( +
    + {visibleDetails.map((detail) => ( +

    + {detail} +

    + ))} +
    + ) : null} +
    + ); + })} +
    + ); +} + +export function getProvisioningFailureHint( + message: string | null | undefined, + checks: ProvisioningProviderCheck[] +): string { + const combined = [message ?? '', ...checks.flatMap((check) => check.details)] + .join('\n') + .toLowerCase(); + + if (combined.includes('working directory does not exist:')) { + return 'Choose an existing working directory, then reopen this dialog.'; + } + if (combined.includes('not authenticated') || combined.includes('not logged in')) { + return 'Authenticate the required provider in Claude CLI, then reopen this dialog.'; + } + if (combined.includes('provider is not configured for runtime use')) { + return 'Configure the selected provider runtime, then reopen this dialog.'; + } + if ( + combined.includes('spawn ') || + combined.includes(' enoent') || + combined.includes('eacces') || + combined.includes('enoexec') || + combined.includes('bad cpu type in executable') || + combined.includes('image not found') + ) { + return 'Make sure the local Claude CLI binary exists and can be started, then reopen this dialog.'; + } + + return 'Resolve the issue above, then reopen this dialog.'; +} diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx index b97c4043..c1003bcb 100644 --- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx +++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Label } from '@renderer/components/ui/label'; import { @@ -8,6 +8,7 @@ import { TooltipTrigger, } from '@renderer/components/ui/tooltip'; import { cn } from '@renderer/lib/utils'; +import { useStore } from '@renderer/store'; import { Check, ChevronDown, Info } from 'lucide-react'; // --- Provider SVG Icons (real brand logos from Simple Icons, monochrome currentColor) --- @@ -26,38 +27,9 @@ const OpenAIIcon: React.FC<{ className?: string }> = ({ className }) => ( ); -/** Google Gemini — official sparkle/star mark (Simple Icons) */ -const GoogleIcon: React.FC<{ className?: string }> = ({ className }) => ( +const GoogleGeminiIcon: React.FC<{ className?: string }> = ({ className }) => ( - - -); - -/** Local — server rack icon */ -const LocalIcon: React.FC<{ className?: string }> = ({ className }) => ( - - - - - - - + ); @@ -72,22 +44,88 @@ interface ProviderDef { const PROVIDERS: ProviderDef[] = [ { id: 'anthropic', label: 'Anthropic', icon: AnthropicIcon, comingSoon: false }, - { id: 'openai', label: 'OpenAI', icon: OpenAIIcon, comingSoon: true }, - { id: 'google', label: 'Google', icon: GoogleIcon, comingSoon: true }, - { id: 'local', label: 'Local', icon: LocalIcon, comingSoon: true }, + { id: 'codex', label: 'Codex', icon: OpenAIIcon, comingSoon: false }, + { id: 'gemini', label: 'Gemini', icon: GoogleGeminiIcon, comingSoon: false }, ]; -const ACTIVE_PROVIDER = PROVIDERS[0]; - -// --- Model options (Anthropic only for now) --- - -const MODEL_OPTIONS = [ +const ANTHROPIC_MODEL_OPTIONS = [ { value: '', label: 'Default' }, { value: 'opus', label: 'Opus 4.6' }, { value: 'sonnet', label: 'Sonnet 4.6' }, { value: 'haiku', label: 'Haiku 4.5' }, ] as const; +const CODEX_MODEL_OPTIONS = [ + { value: '', label: 'Default' }, + { value: 'gpt-5.4', label: 'GPT-5.4' }, + { value: 'gpt-5.4-mini', label: 'GPT-5.4 Mini' }, + { value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex' }, + { value: 'gpt-5.3-codex-spark', label: 'GPT-5.3 Codex Spark' }, + { value: 'gpt-5.2', label: 'GPT-5.2' }, + { value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' }, + { value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini' }, + { value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' }, +] as const; + +const GEMINI_MODEL_OPTIONS = [ + { value: '', label: 'Default' }, + { value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' }, + { value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' }, + { value: 'gemini-2.5-flash-lite', label: 'Gemini 2.5 Flash Lite' }, +] as const; + +const MODEL_LABEL_OVERRIDES: Record = { + 'claude-sonnet-4-6': 'Sonnet 4.6', + 'claude-sonnet-4-6[1m]': 'Sonnet 4.6 (1M)', + 'claude-opus-4-6': 'Opus 4.6', + 'claude-opus-4-6[1m]': 'Opus 4.6 (1M)', + 'claude-haiku-4-5-20251001': 'Haiku 4.5', + 'gpt-5.4': 'GPT-5.4', + 'gpt-5.4-mini': 'GPT-5.4 Mini', + 'gpt-5.3-codex': 'GPT-5.3 Codex', + 'gpt-5.3-codex-spark': 'GPT-5.3 Spark', + 'gpt-5.2-codex': 'GPT-5.2 Codex', + 'gpt-5.2': 'GPT-5.2', + 'gpt-5.1-codex-mini': 'GPT-5.1 Mini', + 'gpt-5.1-codex-max': 'GPT-5.1 Max', + 'gemini-2.5-pro': 'Gemini 2.5 Pro', + 'gemini-2.5-flash': 'Gemini 2.5 Flash', + 'gemini-2.5-flash-lite': 'Gemini 2.5 Flash Lite', +}; + +export function getTeamModelLabel(model: string): string { + return MODEL_LABEL_OVERRIDES[model] ?? model; +} + +export function getTeamProviderLabel(providerId: 'anthropic' | 'codex' | 'gemini'): string { + switch (providerId) { + case 'codex': + return 'Codex'; + case 'gemini': + return 'Gemini'; + case 'anthropic': + default: + return 'Anthropic'; + } +} + +export function getTeamEffortLabel(effort: string): string { + const trimmed = effort.trim(); + if (!trimmed) return 'Default'; + return trimmed.charAt(0).toUpperCase() + trimmed.slice(1); +} + +export function formatTeamModelSummary( + providerId: 'anthropic' | 'codex' | 'gemini', + model: string, + effort?: string +): string { + 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(' · '); +} + /** * Computes the effective model string for team provisioning. * By default adds [1m] suffix for 1M context (Opus/Sonnet). @@ -96,25 +134,33 @@ const MODEL_OPTIONS = [ */ export function computeEffectiveTeamModel( selectedModel: string, - limitContext: boolean + limitContext: boolean, + providerId: 'anthropic' | 'codex' | 'gemini' = 'anthropic' ): string | undefined { const base = selectedModel || undefined; + if (providerId !== 'anthropic') return base; if (limitContext) return base; if (base === 'haiku') return base; return base ? `${base}[1m]` : 'opus[1m]'; } export interface TeamModelSelectorProps { + providerId: 'anthropic' | 'codex' | 'gemini'; + onProviderChange: (providerId: 'anthropic' | 'codex' | 'gemini') => void; value: string; onValueChange: (value: string) => void; id?: string; } export const TeamModelSelector: React.FC = ({ + providerId, + onProviderChange, value, onValueChange, id, }) => { + const cliStatus = useStore((s) => s.cliStatus); + const multimodelAvailable = cliStatus?.flavor === 'free-code'; const [dropdownOpen, setDropdownOpen] = useState(false); const containerRef = useRef(null); @@ -132,29 +178,55 @@ export const TeamModelSelector: React.FC = ({ return () => document.removeEventListener('mousedown', handleClickOutside); }, [dropdownOpen]); - const ProviderIcon = ACTIVE_PROVIDER.icon; + const activeProvider = PROVIDERS.find((provider) => provider.id === providerId) ?? PROVIDERS[0]; + const ProviderIcon = activeProvider.icon; + const isProviderSelectable = (candidateProviderId: string): boolean => + multimodelAvailable || candidateProviderId === 'anthropic'; + const activeProviderSelectable = isProviderSelectable(providerId); + const runtimeModels = + cliStatus?.providers.find((provider) => provider.providerId === providerId)?.models ?? []; + const modelOptions = useMemo(() => { + const fallback = + providerId === 'codex' + ? CODEX_MODEL_OPTIONS + : providerId === 'gemini' + ? GEMINI_MODEL_OPTIONS + : ANTHROPIC_MODEL_OPTIONS; + if (runtimeModels.length === 0) { + return [...fallback]; + } + const dynamicOptions = runtimeModels.map((model) => ({ + value: model, + label: getTeamModelLabel(model), + })); + return [{ value: '', label: 'Default' }, ...dynamicOptions]; + }, [providerId, runtimeModels]); return (
    -
    -
    - {/* Provider button */} +
    +
    - {/* Model pills */} - {MODEL_OPTIONS.map((opt) => ( + {/* Provider dropdown */} + {dropdownOpen && ( +
    + {PROVIDERS.map((provider, index) => { + const Icon = provider.icon; + const isActive = provider.id === activeProvider.id; + const isFirst = index === 0; + const prevWasActive = index > 0 && !PROVIDERS[index - 1].comingSoon; + + return ( + + {prevWasActive && !isFirst && ( +
    + )} + + + ); + })} +
    + )} +
    + {!multimodelAvailable && ( +

    + Codex and Gemini require Multimodel mode. +

    + )} + +
    + {modelOptions.map((opt) => ( ))}
    - - {/* Provider dropdown */} - {dropdownOpen && ( -
    - {PROVIDERS.map((provider, index) => { - const Icon = provider.icon; - const isActive = provider.id === ACTIVE_PROVIDER.id; - const isFirst = index === 0; - const prevWasActive = index > 0 && !PROVIDERS[index - 1].comingSoon; - - return ( - - {prevWasActive && !isFirst && ( -
    - )} - - - ); - })} -
    - )}
    ); diff --git a/src/renderer/components/team/members/LeadModelRow.tsx b/src/renderer/components/team/members/LeadModelRow.tsx new file mode 100644 index 00000000..3321f7cc --- /dev/null +++ b/src/renderer/components/team/members/LeadModelRow.tsx @@ -0,0 +1,138 @@ +import React, { useState } from 'react'; + +import { Checkbox } from '@renderer/components/ui/checkbox'; +import { Label } from '@renderer/components/ui/label'; +import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector'; +import { LimitContextCheckbox } from '@renderer/components/team/dialogs/LimitContextCheckbox'; +import { + getTeamModelLabel, + TeamModelSelector, +} from '@renderer/components/team/dialogs/TeamModelSelector'; +import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { useTheme } from '@renderer/hooks/useTheme'; +import { getMemberColorByName } from '@shared/constants/memberColors'; +import { ChevronDown, ChevronRight, Info } from 'lucide-react'; + +import { Button } from '../../ui/button'; + +import type { EffortLevel, TeamProviderId } from '@shared/types'; + +interface LeadModelRowProps { + providerId: TeamProviderId; + model: string; + effort?: EffortLevel; + limitContext: boolean; + onProviderChange: (providerId: TeamProviderId) => void; + onModelChange: (model: string) => void; + onEffortChange: (effort: string) => void; + onLimitContextChange: (value: boolean) => void; + syncModelsWithTeammates: boolean; + onSyncModelsWithTeammatesChange: (value: boolean) => void; +} + +export const LeadModelRow = ({ + providerId, + model, + effort, + limitContext, + onProviderChange, + onModelChange, + onEffortChange, + onLimitContextChange, + syncModelsWithTeammates, + onSyncModelsWithTeammatesChange, +}: LeadModelRowProps): React.JSX.Element => { + const { isLight } = useTheme(); + const [modelExpanded, setModelExpanded] = useState(false); + const leadColorSet = getTeamColorSet(getMemberColorByName('lead')); + const modelButtonLabel = model.trim() ? getTeamModelLabel(model.trim()) : 'Default'; + + return ( +
    + + ); +}; diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index c47d2ef8..85c53fed 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -26,6 +26,7 @@ import type { interface MemberCardProps { member: ResolvedTeamMember; memberColor: string; + runtimeSummary?: string; taskCounts?: TaskStatusCounts | null; isTeamAlive?: boolean; isTeamProvisioning?: boolean; @@ -46,6 +47,7 @@ interface MemberCardProps { export const MemberCard = ({ member, memberColor, + runtimeSummary, taskCounts, isTeamAlive, isTeamProvisioning, @@ -131,42 +133,49 @@ export const MemberCard = ({ aria-label={presenceLabel} />
    -
    - - {displayMemberName(member.name)} - - {member.gitBranch ? ( - - - {member.gitBranch} +
    +
    + + {displayMemberName(member.name)} - ) : null} - {currentTask ? ( - - ) : null} - {reviewTask ? ( - - ) : null} - {!activityTask && isAwaitingReply ? ( - <> - - - awaiting reply + {member.gitBranch ? ( + + + {member.gitBranch} - + ) : null} + {currentTask ? ( + + ) : null} + {reviewTask ? ( + + ) : null} + {!activityTask && isAwaitingReply ? ( + <> + + + awaiting reply + + + ) : null} +
    + {runtimeSummary ? ( +
    + {runtimeSummary} +
    ) : null}
    {(() => { diff --git a/src/renderer/components/team/members/MemberDraftRow.tsx b/src/renderer/components/team/members/MemberDraftRow.tsx index f27d0055..1cff48df 100644 --- a/src/renderer/components/team/members/MemberDraftRow.tsx +++ b/src/renderer/components/team/members/MemberDraftRow.tsx @@ -1,6 +1,10 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { TeamModelSelector } from '@renderer/components/team/dialogs/TeamModelSelector'; +import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector'; +import { + getTeamModelLabel, + TeamModelSelector, +} from '@renderer/components/team/dialogs/TeamModelSelector'; import { RoleSelect } from '@renderer/components/team/RoleSelect'; import { Button } from '@renderer/components/ui/button'; import { Input } from '@renderer/components/ui/input'; @@ -16,6 +20,7 @@ import { ChevronDown, ChevronRight, Info, Trash2 } from 'lucide-react'; import type { MemberDraft } from './membersEditorTypes'; import type { InlineChip } from '@renderer/types/inlineChip'; import type { MentionSuggestion } from '@renderer/types/mention'; +import type { EffortLevel, TeamProviderId } from '@shared/types'; interface MemberDraftRowProps { member: MemberDraft; @@ -29,11 +34,20 @@ interface MemberDraftRowProps { showWorkflow?: boolean; onWorkflowChange?: (id: string, workflow: string) => void; onWorkflowChipsChange?: (id: string, chips: InlineChip[]) => void; + onProviderChange: (id: string, providerId: TeamProviderId) => void; + onModelChange: (id: string, model: string) => void; + onEffortChange: (id: string, effort: string) => void; + inheritedProviderId?: TeamProviderId; + inheritedModel?: string; + inheritedEffort?: EffortLevel; draftKeyPrefix?: string; projectPath?: string | null; mentionSuggestions?: MentionSuggestion[]; taskSuggestions?: MentionSuggestion[]; teamSuggestions?: MentionSuggestion[]; + lockProviderModel?: boolean; + forceInheritedModelSettings?: boolean; + modelLockReason?: string; } export const MemberDraftRow = ({ @@ -48,11 +62,20 @@ export const MemberDraftRow = ({ showWorkflow = false, onWorkflowChange, onWorkflowChipsChange, + onProviderChange, + onModelChange, + onEffortChange, + inheritedProviderId = 'anthropic', + inheritedModel = '', + inheritedEffort, draftKeyPrefix, projectPath, mentionSuggestions = [], taskSuggestions, teamSuggestions, + lockProviderModel = false, + forceInheritedModelSettings = false, + modelLockReason, }: MemberDraftRowProps): React.JSX.Element => { const { isLight } = useTheme(); const memberColorSet = getTeamColorSet( @@ -122,10 +145,22 @@ export const MemberDraftRow = ({ const suggestionsExcludingSelf = mentionSuggestions.filter( (s) => s.name.toLowerCase() !== member.name.trim().toLowerCase() ); + const effectiveProviderId = forceInheritedModelSettings + ? inheritedProviderId + : (member.providerId ?? inheritedProviderId); + const effectiveModel = forceInheritedModelSettings + ? inheritedModel + : (member.model ?? inheritedModel); + const effectiveEffort = forceInheritedModelSettings + ? inheritedEffort + : (member.effort ?? inheritedEffort); + const modelButtonLabel = effectiveModel?.trim() + ? getTeamModelLabel(effectiveModel.trim()) + : 'Default'; return (
    -
    - {showWorkflow && onWorkflowChange ? ( +
    +
    + {showWorkflow && onWorkflowChange ? ( + + ) : null} +
    + +
    - ) : null} - - +
    {showWorkflow && onWorkflowChange && workflowExpanded ? (
    @@ -241,14 +282,38 @@ export const MemberDraftRow = ({ ) : null} {modelExpanded && (
    -
    - {}} /> -
    + { + if (lockProviderModel) return; + onProviderChange(member.id, providerId); + }} + value={effectiveModel ?? ''} + onValueChange={(value) => { + if (lockProviderModel) return; + onModelChange(member.id, value); + }} + id={`member-${member.id}-model`} + /> + { + if (lockProviderModel) return; + onEffortChange(member.id, value); + }} + id={`member-${member.id}-effort`} + /> + {lockProviderModel && ( +

    + {modelLockReason ?? + 'Provider, model, and effort changes are disabled while the team is live. Reconnect the team to apply them safely.'} +

    + )}

    - Claude Code doesn't support per-member model selection yet — all teammates - inherit the team launch model. We plan to solve this via a local proxy. + If this teammate uses a different provider than the lead, they will be started in a + separate process automatically.

    diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index efdfc083..07906fde 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -1,5 +1,11 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +import { + getTeamEffortLabel, + getTeamModelLabel, + getTeamProviderLabel, +} from '@renderer/components/team/dialogs/TeamModelSelector'; +import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; @@ -27,6 +33,7 @@ interface MemberListProps { isTeamAlive?: boolean; isTeamProvisioning?: boolean; leadActivity?: LeadActivityState; + launchParams?: TeamLaunchParams; onMemberClick?: (member: ResolvedTeamMember) => void; onSendMessage?: (member: ResolvedTeamMember) => void; onAssignTask?: (member: ResolvedTeamMember) => void; @@ -42,6 +49,7 @@ export const MemberList = ({ isTeamAlive, isTeamProvisioning, leadActivity, + launchParams, onMemberClick, onSendMessage, onAssignTask, @@ -77,6 +85,45 @@ export const MemberList = ({ const removedMembers = members.filter((m) => m.removedAt); const colorMap = buildMemberColorMap(members); + const buildRuntimeSummary = useCallback( + (member: ResolvedTeamMember): string | undefined => { + const hasMemberOverride = Boolean(member.providerId || member.model || member.effort); + if (!hasMemberOverride && launchParams) { + return undefined; + } + + const defaultProvider = launchParams?.providerId ?? 'anthropic'; + const memberProvider = member.providerId ?? defaultProvider; + const defaultModel = launchParams?.model?.trim() || ''; + const memberModel = member.model?.trim() || ''; + const defaultEffort = launchParams?.effort; + const memberEffort = member.effort; + + const showProvider = + !launchParams || Boolean(member.providerId && memberProvider !== defaultProvider); + const showModel = !launchParams + ? Boolean(memberModel) + : Boolean(memberModel && memberModel !== defaultModel); + const showEffort = !launchParams + ? Boolean(memberEffort) + : Boolean(memberEffort && memberEffort !== defaultEffort); + + const parts: string[] = []; + if (showProvider) { + parts.push(getTeamProviderLabel(memberProvider)); + } + if (showModel) { + parts.push(getTeamModelLabel(memberModel)); + } + if (showEffort && memberEffort) { + parts.push(getTeamEffortLabel(memberEffort)); + } + + return parts.length > 0 ? parts.join(' · ') : undefined; + }, + [launchParams] + ); + if (members.length === 0) { return (
    @@ -111,6 +158,7 @@ export const MemberList = ({ reviewTask={isRemoved ? null : reviewTask} isAwaitingReply={isRemoved ? false : awaitingReply} isRemoved={isRemoved} + runtimeSummary={isRemoved ? buildRuntimeSummary(member) : buildRuntimeSummary(member)} spawnStatus={isRemoved ? undefined : spawnEntry?.status} spawnError={isRemoved ? undefined : spawnEntry?.error} onOpenTask={!isRemoved && currentTask ? () => onOpenTask?.(currentTask) : undefined} diff --git a/src/renderer/components/team/members/MembersEditorSection.tsx b/src/renderer/components/team/members/MembersEditorSection.tsx index 608fbb54..d9fab145 100644 --- a/src/renderer/components/team/members/MembersEditorSection.tsx +++ b/src/renderer/components/team/members/MembersEditorSection.tsx @@ -20,6 +20,7 @@ import { import type { MemberDraft } from './membersEditorTypes'; import type { InlineChip } from '@renderer/types/inlineChip'; import type { MentionSuggestion } from '@renderer/types/mention'; +import type { EffortLevel, TeamProviderId } from '@shared/types'; function membersToJsonText(drafts: MemberDraft[]): string { const arr = drafts @@ -30,6 +31,9 @@ function membersToJsonText(drafts: MemberDraft[]): string { if (role) obj.role = role; const workflow = getWorkflowForExport(d); if (workflow) obj.workflow = workflow; + if (d.providerId && d.providerId !== 'anthropic') obj.providerId = d.providerId; + if (d.model?.trim()) obj.model = d.model.trim(); + if (d.effort) obj.effort = d.effort; return obj; }); return JSON.stringify(arr, null, 2); @@ -42,6 +46,13 @@ function parseJsonToDrafts(text: string): MemberDraft[] { const name = typeof item.name === 'string' ? item.name : ''; const role = typeof item.role === 'string' ? item.role.trim() : ''; const workflow = typeof item.workflow === 'string' ? item.workflow.trim() : ''; + const providerId: TeamProviderId = + item.providerId === 'codex' || item.providerId === 'gemini' ? item.providerId : 'anthropic'; + const model = typeof item.model === 'string' ? item.model.trim() : ''; + const effort: EffortLevel | undefined = + item.effort === 'low' || item.effort === 'medium' || item.effort === 'high' + ? item.effort + : undefined; const presetRoles: readonly string[] = PRESET_ROLES; const isPreset = presetRoles.includes(role); return createMemberDraft({ @@ -49,6 +60,9 @@ function parseJsonToDrafts(text: string): MemberDraft[] { roleSelection: role ? (isPreset ? role : CUSTOM_ROLE) : '', customRole: role && !isPreset ? role : '', workflow: workflow || undefined, + providerId, + model, + effort, }); }); } @@ -74,6 +88,16 @@ export interface MembersEditorSectionProps { hideContent?: boolean; /** Existing team members — used to reserve their colors so drafts get the next available ones */ existingMembers?: readonly { name: string; color?: string; removedAt?: number | string | null }[]; + /** Default provider to use for newly added member rows. */ + defaultProviderId?: TeamProviderId; + /** When true, provider/model controls stay read-only for existing rows. */ + lockProviderModel?: boolean; + inheritedProviderId?: TeamProviderId; + inheritedModel?: string; + inheritedEffort?: EffortLevel; + inheritModelSettingsByDefault?: boolean; + forceInheritedModelSettings?: boolean; + modelLockReason?: string; } export const MembersEditorSection = ({ @@ -90,6 +114,14 @@ export const MembersEditorSection = ({ headerExtra, hideContent = false, existingMembers, + defaultProviderId = 'anthropic', + lockProviderModel = false, + inheritedProviderId, + inheritedModel, + inheritedEffort, + inheritModelSettingsByDefault = false, + forceInheritedModelSettings = false, + modelLockReason, }: MembersEditorSectionProps): React.JSX.Element => { const [jsonEditorOpen, setJsonEditorOpen] = useState(false); const [jsonText, setJsonText] = useState(''); @@ -150,13 +182,52 @@ export const MembersEditorSection = ({ onChange(members.map((c) => (c.id === memberId ? { ...c, workflowChips } : c))); }; + const updateMemberProvider = (memberId: string, providerId: TeamProviderId): void => { + onChange( + members.map((c) => + c.id === memberId + ? { + ...c, + providerId, + model: c.providerId === providerId ? c.model : '', + } + : c + ) + ); + }; + + const updateMemberModel = (memberId: string, model: string): void => { + onChange(members.map((c) => (c.id === memberId ? { ...c, model } : c))); + }; + + const updateMemberEffort = (memberId: string, effort: string): void => { + onChange( + members.map((c) => + c.id === memberId + ? { + ...c, + effort: + effort === 'low' || effort === 'medium' || effort === 'high' ? effort : undefined, + } + : c + ) + ); + }; + const removeMember = (memberId: string): void => { onChange(members.filter((c) => c.id !== memberId)); }; const addMember = (): void => { const suggestedName = getNextSuggestedMemberName(members.map((member) => member.name)); - onChange([...members, createMemberDraft({ name: suggestedName })]); + onChange([ + ...members, + createMemberDraft( + inheritModelSettingsByDefault + ? { name: suggestedName } + : { name: suggestedName, providerId: defaultProviderId } + ), + ]); }; const names = members.map((m) => m.name.trim().toLowerCase()).filter(Boolean); @@ -207,11 +278,20 @@ export const MembersEditorSection = ({ showWorkflow={showWorkflow} onWorkflowChange={showWorkflow ? updateMemberWorkflow : undefined} onWorkflowChipsChange={showWorkflow ? updateMemberWorkflowChips : undefined} + onProviderChange={updateMemberProvider} + onModelChange={updateMemberModel} + onEffortChange={updateMemberEffort} + inheritedProviderId={inheritedProviderId} + inheritedModel={inheritedModel} + inheritedEffort={inheritedEffort} + forceInheritedModelSettings={forceInheritedModelSettings} draftKeyPrefix={draftKeyPrefix} projectPath={projectPath} mentionSuggestions={mentionSuggestions} taskSuggestions={taskSuggestions} teamSuggestions={teamSuggestions} + lockProviderModel={lockProviderModel} + modelLockReason={modelLockReason} /> ))} {jsonEditorOpen && showJsonEditor ? ( @@ -243,7 +323,9 @@ export { buildMemberDraftColorMap, buildMemberDraftSuggestions, buildMembersFromDrafts, + clearMemberModelOverrides, createMemberDraft, + createMemberDraftsFromInputs, getMemberDraftRole, validateMemberNameInline, } from './membersEditorUtils'; diff --git a/src/renderer/components/team/members/TeamRosterEditorSection.tsx b/src/renderer/components/team/members/TeamRosterEditorSection.tsx new file mode 100644 index 00000000..8f467b5d --- /dev/null +++ b/src/renderer/components/team/members/TeamRosterEditorSection.tsx @@ -0,0 +1,121 @@ +import React from 'react'; + +import { MembersEditorSection } from './MembersEditorSection'; +import { LeadModelRow } from './LeadModelRow'; + +import type { MemberDraft } from './membersEditorTypes'; +import type { MentionSuggestion } from '@renderer/types/mention'; +import type { EffortLevel, TeamProviderId } from '@shared/types'; + +interface TeamRosterEditorSectionProps { + members: MemberDraft[]; + onMembersChange: (members: MemberDraft[]) => void; + fieldError?: string; + validateMemberName?: (name: string) => string | null; + showWorkflow?: boolean; + showJsonEditor?: boolean; + draftKeyPrefix?: string; + projectPath?: string | null; + taskSuggestions?: MentionSuggestion[]; + teamSuggestions?: MentionSuggestion[]; + hideMembersContent?: boolean; + existingMembers?: readonly { name: string; color?: string; removedAt?: number | string | null }[]; + defaultProviderId?: TeamProviderId; + inheritedProviderId: TeamProviderId; + inheritedModel: string; + inheritedEffort?: EffortLevel; + inheritModelSettingsByDefault?: boolean; + forceInheritedModelSettings?: boolean; + lockProviderModel?: boolean; + modelLockReason?: string; + providerId: TeamProviderId; + model: string; + effort?: EffortLevel; + limitContext: boolean; + onProviderChange: (providerId: TeamProviderId) => void; + onModelChange: (model: string) => void; + onEffortChange: (effort: string) => void; + onLimitContextChange: (value: boolean) => void; + syncModelsWithTeammates: boolean; + onSyncModelsWithTeammatesChange: (value: boolean) => void; + headerTop?: React.ReactNode; + headerBottom?: React.ReactNode; +} + +export const TeamRosterEditorSection = ({ + members, + onMembersChange, + fieldError, + validateMemberName, + showWorkflow = false, + showJsonEditor = true, + draftKeyPrefix, + projectPath, + taskSuggestions, + teamSuggestions, + hideMembersContent = false, + existingMembers, + defaultProviderId = 'anthropic', + inheritedProviderId, + inheritedModel, + inheritedEffort, + inheritModelSettingsByDefault = false, + forceInheritedModelSettings = false, + lockProviderModel = false, + modelLockReason, + providerId, + model, + effort, + limitContext, + onProviderChange, + onModelChange, + onEffortChange, + onLimitContextChange, + syncModelsWithTeammates, + onSyncModelsWithTeammatesChange, + headerTop, + headerBottom, +}: TeamRosterEditorSectionProps): React.JSX.Element => { + return ( + + {headerTop} + + {headerBottom} +
    + } + /> + ); +}; diff --git a/src/renderer/components/team/members/membersEditorTypes.ts b/src/renderer/components/team/members/membersEditorTypes.ts index f4649308..d73ac590 100644 --- a/src/renderer/components/team/members/membersEditorTypes.ts +++ b/src/renderer/components/team/members/membersEditorTypes.ts @@ -1,4 +1,5 @@ import type { InlineChip } from '@renderer/types/inlineChip'; +import type { EffortLevel, TeamProviderId } from '@shared/types'; export interface MemberDraft { id: string; @@ -7,6 +8,9 @@ export interface MemberDraft { customRole: string; workflow?: string; workflowChips?: InlineChip[]; + providerId?: TeamProviderId; + model?: string; + effort?: EffortLevel; } export interface MembersEditorValue { diff --git a/src/renderer/components/team/members/membersEditorUtils.ts b/src/renderer/components/team/members/membersEditorUtils.ts index 8bafddc2..906f0a2c 100644 --- a/src/renderer/components/team/members/membersEditorUtils.ts +++ b/src/renderer/components/team/members/membersEditorUtils.ts @@ -1,10 +1,10 @@ -import { CUSTOM_ROLE, NO_ROLE } from '@renderer/constants/teamRoles'; +import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles'; import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import type { MemberDraft } from './membersEditorTypes'; import type { MentionSuggestion } from '@renderer/types/mention'; -import type { TeamProvisioningMemberInput } from '@shared/types'; +import type { EffortLevel, TeamProvisioningMemberInput, TeamProviderId } from '@shared/types'; function isValidMemberName(name: string): boolean { if (name.length < 1 || name.length > 128) return false; @@ -33,9 +33,60 @@ export function createMemberDraft(initial?: Partial): MemberDraft { roleSelection: initial?.roleSelection ?? '', customRole: initial?.customRole ?? '', workflow: initial?.workflow, + providerId: initial?.providerId, + model: initial?.model ?? '', + effort: initial?.effort, }; } +export function createMemberDraftsFromInputs( + members: readonly { + name: string; + role?: string; + workflow?: string; + providerId?: TeamProviderId; + model?: string; + effort?: EffortLevel; + removedAt?: number | string | null; + }[] +): MemberDraft[] { + return members + .filter((member) => !member.removedAt) + .map((member) => { + const role = typeof member.role === 'string' ? member.role.trim() : ''; + const presetRoles: readonly string[] = PRESET_ROLES; + const isPreset = presetRoles.includes(role); + return createMemberDraft({ + name: member.name, + roleSelection: role ? (isPreset ? role : CUSTOM_ROLE) : '', + customRole: role && !isPreset ? role : '', + workflow: member.workflow, + providerId: + member.providerId === 'codex' || member.providerId === 'gemini' + ? member.providerId + : 'anthropic', + model: member.model ?? '', + effort: normalizeDraftEffort(member.effort), + }); + }); +} + +export function clearMemberModelOverrides(member: MemberDraft): MemberDraft { + return { + ...member, + providerId: undefined, + model: '', + effort: undefined, + }; +} + +function normalizeDraftEffort(value: string | undefined): EffortLevel | undefined { + if (value === 'low' || value === 'medium' || value === 'high') { + return value; + } + return undefined; +} + interface ExistingMemberColorInput { name: string; color?: string; @@ -113,6 +164,21 @@ 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') { + result.providerId = providerId; + } + const model = member.model?.trim(); + if (model) { + result.model = model; + } + const effort = normalizeDraftEffort(member.effort); + if (effort) { + result.effort = effort; + } return result; }) .filter((member): member is NonNullable => member !== null); diff --git a/src/renderer/hooks/useCreateTeamDraft.ts b/src/renderer/hooks/useCreateTeamDraft.ts index e537b3b4..5eaadf2b 100644 --- a/src/renderer/hooks/useCreateTeamDraft.ts +++ b/src/renderer/hooks/useCreateTeamDraft.ts @@ -33,6 +33,8 @@ export interface UseCreateTeamDraftResult { setTeamName: (v: string) => void; members: MemberDraft[]; setMembers: (v: MemberDraft[]) => void; + syncModelsWithLead: boolean; + setSyncModelsWithLead: (v: boolean) => void; cwdMode: 'project' | 'custom'; setCwdMode: (v: 'project' | 'custom') => void; selectedProjectPath: string; @@ -63,13 +65,18 @@ const DEBOUNCE_MS = 400; // --------------------------------------------------------------------------- function serializeMembers(members: MemberDraft[]): SerializedMemberDraft[] { - return members.map(({ id, name, roleSelection, customRole, workflow }) => ({ - id, - name, - roleSelection, - customRole, - workflow, - })); + return members.map( + ({ id, name, roleSelection, customRole, workflow, providerId, model, effort }) => ({ + id, + name, + roleSelection, + customRole, + workflow, + providerId, + model, + effort, + }) + ); } function deserializeMembers(serialized: SerializedMemberDraft[]): MemberDraft[] { @@ -80,6 +87,9 @@ function deserializeMembers(serialized: SerializedMemberDraft[]): MemberDraft[] roleSelection: m.roleSelection, customRole: m.customRole, workflow: m.workflow, + providerId: m.providerId, + model: m.model, + effort: m.effort, }) ); } @@ -92,6 +102,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult { // ── State ────────────────────────────────────────────────────────────── const [teamName, setTeamNameState] = useState(''); const [members, setMembersState] = useState([]); + const [syncModelsWithLead, setSyncModelsWithLeadState] = useState(true); const [cwdMode, setCwdModeState] = useState<'project' | 'custom'>('project'); const [selectedProjectPath, setSelectedProjectPathState] = useState(''); const [customCwd, setCustomCwdState] = useState(''); @@ -103,6 +114,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult { // ── Refs (latest values for debounced callbacks) ─────────────────────── const teamNameRef = useRef(''); const membersRef = useRef([]); + const syncModelsWithLeadRef = useRef(true); const cwdModeRef = useRef<'project' | 'custom'>('project'); const selectedProjectPathRef = useRef(''); const customCwdRef = useRef(''); @@ -128,6 +140,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult { version: 1, teamName: teamNameRef.current, members: serializeMembers(membersRef.current), + syncModelsWithLead: syncModelsWithLeadRef.current, cwdMode: cwdModeRef.current, selectedProjectPath: selectedProjectPathRef.current, customCwd: customCwdRef.current, @@ -187,6 +200,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult { teamNameRef.current = snap.teamName; membersRef.current = deserialized; + syncModelsWithLeadRef.current = snap.syncModelsWithLead ?? true; cwdModeRef.current = snap.cwdMode; selectedProjectPathRef.current = snap.selectedProjectPath; customCwdRef.current = snap.customCwd; @@ -196,6 +210,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult { setTeamNameState(snap.teamName); setMembersState(deserialized); + setSyncModelsWithLeadState(snap.syncModelsWithLead ?? true); setCwdModeState(snap.cwdMode); setSelectedProjectPathState(snap.selectedProjectPath); setCustomCwdState(snap.customCwd); @@ -260,6 +275,16 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult { [scheduleSave] ); + const setSyncModelsWithLead = useCallback( + (v: boolean) => { + userTouchedRef.current = true; + syncModelsWithLeadRef.current = v; + setSyncModelsWithLeadState(v); + scheduleSave(); + }, + [scheduleSave] + ); + const setCwdMode = useCallback( (v: 'project' | 'custom') => { userTouchedRef.current = true; @@ -334,6 +359,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult { teamNameRef.current = ''; membersRef.current = []; + syncModelsWithLeadRef.current = true; cwdModeRef.current = 'project'; selectedProjectPathRef.current = ''; customCwdRef.current = ''; @@ -343,6 +369,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult { setTeamNameState(''); setMembersState([]); + setSyncModelsWithLeadState(true); setCwdModeState('project'); setSelectedProjectPathState(''); setCustomCwdState(''); @@ -358,6 +385,8 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult { setTeamName, members, setMembers, + syncModelsWithLead, + setSyncModelsWithLead, cwdMode, setCwdMode, selectedProjectPath, diff --git a/src/renderer/services/createTeamDraftStorage.ts b/src/renderer/services/createTeamDraftStorage.ts index 98eb52cc..eb616f60 100644 --- a/src/renderer/services/createTeamDraftStorage.ts +++ b/src/renderer/services/createTeamDraftStorage.ts @@ -25,12 +25,16 @@ export interface SerializedMemberDraft { roleSelection: string; customRole: string; workflow?: string; + providerId?: 'anthropic' | 'codex' | 'gemini'; + model?: string; + effort?: 'low' | 'medium' | 'high'; } export interface CreateTeamDraftSnapshot { version: number; teamName: string; members: SerializedMemberDraft[]; + syncModelsWithLead?: boolean; cwdMode: 'project' | 'custom'; selectedProjectPath: string; customCwd: string; @@ -57,7 +61,16 @@ function isValidMember(m: unknown): m is SerializedMemberDraft { typeof obj.id === 'string' && typeof obj.name === 'string' && typeof obj.roleSelection === 'string' && - typeof obj.customRole === 'string' + typeof obj.customRole === 'string' && + (obj.providerId === undefined || + obj.providerId === 'anthropic' || + obj.providerId === 'codex' || + obj.providerId === 'gemini') && + (obj.model === undefined || typeof obj.model === 'string') && + (obj.effort === undefined || + obj.effort === 'low' || + obj.effort === 'medium' || + obj.effort === 'high') ); } @@ -70,6 +83,7 @@ function isValidSnapshot(data: unknown): data is CreateTeamDraftSnapshot { typeof obj.teamName === 'string' && Array.isArray(obj.members) && obj.members.every(isValidMember) && + (obj.syncModelsWithLead === undefined || typeof obj.syncModelsWithLead === 'boolean') && (obj.cwdMode === 'project' || obj.cwdMode === 'custom') && typeof obj.selectedProjectPath === 'string' && typeof obj.customCwd === 'string' && @@ -154,6 +168,7 @@ function emptySnapshot(): CreateTeamDraftSnapshot { version: SNAPSHOT_VERSION, teamName: '', members: [], + syncModelsWithLead: true, cwdMode: 'project', selectedProjectPath: '', customCwd: '', diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index c4e1952d..71818524 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -559,6 +559,7 @@ export interface GlobalTaskDetailState { /** Per-team launch parameters shown in the header badge. */ export interface TeamLaunchParams { + providerId?: 'anthropic' | 'codex' | 'gemini'; model?: string; // 'opus' | 'sonnet' | 'haiku' effort?: EffortLevel; limitContext?: boolean; @@ -1949,6 +1950,7 @@ export const createTeamSlice: StateCreator = (set, // Persist per-team launch params (model, effort, limit context) const baseModel = extractBaseModel(request.model); const params: TeamLaunchParams = { + providerId: request.providerId ?? 'anthropic', model: baseModel || 'default', effort: request.effort, limitContext: request.limitContext ?? false, @@ -2125,6 +2127,7 @@ export const createTeamSlice: StateCreator = (set, // Persist per-team launch params (model, effort, limit context) const baseModel = extractBaseModel(request.model); const params: TeamLaunchParams = { + providerId: request.providerId ?? 'anthropic', model: baseModel || 'default', effort: request.effort, limitContext: request.limitContext ?? false, diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index e52ab6ea..e7994209 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -427,7 +427,11 @@ export interface TeamsAPI { permanentlyDeleteTeam: (teamName: string) => Promise; getSavedRequest: (teamName: string) => Promise; deleteDraft: (teamName: string) => Promise; - prepareProvisioning: (cwd?: string) => Promise; + prepareProvisioning: ( + cwd?: string, + providerId?: TeamLaunchRequest['providerId'], + providerIds?: TeamLaunchRequest['providerId'][] + ) => Promise; createTeam: (request: TeamCreateRequest) => Promise; getProvisioningStatus: (runId: string) => Promise; cancelProvisioning: (runId: string) => Promise; diff --git a/src/shared/types/schedule.ts b/src/shared/types/schedule.ts index 606b81eb..9f76b1b1 100644 --- a/src/shared/types/schedule.ts +++ b/src/shared/types/schedule.ts @@ -5,7 +5,7 @@ * Repository Pattern abstraction allows swapping storage backend (JSON → sql.js/Drizzle). */ -import type { EffortLevel } from './team'; +import type { EffortLevel, TeamProviderId } from './team'; // ============================================================================= // Schedule Status Types @@ -49,6 +49,7 @@ export interface Schedule { export interface ScheduleLaunchConfig { cwd: string; prompt: string; + providerId?: TeamProviderId; model?: string; effort?: EffortLevel; skipPermissions?: boolean; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index b8f4e817..c833eec0 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -5,6 +5,9 @@ export interface TeamMember { role?: string; /** Per-agent workflow/instructions injected into spawn prompt. */ workflow?: string; + providerId?: TeamProviderId; + model?: string; + effort?: EffortLevel; color?: string; joinedAt?: number; cwd?: string; @@ -471,6 +474,9 @@ export interface ResolvedTeamMember { agentType?: string; role?: string; workflow?: string; + providerId?: TeamProviderId; + model?: string; + effort?: EffortLevel; cwd?: string; /** Set only when member's git branch differs from the lead's branch. */ gitBranch?: string; @@ -503,11 +509,13 @@ export interface TeamData { } export type EffortLevel = 'low' | 'medium' | 'high'; +export type TeamProviderId = 'anthropic' | 'codex' | 'gemini'; export interface TeamLaunchRequest { teamName: string; cwd: string; prompt?: string; + providerId?: TeamProviderId; model?: string; effort?: EffortLevel; /** When true, context window is limited to 200K tokens instead of the default. */ @@ -633,6 +641,9 @@ export interface TeamProvisioningMemberInput { role?: string; /** Per-agent workflow/instructions injected into spawn prompt. */ workflow?: string; + providerId?: TeamProviderId; + model?: string; + effort?: EffortLevel; } export interface TeamCreateRequest { @@ -643,6 +654,7 @@ export interface TeamCreateRequest { members: TeamProvisioningMemberInput[]; cwd: string; prompt?: string; + providerId?: TeamProviderId; model?: string; effort?: EffortLevel; /** When true, context window is limited to 200K tokens instead of the default. */ @@ -780,6 +792,9 @@ export interface AddMemberRequest { name: string; role?: string; workflow?: string; + providerId?: TeamProviderId; + model?: string; + effort?: EffortLevel; } export interface RemoveMemberRequest { diff --git a/src/shared/utils/cliArgsParser.ts b/src/shared/utils/cliArgsParser.ts index 5adae22c..5a22ac91 100644 --- a/src/shared/utils/cliArgsParser.ts +++ b/src/shared/utils/cliArgsParser.ts @@ -24,6 +24,12 @@ export const PROTECTED_CLI_FLAGS = new Set([ '--mcp-config', '--disallowedTools', '--verbose', + '--model', + '--effort', + '--resume', + '--permission-mode', + '--permission-prompt-tool', + '--dangerously-skip-permissions', ]); /** diff --git a/test/main/services/team/TeamMemberResolver.test.ts b/test/main/services/team/TeamMemberResolver.test.ts index 24ec3686..e0718abf 100644 --- a/test/main/services/team/TeamMemberResolver.test.ts +++ b/test/main/services/team/TeamMemberResolver.test.ts @@ -91,6 +91,28 @@ describe('TeamMemberResolver', () => { expect(names).not.toContain('dream-team.team-lead'); }); + it('ignores leaked generated agent ids from inbox file names', () => { + const resolver = new TeamMemberResolver(); + const config: TeamConfig = { + name: 'Team', + members: [{ name: 'team-lead', agentType: 'team-lead', role: 'lead' }], + }; + const metaMembers: TeamConfig['members'] = [ + { name: 'alice', agentType: 'general-purpose' }, + { name: 'bob', agentType: 'general-purpose' }, + ]; + const inboxNames = ['a3975f80d37fbcea1', 'alice', 'a68a8f6a643e59bfd']; + + const members = resolver.resolveMembers(config, metaMembers, inboxNames, [], []); + const names = members.map((m) => m.name); + + expect(names).toContain('alice'); + expect(names).toContain('bob'); + expect(names).toContain('team-lead'); + expect(names).not.toContain('a3975f80d37fbcea1'); + expect(names).not.toContain('a68a8f6a643e59bfd'); + }); + it('keeps dotted names when they are explicitly configured members', () => { const resolver = new TeamMemberResolver(); const config: TeamConfig = { diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index 41285f18..e5d7874e 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -79,6 +79,38 @@ describe('TeamProvisioningService prepare/auth behavior', () => { expect(probeSpy.mock.calls[1]?.[1]).toBe(cwdB); }); + it('checks each unique provider during multi-provider prepare and blocks on provider auth failure', async () => { + const svc = new TeamProvisioningService(); + const getCachedOrProbeResult = vi.spyOn(svc as any, 'getCachedOrProbeResult'); + getCachedOrProbeResult.mockImplementation(async (_cwd: unknown, providerId: unknown) => { + if (providerId === 'codex') { + return { + claudePath: '/fake/claude', + authSource: 'none', + warning: 'Not logged in to Codex runtime', + }; + } + return { + claudePath: '/fake/claude', + authSource: 'oauth_token', + }; + }); + + const result = await svc.prepareForProvisioning(tempRoot, { + forceFresh: true, + providerId: 'anthropic', + providerIds: ['codex', 'anthropic'], + }); + + expect(result.ready).toBe(false); + expect(result.message).toBe('Codex: Not logged in to Codex runtime'); + expect(getCachedOrProbeResult).toHaveBeenCalledTimes(2); + expect(getCachedOrProbeResult.mock.calls.map((call) => call[1])).toEqual([ + 'anthropic', + 'codex', + ]); + }); + it('maps ANTHROPIC_AUTH_TOKEN into ANTHROPIC_API_KEY for headless preflight', async () => { const svc = new TeamProvisioningService(); vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({ diff --git a/test/main/services/team/TeamProvisioningServiceRoster.test.ts b/test/main/services/team/TeamProvisioningServiceRoster.test.ts index 53e69e78..aa86048d 100644 --- a/test/main/services/team/TeamProvisioningServiceRoster.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRoster.test.ts @@ -59,6 +59,27 @@ describe('TeamProvisioningService (launch roster discovery)', () => { expect(result.members.map((m: { name: string }) => m.name)).toEqual(['alice-2']); }); + it('inbox fallback merges provider/model overrides from config for multimodel reconnect', async () => { + const svc = new TeamProvisioningService( + {} as never, + { listInboxNames: vi.fn(async () => ['bob']) } as never, + { getMembers: vi.fn(async () => []) } as never, + {} as never + ); + + const configRaw = JSON.stringify({ + name: 't', + members: [{ name: 'bob', role: 'reviewer', provider: 'codex', model: 'gpt-5.4' }], + }); + + const result = await (svc as unknown as any).resolveLaunchExpectedMembers('t', configRaw); + expect(result.source).toBe('inboxes'); + expect(result.members).toEqual([ + { name: 'bob', role: 'reviewer', workflow: undefined, providerId: 'codex', model: 'gpt-5.4' }, + ]); + expect(result.warning).toContain('best-effort'); + }); + it('members.meta.json fallback never returns reserved names (user/team-lead)', async () => { const svc = new TeamProvisioningService( {} as never, From 759cae2669ab61ade38e1c1b88c39681199022ee Mon Sep 17 00:00:00 2001 From: iliya Date: Thu, 2 Apr 2026 10:23:57 +0300 Subject: [PATCH 03/67] fix(pricing): include codex and gemini model costs --- resources/pricing.json | 621 +++++++++++++++++++++++++----- scripts/fetch-pricing-data.ts | 37 +- src/shared/utils/pricing.ts | 9 + test/shared/utils/pricing.test.ts | 21 + 4 files changed, 576 insertions(+), 112 deletions(-) diff --git a/resources/pricing.json b/resources/pricing.json index 17b13eec..be42ab2e 100644 --- a/resources/pricing.json +++ b/resources/pricing.json @@ -36,7 +36,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true }, "anthropic.claude-haiku-4-5@20251001": { "cache_creation_input_token_cost": 0.00000125, @@ -59,7 +60,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_streaming": true + "supports_native_streaming": true, + "supports_native_structured_output": true }, "anthropic.claude-3-5-sonnet-20240620-v1:0": { "input_cost_per_token": 0.000003, @@ -281,22 +283,19 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "tool_use_system_prompt_tokens": 159, + "supports_native_structured_output": true }, "anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 0.00000625, - "cache_creation_input_token_cost_above_200k_tokens": 0.0000125, "cache_read_input_token_cost": 5e-7, - "cache_read_input_token_cost_above_200k_tokens": 0.000001, "input_cost_per_token": 0.000005, - "input_cost_per_token_above_200k_tokens": 0.00001, "litellm_provider": "bedrock_converse", "max_input_tokens": 1000000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 0.000025, - "output_cost_per_token_above_200k_tokens": 0.0000375, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, @@ -311,22 +310,19 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true }, "global.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 0.00000625, - "cache_creation_input_token_cost_above_200k_tokens": 0.0000125, "cache_read_input_token_cost": 5e-7, - "cache_read_input_token_cost_above_200k_tokens": 0.000001, "input_cost_per_token": 0.000005, - "input_cost_per_token_above_200k_tokens": 0.00001, "litellm_provider": "bedrock_converse", "max_input_tokens": 1000000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 0.000025, - "output_cost_per_token_above_200k_tokens": 0.0000375, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, @@ -341,22 +337,19 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true }, "us.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 0.000006875, - "cache_creation_input_token_cost_above_200k_tokens": 0.00001375, "cache_read_input_token_cost": 5.5e-7, - "cache_read_input_token_cost_above_200k_tokens": 0.0000011, "input_cost_per_token": 0.0000055, - "input_cost_per_token_above_200k_tokens": 0.000011, "litellm_provider": "bedrock_converse", "max_input_tokens": 1000000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 0.0000275, - "output_cost_per_token_above_200k_tokens": 0.00004125, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, @@ -371,22 +364,19 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true }, "eu.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 0.000006875, - "cache_creation_input_token_cost_above_200k_tokens": 0.00001375, "cache_read_input_token_cost": 5.5e-7, - "cache_read_input_token_cost_above_200k_tokens": 0.0000011, "input_cost_per_token": 0.0000055, - "input_cost_per_token_above_200k_tokens": 0.000011, "litellm_provider": "bedrock_converse", "max_input_tokens": 1000000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 0.0000275, - "output_cost_per_token_above_200k_tokens": 0.00004125, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, @@ -401,22 +391,19 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true }, "au.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 0.000006875, - "cache_creation_input_token_cost_above_200k_tokens": 0.00001375, "cache_read_input_token_cost": 5.5e-7, - "cache_read_input_token_cost_above_200k_tokens": 0.0000011, "input_cost_per_token": 0.0000055, - "input_cost_per_token_above_200k_tokens": 0.000011, "litellm_provider": "bedrock_converse", "max_input_tokens": 1000000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 0.0000275, - "output_cost_per_token_above_200k_tokens": 0.00004125, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, @@ -431,22 +418,19 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true }, "anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 0.00000375, - "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, "cache_read_input_token_cost": 3e-7, - "cache_read_input_token_cost_above_200k_tokens": 6e-7, "input_cost_per_token": 0.000003, - "input_cost_per_token_above_200k_tokens": 0.000006, "litellm_provider": "bedrock_converse", - "max_input_tokens": 200000, + "max_input_tokens": 1000000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 0.000015, - "output_cost_per_token_above_200k_tokens": 0.0000225, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, @@ -461,22 +445,19 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true }, "global.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 0.00000375, - "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, "cache_read_input_token_cost": 3e-7, - "cache_read_input_token_cost_above_200k_tokens": 6e-7, "input_cost_per_token": 0.000003, - "input_cost_per_token_above_200k_tokens": 0.000006, "litellm_provider": "bedrock_converse", - "max_input_tokens": 200000, + "max_input_tokens": 1000000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 0.000015, - "output_cost_per_token_above_200k_tokens": 0.0000225, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, @@ -491,22 +472,19 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true }, "us.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 0.000004125, - "cache_creation_input_token_cost_above_200k_tokens": 0.00000825, "cache_read_input_token_cost": 3.3e-7, - "cache_read_input_token_cost_above_200k_tokens": 6.6e-7, "input_cost_per_token": 0.0000033, - "input_cost_per_token_above_200k_tokens": 0.0000066, "litellm_provider": "bedrock_converse", - "max_input_tokens": 200000, + "max_input_tokens": 1000000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 0.0000165, - "output_cost_per_token_above_200k_tokens": 0.00002475, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, @@ -521,22 +499,19 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true }, "eu.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 0.000004125, - "cache_creation_input_token_cost_above_200k_tokens": 0.00000825, "cache_read_input_token_cost": 3.3e-7, - "cache_read_input_token_cost_above_200k_tokens": 6.6e-7, "input_cost_per_token": 0.0000033, - "input_cost_per_token_above_200k_tokens": 0.0000066, "litellm_provider": "bedrock_converse", - "max_input_tokens": 200000, + "max_input_tokens": 1000000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 0.0000165, - "output_cost_per_token_above_200k_tokens": 0.00002475, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, @@ -551,22 +526,19 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true }, "au.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 0.000004125, - "cache_creation_input_token_cost_above_200k_tokens": 0.00000825, "cache_read_input_token_cost": 3.3e-7, - "cache_read_input_token_cost_above_200k_tokens": 6.6e-7, "input_cost_per_token": 0.0000033, - "input_cost_per_token_above_200k_tokens": 0.0000066, "litellm_provider": "bedrock_converse", - "max_input_tokens": 200000, + "max_input_tokens": 1000000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 0.0000165, - "output_cost_per_token_above_200k_tokens": 0.00002475, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, @@ -581,7 +553,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true }, "anthropic.claude-sonnet-4-20250514-v1:0": { "cache_creation_input_token_cost": 0.00000375, @@ -641,7 +614,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "tool_use_system_prompt_tokens": 159, + "supports_native_structured_output": true }, "anthropic.claude-v1": { "input_cost_per_token": 0.000008, @@ -733,7 +707,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true }, "apac.anthropic.claude-3-sonnet-20240229-v1:0": { "input_cost_per_token": 0.000003, @@ -809,7 +784,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true }, "azure_ai/claude-haiku-4-5": { "cache_creation_input_token_cost": 0.00000125, @@ -928,7 +904,7 @@ "cache_read_input_token_cost": 3e-7, "input_cost_per_token": 0.000003, "litellm_provider": "azure_ai", - "max_input_tokens": 200000, + "max_input_tokens": 1000000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", @@ -1452,18 +1428,14 @@ }, "claude-sonnet-4-6": { "cache_creation_input_token_cost": 0.00000375, - "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, "cache_read_input_token_cost": 3e-7, - "cache_read_input_token_cost_above_200k_tokens": 6e-7, "input_cost_per_token": 0.000003, - "input_cost_per_token_above_200k_tokens": 0.000006, "litellm_provider": "anthropic", - "max_input_tokens": 200000, + "max_input_tokens": 1000000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 0.000015, - "output_cost_per_token_above_200k_tokens": 0.0000225, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, @@ -1644,19 +1616,15 @@ }, "claude-opus-4-6": { "cache_creation_input_token_cost": 0.00000625, - "cache_creation_input_token_cost_above_200k_tokens": 0.0000125, "cache_creation_input_token_cost_above_1hr": 0.00001, "cache_read_input_token_cost": 5e-7, - "cache_read_input_token_cost_above_200k_tokens": 0.000001, "input_cost_per_token": 0.000005, - "input_cost_per_token_above_200k_tokens": 0.00001, "litellm_provider": "anthropic", "max_input_tokens": 1000000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 0.000025, - "output_cost_per_token_above_200k_tokens": 0.0000375, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, @@ -1679,19 +1647,15 @@ }, "claude-opus-4-6-20260205": { "cache_creation_input_token_cost": 0.00000625, - "cache_creation_input_token_cost_above_200k_tokens": 0.0000125, "cache_creation_input_token_cost_above_1hr": 0.00001, "cache_read_input_token_cost": 5e-7, - "cache_read_input_token_cost_above_200k_tokens": 0.000001, "input_cost_per_token": 0.000005, - "input_cost_per_token_above_200k_tokens": 0.00001, "litellm_provider": "anthropic", "max_input_tokens": 1000000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 0.000025, - "output_cost_per_token_above_200k_tokens": 0.0000375, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, @@ -1968,7 +1932,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true }, "eu.anthropic.claude-3-5-sonnet-20240620-v1:0": { "input_cost_per_token": 0.000003, @@ -2182,7 +2147,143 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true + }, + "gemini-2.5-flash": { + "cache_read_input_token_cost": 3e-8, + "input_cost_per_audio_token": 0.000001, + "input_cost_per_token": 3e-7, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 0.0000025, + "output_cost_per_token": 0.0000025, + "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.5-flash-lite": { + "cache_read_input_token_cost": 1e-8, + "input_cost_per_audio_token": 3e-7, + "input_cost_per_token": 1e-7, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 4e-7, + "output_cost_per_token": 4e-7, + "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.5-pro": { + "cache_read_input_token_cost": 1.25e-7, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-7, + "cache_creation_input_token_cost_above_200k_tokens": 2.5e-7, + "input_cost_per_token": 0.00000125, + "input_cost_per_token_above_200k_tokens": 0.0000025, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 0.00001, + "output_cost_per_token_above_200k_tokens": 0.000015, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true }, "gmi/anthropic/claude-opus-4.5": { "input_cost_per_token": 0.000005, @@ -2256,7 +2357,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true }, "global.anthropic.claude-sonnet-4-20250514-v1:0": { "cache_creation_input_token_cost": 0.00000375, @@ -2308,7 +2410,287 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true + }, + "gpt-5.2": { + "cache_read_input_token_cost": 1.75e-7, + "cache_read_input_token_cost_priority": 3.5e-7, + "input_cost_per_token": 0.00000175, + "input_cost_per_token_priority": 0.0000035, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000014, + "output_cost_per_token_priority": 0.000028, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text", + "image" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_service_tier": true, + "supports_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": true + }, + "gpt-5.4": { + "cache_read_input_token_cost": 2.5e-7, + "cache_read_input_token_cost_above_272k_tokens": 5e-7, + "cache_read_input_token_cost_flex": 1.3e-7, + "cache_read_input_token_cost_priority": 5e-7, + "cache_read_input_token_cost_above_272k_tokens_priority": 0.000001, + "input_cost_per_token": 0.0000025, + "input_cost_per_token_above_272k_tokens": 0.000005, + "input_cost_per_token_flex": 0.00000125, + "input_cost_per_token_batches": 0.00000125, + "input_cost_per_token_priority": 0.000005, + "input_cost_per_token_above_272k_tokens_priority": 0.00001, + "litellm_provider": "openai", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "output_cost_per_token_above_272k_tokens": 0.0000225, + "output_cost_per_token_flex": 0.0000075, + "output_cost_per_token_batches": 0.0000075, + "output_cost_per_token_priority": 0.0000225, + "output_cost_per_token_above_272k_tokens_priority": 0.00003375, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_service_tier": true, + "supports_vision": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": true + }, + "gpt-5.4-mini": { + "cache_read_input_token_cost": 7.5e-8, + "cache_read_input_token_cost_flex": 1e-8, + "cache_read_input_token_cost_batches": 3.8e-8, + "input_cost_per_token": 7.5e-7, + "input_cost_per_token_flex": 3.75e-7, + "input_cost_per_token_batches": 3.75e-7, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.0000045, + "output_cost_per_token_flex": 0.00000225, + "output_cost_per_token_batches": 0.00000225, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_service_tier": true, + "supports_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, + "gpt-5.1-codex-max": { + "cache_read_input_token_cost": 1.25e-7, + "input_cost_per_token": 0.00000125, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "responses", + "output_cost_per_token": 0.00001, + "supported_endpoints": [ + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": false, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": true + }, + "gpt-5.1-codex-mini": { + "cache_read_input_token_cost": 2.5e-8, + "cache_read_input_token_cost_priority": 4.5e-8, + "input_cost_per_token": 2.5e-7, + "input_cost_per_token_priority": 4.5e-7, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "responses", + "output_cost_per_token": 0.000002, + "output_cost_per_token_priority": 0.0000036, + "supported_endpoints": [ + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": false, + "supports_xhigh_reasoning_effort": false, + "supports_minimal_reasoning_effort": true + }, + "gpt-5.2-codex": { + "cache_read_input_token_cost": 1.75e-7, + "cache_read_input_token_cost_priority": 3.5e-7, + "input_cost_per_token": 0.00000175, + "input_cost_per_token_priority": 0.0000035, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "responses", + "output_cost_per_token": 0.000014, + "output_cost_per_token_priority": 0.000028, + "supported_endpoints": [ + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": false, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": true + }, + "gpt-5.3-codex": { + "cache_read_input_token_cost": 1.75e-7, + "cache_read_input_token_cost_priority": 3.5e-7, + "input_cost_per_token": 0.00000175, + "input_cost_per_token_priority": 0.0000035, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "responses", + "output_cost_per_token": 0.000014, + "output_cost_per_token_priority": 0.000028, + "supported_endpoints": [ + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": false, + "supports_xhigh_reasoning_effort": false, + "supports_minimal_reasoning_effort": true }, "gradient_ai/anthropic-claude-3-opus": { "input_cost_per_token": 0.000015, @@ -2394,7 +2776,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true }, "jp.anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 0.000001375, @@ -2416,7 +2799,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true }, "openrouter/anthropic/claude-3-haiku": { "input_cost_per_image": 0.0004, @@ -2746,7 +3130,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true }, "us.anthropic.claude-3-5-sonnet-20240620-v1:0": { "input_cost_per_token": 0.000003, @@ -2904,7 +3289,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true }, "au.anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 0.000001375, @@ -2925,7 +3311,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true }, "us.anthropic.claude-opus-4-20250514-v1:0": { "cache_creation_input_token_cost": 0.00001875, @@ -2977,7 +3364,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "tool_use_system_prompt_tokens": 159, + "supports_native_structured_output": true }, "global.anthropic.claude-opus-4-5-20251101-v1:0": { "cache_creation_input_token_cost": 0.00000625, @@ -3003,7 +3391,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "tool_use_system_prompt_tokens": 159, + "supports_native_structured_output": true }, "eu.anthropic.claude-opus-4-5-20251101-v1:0": { "cache_creation_input_token_cost": 0.00000625, @@ -3029,7 +3418,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "tool_use_system_prompt_tokens": 159, + "supports_native_structured_output": true }, "us.anthropic.claude-sonnet-4-20250514-v1:0": { "cache_creation_input_token_cost": 0.00000375, @@ -3642,18 +4032,14 @@ }, "vertex_ai/claude-opus-4-6": { "cache_creation_input_token_cost": 0.00000625, - "cache_creation_input_token_cost_above_200k_tokens": 0.0000125, "cache_read_input_token_cost": 5e-7, - "cache_read_input_token_cost_above_200k_tokens": 0.000001, "input_cost_per_token": 0.000005, - "input_cost_per_token_above_200k_tokens": 0.00001, "litellm_provider": "vertex_ai-anthropic_models", "max_input_tokens": 1000000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 0.000025, - "output_cost_per_token_above_200k_tokens": 0.0000375, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, @@ -3672,18 +4058,14 @@ }, "vertex_ai/claude-opus-4-6@default": { "cache_creation_input_token_cost": 0.00000625, - "cache_creation_input_token_cost_above_200k_tokens": 0.0000125, "cache_read_input_token_cost": 5e-7, - "cache_read_input_token_cost_above_200k_tokens": 0.000001, "input_cost_per_token": 0.000005, - "input_cost_per_token_above_200k_tokens": 0.00001, "litellm_provider": "vertex_ai-anthropic_models", "max_input_tokens": 1000000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 0.000025, - "output_cost_per_token_above_200k_tokens": 0.0000375, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, @@ -3728,18 +4110,14 @@ }, "vertex_ai/claude-sonnet-4-6": { "cache_creation_input_token_cost": 0.00000375, - "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, "cache_read_input_token_cost": 3e-7, - "cache_read_input_token_cost_above_200k_tokens": 6e-7, "input_cost_per_token": 0.000003, - "input_cost_per_token_above_200k_tokens": 0.000006, "litellm_provider": "vertex_ai-anthropic_models", - "max_input_tokens": 200000, + "max_input_tokens": 1000000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 0.000015, - "output_cost_per_token_above_200k_tokens": 0.0000225, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -3871,18 +4249,14 @@ }, "vertex_ai/claude-sonnet-4-6@default": { "cache_creation_input_token_cost": 0.00000375, - "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, "cache_read_input_token_cost": 3e-7, - "cache_read_input_token_cost_above_200k_tokens": 6e-7, "input_cost_per_token": 0.000003, - "input_cost_per_token_above_200k_tokens": 0.000006, "litellm_provider": "vertex_ai-anthropic_models", - "max_input_tokens": 200000, + "max_input_tokens": 1000000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 0.000015, - "output_cost_per_token_above_200k_tokens": 0.0000225, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -3898,5 +4272,42 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 } + }, + "gpt-5.3-codex-spark": { + "cache_read_input_token_cost": 1.75e-7, + "cache_read_input_token_cost_priority": 3.5e-7, + "input_cost_per_token": 0.00000175, + "input_cost_per_token_priority": 0.0000035, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "responses", + "output_cost_per_token": 0.000014, + "output_cost_per_token_priority": 0.000028, + "supported_endpoints": [ + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": false, + "supports_xhigh_reasoning_effort": false, + "supports_minimal_reasoning_effort": true } } \ No newline at end of file diff --git a/scripts/fetch-pricing-data.ts b/scripts/fetch-pricing-data.ts index 703a0e21..7994d3a8 100644 --- a/scripts/fetch-pricing-data.ts +++ b/scripts/fetch-pricing-data.ts @@ -2,7 +2,7 @@ /** * Fetch latest model pricing from LiteLLM and save to renderer assets. - * Filters to Claude models only to reduce bundle size. + * Filters to the models this app currently exposes in the UI/runtime to reduce bundle size. * Runs automatically during prebuild. */ @@ -41,11 +41,28 @@ function isValidModelPricing(entry: unknown): entry is ModelPricing { ); } +const EXPLICIT_MODEL_ALLOWLIST = new Set([ + 'gpt-5.4', + 'gpt-5.4-mini', + 'gpt-5.3-codex', + 'gpt-5.2-codex', + 'gpt-5.2', + 'gpt-5.1-codex-max', + 'gpt-5.1-codex-mini', + 'gemini-2.5-pro', + 'gemini-2.5-flash', + 'gemini-2.5-flash-lite', +]); + function isClaudeModel(modelName: string): boolean { const lower = modelName.toLowerCase(); return lower.includes('claude'); } +function isIncludedModel(modelName: string): boolean { + return isClaudeModel(modelName) || EXPLICIT_MODEL_ALLOWLIST.has(modelName); +} + async function fetchPricingData(): Promise> { console.log('Fetching pricing data from LiteLLM...'); @@ -63,16 +80,22 @@ async function fetchPricingData(): Promise> { const data = (await response.json()) as Record; console.log(`Fetched pricing for ${Object.keys(data).length} models`); - // Filter to Claude models only and validate entries - const claudeModels: Record = {}; + // Filter to the models currently exposed by this app and validate entries. + const selectedModels: Record = {}; for (const [modelName, entry] of Object.entries(data)) { - if (isClaudeModel(modelName) && isValidModelPricing(entry)) { - claudeModels[modelName] = entry; + if (isIncludedModel(modelName) && isValidModelPricing(entry)) { + selectedModels[modelName] = entry; } } - console.log(`Filtered to ${Object.keys(claudeModels).length} Claude models`); - return claudeModels; + // LiteLLM currently publishes no priced top-level entry for gpt-5.3-codex-spark. + // Keep cost estimation non-zero by aliasing it to the closest published Codex tier. + if (!selectedModels['gpt-5.3-codex-spark'] && selectedModels['gpt-5.3-codex']) { + selectedModels['gpt-5.3-codex-spark'] = selectedModels['gpt-5.3-codex']; + } + + console.log(`Filtered to ${Object.keys(selectedModels).length} supported models`); + return selectedModels; } catch (error) { clearTimeout(timeout); if (error instanceof Error && error.name === 'AbortError') { diff --git a/src/shared/utils/pricing.ts b/src/shared/utils/pricing.ts index bf504472..816a34d8 100644 --- a/src/shared/utils/pricing.ts +++ b/src/shared/utils/pricing.ts @@ -22,6 +22,9 @@ export interface DisplayPricing { const TIER_THRESHOLD = 200_000; const PRICING_MAP = pricingData as Record; +const PRICING_ALIASES: Record = { + 'gpt-5.3-codex-spark': 'gpt-5.3-codex', +}; // Pre-compute lowercase key map for O(1) case-insensitive lookups const LOWERCASE_KEY_MAP = new Map(); @@ -55,6 +58,12 @@ export function getPricing(modelName: string): LiteLLMPricing | null { return tryGetPricing(originalKey); } + const alias = PRICING_ALIASES[lowerName]; + if (alias) { + const aliased = tryGetPricing(alias); + if (aliased) return aliased; + } + return null; } diff --git a/test/shared/utils/pricing.test.ts b/test/shared/utils/pricing.test.ts index b711e17c..f81b2909 100644 --- a/test/shared/utils/pricing.test.ts +++ b/test/shared/utils/pricing.test.ts @@ -20,6 +20,27 @@ describe('Shared Pricing Module', () => { expect(pricing).not.toBeNull(); }); + it('should find pricing for codex runtime models', () => { + const pricing = getPricing('gpt-5.1-codex-mini'); + expect(pricing).not.toBeNull(); + expect(pricing!.input_cost_per_token).toBeGreaterThan(0); + expect(pricing!.output_cost_per_token).toBeGreaterThan(0); + }); + + it('should alias codex spark pricing to the closest published codex tier', () => { + const sparkPricing = getPricing('gpt-5.3-codex-spark'); + const codexPricing = getPricing('gpt-5.3-codex'); + expect(sparkPricing).not.toBeNull(); + expect(sparkPricing).toEqual(codexPricing); + }); + + it('should find pricing for gemini runtime models', () => { + const pricing = getPricing('gemini-2.5-flash-lite'); + expect(pricing).not.toBeNull(); + expect(pricing!.input_cost_per_token).toBeGreaterThan(0); + expect(pricing!.output_cost_per_token).toBeGreaterThan(0); + }); + it('should return null for unknown models', () => { const pricing = getPricing('totally-fake-model-xyz'); expect(pricing).toBeNull(); From 074b614469d5ca71e56fd781b93218773b74a1bd Mon Sep 17 00:00:00 2001 From: iliya Date: Thu, 2 Apr 2026 15:15:32 +0300 Subject: [PATCH 04/67] feat: improve team launch model and status UX --- .../infrastructure/CliInstallerService.ts | 9 + .../services/team/TeamMcpConfigBuilder.ts | 34 ++- src/main/services/team/TeamMemberResolver.ts | 28 +- .../services/team/TeamProvisioningService.ts | 265 ++++++++++++++++-- src/renderer/api/httpClient.ts | 1 + .../components/dashboard/CliStatusBanner.tsx | 51 +++- .../settings/sections/CliStatusSection.tsx | 8 +- .../components/team/TeamDetailView.tsx | 53 ++-- src/renderer/components/team/TeamListView.tsx | 4 +- .../team/dialogs/CreateTeamDialog.tsx | 60 +++- .../team/dialogs/LaunchTeamDialog.tsx | 202 ++++++++----- .../team/dialogs/TeamModelSelector.tsx | 22 +- .../components/team/members/LeadModelRow.tsx | 70 ++--- .../team/members/MemberDraftRow.tsx | 92 ++++-- .../team/members/MembersEditorSection.tsx | 67 ++++- .../team/members/TeamRosterEditorSection.tsx | 3 + .../team/members/membersEditorTypes.ts | 1 + .../team/members/membersEditorUtils.ts | 32 +++ src/shared/types/cliInstaller.ts | 2 + test/renderer/store/cliInstallerSlice.test.ts | 2 + 20 files changed, 786 insertions(+), 220 deletions(-) diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index 70c9cfe2..0310ee44 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -433,6 +433,7 @@ export class CliInstallerService { latestVersion: null, updateAvailable: false, authLoggedIn: false, + authStatusChecking: true, authMethod: null, providers, }; @@ -497,6 +498,7 @@ export class CliInstallerService { if (binaryPath) { r.installed = true; r.binaryPath = binaryPath; + r.authStatusChecking = true; this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(r) }); try { @@ -522,6 +524,7 @@ export class CliInstallerService { ]); } else { // No binary — still check latest version for "install" prompt + r.authStatusChecking = false; if (r.supportsSelfUpdate) { await this.fetchLatestVersion(r); } @@ -541,6 +544,7 @@ export class CliInstallerService { diag: CliInstallerStatusRunDiag ): Promise { if (result.flavor === 'free-code') { + result.authStatusChecking = true; try { const providers = await this.multimodelBridgeService.getProviderStatuses( binaryPath, @@ -556,10 +560,12 @@ export class CliInstallerService { result.authLoggedIn = providers.some((provider) => provider.authenticated); result.authMethod = providers.find((provider) => provider.authenticated)?.authMethod ?? null; + result.authStatusChecking = false; this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(result) }); } catch (error) { const msg = getErrorMessage(error); diag.authLastError = msg; + result.authStatusChecking = false; logger.warn(`Provider status check failed for claude-multimodel: ${msg}`); } return; @@ -578,6 +584,7 @@ export class CliInstallerService { const auth = parseClaudeAuthStatusStdout(authStdout); result.authLoggedIn = auth.loggedIn === true; result.authMethod = auth.authMethod ?? null; + result.authStatusChecking = false; diag.authLastError = null; logger.info( `Auth status: loggedIn=${result.authLoggedIn}, method=${result.authMethod ?? 'null'}` + @@ -598,6 +605,7 @@ export class CliInstallerService { `Auth status check failed after ${AUTH_STATUS_MAX_RETRIES} attempts: ${msg}` ); result.authLoggedIn = false; + result.authStatusChecking = false; } } } @@ -621,6 +629,7 @@ export class CliInstallerService { if (timer) { clearTimeout(timer); } + result.authStatusChecking = false; diag.authTimedOut = hitAuthTimeout; } } diff --git a/src/main/services/team/TeamMcpConfigBuilder.ts b/src/main/services/team/TeamMcpConfigBuilder.ts index 2b96a824..0a15034d 100644 --- a/src/main/services/team/TeamMcpConfigBuilder.ts +++ b/src/main/services/team/TeamMcpConfigBuilder.ts @@ -69,6 +69,13 @@ function getSourceServerEntry(): string { return path.join(getWorkspaceMcpServerDir(), 'src', 'index.ts'); } +function getWorkspaceTsxBinCandidates(): string[] { + return [ + path.join(getWorkspaceMcpServerDir(), 'node_modules', '.bin', 'tsx'), + path.join(getWorkspaceRoot(), 'node_modules', '.bin', 'tsx'), + ]; +} + async function pathExists(targetPath: string): Promise { try { await fs.promises.access(targetPath, fs.constants.F_OK); @@ -211,17 +218,7 @@ async function resolveMcpLaunchSpec(): Promise { logger.warn(`Packaged MCP entry not found at ${packagedEntry}, falling back to workspace`); } - // 2. Dev mode — prefer source for hot changes - const sourceEntry = getSourceServerEntry(); - checked.push(sourceEntry); - if (await pathExists(sourceEntry)) { - return { - command: 'pnpm', - args: ['--dir', getWorkspaceMcpServerDir(), 'exec', 'tsx', sourceEntry], - }; - } - - // 3. Dev mode — built dist + // 2. Dev mode — prefer built dist for reliable direct execution const builtEntry = getBuiltServerEntry(); checked.push(builtEntry); if (await pathExists(builtEntry)) { @@ -231,6 +228,21 @@ async function resolveMcpLaunchSpec(): Promise { }; } + // 3. Dev mode fallback — run source directly through a local tsx binary + const sourceEntry = getSourceServerEntry(); + checked.push(sourceEntry); + if (await pathExists(sourceEntry)) { + for (const tsxBin of getWorkspaceTsxBinCandidates()) { + checked.push(tsxBin); + if (await pathExists(tsxBin)) { + return { + command: tsxBin, + args: [sourceEntry], + }; + } + } + } + throw new Error( `agent-teams-mcp entrypoint not found. Checked paths:\n${checked.map((p) => ` - ${p}`).join('\n')}` ); diff --git a/src/main/services/team/TeamMemberResolver.ts b/src/main/services/team/TeamMemberResolver.ts index 92832d0e..d2295643 100644 --- a/src/main/services/team/TeamMemberResolver.ts +++ b/src/main/services/team/TeamMemberResolver.ts @@ -8,6 +8,7 @@ import type { MemberStatus, ResolvedTeamMember, TeamConfig, + TeamMember, TeamTaskWithKanban, } from '@shared/types'; @@ -134,15 +135,26 @@ export class TeamMemberResolver { if (Array.isArray(config.members)) { for (const m of config.members) { if (typeof m?.name === 'string' && m.name.trim() !== '') { + const configMember = m as TeamMember & { provider?: 'anthropic' | 'codex' | 'gemini' }; + const providerId = + configMember.providerId === 'anthropic' || + configMember.providerId === 'codex' || + configMember.providerId === 'gemini' + ? configMember.providerId + : configMember.provider === 'anthropic' || + configMember.provider === 'codex' || + configMember.provider === 'gemini' + ? configMember.provider + : undefined; configMemberMap.set(m.name.trim(), { - agentType: m.agentType, - role: m.role, - workflow: m.workflow, - providerId: m.providerId, - model: m.model, - effort: m.effort, - color: m.color, - cwd: m.cwd, + agentType: configMember.agentType, + role: configMember.role, + workflow: configMember.workflow, + providerId, + model: configMember.model, + effort: configMember.effort, + color: configMember.color, + cwd: configMember.cwd, }); } } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 9841a248..6cb62dc6 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -295,6 +295,7 @@ interface ProvisioningRun { onProgress: (progress: TeamProvisioningProgress) => void; expectedMembers: string[]; request: TeamCreateRequest; + effectiveMembers: TeamCreateRequest['members']; lastLogProgressAt: number; /** Monotonic ms timestamp of last stdout/stderr data. For stall detection. */ lastDataReceivedAt: number; @@ -491,6 +492,45 @@ function formatWorkflowBlock(workflow: string, indent: string): string { return `\n${indent}---BEGIN WORKFLOW---\n${body}\n${indent}---END WORKFLOW---`; } +type TeamMemberInput = TeamCreateRequest['members'][number]; + +function normalizeTeamMemberProviderId(providerId: unknown): TeamProviderId | undefined { + return providerId === 'codex' || providerId === 'gemini' || providerId === 'anthropic' + ? providerId + : undefined; +} + +function buildEffectiveTeamMemberSpec( + member: TeamMemberInput, + defaults: { + providerId?: TeamProviderId; + model?: string; + effort?: TeamCreateRequest['effort']; + } +): TeamMemberInput { + const memberProviderId = normalizeTeamMemberProviderId(member.providerId); + const defaultProviderId = normalizeTeamMemberProviderId(defaults.providerId); + const model = member.model?.trim() || defaults.model?.trim() || undefined; + + return { + ...member, + providerId: memberProviderId ?? defaultProviderId ?? 'anthropic', + model, + effort: member.effort ?? defaults.effort, + }; +} + +function buildEffectiveTeamMemberSpecs( + members: TeamCreateRequest['members'], + defaults: { + providerId?: TeamProviderId; + model?: string; + effort?: TeamCreateRequest['effort']; + } +): TeamCreateRequest['members'] { + return members.map((member) => buildEffectiveTeamMemberSpec(member, defaults)); +} + function buildMembersPrompt(members: TeamCreateRequest['members']): string { return members .map((member) => { @@ -983,17 +1023,20 @@ function buildTaskBoardSnapshot(tasks: TeamTask[]): string { return `\nCurrent task board (in_progress/pending):\n${lines.join('\n')}\n`; } -function buildProvisioningPrompt(request: TeamCreateRequest): string { +function buildProvisioningPrompt( + request: TeamCreateRequest, + effectiveMembers: TeamCreateRequest['members'] +): string { const displayName = request.displayName?.trim() || request.teamName; const userPromptBlock = request.prompt?.trim() ? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n` : ''; const leadName = - request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead'; + effectiveMembers.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead'; const projectName = path.basename(request.cwd); - const isSolo = request.members.length === 0; + const isSolo = effectiveMembers.length === 0; const step3Block = isSolo ? `3) If user instructions describe work to be done — create tasks on the team board and assign each task to yourself (“${leadName}”) as owner.\n` + @@ -1036,14 +1079,15 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string { With member_briefing bootstrap enabled, the teammate will fetch durable rules after spawn. Per-member spawn instructions: -${request.members +${effectiveMembers .map((m) => { const providerLine = m.providerId && m.providerId !== 'anthropic' ? ` - provider: “${m.providerId}”\n` : ''; const modelLine = m.model?.trim() ? ` - model: “${m.model.trim()}”\n` : ''; + const effortLine = m.effort ? ` - effort: “${m.effort}”\n` : ''; return ` For “${m.name}”: - name: “${m.name}” -${providerLine}${modelLine} - prompt: +${providerLine}${modelLine}${effortLine} - prompt: ${buildMemberSpawnPrompt(m, displayName, request.teamName, leadName) .split('\n') .map((line) => ` ${line}`) @@ -1055,7 +1099,7 @@ ${buildMemberSpawnPrompt(m, displayName, request.teamName, leadName) teamName: request.teamName, leadName, isSolo, - members: request.members, + members: effectiveMembers, }); return `agent_teams_ui [Agent Team: “${request.teamName}” | Project: “${projectName}” | Lead: “${leadName}”] — team does NOT exist yet. You must create it. @@ -3051,6 +3095,14 @@ export class TeamProvisioningService { // Respawn with saved context — CLI handles its own auth refresh. let child: ReturnType; try { + if (mcpFlagIdx !== -1 && mcpFlagIdx + 1 < ctx.args.length) { + await this.validateAgentTeamsMcpRuntime( + ctx.claudePath, + ctx.cwd, + ctx.env, + ctx.args[mcpFlagIdx + 1]! + ); + } child = spawnCli(ctx.claudePath, ctx.args, { cwd: ctx.cwd, env: { ...ctx.env }, @@ -3292,6 +3344,11 @@ export class TeamProvisioningService { throw new Error('Claude CLI not found; install it or provide a valid path'); } + const effectiveMemberSpecs = buildEffectiveTeamMemberSpecs(request.members, { + providerId: request.providerId, + model: request.model, + effort: request.effort, + }); const runId = randomUUID(); const startedAt = nowIso(); const run: ProvisioningRun = { @@ -3318,6 +3375,7 @@ export class TeamProvisioningService { onProgress, expectedMembers: request.members.map((member) => member.name), request, + effectiveMembers: effectiveMemberSpecs, lastLogProgressAt: 0, lastDataReceivedAt: 0, // intentionally 0 — real reset happens after spawn (see startStallWatchdog call sites) lastStdoutReceivedAt: 0, @@ -3372,13 +3430,14 @@ export class TeamProvisioningService { this.provisioningRunByTeam.set(request.teamName, runId); run.onProgress(run.progress); - const prompt = buildProvisioningPrompt(request); + const prompt = buildProvisioningPrompt(request, effectiveMemberSpecs); let child: ReturnType; const { env: shellEnv } = await this.buildProvisioningEnv(request.providerId); let mcpConfigPath: string; try { mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd); run.mcpConfigPath = mcpConfigPath; + await this.validateAgentTeamsMcpRuntime(claudePath, request.cwd, shellEnv, mcpConfigPath); } catch (error) { this.runs.delete(runId); this.provisioningRunByTeam.delete(request.teamName); @@ -3718,12 +3777,20 @@ export class TeamProvisioningService { const runId = randomUUID(); const startedAt = nowIso(); + const effectiveMemberSpecs = buildEffectiveTeamMemberSpecs(expectedMemberSpecs, { + providerId: request.providerId, + model: request.model, + effort: request.effort, + }); + // Build a synthetic TeamCreateRequest for reuse by shared infrastructure const syntheticRequest: TeamCreateRequest = { teamName: request.teamName, members: expectedMemberSpecs, cwd: request.cwd, providerId: request.providerId, + model: request.model, + effort: request.effort, skipPermissions: request.skipPermissions, }; @@ -3764,6 +3831,7 @@ export class TeamProvisioningService { onProgress, expectedMembers, request: syntheticRequest, + effectiveMembers: effectiveMemberSpecs, lastLogProgressAt: 0, lastDataReceivedAt: 0, // intentionally 0 — real reset happens after spawn (see startStallWatchdog call sites) lastStdoutReceivedAt: 0, @@ -3837,7 +3905,7 @@ export class TeamProvisioningService { const prompt = buildLaunchPrompt( request, - expectedMemberSpecs, + effectiveMemberSpecs, existingTasks, Boolean(previousSessionId) ); @@ -3847,6 +3915,7 @@ export class TeamProvisioningService { try { mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd); run.mcpConfigPath = mcpConfigPath; + await this.validateAgentTeamsMcpRuntime(claudePath, request.cwd, shellEnv, mcpConfigPath); } catch (error) { this.runs.delete(runId); this.provisioningRunByTeam.delete(request.teamName); @@ -6862,7 +6931,13 @@ export class TeamProvisioningService { run.teamName, run.request.cwd, run.detectedSessionId, - run.request.color + run.request.color, + { + providerId: run.request.providerId, + model: run.request.model, + effort: run.request.effort, + members: run.effectiveMembers, + } ); await this.cleanupPrelaunchBackup(run.teamName); @@ -6996,7 +7071,13 @@ export class TeamProvisioningService { run.teamName, run.request.cwd, run.detectedSessionId, - run.request.color + run.request.color, + { + providerId: run.request.providerId, + model: run.request.model, + effort: run.request.effort, + members: run.effectiveMembers, + } ); // Clean up team.meta.json — provisioning succeeded, config.json is now authoritative. @@ -7789,7 +7870,13 @@ export class TeamProvisioningService { run.teamName, run.request.cwd, run.detectedSessionId, - run.request.color + run.request.color, + { + providerId: run.request.providerId, + model: run.request.model, + effort: run.request.effort, + members: run.effectiveMembers, + } ); // Process was killed by timeout — mark as disconnected, not ready const progress = updateProgress(run, 'disconnected', 'Team provisioned but process timed out', { @@ -7963,6 +8050,96 @@ export class TeamProvisioningService { } } + private applyEffectiveLaunchStateToConfig( + config: Record, + launchState?: { + providerId?: TeamProviderId; + model?: string; + effort?: TeamCreateRequest['effort']; + members?: TeamCreateRequest['members']; + } + ): void { + if (!launchState || !Array.isArray(config.members)) { + return; + } + + const effectiveLeadProviderId = + normalizeTeamMemberProviderId(launchState.providerId) ?? 'anthropic'; + const effectiveLeadModel = launchState.model?.trim() || undefined; + const effectiveLeadEffort = + launchState.effort === 'low' || + launchState.effort === 'medium' || + launchState.effort === 'high' + ? launchState.effort + : undefined; + + const membersByName = new Map( + (launchState.members ?? []).map((member) => [member.name.toLowerCase(), member] as const) + ); + + config.members = (config.members as Record[]).map((member) => { + if (!member || typeof member !== 'object') { + return member; + } + + const rawName = typeof member.name === 'string' ? member.name.trim() : ''; + const nextMember = { ...member }; + + const assignRuntimeState = (state: { + providerId?: TeamProviderId; + model?: string; + effort?: TeamCreateRequest['effort']; + }): void => { + const providerId = normalizeTeamMemberProviderId(state.providerId); + if (providerId) { + nextMember.provider = providerId; + nextMember.providerId = providerId; + } else { + delete nextMember.provider; + delete nextMember.providerId; + } + + const model = state.model?.trim() || undefined; + if (model) { + nextMember.model = model; + } else { + delete nextMember.model; + } + + const effort = + state.effort === 'low' || state.effort === 'medium' || state.effort === 'high' + ? state.effort + : undefined; + if (effort) { + nextMember.effort = effort; + } else { + delete nextMember.effort; + } + }; + + if (isLeadMember(nextMember) || rawName.toLowerCase() === 'team-lead') { + assignRuntimeState({ + providerId: effectiveLeadProviderId, + model: effectiveLeadModel, + effort: effectiveLeadEffort, + }); + return nextMember; + } + + const effectiveMember = membersByName.get(rawName.toLowerCase()); + if (!effectiveMember) { + return nextMember; + } + + assignRuntimeState({ + providerId: effectiveMember.providerId, + model: effectiveMember.model, + effort: effectiveMember.effort, + }); + return nextMember; + }); + } + /** * Single atomic read-mutate-write for post-launch config updates. * Combines session history append and projectPath update to avoid @@ -7972,7 +8149,13 @@ export class TeamProvisioningService { teamName: string, projectPath: string, detectedSessionId: string | null, - color?: string + color?: string, + launchState?: { + providerId?: TeamProviderId; + model?: string; + effort?: TeamCreateRequest['effort']; + members?: TeamCreateRequest['members']; + } ): Promise { const MAX_SESSION_HISTORY = 5000; const MAX_PROJECT_PATH_HISTORY = 500; @@ -8049,6 +8232,8 @@ export class TeamProvisioningService { : pathHistory; } + this.applyEffectiveLaunchStateToConfig(config, launchState); + await atomicWriteAsync(configPath, JSON.stringify(config, null, 2)); } catch (error) { logger.warn( @@ -8751,6 +8936,7 @@ export class TeamProvisioningService { role?: string; workflow?: string; agentType?: string; + providerId?: string; provider?: string; model?: string; effort?: string; @@ -8771,10 +8957,7 @@ export class TeamProvisioningService { role: typeof member.role === 'string' ? member.role.trim() || undefined : undefined, workflow: typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined, - providerId: - member.provider === 'codex' || member.provider === 'gemini' - ? member.provider - : undefined, + providerId: normalizeTeamMemberProviderId(member.providerId ?? member.provider), model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined, effort: member.effort === 'low' || member.effort === 'medium' || member.effort === 'high' @@ -8985,6 +9168,56 @@ export class TeamProvisioningService { return output; } + private buildAgentTeamsMcpValidationError(output: string): string { + const detail = this.normalizeApiRetryErrorMessage(output) || output.trim(); + if (!detail) { + return ( + 'agent-teams MCP loaded config but did not expose member_briefing. ' + + 'The leader would start without required team MCP tools.' + ); + } + return ( + 'agent-teams MCP loaded config but did not expose member_briefing. ' + `Details: ${detail}` + ); + } + + private async validateAgentTeamsMcpRuntime( + claudePath: string, + cwd: string, + env: NodeJS.ProcessEnv, + mcpConfigPath: string + ): Promise { + const result = await this.spawnProbe( + claudePath, + [ + '--setting-sources', + 'user,project,local', + '--mcp-config', + mcpConfigPath, + '--', + 'mcp', + 'get', + 'agent-teams', + ], + cwd, + env, + VERIFY_TIMEOUT_MS + ); + + const combinedOutput = buildCombinedLogs(result.stdout, result.stderr).trim(); + if (result.exitCode !== 0) { + throw new Error(this.buildAgentTeamsMcpValidationError(combinedOutput)); + } + + const normalizedOutput = combinedOutput.toLowerCase(); + if ( + !normalizedOutput.includes('status: ✓ connected') && + !normalizedOutput.includes('status: connected') + ) { + throw new Error(this.buildAgentTeamsMcpValidationError(combinedOutput)); + } + } + private async spawnProbe( claudePath: string, args: string[], diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 0c74c587..5b14ca3c 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -1072,6 +1072,7 @@ export class HttpAPIClient implements ElectronAPI { latestVersion: null, updateAvailable: false, authLoggedIn: false, + authStatusChecking: false, authMethod: null, providers: [], }), diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index 23ee9564..fa2256ac 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -125,8 +125,10 @@ const SLOW_CHECK_DELAY_MS = 5_000; const CliCheckingSpinner = ({ styles, + label, }: { styles: { border: string; bg: string }; + label: string; }): React.JSX.Element => { const [showHint, setShowHint] = useState(false); @@ -146,7 +148,7 @@ const CliCheckingSpinner = ({ />
    - Checking AI Providers... + {label} {showHint && (

    @@ -309,6 +311,10 @@ function formatRuntimeAuthSummary( return `Providers: ${connected}/${denominator} connected`; } + if (cliStatus.authStatusChecking) { + return 'Checking authentication...'; + } + if (cliStatus.authLoggedIn) { return 'Authenticated'; } @@ -316,6 +322,18 @@ function formatRuntimeAuthSummary( return null; } +function isCheckingMultimodelStatus( + cliStatus: NonNullable['cliStatus']> +): boolean { + return ( + cliStatus.flavor === 'free-code' && + cliStatus.providers.length > 0 && + cliStatus.providers.every( + (provider) => provider.statusMessage === 'Checking...' && !provider.authenticated + ) + ); +} + function createLoadingMultimodelStatus(): CliInstallationStatus { const providers: CliProviderStatus[] = [ { providerId: 'anthropic' as const, displayName: 'Anthropic' }, @@ -349,6 +367,7 @@ function createLoadingMultimodelStatus(): CliInstallationStatus { latestVersion: null, updateAvailable: false, authLoggedIn: false, + authStatusChecking: true, authMethod: null, providers, }; @@ -643,6 +662,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => { async (enabled: boolean) => { setIsSwitchingFlavor(true); try { + useStore.setState({ + cliStatus: enabled ? createLoadingMultimodelStatus() : null, + cliStatusLoading: true, + cliStatusError: null, + }); await updateConfig('general', { multimodelEnabled: enabled }); await invalidateCliStatus(); await fetchCliStatus(); @@ -695,6 +719,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => { if (installerState === 'completed') return 'success'; if (installerState !== 'idle') return 'info'; if (!cliStatus) return 'loading'; + if (isCheckingMultimodelStatus(cliStatus)) return 'info'; + if (cliStatus.authStatusChecking) return 'info'; if (!cliStatus.installed) return 'error'; if (cliStatus.installed && !cliStatus.authLoggedIn) return 'warning'; if (cliStatus.updateAvailable) return 'info'; @@ -785,7 +811,12 @@ export const CliStatusBanner = (): React.JSX.Element | null => { } // Claude-only mode: keep the generic loading spinner. - return ; + return ( + + ); } // ── Downloading ──────────────────────────────────────────────────────── @@ -937,8 +968,12 @@ export const CliStatusBanner = (): React.JSX.Element | null => { } // Installed but not logged in — yellow warning banner - if (cliStatus.installed && cliStatus.flavor !== 'free-code' && !cliStatus.authLoggedIn) { - if (isVerifyingAuth) { + if ( + cliStatus.installed && + cliStatus.flavor !== 'free-code' && + (cliStatus.authStatusChecking || isVerifyingAuth) + ) { + if (cliStatus.authStatusChecking || isVerifyingAuth) { return (

    {
    ); } + } + + if ( + cliStatus.installed && + cliStatus.flavor !== 'free-code' && + !cliStatus.authStatusChecking && + !cliStatus.authLoggedIn + ) { return ( <>
    ({ ...provider, @@ -223,6 +224,11 @@ export const CliStatusSection = (): React.JSX.Element | null => { async (enabled: boolean) => { setIsSwitchingFlavor(true); try { + useStore.setState({ + cliStatus: enabled ? createLoadingMultimodelStatus() : null, + cliStatusLoading: true, + cliStatusError: null, + }); await updateConfig('general', { multimodelEnabled: enabled }); await invalidateCliStatus(); await fetchCliStatus(); @@ -261,7 +267,7 @@ export const CliStatusSection = (): React.JSX.Element | null => { style={{ color: 'var(--color-text-muted)' }} > - Checking AI Providers... + {multimodelEnabled ? 'Checking AI Providers...' : 'Checking Claude CLI...'}
    )} diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 89369941..7579e4d6 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -1136,30 +1136,35 @@ export const TeamDetailView = ({ const teamSummary = teams.find((t) => t.teamName === teamName); return ( <> -
    -
    -

    Team not launched yet

    -

    - This is a draft team — {teamSummary?.displayName || teamName} has - been configured with {teamSummary?.memberCount ?? 0} member - {teamSummary?.memberCount === 1 ? '' : 's'} but hasn't been provisioned by CLI - yet. Click Launch to select a model and start the team. -

    -
    - - +
    +
    + +
    +
    +
    +

    Team not launched yet

    +

    + This is a draft team — {teamSummary?.displayName || teamName} has + been configured with {teamSummary?.memberCount ?? 0} member + {teamSummary?.memberCount === 1 ? '' : 's'} but hasn't been provisioned by CLI + yet. Click Launch to select a model and start the team. +

    +
    + + +
    diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index 4f10d048..b7d14a65 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -592,14 +592,14 @@ export const TeamListView = (): React.JSX.Element => { setLaunchingTeamName(request.teamName); try { await launchTeam(request); - openTeamTab(request.teamName, request.cwd); } catch (err) { console.error('Failed to launch team:', err); + throw err; } finally { setLaunchingTeamName(null); } }, - [launchTeam, openTeamTab] + [launchTeam] ); useEffect(() => { diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index a6689fb0..faf44f48 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -7,6 +7,8 @@ import { buildMembersFromDrafts, clearMemberModelOverrides, createMemberDraft, + normalizeMemberDraftForProviderMode, + normalizeProviderForMode, validateMemberNameInline, } from '@renderer/components/team/members/MembersEditorSection'; import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection'; @@ -33,6 +35,7 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { useTheme } from '@renderer/hooks/useTheme'; import { cn } from '@renderer/lib/utils'; +import { useStore } from '@renderer/store'; import { normalizePath } from '@renderer/utils/pathNormalize'; import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react'; @@ -266,6 +269,7 @@ export const CreateTeamDialog = ({ onOpenTeam, }: CreateTeamDialogProps): React.JSX.Element => { const { isLight } = useTheme(); + const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true); // ── Persisted draft state (survives tab navigation) ────────────────── const { @@ -362,13 +366,14 @@ export const CreateTeamDialog = ({ }; const setSelectedProviderId = (value: TeamProviderId): void => { - setSelectedProviderIdRaw(value); - localStorage.setItem('team:lastSelectedProvider', value); - if (value !== 'anthropic') { + const normalizedValue = normalizeProviderForMode(value, multimodelEnabled); + setSelectedProviderIdRaw(normalizedValue); + localStorage.setItem('team:lastSelectedProvider', normalizedValue); + if (normalizedValue !== 'anthropic') { setLimitContextRaw(false); localStorage.setItem('team:lastLimitContext', 'false'); } - setSelectedModelRaw(getStoredTeamModel(value)); + setSelectedModelRaw(getStoredTeamModel(normalizedValue)); }; const setLimitContext = (value: boolean): void => { @@ -444,7 +449,10 @@ export const CreateTeamDialog = ({ [members, syncModelsWithLead] ); - const selectedMemberProviders = useMemo(() => { + const selectedMemberProviders = useMemo(() => { + if (!multimodelEnabled) { + return ['anthropic']; + } if (soloTeam || syncModelsWithLead) { return [selectedProviderId]; } @@ -456,7 +464,22 @@ export const CreateTeamDialog = ({ ), ]) ); - }, [members, selectedProviderId, soloTeam, syncModelsWithLead]); + }, [members, multimodelEnabled, selectedProviderId, soloTeam, syncModelsWithLead]); + + useEffect(() => { + if (multimodelEnabled) { + return; + } + if (selectedProviderId !== 'anthropic') { + setSelectedProviderIdRaw('anthropic'); + setSelectedModelRaw(getStoredTeamModel('anthropic')); + } + const nextMembers = members.map((member) => normalizeMemberDraftForProviderMode(member, false)); + const changed = nextMembers.some((member, index) => member !== members[index]); + if (changed) { + setMembers(nextMembers); + } + }, [members, multimodelEnabled, selectedProviderId, setMembers]); useEffect(() => { if (!open || !canCreate || !launchTeam) { @@ -631,15 +654,22 @@ export const CreateTeamDialog = ({ const presetRoles: readonly string[] = PRESET_ROLES; const isPreset = m.role != null && presetRoles.includes(m.role); const isCustom = m.role != null && m.role.length > 0 && !isPreset; - return createMemberDraft({ - name: m.name, - roleSelection: isCustom ? CUSTOM_ROLE : (m.role ?? ''), - customRole: isCustom ? m.role : '', - workflow: m.workflow, - providerId: m.providerId, - model: m.model ?? '', - effort: m.effort, - }); + return normalizeMemberDraftForProviderMode( + createMemberDraft({ + name: m.name, + 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 ?? '') + : '', + effort: m.effort, + }), + multimodelEnabled + ); }) ); setSyncModelsWithLead( diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index ed22b46b..978b1658 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -7,9 +7,12 @@ import { buildMembersFromDrafts, clearMemberModelOverrides, createMemberDraftsFromInputs, + normalizeMemberDraftForProviderMode, + normalizeProviderForMode, validateMemberNameInline, } from '@renderer/components/team/members/MembersEditorSection'; import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection'; +import { TeamProvisioningBanner } from '@renderer/components/team/TeamProvisioningBanner'; import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox'; import { Button } from '@renderer/components/ui/button'; import { Checkbox } from '@renderer/components/ui/checkbox'; @@ -33,6 +36,10 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; +import { + getCurrentProvisioningProgressForTeam, + isTeamProvisioningActive, +} from '@renderer/store/slices/teamSlice'; import { normalizePath } from '@renderer/utils/pathNormalize'; import { nameColorSet } from '@renderer/utils/projectColor'; import { @@ -155,6 +162,7 @@ function getProviderLabel(providerId: TeamProviderId): string { export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Element => { const { open, onClose } = props; const { isLight } = useTheme(); + const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true); const isLaunch = props.mode === 'launch'; const isSchedule = props.mode === 'schedule'; const schedule = isSchedule ? (props.schedule ?? null) : null; @@ -261,21 +269,42 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen () => (syncModelsWithLead ? membersDrafts.map(clearMemberModelOverrides) : membersDrafts), [membersDrafts, syncModelsWithLead] ); - const selectedMemberProviders = useMemo( + const selectedMemberProviders = useMemo( () => - Array.from( - new Set([ - selectedProviderId, - ...effectiveMemberDrafts.flatMap((member) => - member.providerId === 'codex' || member.providerId === 'gemini' - ? [member.providerId] - : [] + !multimodelEnabled + ? ['anthropic'] + : Array.from( + new Set([ + selectedProviderId, + ...effectiveMemberDrafts.flatMap((member) => + member.providerId === 'codex' || member.providerId === 'gemini' + ? [member.providerId] + : [] + ), + ]) ), - ]) - ), - [effectiveMemberDrafts, selectedProviderId] + [effectiveMemberDrafts, multimodelEnabled, selectedProviderId] ); + useEffect(() => { + if (multimodelEnabled) { + return; + } + if (selectedProviderId !== 'anthropic') { + setSelectedProviderIdRaw('anthropic'); + setSelectedModelRaw(getStoredTeamModel('anthropic')); + } + setMembersDrafts((prev) => { + let changed = false; + const next = prev.map((member) => { + const normalized = normalizeMemberDraftForProviderMode(member, false); + if (normalized !== member) changed = true; + return normalized; + }); + return changed ? next : prev; + }); + }, [multimodelEnabled, selectedProviderId]); + // Schedule store actions const createSchedule = useStore((s) => s.createSchedule); const updateSchedule = useStore((s) => s.updateSchedule); @@ -302,13 +331,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen }; const setSelectedProviderId = (value: TeamProviderId): void => { - setSelectedProviderIdRaw(value); - localStorage.setItem('team:lastSelectedProvider', value); - if (value !== 'anthropic') { + const normalizedValue = normalizeProviderForMode(value, multimodelEnabled); + setSelectedProviderIdRaw(normalizedValue); + localStorage.setItem('team:lastSelectedProvider', normalizedValue); + if (normalizedValue !== 'anthropic') { setLimitContextRaw(false); localStorage.setItem('team:lastLimitContext', 'false'); } - setSelectedModelRaw(getStoredTeamModel(value)); + setSelectedModelRaw(getStoredTeamModel(normalizedValue)); }; const setSelectedModel = (value: string): void => { @@ -401,8 +431,16 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen promptDraft.setValue(schedule.launchConfig.prompt); setCustomCwd(schedule.launchConfig.cwd); setCwdMode('custom'); - setSelectedProviderIdRaw(schedule.launchConfig.providerId ?? 'anthropic'); - setSelectedModelRaw(schedule.launchConfig.model ?? ''); + const scheduleProviderId = normalizeProviderForMode( + schedule.launchConfig.providerId, + multimodelEnabled + ); + setSelectedProviderIdRaw(scheduleProviderId); + setSelectedModelRaw( + scheduleProviderId === normalizeProviderForMode(schedule.launchConfig.providerId, true) + ? (schedule.launchConfig.model ?? '') + : getStoredTeamModel('anthropic') + ); setSkipPermissionsRaw(schedule.launchConfig.skipPermissions !== false); setSelectedEffortRaw(schedule.launchConfig.effort ?? ''); } else { @@ -417,8 +455,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen setCwdMode('project'); setSelectedProjectPath(''); setCustomCwd(''); - setSelectedProviderIdRaw(getStoredTeamProvider()); - setSelectedModelRaw(getStoredTeamModel(getStoredTeamProvider())); + const storedProviderId = normalizeProviderForMode(getStoredTeamProvider(), multimodelEnabled); + setSelectedProviderIdRaw(storedProviderId); + setSelectedModelRaw(getStoredTeamModel(storedProviderId)); setSelectedEffortRaw('medium'); } @@ -442,10 +481,11 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen } if (cancelled) return; - const nextProviderId = + const rawNextProviderId = savedRequest?.providerId === 'codex' || savedRequest?.providerId === 'gemini' ? savedRequest.providerId : 'anthropic'; + const nextProviderId = normalizeProviderForMode(rawNextProviderId, multimodelEnabled); const providerFromSaved = Boolean(savedRequest?.providerId); const nextMembersSource = members.length > 0 @@ -455,15 +495,28 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen : []; const storedEffort = localStorage.getItem('team:lastSelectedEffort'); - setMembersDrafts(createMemberDraftsFromInputs(nextMembersSource)); + setMembersDrafts( + createMemberDraftsFromInputs(nextMembersSource).map((member) => + normalizeMemberDraftForProviderMode(member, multimodelEnabled) + ) + ); setSyncModelsWithLead( !nextMembersSource.some((member) => member.providerId || member.model || member.effort) ); - setSelectedProviderIdRaw(providerFromSaved ? nextProviderId : getStoredTeamProvider()); + setSelectedProviderIdRaw( + providerFromSaved + ? nextProviderId + : normalizeProviderForMode(getStoredTeamProvider(), multimodelEnabled) + ); setSelectedModelRaw( - typeof savedRequest?.model === 'string' + typeof savedRequest?.model === 'string' && + nextProviderId === normalizeProviderForMode(rawNextProviderId, true) ? savedRequest.model - : getStoredTeamModel(providerFromSaved ? nextProviderId : getStoredTeamProvider()) + : getStoredTeamModel( + providerFromSaved + ? nextProviderId + : normalizeProviderForMode(getStoredTeamProvider(), multimodelEnabled) + ) ); setSelectedEffortRaw( savedRequest?.effort ?? (storedEffort === null ? 'medium' : storedEffort) @@ -481,7 +534,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen return () => { cancelled = true; }; - }, [open, isLaunch, effectiveTeamName, members]); + }, [open, isLaunch, effectiveTeamName, members, multimodelEnabled]); // --------------------------------------------------------------------------- // Launch-only effects @@ -773,6 +826,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const provisioningError = isLaunch ? props.provisioningError : null; const activeError = localError ?? provisioningError; + const currentProvisioning = useStore((s) => + isLaunch && effectiveTeamName + ? getCurrentProvisioningProgressForTeam(s, effectiveTeamName) + : null + ); + const launchInFlight = useStore((s) => + isLaunch && effectiveTeamName ? isTeamProvisioningActive(s, effectiveTeamName) : false + ); // --------------------------------------------------------------------------- // Submit @@ -827,8 +888,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined, extraCliArgs: customArgs.trim() || undefined, }); - resetFormState(); - onClose(); } else { // Schedule mode: create or update const parsedBudget = maxBudgetUsd ? parseFloat(maxBudgetUsd) : undefined; @@ -883,7 +942,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen // --------------------------------------------------------------------------- const isDisabled = isLaunch - ? isSubmitting || prepareState !== 'ready' + ? isSubmitting || launchInFlight : isSubmitting || validationErrors.length > 0; // --------------------------------------------------------------------------- @@ -905,7 +964,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen 'Schedule automatic Claude task execution' ); - const submitLabel = isLaunch ? 'Launch' : isEditing ? 'Save Changes' : 'Create Schedule'; + const submitLabel = isLaunch + ? prepareState === 'idle' || prepareState === 'loading' + ? 'Skip and Launch' + : 'Launch' + : isEditing + ? 'Save Changes' + : 'Create Schedule'; const submittingLabel = isLaunch ? 'Launching...' : isEditing ? 'Saving...' : 'Creating...'; @@ -1160,38 +1225,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen projectsError={projectsError} /> - {isLaunch ? ( - - ) : null} - {/* ═══════════════════════════════════════════════════════════════════ Launch: optional settings Schedule: prompt + execution defaults @@ -1203,6 +1236,38 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen summary={launchOptionalSummary} >
    + +
    ) : null} + {isLaunch && effectiveTeamName && (currentProvisioning || provisioningError) ? ( +
    + +
    + ) : null} + {/* Launch-only: CLI warm-up status */} {isLaunch ? ( @@ -1405,13 +1476,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen

    Pre-flight check to catch errors before launch -

    @@ -1462,7 +1526,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen disabled={isDisabled} onClick={handleSubmit} > - {isSubmitting ? ( + {isSubmitting || launchInFlight ? ( <> {submittingLabel} diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx index c1003bcb..654c4385 100644 --- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx +++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx @@ -160,7 +160,8 @@ export const TeamModelSelector: React.FC = ({ id, }) => { const cliStatus = useStore((s) => s.cliStatus); - const multimodelAvailable = cliStatus?.flavor === 'free-code'; + const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true); + const multimodelAvailable = multimodelEnabled || cliStatus?.flavor === 'free-code'; const [dropdownOpen, setDropdownOpen] = useState(false); const containerRef = useRef(null); @@ -180,6 +181,12 @@ export const TeamModelSelector: React.FC = ({ const activeProvider = PROVIDERS.find((provider) => provider.id === providerId) ?? PROVIDERS[0]; const ProviderIcon = activeProvider.icon; + const defaultModelTooltip = useMemo(() => { + if (providerId === 'anthropic') { + return 'Default model from Claude CLI (/model).\nUses the runtime default for the selected provider.'; + } + return 'Uses the runtime default for the selected provider.'; + }, [providerId]); const isProviderSelectable = (candidateProviderId: string): boolean => multimodelAvailable || candidateProviderId === 'anthropic'; const activeProviderSelectable = isProviderSelectable(providerId); @@ -192,7 +199,7 @@ export const TeamModelSelector: React.FC = ({ : providerId === 'gemini' ? GEMINI_MODEL_OPTIONS : ANTHROPIC_MODEL_OPTIONS; - if (runtimeModels.length === 0) { + if (providerId === 'anthropic' || runtimeModels.length === 0) { return [...fallback]; } const dynamicOptions = runtimeModels.map((model) => ({ @@ -310,7 +317,7 @@ export const TeamModelSelector: React.FC = ({ )}
    {modelOptions.map((opt) => ( @@ -342,9 +349,12 @@ export const TeamModelSelector: React.FC = ({ - Default model from Claude CLI (/model). -
    - Uses the runtime default for the selected provider. + {defaultModelTooltip.split('\n').map((line, index) => ( + + {index > 0 ?
    : null} + {line} +
    + ))}
    diff --git a/src/renderer/components/team/members/LeadModelRow.tsx b/src/renderer/components/team/members/LeadModelRow.tsx index 3321f7cc..2bc85aea 100644 --- a/src/renderer/components/team/members/LeadModelRow.tsx +++ b/src/renderer/components/team/members/LeadModelRow.tsx @@ -49,7 +49,7 @@ export const LeadModelRow = ({ return (