feat(codex): add app-server account management and polish native UX

This commit is contained in:
777genius 2026-04-20 20:18:21 +03:00
parent 8093201b78
commit 5683973c04
114 changed files with 17003 additions and 609 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,303 @@
# Codex App-Server Account Feature - Signoff Evidence
Date: 2026-04-20
Worktree:
- `/Users/belief/dev/projects/claude/claude_team_codex_native_runtime_plan`
Branch:
- `spike/codex-native-runtime-plan`
Related plan:
- [codex-app-server-account-feature-plan.md](./codex-app-server-account-feature-plan.md)
## Scope
This signoff covers the app-server-backed Codex account feature work implemented in this repo:
- shared Codex app-server transport extraction
- `codex-account` feature slice
- Codex `preferredAuthMode` config migration and validation
- renderer/runtime integration for managed ChatGPT account plus API key truth
- per-launch `forced_login_method` overrides for native Codex execution
- lazy rate-limits support
- login lifecycle wiring in the real UI path
## Automated Verification
### Targeted tests
Command:
```bash
pnpm vitest run \
test/features/codex-account/core/evaluateCodexLaunchReadiness.test.ts \
test/features/codex-account/main/CodexAccountEnvBuilder.test.ts \
test/features/codex-account/main/createCodexAccountFeature.test.ts \
test/features/codex-account/main/CodexLoginSessionManager.test.ts \
test/features/codex-account/preload/createCodexAccountBridge.test.ts \
test/features/codex-account/renderer/useCodexAccountSnapshot.test.ts \
test/main/services/runtime/providerAwareCliEnv.test.ts \
test/main/services/runtime/ProviderConnectionService.test.ts \
test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts \
test/main/services/schedule/ScheduledTaskExecutor.test.ts \
test/main/services/team/TeamProvisioningServicePrepare.test.ts \
test/main/services/team/TeamProvisioningServicePrompts.test.ts \
test/main/services/infrastructure/ConfigManager.codexMigration.test.ts \
test/renderer/api/httpClient.codexAccount.test.ts \
test/renderer/api/httpClient.exactTaskLogs.test.ts \
test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts \
test/renderer/components/runtime/providerConnectionUi.test.ts \
test/renderer/components/cli/CliStatusVisibility.test.ts \
test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts \
test/main/ipc/configValidation.test.ts \
test/features/recent-projects/main/infrastructure/CodexAppServerClient.test.ts \
test/features/recent-projects/main/adapters/output/CodexRecentProjectsSourceAdapter.test.ts \
test/features/recent-projects/core/application/ListDashboardRecentProjectsUseCase.test.ts \
test/features/recent-projects/contracts/normalizeDashboardRecentProjectsPayload.test.ts \
test/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.test.ts
```
Result:
- `25` test files passed
- `204` tests passed
### Typecheck
Command:
```bash
pnpm exec tsc -p tsconfig.json --noEmit
```
Result:
- passed
### Targeted lint
Command:
```bash
pnpm exec eslint \
src/main/services/infrastructure/ConfigManager.ts \
src/main/services/runtime/ProviderConnectionService.ts \
src/main/services/runtime/providerAwareCliEnv.ts \
src/main/services/schedule/ScheduledTaskExecutor.ts \
src/features/codex-account/preload/createCodexAccountBridge.ts \
src/features/codex-account/renderer/hooks/useCodexAccountSnapshot.ts \
src/renderer/api/httpClient.ts \
src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx \
test/main/services/infrastructure/ConfigManager.codexMigration.test.ts \
test/features/codex-account/preload/createCodexAccountBridge.test.ts \
test/features/codex-account/renderer/useCodexAccountSnapshot.test.ts \
test/main/services/runtime/ProviderConnectionService.test.ts \
test/main/services/runtime/providerAwareCliEnv.test.ts \
test/renderer/api/httpClient.codexAccount.test.ts \
test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts
```
Result:
- passed
## Live Read-Only Signoff
### 1. Real `codex app-server account/read`
Probe result:
```json
{
"account": {
"type": "chatgpt",
"email": "quantjumppro@gmail.com",
"planType": "pro"
},
"requiresOpenaiAuth": true
}
```
What this proves:
- installed Codex binary supports the stable app-server initialize flow used by the extracted transport
- ChatGPT account autodetect works on the real machine
- managed account truth is available without touching legacy transport
### 2. Real `codex app-server account/rateLimits/read`
Probe result summary:
```json
{
"rateLimits": {
"limitId": "codex",
"primary": {
"usedPercent": 77,
"windowDurationMins": 300
},
"secondary": {
"usedPercent": 45,
"windowDurationMins": 10080
},
"credits": {
"hasCredits": false,
"unlimited": false,
"balance": "0"
},
"planType": "pro"
}
}
```
What this proves:
- live rate-limit payload shape matches the feature assumptions
- plan/rate-limit surface can be driven from the real app-server contract
### 3. Real feature-facade snapshot
Command path:
- `createCodexAccountFeature(...).refreshSnapshot({ includeRateLimits: true })`
Observed summary:
```json
{
"preferredAuthMode": "chatgpt",
"effectiveAuthMode": "chatgpt",
"appServerState": "healthy",
"managedAccount": {
"type": "chatgpt",
"email": "quantjumppro@gmail.com",
"planType": "pro"
},
"apiKey": {
"available": true,
"source": "environment",
"sourceLabel": "Detected from OPENAI_API_KEY"
},
"launchAllowed": true,
"launchReadinessState": "ready_chatgpt",
"planType": "pro",
"rateLimitPrimaryUsedPercent": 77
}
```
What this proves:
- the real feature composition builds the expected snapshot
- app-server truth, API-key availability merge, readiness evaluation, and rate-limit shaping all work together
### 4. Live preference-resolution checks through the feature facade
Observed summary:
```json
{
"preferredAuthMode": "auto",
"effectiveAuthMode": "chatgpt",
"launchAllowed": true,
"launchReadinessState": "ready_both",
"managedAccountType": "chatgpt",
"apiKeyAvailable": true
}
```
```json
{
"preferredAuthMode": "api_key",
"effectiveAuthMode": "api_key",
"launchAllowed": true,
"launchReadinessState": "ready_api_key",
"managedAccountType": "chatgpt",
"apiKeyAvailable": true
}
```
What this proves:
- `auto` mode prefers ChatGPT when both auth sources exist
- `api_key` preference still resolves correctly even when a managed account is also present
### 5. Live execution env sanitization check through `ProviderConnectionService`
With a connected managed-account snapshot and `preferredAuthMode = "chatgpt"`, observed result:
```json
{
"OPENAI_API_KEY": null,
"CODEX_API_KEY": null
}
```
What this proves:
- ChatGPT-mode execution sanitizes ambient API-key env vars when managed-account launch is selected
### 6. Live provider-aware launch override check
Command path:
- `providerConnectionService.setCodexAccountFeature(createCodexAccountFeature(...))`
- `buildProviderAwareCliEnv({ binaryPath: "codex", providerId: "codex" })`
Observed summary:
```json
{
"providerArgs": [
"-c",
"forced_login_method=\"chatgpt\""
],
"connectionIssues": {}
}
```
What this proves:
- the native Codex launch policy now emits a deterministic `forced_login_method` override
- the override is available through the shared provider-aware execution seam used by runtime launch paths
## Definition Of Done Cross-Check
1. Previously logged-in ChatGPT Codex account autodetects automatically.
Status: yes - proven by live `account/read`.
2. UI clearly distinguishes managed account and API key availability.
Status: yes - implemented in the Codex panel and covered by renderer tests.
3. `auto` mode works and prefers ChatGPT when available.
Status: yes - proven by live feature-facade check.
4. Launch policy no longer falsely requires API key when managed account exists.
Status: yes - feature readiness plus live snapshot show `launchAllowed = true` in ChatGPT mode.
5. ChatGPT-mode execution sanitizes API-key env vars.
Status: yes - covered by tests and live `ProviderConnectionService` probe.
6. API-key mode still works.
Status: yes - covered by tests and live preference-resolution probe.
7. Login, cancel, and logout work from the real UI.
Status: code path and tests are implemented; live destructive signoff was intentionally not executed in this document to avoid mutating the active local Codex account session.
8. Codex terminal-login path is no longer used in normal UI flows.
Status: yes - normal Codex settings flow uses feature IPC actions, not terminal modal auth.
9. `recent-projects` remains green.
Status: yes - targeted recent-projects safety suites passed.
10. Existing non-Codex provider UX remains unchanged.
Status: targeted Anthropic/Gemini runtime and renderer tests passed.
## Conclusion
For this repo, the planned app-server account feature work is in signoffable shape:
- architecture is aligned with the feature-slice plan
- renderer/runtime behavior is covered by targeted automated tests
- live app-server read and rate-limit contracts were verified on the installed Codex binary
- provider-aware native launch paths now receive deterministic Codex auth-mode overrides

View file

@ -0,0 +1,15 @@
import type { CodexAccountSnapshotDto } from './dto';
export interface CodexAccountElectronApi {
getCodexAccountSnapshot: () => Promise<CodexAccountSnapshotDto>;
refreshCodexAccountSnapshot: (options?: {
includeRateLimits?: boolean;
forceRefreshToken?: boolean;
}) => Promise<CodexAccountSnapshotDto>;
startCodexChatgptLogin: () => Promise<CodexAccountSnapshotDto>;
cancelCodexChatgptLogin: () => Promise<CodexAccountSnapshotDto>;
logoutCodexAccount: () => Promise<CodexAccountSnapshotDto>;
onCodexAccountSnapshotChanged: (
callback: (event: unknown, snapshot: CodexAccountSnapshotDto) => void
) => () => void;
}

View file

@ -0,0 +1,6 @@
export const CODEX_ACCOUNT_GET_SNAPSHOT = 'codexAccount:getSnapshot';
export const CODEX_ACCOUNT_REFRESH_SNAPSHOT = 'codexAccount:refreshSnapshot';
export const CODEX_ACCOUNT_START_CHATGPT_LOGIN = 'codexAccount:startChatgptLogin';
export const CODEX_ACCOUNT_CANCEL_CHATGPT_LOGIN = 'codexAccount:cancelChatgptLogin';
export const CODEX_ACCOUNT_LOGOUT = 'codexAccount:logout';
export const CODEX_ACCOUNT_SNAPSHOT_CHANGED = 'codexAccount:snapshotChanged';

View file

@ -0,0 +1,83 @@
export type CodexAccountAuthMode = 'auto' | 'chatgpt' | 'api_key';
export type CodexAccountEffectiveAuthMode = 'chatgpt' | 'api_key' | null;
export type CodexAccountPlanType =
| 'free'
| 'go'
| 'plus'
| 'pro'
| 'team'
| 'business'
| 'enterprise'
| 'edu'
| 'unknown';
export type CodexAccountAppServerState =
| 'healthy'
| 'degraded'
| 'runtime-missing'
| 'incompatible';
export type CodexAccountLoginStatus = 'idle' | 'starting' | 'pending' | 'failed' | 'cancelled';
export type CodexLaunchReadinessState =
| 'ready_chatgpt'
| 'ready_api_key'
| 'ready_both'
| 'missing_auth'
| 'warning_degraded_but_launchable'
| 'runtime_missing'
| 'incompatible';
export interface CodexManagedAccountDto {
type: 'chatgpt' | 'api_key';
email: string | null;
planType: CodexAccountPlanType | null;
}
export interface CodexApiKeyAvailabilityDto {
available: boolean;
source: 'stored' | 'environment' | null;
sourceLabel: string | null;
}
export interface CodexRateLimitWindowDto {
usedPercent: number;
windowDurationMins: number | null;
resetsAt: number | null;
}
export interface CodexCreditsSnapshotDto {
hasCredits: boolean;
unlimited: boolean;
balance: string | null;
}
export interface CodexRateLimitSnapshotDto {
limitId: string | null;
limitName: string | null;
primary: CodexRateLimitWindowDto | null;
secondary: CodexRateLimitWindowDto | null;
credits: CodexCreditsSnapshotDto | null;
planType: CodexAccountPlanType | null;
}
export interface CodexLoginStateDto {
status: CodexAccountLoginStatus;
error: string | null;
startedAt: string | null;
}
export interface CodexAccountSnapshotDto {
preferredAuthMode: CodexAccountAuthMode;
effectiveAuthMode: CodexAccountEffectiveAuthMode;
launchAllowed: boolean;
launchIssueMessage: string | null;
launchReadinessState: CodexLaunchReadinessState;
appServerState: CodexAccountAppServerState;
appServerStatusMessage: string | null;
managedAccount: CodexManagedAccountDto | null;
apiKey: CodexApiKeyAvailabilityDto;
requiresOpenaiAuth: boolean | null;
localAccountArtifactsPresent?: boolean;
localActiveChatgptAccountPresent?: boolean;
login: CodexLoginStateDto;
rateLimits: CodexRateLimitSnapshotDto | null;
updatedAt: string;
}

View file

@ -0,0 +1,3 @@
export type * from './api';
export * from './channels';
export type * from './dto';

View file

@ -0,0 +1,124 @@
import type {
CodexAccountAppServerState,
CodexAccountAuthMode,
CodexAccountEffectiveAuthMode,
CodexApiKeyAvailabilityDto,
CodexLaunchReadinessState,
CodexManagedAccountDto,
} from '@features/codex-account/contracts';
export interface CodexLaunchReadinessResult {
state: CodexLaunchReadinessState;
effectiveAuthMode: CodexAccountEffectiveAuthMode;
launchAllowed: boolean;
issueMessage: string | null;
}
export function evaluateCodexLaunchReadiness(input: {
preferredAuthMode: CodexAccountAuthMode;
managedAccount: CodexManagedAccountDto | null;
apiKey: CodexApiKeyAvailabilityDto;
appServerState: CodexAccountAppServerState;
appServerStatusMessage: string | null;
localActiveChatgptAccountPresent?: boolean;
}): CodexLaunchReadinessResult {
const managedAccountAvailable = input.managedAccount?.type === 'chatgpt';
const apiKeyAvailable = input.apiKey.available;
if (input.appServerState === 'runtime-missing') {
return {
state: 'runtime_missing',
effectiveAuthMode: null,
launchAllowed: false,
issueMessage:
input.appServerStatusMessage ?? 'Codex CLI is not available, so native Codex cannot start.',
};
}
if (input.preferredAuthMode === 'chatgpt') {
if (managedAccountAvailable) {
return {
state:
input.appServerState === 'degraded' ? 'warning_degraded_but_launchable' : 'ready_chatgpt',
effectiveAuthMode: 'chatgpt',
launchAllowed: true,
issueMessage:
input.appServerState === 'degraded'
? (input.appServerStatusMessage ??
'ChatGPT account detected, but account verification is currently degraded.')
: null,
};
}
return {
state: input.appServerState === 'incompatible' ? 'incompatible' : 'missing_auth',
effectiveAuthMode: null,
launchAllowed: false,
issueMessage:
input.appServerState === 'incompatible'
? (input.appServerStatusMessage ??
'This Codex installation does not support ChatGPT account management.')
: input.localActiveChatgptAccountPresent
? 'Reconnect ChatGPT to refresh the current Codex subscription session.'
: 'Connect a ChatGPT account to use your Codex subscription.',
};
}
if (input.preferredAuthMode === 'api_key') {
if (apiKeyAvailable) {
return {
state: 'ready_api_key',
effectiveAuthMode: 'api_key',
launchAllowed: true,
issueMessage: null,
};
}
return {
state: 'missing_auth',
effectiveAuthMode: null,
launchAllowed: false,
issueMessage: 'Add OPENAI_API_KEY or CODEX_API_KEY to use Codex API key mode.',
};
}
if (managedAccountAvailable) {
return {
state:
input.appServerState === 'degraded'
? 'warning_degraded_but_launchable'
: apiKeyAvailable
? 'ready_both'
: 'ready_chatgpt',
effectiveAuthMode: 'chatgpt',
launchAllowed: true,
issueMessage:
input.appServerState === 'degraded'
? (input.appServerStatusMessage ??
'ChatGPT account detected, but account verification is currently degraded.')
: null,
};
}
if (apiKeyAvailable) {
return {
state: 'ready_api_key',
effectiveAuthMode: 'api_key',
launchAllowed: true,
issueMessage: null,
};
}
return {
state: input.appServerState === 'incompatible' ? 'incompatible' : 'missing_auth',
effectiveAuthMode: null,
launchAllowed: false,
issueMessage:
input.appServerState === 'incompatible'
? (input.appServerStatusMessage ??
'This Codex installation does not support ChatGPT account management.')
: input.localActiveChatgptAccountPresent
? 'Reconnect ChatGPT to refresh the current Codex subscription session, or add OPENAI_API_KEY / CODEX_API_KEY to use Codex.'
: 'Connect a ChatGPT account or add OPENAI_API_KEY / CODEX_API_KEY to use Codex.',
};
}

View file

@ -0,0 +1,3 @@
export type * from './contracts';
export type { CodexLaunchReadinessResult } from './core/domain/evaluateCodexLaunchReadiness';
export { evaluateCodexLaunchReadiness } from './core/domain/evaluateCodexLaunchReadiness';

View file

@ -0,0 +1,33 @@
import {
CODEX_ACCOUNT_CANCEL_CHATGPT_LOGIN,
CODEX_ACCOUNT_GET_SNAPSHOT,
CODEX_ACCOUNT_LOGOUT,
CODEX_ACCOUNT_REFRESH_SNAPSHOT,
CODEX_ACCOUNT_START_CHATGPT_LOGIN,
} from '@features/codex-account/contracts';
import type { CodexAccountFeatureFacade } from '../../../composition/createCodexAccountFeature';
import type { IpcMain } from 'electron';
export function registerCodexAccountIpc(
ipcMain: IpcMain,
feature: CodexAccountFeatureFacade
): void {
ipcMain.handle(CODEX_ACCOUNT_GET_SNAPSHOT, () => feature.getSnapshot());
ipcMain.handle(
CODEX_ACCOUNT_REFRESH_SNAPSHOT,
(_event, options?: { includeRateLimits?: boolean; forceRefreshToken?: boolean }) =>
feature.refreshSnapshot(options)
);
ipcMain.handle(CODEX_ACCOUNT_START_CHATGPT_LOGIN, () => feature.startChatgptLogin());
ipcMain.handle(CODEX_ACCOUNT_CANCEL_CHATGPT_LOGIN, () => feature.cancelLogin());
ipcMain.handle(CODEX_ACCOUNT_LOGOUT, () => feature.logout());
}
export function removeCodexAccountIpc(ipcMain: IpcMain): void {
ipcMain.removeHandler(CODEX_ACCOUNT_GET_SNAPSHOT);
ipcMain.removeHandler(CODEX_ACCOUNT_REFRESH_SNAPSHOT);
ipcMain.removeHandler(CODEX_ACCOUNT_START_CHATGPT_LOGIN);
ipcMain.removeHandler(CODEX_ACCOUNT_CANCEL_CHATGPT_LOGIN);
ipcMain.removeHandler(CODEX_ACCOUNT_LOGOUT);
}

View file

@ -0,0 +1,19 @@
import {
CODEX_ACCOUNT_SNAPSHOT_CHANGED,
type CodexAccountSnapshotDto,
} from '@features/codex-account/contracts';
import { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
import type { BrowserWindow } from 'electron';
export class CodexAccountSnapshotPresenter {
private mainWindow: BrowserWindow | null = null;
setMainWindow(window: BrowserWindow | null): void {
this.mainWindow = window;
}
publish(snapshot: CodexAccountSnapshotDto): void {
safeSendToRenderer(this.mainWindow, CODEX_ACCOUNT_SNAPSHOT_CHANGED, snapshot);
}
}

View file

@ -0,0 +1,693 @@
import {
type CodexAccountAuthMode,
type CodexAccountSnapshotDto,
type CodexApiKeyAvailabilityDto,
type CodexCreditsSnapshotDto,
type CodexLoginStateDto,
type CodexManagedAccountDto,
type CodexRateLimitSnapshotDto,
type CodexRateLimitWindowDto,
} from '@features/codex-account/contracts';
import {
type CodexLaunchReadinessResult,
evaluateCodexLaunchReadiness,
} from '@features/codex-account/core/domain/evaluateCodexLaunchReadiness';
import { ApiKeyService } from '@main/services/extensions';
import {
type CodexAppServerGetAccountRateLimitsResponse,
type CodexAppServerGetAccountResponse,
type CodexAppServerRateLimitSnapshot,
CodexAppServerSessionFactory,
CodexBinaryResolver,
JsonRpcStdioClient,
} from '@main/services/infrastructure/codexAppServer';
import { getCachedShellEnv } from '@main/utils/shellEnv';
import { CodexAccountSnapshotPresenter } from '../adapters/output/presenters/CodexAccountSnapshotPresenter';
import { CodexAccountAppServerClient } from '../infrastructure/CodexAccountAppServerClient';
import { CodexAccountEnvBuilder } from '../infrastructure/CodexAccountEnvBuilder';
import { CodexLoginSessionManager } from '../infrastructure/CodexLoginSessionManager';
import { detectCodexLocalAccountState } from '../infrastructure/detectCodexLocalAccountArtifacts';
import type { Logger } from '@shared/utils/logger';
import type { BrowserWindow } from 'electron';
type LoggerPort = Pick<Logger, 'info' | 'warn' | 'error'>;
const SNAPSHOT_CACHE_TTL_MS = 5_000;
const RATE_LIMITS_CACHE_TTL_MS = 45_000;
const LAST_KNOWN_GOOD_MANAGED_ACCOUNT_TTL_MS = 60_000;
interface CodexLastKnownAccount {
payload: CodexAppServerGetAccountResponse;
observedAt: number;
}
interface CodexLastKnownRateLimits {
payload: CodexAppServerGetAccountRateLimitsResponse;
observedAt: number;
}
interface CodexSnapshotRefreshOptions {
includeRateLimits: boolean;
forceRefreshToken: boolean;
}
function hasChatgptManagedAccount(
payload: CodexAppServerGetAccountResponse | null | undefined
): boolean {
return payload?.account?.type === 'chatgpt';
}
function deepClone<T>(value: T): T {
return structuredClone(value);
}
function asCodexManagedAccount(
account: CodexAppServerGetAccountResponse['account']
): CodexManagedAccountDto | null {
if (!account) {
return null;
}
if (account.type === 'apiKey') {
return {
type: 'api_key',
email: null,
planType: null,
};
}
return {
type: 'chatgpt',
email: account.email,
planType: account.planType,
};
}
function asRateLimitWindow(
window: CodexAppServerRateLimitSnapshot['primary']
): CodexRateLimitWindowDto | null {
if (!window) {
return null;
}
return {
usedPercent: window.usedPercent,
windowDurationMins: window.windowDurationMins,
resetsAt: window.resetsAt,
};
}
function asCreditsSnapshot(
credits: CodexAppServerRateLimitSnapshot['credits']
): CodexCreditsSnapshotDto | null {
if (!credits) {
return null;
}
return {
hasCredits: credits.hasCredits,
unlimited: credits.unlimited,
balance: credits.balance,
};
}
function asRateLimits(
snapshot: CodexAppServerRateLimitSnapshot | null
): CodexRateLimitSnapshotDto | null {
if (!snapshot) {
return null;
}
return {
limitId: snapshot.limitId,
limitName: snapshot.limitName,
primary: asRateLimitWindow(snapshot.primary),
secondary: asRateLimitWindow(snapshot.secondary),
credits: asCreditsSnapshot(snapshot.credits),
planType: snapshot.planType,
};
}
function getPreferredAuthMode(configManager: {
getConfig: () => {
providerConnections: {
codex: {
preferredAuthMode?: CodexAccountAuthMode;
};
};
};
}): CodexAccountAuthMode {
return configManager.getConfig().providerConnections.codex.preferredAuthMode ?? 'auto';
}
function classifyAppServerFailure(error: unknown): {
appServerState: CodexAccountSnapshotDto['appServerState'];
appServerStatusMessage: string;
} {
const message = error instanceof Error ? error.message : String(error);
const lower = message.toLowerCase();
if (
lower.includes('unknown method') ||
lower.includes('method not found') ||
lower.includes('unknown command') ||
lower.includes('no such command')
) {
return {
appServerState: 'incompatible',
appServerStatusMessage:
'The installed Codex binary does not support app-server account management yet.',
};
}
return {
appServerState: 'degraded',
appServerStatusMessage: message,
};
}
function normalizeRefreshOptions(options?: {
includeRateLimits?: boolean;
forceRefreshToken?: boolean;
}): CodexSnapshotRefreshOptions {
return {
includeRateLimits: options?.includeRateLimits === true,
forceRefreshToken: options?.forceRefreshToken === true,
};
}
function mergeRefreshOptions(
current: CodexSnapshotRefreshOptions | null,
next: CodexSnapshotRefreshOptions
): CodexSnapshotRefreshOptions {
if (!current) {
return next;
}
return {
includeRateLimits: current.includeRateLimits || next.includeRateLimits,
forceRefreshToken: current.forceRefreshToken || next.forceRefreshToken,
};
}
function createDeferred(): { promise: Promise<void>; resolve: () => void } {
let resolve: (() => void) | null = null;
const promise = new Promise<void>((fulfill) => {
resolve = fulfill;
});
if (!resolve) {
throw new Error('Failed to create deferred promise.');
}
return {
promise,
resolve,
};
}
export interface CodexAccountFeatureFacade {
getSnapshot(): Promise<CodexAccountSnapshotDto>;
refreshSnapshot(options?: {
includeRateLimits?: boolean;
forceRefreshToken?: boolean;
}): Promise<CodexAccountSnapshotDto>;
startChatgptLogin(): Promise<CodexAccountSnapshotDto>;
cancelLogin(): Promise<CodexAccountSnapshotDto>;
logout(): Promise<CodexAccountSnapshotDto>;
subscribe(listener: (snapshot: CodexAccountSnapshotDto) => void): () => void;
setMainWindow(window: BrowserWindow | null): void;
getLaunchReadiness(): Promise<CodexLaunchReadinessResult>;
dispose(): Promise<void>;
}
class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
private readonly listeners = new Set<(snapshot: CodexAccountSnapshotDto) => void>();
private readonly presenter = new CodexAccountSnapshotPresenter();
private readonly envBuilder = new CodexAccountEnvBuilder();
private readonly appServerClient: CodexAccountAppServerClient;
private readonly loginSessionManager: CodexLoginSessionManager;
private snapshotCache: CodexAccountSnapshotDto | null = null;
private snapshotObservedAt = 0;
private refreshPromise: Promise<CodexAccountSnapshotDto> | null = null;
private pendingRefreshOptions: CodexSnapshotRefreshOptions | null = null;
private lastKnownAccount: CodexLastKnownAccount | null = null;
private lastKnownRateLimits: CodexLastKnownRateLimits | null = null;
private mutationQueue: Promise<void> = Promise.resolve();
private mutationQueueRelease: (() => void) | null = null;
private activeMutationCount = 0;
constructor(
private readonly logger: LoggerPort,
private readonly configManager: {
getConfig: () => {
providerConnections: {
codex: {
preferredAuthMode?: CodexAccountAuthMode;
};
};
};
},
private readonly apiKeyService = new ApiKeyService()
) {
const sessionFactory = new CodexAppServerSessionFactory(new JsonRpcStdioClient(logger));
this.appServerClient = new CodexAccountAppServerClient(sessionFactory);
this.loginSessionManager = new CodexLoginSessionManager(sessionFactory, logger);
this.loginSessionManager.subscribe(() => {
void this.emitCurrentSnapshot();
});
this.loginSessionManager.onSettled(() => {
void this.refreshSnapshot({
includeRateLimits: true,
forceRefreshToken: true,
});
});
}
async getSnapshot(): Promise<CodexAccountSnapshotDto> {
if (this.snapshotCache && Date.now() - this.snapshotObservedAt <= SNAPSHOT_CACHE_TTL_MS) {
return deepClone(this.snapshotCache);
}
return this.refreshSnapshot();
}
async refreshSnapshot(options?: {
includeRateLimits?: boolean;
forceRefreshToken?: boolean;
}): Promise<CodexAccountSnapshotDto> {
this.pendingRefreshOptions = mergeRefreshOptions(
this.pendingRefreshOptions,
normalizeRefreshOptions(options)
);
if (!this.refreshPromise) {
this.refreshPromise = this.drainRefreshQueue().finally(() => {
this.refreshPromise = null;
});
}
return this.refreshPromise;
}
async startChatgptLogin(): Promise<CodexAccountSnapshotDto> {
let binaryMissing = false;
await this.runSerializedMutation(async () => {
const binaryPath = await CodexBinaryResolver.resolve();
if (!binaryPath) {
binaryMissing = true;
return;
}
const env = this.envBuilder.buildControlPlaneEnv({ binaryPath });
await this.loginSessionManager.start({ binaryPath, env });
});
if (binaryMissing) {
return this.loadSnapshot();
}
return this.emitCurrentSnapshot();
}
async cancelLogin(): Promise<CodexAccountSnapshotDto> {
await this.runSerializedMutation(async () => {
await this.loginSessionManager.cancel();
});
return this.emitCurrentSnapshot();
}
async logout(): Promise<CodexAccountSnapshotDto> {
await this.runSerializedMutation(async () => {
await this.loginSessionManager.cancel().catch(() => undefined);
const binaryPath = await CodexBinaryResolver.resolve();
if (!binaryPath) {
throw new Error('Codex CLI is not available, so logout cannot be completed.');
}
const env = this.envBuilder.buildControlPlaneEnv({ binaryPath });
await this.appServerClient.logout({ binaryPath, env });
this.lastKnownAccount = null;
this.lastKnownRateLimits = null;
await this.publishLoggedOutSnapshot();
});
return this.refreshSnapshot({ includeRateLimits: true, forceRefreshToken: true });
}
subscribe(listener: (snapshot: CodexAccountSnapshotDto) => void): () => void {
this.listeners.add(listener);
return (): void => {
this.listeners.delete(listener);
};
}
setMainWindow(window: BrowserWindow | null): void {
this.presenter.setMainWindow(window);
}
async getLaunchReadiness(): Promise<CodexLaunchReadinessResult> {
const snapshot = await this.getSnapshot();
return evaluateCodexLaunchReadiness({
preferredAuthMode: snapshot.preferredAuthMode,
managedAccount: snapshot.managedAccount,
apiKey: snapshot.apiKey,
appServerState: snapshot.appServerState,
appServerStatusMessage: snapshot.appServerStatusMessage,
localActiveChatgptAccountPresent: snapshot.localActiveChatgptAccountPresent,
});
}
async dispose(): Promise<void> {
await this.loginSessionManager.dispose();
this.listeners.clear();
this.snapshotCache = null;
this.refreshPromise = null;
this.pendingRefreshOptions = null;
this.lastKnownAccount = null;
this.lastKnownRateLimits = null;
this.activeMutationCount = 0;
if (this.mutationQueueRelease) {
this.mutationQueueRelease();
this.mutationQueueRelease = null;
}
this.mutationQueue = Promise.resolve();
}
private async drainRefreshQueue(): Promise<CodexAccountSnapshotDto> {
let lastSnapshot: CodexAccountSnapshotDto | null = null;
while (this.pendingRefreshOptions) {
const nextOptions = this.pendingRefreshOptions;
this.pendingRefreshOptions = null;
await this.mutationQueue.catch(() => undefined);
lastSnapshot = await this.loadSnapshot(nextOptions);
}
if (!lastSnapshot) {
if (this.snapshotCache) {
return deepClone(this.snapshotCache);
}
return this.loadSnapshot();
}
return lastSnapshot;
}
private async loadSnapshot(options?: {
includeRateLimits?: boolean;
forceRefreshToken?: boolean;
}): Promise<CodexAccountSnapshotDto> {
const preferredAuthMode = getPreferredAuthMode(this.configManager);
const apiKey = await this.loadApiKeyAvailability();
const localAccountState = await detectCodexLocalAccountState();
const localAccountArtifactsPresent = localAccountState.hasArtifacts;
const localActiveChatgptAccountPresent = localAccountState.hasActiveChatgptAccount;
const binaryPath = await CodexBinaryResolver.resolve();
const login = this.loginSessionManager.getState();
const now = Date.now();
if (!binaryPath) {
const snapshot = this.setSnapshot({
preferredAuthMode,
effectiveAuthMode: null,
launchAllowed: false,
launchIssueMessage: 'Codex CLI not found. Install Codex to use native account management.',
launchReadinessState: 'runtime_missing',
appServerState: 'runtime-missing',
appServerStatusMessage:
'Codex CLI not found. Install Codex to use native account management.',
managedAccount: null,
apiKey,
requiresOpenaiAuth: null,
localAccountArtifactsPresent,
localActiveChatgptAccountPresent,
login,
rateLimits: null,
updatedAt: new Date(now).toISOString(),
});
return snapshot;
}
const env = this.envBuilder.buildControlPlaneEnv({ binaryPath });
let appServerState: CodexAccountSnapshotDto['appServerState'] = 'healthy';
let appServerStatusMessage: string | null = null;
let accountPayload = this.lastKnownAccount?.payload ?? null;
let requiresOpenaiAuth: boolean | null = accountPayload?.requiresOpenaiAuth ?? null;
try {
const accountResult = await this.appServerClient.readAccount({
binaryPath,
env,
refreshToken: options?.forceRefreshToken ?? false,
});
const canReuseLastKnownManagedAccount =
options?.forceRefreshToken !== true &&
localActiveChatgptAccountPresent &&
accountResult.account.account == null &&
accountResult.account.requiresOpenaiAuth === true &&
this.lastKnownAccount !== null &&
now - this.lastKnownAccount.observedAt <= LAST_KNOWN_GOOD_MANAGED_ACCOUNT_TTL_MS &&
hasChatgptManagedAccount(this.lastKnownAccount.payload);
if (canReuseLastKnownManagedAccount) {
accountPayload = this.lastKnownAccount!.payload;
requiresOpenaiAuth = this.lastKnownAccount!.payload.requiresOpenaiAuth;
} else {
accountPayload = accountResult.account;
requiresOpenaiAuth = accountResult.account.requiresOpenaiAuth;
this.lastKnownAccount = {
payload: accountResult.account,
observedAt: now,
};
}
} catch (error) {
const failure = classifyAppServerFailure(error);
appServerState = failure.appServerState;
appServerStatusMessage = failure.appServerStatusMessage;
if (
!this.lastKnownAccount ||
now - this.lastKnownAccount.observedAt > LAST_KNOWN_GOOD_MANAGED_ACCOUNT_TTL_MS
) {
accountPayload = null;
requiresOpenaiAuth = null;
} else {
accountPayload = this.lastKnownAccount.payload;
requiresOpenaiAuth = this.lastKnownAccount.payload.requiresOpenaiAuth;
}
}
let rateLimits: CodexRateLimitSnapshotDto | null = null;
const shouldLoadRateLimits =
options?.includeRateLimits === true ||
(this.lastKnownRateLimits !== null &&
now - this.lastKnownRateLimits.observedAt <= RATE_LIMITS_CACHE_TTL_MS);
if (shouldLoadRateLimits) {
try {
if (
this.lastKnownRateLimits &&
now - this.lastKnownRateLimits.observedAt <= RATE_LIMITS_CACHE_TTL_MS
) {
rateLimits = asRateLimits(this.lastKnownRateLimits.payload.rateLimits);
} else {
const rateLimitsPayload = await this.appServerClient.readRateLimits({
binaryPath,
env,
});
this.lastKnownRateLimits = {
payload: rateLimitsPayload,
observedAt: now,
};
rateLimits = asRateLimits(rateLimitsPayload.rateLimits);
}
} catch (error) {
this.logger.warn('codex account rate limits refresh failed', {
error: error instanceof Error ? error.message : String(error),
});
rateLimits = this.lastKnownRateLimits
? asRateLimits(this.lastKnownRateLimits.payload.rateLimits)
: null;
}
}
const managedAccount = asCodexManagedAccount(accountPayload?.account ?? null);
const readiness = evaluateCodexLaunchReadiness({
preferredAuthMode,
managedAccount,
apiKey,
appServerState,
appServerStatusMessage,
localActiveChatgptAccountPresent,
});
const snapshot = this.setSnapshot({
preferredAuthMode,
effectiveAuthMode: readiness.effectiveAuthMode,
launchAllowed: readiness.launchAllowed,
launchIssueMessage: readiness.issueMessage,
launchReadinessState: readiness.state,
appServerState,
appServerStatusMessage,
managedAccount,
apiKey,
requiresOpenaiAuth,
localAccountArtifactsPresent,
localActiveChatgptAccountPresent,
login,
rateLimits,
updatedAt: new Date(now).toISOString(),
});
return snapshot;
}
private setSnapshot(nextSnapshot: CodexAccountSnapshotDto): CodexAccountSnapshotDto {
this.snapshotCache = deepClone(nextSnapshot);
this.snapshotObservedAt = Date.now();
const snapshot = deepClone(nextSnapshot);
this.presenter.publish(snapshot);
for (const listener of this.listeners) {
listener(snapshot);
}
return snapshot;
}
private async emitCurrentSnapshot(): Promise<CodexAccountSnapshotDto> {
if (!this.snapshotCache) {
return this.refreshSnapshot();
}
return this.setSnapshot({
...this.snapshotCache,
login: this.loginSessionManager.getState(),
updatedAt: new Date().toISOString(),
});
}
private async publishLoggedOutSnapshot(): Promise<CodexAccountSnapshotDto> {
const preferredAuthMode = getPreferredAuthMode(this.configManager);
const apiKey = this.snapshotCache?.apiKey ?? (await this.loadApiKeyAvailability());
const localAccountState = await detectCodexLocalAccountState();
const localAccountArtifactsPresent = localAccountState.hasArtifacts;
const localActiveChatgptAccountPresent = localAccountState.hasActiveChatgptAccount;
const readiness = evaluateCodexLaunchReadiness({
preferredAuthMode,
managedAccount: null,
apiKey,
appServerState: 'healthy',
appServerStatusMessage: null,
localActiveChatgptAccountPresent,
});
const login = this.asIdleLoginState(this.loginSessionManager.getState());
return this.setSnapshot({
preferredAuthMode,
effectiveAuthMode: readiness.effectiveAuthMode,
launchAllowed: readiness.launchAllowed,
launchIssueMessage: readiness.issueMessage,
launchReadinessState: readiness.state,
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount: null,
apiKey,
requiresOpenaiAuth: false,
localAccountArtifactsPresent,
localActiveChatgptAccountPresent,
login,
rateLimits: null,
updatedAt: new Date().toISOString(),
});
}
private asIdleLoginState(loginState: CodexLoginStateDto): CodexLoginStateDto {
return {
status: 'idle',
error: loginState.status === 'failed' ? loginState.error : null,
startedAt: null,
};
}
private async runSerializedMutation<T>(operation: () => Promise<T>): Promise<T> {
const previousMutation = this.mutationQueue.catch(() => undefined);
const deferred = createDeferred();
this.mutationQueue = deferred.promise;
this.mutationQueueRelease = deferred.resolve;
await previousMutation;
await this.refreshPromise?.catch(() => undefined);
this.activeMutationCount += 1;
try {
return await operation();
} finally {
this.activeMutationCount = Math.max(0, this.activeMutationCount - 1);
deferred.resolve();
if (this.mutationQueueRelease === deferred.resolve) {
this.mutationQueueRelease = null;
}
}
}
private async loadApiKeyAvailability(): Promise<CodexApiKeyAvailabilityDto> {
const storedKey = await this.apiKeyService.lookupPreferred('OPENAI_API_KEY');
if (storedKey?.value.trim()) {
return {
available: true,
source: 'stored',
sourceLabel: 'Stored in app',
};
}
const shellEnv = getCachedShellEnv() ?? {};
const envSources = [shellEnv, process.env];
for (const envSource of envSources) {
const codexKey = envSource.CODEX_API_KEY;
if (typeof codexKey === 'string' && codexKey.trim()) {
return {
available: true,
source: 'environment',
sourceLabel: 'Detected from CODEX_API_KEY',
};
}
const openAiKey = envSource.OPENAI_API_KEY;
if (typeof openAiKey === 'string' && openAiKey.trim()) {
return {
available: true,
source: 'environment',
sourceLabel: 'Detected from OPENAI_API_KEY',
};
}
}
return {
available: false,
source: null,
sourceLabel: null,
};
}
}
export function createCodexAccountFeature(deps: {
logger: LoggerPort;
configManager: {
getConfig: () => {
providerConnections: {
codex: {
preferredAuthMode?: CodexAccountAuthMode;
};
};
};
};
}): CodexAccountFeatureFacade {
return new CodexAccountFeatureFacadeImpl(deps.logger, deps.configManager);
}

View file

@ -0,0 +1,6 @@
export {
registerCodexAccountIpc,
removeCodexAccountIpc,
} from './adapters/input/ipc/registerCodexAccountIpc';
export type { CodexAccountFeatureFacade } from './composition/createCodexAccountFeature';
export { createCodexAccountFeature } from './composition/createCodexAccountFeature';

View file

@ -0,0 +1,100 @@
import {
type CodexAppServerGetAccountParams,
type CodexAppServerGetAccountRateLimitsResponse,
type CodexAppServerGetAccountResponse,
type CodexAppServerLogoutAccountResponse,
} from '@main/services/infrastructure/codexAppServer';
import type { CodexAppServerSessionFactory } from '@main/services/infrastructure/codexAppServer';
const ACCOUNT_READ_TIMEOUT_MS = 3_500;
const ACCOUNT_RATE_LIMITS_TIMEOUT_MS = 4_500;
const ACCOUNT_LOGOUT_TIMEOUT_MS = 3_500;
const INITIALIZE_TIMEOUT_MS = 6_000;
const TOTAL_TIMEOUT_MS = 9_000;
export class CodexAccountAppServerClient {
constructor(private readonly sessionFactory: CodexAppServerSessionFactory) {}
async readAccount(options: {
binaryPath: string;
env: NodeJS.ProcessEnv;
refreshToken?: boolean;
}): Promise<{
account: CodexAppServerGetAccountResponse;
initialize: { codexHome: string; platformFamily: string; platformOs: string };
}> {
return this.sessionFactory.withSession(
{
binaryPath: options.binaryPath,
env: options.env,
requestTimeoutMs: ACCOUNT_READ_TIMEOUT_MS,
initializeTimeoutMs: INITIALIZE_TIMEOUT_MS,
totalTimeoutMs: TOTAL_TIMEOUT_MS,
label: 'codex app-server account/read',
},
async (session) => {
const account = await session.request<CodexAppServerGetAccountResponse>(
'account/read',
{
refreshToken: options.refreshToken ?? false,
} satisfies CodexAppServerGetAccountParams,
ACCOUNT_READ_TIMEOUT_MS
);
return {
account,
initialize: {
codexHome: session.initializeResponse.codexHome,
platformFamily: session.initializeResponse.platformFamily,
platformOs: session.initializeResponse.platformOs,
},
};
}
);
}
async readRateLimits(options: {
binaryPath: string;
env: NodeJS.ProcessEnv;
}): Promise<CodexAppServerGetAccountRateLimitsResponse> {
return this.sessionFactory.withSession(
{
binaryPath: options.binaryPath,
env: options.env,
requestTimeoutMs: ACCOUNT_RATE_LIMITS_TIMEOUT_MS,
initializeTimeoutMs: INITIALIZE_TIMEOUT_MS,
totalTimeoutMs: TOTAL_TIMEOUT_MS,
label: 'codex app-server account/rateLimits/read',
},
async (session) =>
session.request<CodexAppServerGetAccountRateLimitsResponse>(
'account/rateLimits/read',
undefined,
ACCOUNT_RATE_LIMITS_TIMEOUT_MS
)
);
}
async logout(options: {
binaryPath: string;
env: NodeJS.ProcessEnv;
}): Promise<CodexAppServerLogoutAccountResponse> {
return this.sessionFactory.withSession(
{
binaryPath: options.binaryPath,
env: options.env,
requestTimeoutMs: ACCOUNT_LOGOUT_TIMEOUT_MS,
initializeTimeoutMs: INITIALIZE_TIMEOUT_MS,
totalTimeoutMs: TOTAL_TIMEOUT_MS,
label: 'codex app-server account/logout',
},
async (session) =>
session.request<CodexAppServerLogoutAccountResponse>(
'account/logout',
undefined,
ACCOUNT_LOGOUT_TIMEOUT_MS
)
);
}
}

View file

@ -0,0 +1,66 @@
import { buildRuntimeBaseEnv } from '@main/services/runtime/buildRuntimeBaseEnv';
import { getCachedShellEnv } from '@main/utils/shellEnv';
import type { CodexAccountEffectiveAuthMode } from '@features/codex-account/contracts';
const CODEX_API_KEY_ENV_VAR = 'CODEX_API_KEY';
const OPENAI_API_KEY_ENV_VAR = 'OPENAI_API_KEY';
const PROVIDER_ROUTING_ENV_KEYS = [
'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST',
'CLAUDE_CODE_ENTRY_PROVIDER',
'CLAUDE_CODE_USE_OPENAI',
'CLAUDE_CODE_USE_BEDROCK',
'CLAUDE_CODE_USE_VERTEX',
'CLAUDE_CODE_USE_FOUNDRY',
'CLAUDE_CODE_USE_GEMINI',
'CLAUDE_CODE_GEMINI_BACKEND',
'CLAUDE_CODE_CODEX_BACKEND',
] as const;
export class CodexAccountEnvBuilder {
buildControlPlaneEnv(options: {
binaryPath?: string | null;
shellEnv?: NodeJS.ProcessEnv | null;
env?: NodeJS.ProcessEnv;
}): NodeJS.ProcessEnv {
const { env } = buildRuntimeBaseEnv({
binaryPath: options.binaryPath,
shellEnv: options.shellEnv ?? getCachedShellEnv() ?? {},
env: options.env,
});
for (const key of PROVIDER_ROUTING_ENV_KEYS) {
delete env[key];
}
delete env.OPENAI_API_KEY;
delete env.CODEX_API_KEY;
return env;
}
applyExecutionAuthPolicy(
env: NodeJS.ProcessEnv,
options: {
effectiveAuthMode: CodexAccountEffectiveAuthMode;
apiKeyValue?: string | null;
}
): NodeJS.ProcessEnv {
if (options.effectiveAuthMode === 'chatgpt') {
delete env[OPENAI_API_KEY_ENV_VAR];
delete env[CODEX_API_KEY_ENV_VAR];
return env;
}
if (options.effectiveAuthMode === 'api_key' && options.apiKeyValue?.trim()) {
env[OPENAI_API_KEY_ENV_VAR] = options.apiKeyValue.trim();
env[CODEX_API_KEY_ENV_VAR] = options.apiKeyValue.trim();
return env;
}
delete env[CODEX_API_KEY_ENV_VAR];
if (typeof env[OPENAI_API_KEY_ENV_VAR] !== 'string' || !env[OPENAI_API_KEY_ENV_VAR]?.trim()) {
delete env[OPENAI_API_KEY_ENV_VAR];
}
return env;
}
}

View file

@ -0,0 +1,300 @@
import {
type CodexAppServerAccountLoginCompletedNotification,
type CodexAppServerCancelLoginAccountResponse,
type CodexAppServerLoginAccountResponse,
type CodexAppServerSession,
} from '@main/services/infrastructure/codexAppServer';
import { shell } from 'electron';
import type { CodexLoginStateDto } from '@features/codex-account/contracts';
import type { CodexAppServerSessionFactory } from '@main/services/infrastructure/codexAppServer';
const LOGIN_REQUEST_TIMEOUT_MS = 5_000;
const INITIALIZE_TIMEOUT_MS = 6_000;
const LOGIN_PENDING_TIMEOUT_MS = 10 * 60 * 1_000;
type CodexLoginStateListener = (state: CodexLoginStateDto) => void;
type CodexLoginSettledListener = () => void;
interface CodexLoginLogger {
warn: (message: string, meta?: Record<string, unknown>) => void;
}
export class CodexLoginSessionManager {
private readonly listeners = new Set<CodexLoginStateListener>();
private readonly settledListeners = new Set<CodexLoginSettledListener>();
private state: CodexLoginStateDto = {
status: 'idle',
error: null,
startedAt: null,
};
private pendingStartToken: symbol | null = null;
private activeSession: {
session: CodexAppServerSession;
loginId: string;
disposeNotificationListener: () => void;
timeoutId: ReturnType<typeof setTimeout>;
} | null = null;
constructor(
private readonly sessionFactory: CodexAppServerSessionFactory,
private readonly logger: CodexLoginLogger
) {}
subscribe(listener: CodexLoginStateListener): () => void {
this.listeners.add(listener);
return (): void => {
this.listeners.delete(listener);
};
}
onSettled(listener: CodexLoginSettledListener): () => void {
this.settledListeners.add(listener);
return (): void => {
this.settledListeners.delete(listener);
};
}
getState(): CodexLoginStateDto {
return structuredClone(this.state);
}
async start(options: { binaryPath: string; env: NodeJS.ProcessEnv }): Promise<void> {
if (this.activeSession || this.pendingStartToken) {
return;
}
const startToken = Symbol('codex-login-start');
this.pendingStartToken = startToken;
let session: CodexAppServerSession | null = null;
this.setState({
status: 'starting',
error: null,
startedAt: new Date().toISOString(),
});
try {
session = await this.sessionFactory.openSession({
binaryPath: options.binaryPath,
env: options.env,
requestTimeoutMs: LOGIN_REQUEST_TIMEOUT_MS,
initializeTimeoutMs: INITIALIZE_TIMEOUT_MS,
});
if (this.pendingStartToken !== startToken) {
await session.close().catch(() => undefined);
return;
}
const response = await session.request<CodexAppServerLoginAccountResponse>(
'account/login/start',
{ type: 'chatgpt' },
LOGIN_REQUEST_TIMEOUT_MS
);
if (this.pendingStartToken !== startToken) {
await session.close().catch(() => undefined);
return;
}
if (response.type !== 'chatgpt') {
throw new Error('Codex app-server returned an unexpected login response type');
}
const authUrl = new URL(response.authUrl);
if (authUrl.protocol !== 'https:') {
throw new Error('Codex app-server returned a non-https auth URL');
}
const disposeNotificationListener = session.onNotification((method, params) => {
if (method !== 'account/login/completed') {
return;
}
const notification = params as CodexAppServerAccountLoginCompletedNotification;
if (notification.loginId && notification.loginId !== response.loginId) {
return;
}
void this.handleCompletion(notification);
});
const timeoutId = setTimeout(() => {
void this.failActiveLogin('Timed out while waiting for ChatGPT account login to finish.');
}, LOGIN_PENDING_TIMEOUT_MS);
this.activeSession = {
session,
loginId: response.loginId,
disposeNotificationListener,
timeoutId,
};
this.pendingStartToken = null;
this.setState({
status: 'pending',
error: null,
startedAt: this.state.startedAt,
});
await shell.openExternal(authUrl.toString());
} catch (error) {
const wasAbandonedDuringStart =
this.pendingStartToken !== startToken &&
!this.activeSession &&
(this.state.status === 'cancelled' || this.state.status === 'idle');
if (this.pendingStartToken === startToken) {
this.pendingStartToken = null;
}
await session?.close().catch(() => undefined);
if (session && this.activeSession?.session === session) {
this.activeSession = null;
}
if (wasAbandonedDuringStart) {
return;
}
this.setState({
status: 'failed',
error: error instanceof Error ? error.message : String(error),
startedAt: this.state.startedAt,
});
throw error;
}
}
async cancel(): Promise<void> {
if (this.pendingStartToken && !this.activeSession) {
this.pendingStartToken = null;
this.setState({
status: 'cancelled',
error: null,
startedAt: null,
});
this.emitSettled();
return;
}
if (!this.activeSession) {
this.setState({
status: 'cancelled',
error: null,
startedAt: null,
});
return;
}
const activeSession = this.activeSession;
this.activeSession = null;
clearTimeout(activeSession.timeoutId);
activeSession.disposeNotificationListener();
try {
await activeSession.session.request<CodexAppServerCancelLoginAccountResponse>(
'account/login/cancel',
{ loginId: activeSession.loginId },
LOGIN_REQUEST_TIMEOUT_MS
);
} catch (error) {
this.logger.warn('codex login cancel failed', {
error: error instanceof Error ? error.message : String(error),
});
} finally {
await activeSession.session.close().catch(() => undefined);
}
this.setState({
status: 'cancelled',
error: null,
startedAt: null,
});
this.emitSettled();
}
async dispose(): Promise<void> {
if (this.pendingStartToken) {
this.pendingStartToken = null;
}
if (!this.activeSession) {
this.setState({
status: 'idle',
error: null,
startedAt: null,
});
return;
}
const activeSession = this.activeSession;
this.activeSession = null;
clearTimeout(activeSession.timeoutId);
activeSession.disposeNotificationListener();
await activeSession.session.close().catch(() => undefined);
this.setState({
status: 'idle',
error: null,
startedAt: null,
});
}
private async handleCompletion(
notification: CodexAppServerAccountLoginCompletedNotification
): Promise<void> {
if (!this.activeSession) {
return;
}
const activeSession = this.activeSession;
this.activeSession = null;
clearTimeout(activeSession.timeoutId);
activeSession.disposeNotificationListener();
await activeSession.session.close().catch(() => undefined);
if (notification.success) {
this.setState({
status: 'idle',
error: null,
startedAt: null,
});
} else {
this.setState({
status: 'failed',
error: notification.error ?? 'ChatGPT login failed.',
startedAt: this.state.startedAt,
});
}
this.emitSettled();
}
private async failActiveLogin(errorMessage: string): Promise<void> {
if (!this.activeSession) {
return;
}
const activeSession = this.activeSession;
this.activeSession = null;
clearTimeout(activeSession.timeoutId);
activeSession.disposeNotificationListener();
await activeSession.session.close().catch(() => undefined);
this.setState({
status: 'failed',
error: errorMessage,
startedAt: this.state.startedAt,
});
this.emitSettled();
}
private emitSettled(): void {
for (const listener of this.settledListeners) {
listener();
}
}
private setState(nextState: CodexLoginStateDto): void {
this.state = structuredClone(nextState);
for (const listener of this.listeners) {
listener(this.getState());
}
}
}

View file

@ -0,0 +1,109 @@
import { promises as fs } from 'fs';
import os from 'os';
import path from 'path';
const CODEX_ACCOUNTS_DIR = path.join(os.homedir(), '.codex', 'accounts');
interface CodexAccountsRegistry {
active_account_key?: string | null;
activeAccountKey?: string | null;
}
interface CodexAuthFile {
auth_mode?: string | null;
authMode?: string | null;
}
export interface CodexLocalAccountState {
hasArtifacts: boolean;
hasActiveChatgptAccount: boolean;
}
function encodeAccountKeyForAuthFilename(accountKey: string): string {
return Buffer.from(accountKey, 'utf8')
.toString('base64')
.replaceAll('+', '-')
.replaceAll('/', '_')
.replace(/=+$/u, '');
}
async function readJsonFile<T>(filePath: string): Promise<T | null> {
try {
const raw = await fs.readFile(filePath, 'utf8');
return JSON.parse(raw) as T;
} catch {
return null;
}
}
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
export async function detectCodexLocalAccountState(
accountsDir = CODEX_ACCOUNTS_DIR
): Promise<CodexLocalAccountState> {
try {
const entries = await fs.readdir(accountsDir, { withFileTypes: true });
const hasArtifacts = entries.some(
(entry) =>
entry.isFile() && (entry.name === 'registry.json' || entry.name.endsWith('.auth.json'))
);
if (!hasArtifacts) {
return {
hasArtifacts: false,
hasActiveChatgptAccount: false,
};
}
const registry = await readJsonFile<CodexAccountsRegistry>(
path.join(accountsDir, 'registry.json')
);
const activeAccountKey =
registry?.active_account_key?.trim() || registry?.activeAccountKey?.trim() || null;
if (!activeAccountKey) {
return {
hasArtifacts: true,
hasActiveChatgptAccount: false,
};
}
const authFilePath = path.join(
accountsDir,
`${encodeAccountKeyForAuthFilename(activeAccountKey)}.auth.json`
);
if (!(await fileExists(authFilePath))) {
return {
hasArtifacts: true,
hasActiveChatgptAccount: false,
};
}
const authFile = await readJsonFile<CodexAuthFile>(authFilePath);
const authMode = authFile?.auth_mode ?? authFile?.authMode ?? null;
return {
hasArtifacts: true,
hasActiveChatgptAccount: authMode === 'chatgpt',
};
} catch {
return {
hasArtifacts: false,
hasActiveChatgptAccount: false,
};
}
}
export async function detectCodexLocalAccountArtifacts(
accountsDir = CODEX_ACCOUNTS_DIR
): Promise<boolean> {
const state = await detectCodexLocalAccountState(accountsDir);
return state.hasArtifacts;
}

View file

@ -0,0 +1,40 @@
import {
CODEX_ACCOUNT_CANCEL_CHATGPT_LOGIN,
CODEX_ACCOUNT_GET_SNAPSHOT,
CODEX_ACCOUNT_LOGOUT,
CODEX_ACCOUNT_REFRESH_SNAPSHOT,
CODEX_ACCOUNT_SNAPSHOT_CHANGED,
CODEX_ACCOUNT_START_CHATGPT_LOGIN,
type CodexAccountElectronApi,
} from '@features/codex-account/contracts';
import type { IpcRenderer } from 'electron';
interface CreateCodexAccountBridgeDeps {
ipcRenderer: IpcRenderer;
}
export function createCodexAccountBridge({
ipcRenderer,
}: CreateCodexAccountBridgeDeps): CodexAccountElectronApi {
return {
getCodexAccountSnapshot: () => ipcRenderer.invoke(CODEX_ACCOUNT_GET_SNAPSHOT),
refreshCodexAccountSnapshot: (options) =>
ipcRenderer.invoke(CODEX_ACCOUNT_REFRESH_SNAPSHOT, options),
startCodexChatgptLogin: () => ipcRenderer.invoke(CODEX_ACCOUNT_START_CHATGPT_LOGIN),
cancelCodexChatgptLogin: () => ipcRenderer.invoke(CODEX_ACCOUNT_CANCEL_CHATGPT_LOGIN),
logoutCodexAccount: () => ipcRenderer.invoke(CODEX_ACCOUNT_LOGOUT),
onCodexAccountSnapshotChanged: (callback) => {
ipcRenderer.on(
CODEX_ACCOUNT_SNAPSHOT_CHANGED,
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
);
return (): void => {
ipcRenderer.removeListener(
CODEX_ACCOUNT_SNAPSHOT_CHANGED,
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
);
};
},
};
}

View file

@ -0,0 +1 @@
export { createCodexAccountBridge } from './createCodexAccountBridge';

View file

@ -0,0 +1,232 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api, isElectronMode } from '@renderer/api';
import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
const CODEX_PENDING_LOGIN_REFRESH_MS = 3_000;
const CODEX_VISIBLE_RATE_LIMITS_REFRESH_MS = 10_000;
const CODEX_VISIBLE_STANDARD_REFRESH_MS = 20_000;
const CODEX_HIDDEN_REFRESH_MS = 60_000;
function isDocumentVisible(): boolean {
if (typeof document === 'undefined') {
return true;
}
return document.visibilityState !== 'hidden';
}
function getRefreshIntervalMs(options: {
loginStatus: CodexAccountSnapshotDto['login']['status'] | undefined;
includeRateLimits: boolean;
visible: boolean;
}): number {
if (options.loginStatus === 'starting' || options.loginStatus === 'pending') {
return CODEX_PENDING_LOGIN_REFRESH_MS;
}
if (!options.visible) {
return CODEX_HIDDEN_REFRESH_MS;
}
return options.includeRateLimits
? CODEX_VISIBLE_RATE_LIMITS_REFRESH_MS
: CODEX_VISIBLE_STANDARD_REFRESH_MS;
}
export function useCodexAccountSnapshot(options: {
enabled: boolean;
includeRateLimits?: boolean;
}): {
snapshot: CodexAccountSnapshotDto | null;
loading: boolean;
error: string | null;
refresh: (options?: {
includeRateLimits?: boolean;
forceRefreshToken?: boolean;
silent?: boolean;
}) => Promise<void>;
startChatgptLogin: () => Promise<boolean>;
cancelChatgptLogin: () => Promise<boolean>;
logout: () => Promise<boolean>;
} {
const electronMode = isElectronMode();
const [snapshot, setSnapshot] = useState<CodexAccountSnapshotDto | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [visible, setVisible] = useState(() => isDocumentVisible());
const lastUpdatedAtRef = useRef<number | null>(null);
const applySnapshot = useCallback((nextSnapshot: CodexAccountSnapshotDto) => {
lastUpdatedAtRef.current = Date.now();
setSnapshot(nextSnapshot);
setError(null);
}, []);
const refresh = useCallback(
async (refreshOptions?: {
includeRateLimits?: boolean;
forceRefreshToken?: boolean;
silent?: boolean;
}) => {
if (!electronMode || !options.enabled) {
return;
}
const silent = refreshOptions?.silent === true;
if (!silent) {
setLoading(true);
setError(null);
}
try {
const nextSnapshot = await api.refreshCodexAccountSnapshot({
includeRateLimits: refreshOptions?.includeRateLimits ?? options.includeRateLimits,
forceRefreshToken: refreshOptions?.forceRefreshToken,
});
applySnapshot(nextSnapshot);
} catch (nextError) {
if (!silent) {
setError(
nextError instanceof Error ? nextError.message : 'Failed to refresh Codex account'
);
}
} finally {
if (!silent) {
setLoading(false);
}
}
},
[applySnapshot, electronMode, options.enabled, options.includeRateLimits]
);
useEffect(() => {
if (!electronMode || !options.enabled) {
return;
}
setLoading(true);
setError(null);
const initialSnapshotRequest = options.includeRateLimits
? api.refreshCodexAccountSnapshot({
includeRateLimits: true,
})
: api.getCodexAccountSnapshot();
void initialSnapshotRequest
.then((nextSnapshot) => {
applySnapshot(nextSnapshot);
})
.catch((nextError) => {
setError(nextError instanceof Error ? nextError.message : 'Failed to load Codex account');
})
.finally(() => {
setLoading(false);
});
const unsubscribe = api.onCodexAccountSnapshotChanged((_event, nextSnapshot) => {
applySnapshot(nextSnapshot);
});
return unsubscribe;
}, [applySnapshot, electronMode, options.enabled, options.includeRateLimits]);
useEffect(() => {
if (!electronMode || !options.enabled || typeof document === 'undefined') {
return;
}
const handleVisibilityChange = (): void => {
const nextVisible = isDocumentVisible();
setVisible(nextVisible);
if (!nextVisible) {
return;
}
const staleAfterMs = options.includeRateLimits
? CODEX_VISIBLE_RATE_LIMITS_REFRESH_MS
: CODEX_VISIBLE_STANDARD_REFRESH_MS;
if (
lastUpdatedAtRef.current === null ||
Date.now() - lastUpdatedAtRef.current >= staleAfterMs
) {
void refresh({
includeRateLimits: options.includeRateLimits,
silent: true,
});
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [electronMode, options.enabled, options.includeRateLimits, refresh]);
useEffect(() => {
if (!electronMode || !options.enabled) {
return;
}
const refreshIntervalMs = getRefreshIntervalMs({
loginStatus: snapshot?.login.status,
includeRateLimits: options.includeRateLimits === true,
visible,
});
const intervalId = window.setInterval(() => {
void refresh({
includeRateLimits: options.includeRateLimits,
silent: true,
});
}, refreshIntervalMs);
return () => {
window.clearInterval(intervalId);
};
}, [
electronMode,
options.enabled,
options.includeRateLimits,
refresh,
snapshot?.login.status,
visible,
]);
const runAction = useCallback(
async (runner: () => Promise<CodexAccountSnapshotDto>): Promise<boolean> => {
if (!electronMode || !options.enabled) {
return false;
}
setLoading(true);
setError(null);
try {
const nextSnapshot = await runner();
applySnapshot(nextSnapshot);
return true;
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : 'Codex account action failed');
return false;
} finally {
setLoading(false);
}
},
[applySnapshot, electronMode, options.enabled]
);
return useMemo(
() => ({
snapshot,
loading,
error,
refresh,
startChatgptLogin: () => runAction(() => api.startCodexChatgptLogin()),
cancelChatgptLogin: () => runAction(() => api.cancelCodexChatgptLogin()),
logout: () => runAction(() => api.logoutCodexAccount()),
}),
[error, loading, refresh, runAction, snapshot]
);
}

View file

@ -0,0 +1,14 @@
export { useCodexAccountSnapshot } from './hooks/useCodexAccountSnapshot';
export { mergeCodexCliStatusWithSnapshot } from './mergeCodexCliStatusWithSnapshot';
export { mergeCodexProviderStatusWithSnapshot } from './mergeCodexProviderStatusWithSnapshot';
export {
formatCodexCreditsValue,
formatCodexRemainingPercent,
formatCodexResetWindowLabel,
formatCodexUsageExplanation,
formatCodexUsagePercent,
formatCodexUsageWindowLabel,
formatCodexWindowDuration,
formatCodexWindowDurationLong,
normalizeCodexResetTimestamp,
} from './rateLimitDisplay';

View file

@ -0,0 +1,26 @@
import { mergeCodexProviderStatusWithSnapshot } from './mergeCodexProviderStatusWithSnapshot';
import type { CodexAccountSnapshotDto } from '../contracts';
import type { CliInstallationStatus } from '@shared/types';
export function mergeCodexCliStatusWithSnapshot(
cliStatus: CliInstallationStatus | null,
snapshot: CodexAccountSnapshotDto | null
): CliInstallationStatus | null {
if (!cliStatus || !snapshot) {
return cliStatus;
}
if (!cliStatus.providers.some((provider) => provider.providerId === 'codex')) {
return cliStatus;
}
return {
...cliStatus,
providers: cliStatus.providers.map((provider) =>
provider.providerId === 'codex'
? mergeCodexProviderStatusWithSnapshot(provider, snapshot)
: provider
),
};
}

View file

@ -0,0 +1,216 @@
import type { CodexAccountSnapshotDto } from '../contracts';
import type { CliProviderStatus } from '@shared/types';
const CODEX_NATIVE_BACKEND_ID = 'codex-native';
const CODEX_NATIVE_LABEL = 'Codex native';
const CODEX_NATIVE_DESCRIPTION = 'Use codex exec JSON mode.';
const DEFAULT_CODEX_AUTH_MODES = ['auto', 'chatgpt', 'api_key'] as const;
function isCodexBootstrapPlaceholder(provider: CliProviderStatus): boolean {
return (
provider.providerId === 'codex' &&
provider.supported === false &&
provider.statusMessage === 'Checking...' &&
provider.models.length === 0 &&
provider.backend == null
);
}
function getCodexNativeBackendTruth(
snapshot: CodexAccountSnapshotDto
): Pick<
NonNullable<CliProviderStatus['availableBackends']>[number],
'available' | 'selectable' | 'state' | 'statusMessage' | 'detailMessage'
> {
switch (snapshot.launchReadinessState) {
case 'ready_chatgpt':
case 'ready_api_key':
case 'ready_both':
return {
available: true,
selectable: true,
state: snapshot.appServerState === 'degraded' ? 'degraded' : 'ready',
statusMessage:
snapshot.appServerState === 'degraded'
? (snapshot.launchIssueMessage ??
snapshot.appServerStatusMessage ??
'Ready with degraded account verification.')
: 'Ready',
detailMessage: snapshot.appServerStatusMessage,
};
case 'warning_degraded_but_launchable':
return {
available: true,
selectable: true,
state: 'degraded',
statusMessage:
snapshot.launchIssueMessage ??
snapshot.appServerStatusMessage ??
'Ready with degraded account verification.',
detailMessage: snapshot.appServerStatusMessage,
};
case 'runtime_missing':
return {
available: false,
selectable: false,
state: 'runtime-missing',
statusMessage:
snapshot.launchIssueMessage ?? snapshot.appServerStatusMessage ?? 'Runtime missing',
detailMessage: snapshot.appServerStatusMessage,
};
case 'incompatible':
return {
available: false,
selectable: false,
state: 'disabled',
statusMessage:
snapshot.launchIssueMessage ?? snapshot.appServerStatusMessage ?? 'Runtime incompatible',
detailMessage: snapshot.appServerStatusMessage,
};
case 'missing_auth':
default:
return {
available: false,
selectable: true,
state: 'authentication-required',
statusMessage:
snapshot.launchIssueMessage ??
'Connect a ChatGPT account or add OPENAI_API_KEY / CODEX_API_KEY to use Codex.',
detailMessage: snapshot.appServerStatusMessage,
};
}
}
function getProviderStatusMessage(
snapshot: CodexAccountSnapshotDto,
fallback: string | null | undefined
): string | null {
if (snapshot.launchAllowed) {
if (snapshot.effectiveAuthMode === 'chatgpt') {
return snapshot.appServerState === 'degraded'
? (snapshot.launchIssueMessage ??
'ChatGPT account detected - account verification is currently degraded.')
: 'ChatGPT account ready';
}
if (snapshot.effectiveAuthMode === 'api_key') {
return 'API key ready';
}
}
return snapshot.launchIssueMessage ?? snapshot.appServerStatusMessage ?? fallback ?? null;
}
function mergeCodexNativeBackendOption(
provider: CliProviderStatus,
snapshot: CodexAccountSnapshotDto
): NonNullable<CliProviderStatus['availableBackends']> {
const truth = getCodexNativeBackendTruth(snapshot);
const existingOptions = provider.availableBackends ?? [];
const hasCodexNativeOption = existingOptions.some(
(option) => option.id === CODEX_NATIVE_BACKEND_ID
);
const baseOptions = hasCodexNativeOption
? existingOptions
: [
...existingOptions,
{
id: CODEX_NATIVE_BACKEND_ID,
label: CODEX_NATIVE_LABEL,
description: CODEX_NATIVE_DESCRIPTION,
selectable: true,
recommended: true,
available: true,
state: 'ready' as const,
audience: 'general' as const,
statusMessage: null,
detailMessage: null,
},
];
return baseOptions.map((option) => {
if (option.id !== CODEX_NATIVE_BACKEND_ID) {
return option;
}
return {
...option,
label: option.label || CODEX_NATIVE_LABEL,
description: option.description || CODEX_NATIVE_DESCRIPTION,
recommended: option.recommended !== false,
audience: option.audience ?? 'general',
...truth,
};
});
}
export function mergeCodexProviderStatusWithSnapshot(
provider: CliProviderStatus,
snapshot: CodexAccountSnapshotDto | null
): CliProviderStatus {
if (provider.providerId !== 'codex' || !snapshot) {
return provider;
}
const availableBackends = mergeCodexNativeBackendOption(provider, snapshot);
const baseConnection = provider.connection ?? {
supportsOAuth: false,
supportsApiKey: true,
configurableAuthModes: [...DEFAULT_CODEX_AUTH_MODES],
configuredAuthMode: snapshot.preferredAuthMode,
apiKeyConfigured: snapshot.apiKey.available,
apiKeySource: snapshot.apiKey.source,
apiKeySourceLabel: snapshot.apiKey.sourceLabel,
codex: null,
};
return {
...provider,
supported: provider.supported || isCodexBootstrapPlaceholder(provider),
authenticated: snapshot.launchAllowed,
authMethod:
snapshot.effectiveAuthMode === 'chatgpt'
? 'chatgpt'
: snapshot.effectiveAuthMode === 'api_key'
? 'api_key'
: null,
verificationState: snapshot.launchAllowed
? 'verified'
: snapshot.appServerState === 'runtime-missing' || snapshot.appServerState === 'incompatible'
? 'error'
: 'unknown',
statusMessage: getProviderStatusMessage(snapshot, provider.statusMessage),
selectedBackendId: CODEX_NATIVE_BACKEND_ID,
resolvedBackendId: CODEX_NATIVE_BACKEND_ID,
availableBackends,
backend: {
kind: CODEX_NATIVE_BACKEND_ID,
label: CODEX_NATIVE_LABEL,
endpointLabel: 'codex exec --json',
projectId: provider.backend?.projectId ?? null,
authMethodDetail: snapshot.effectiveAuthMode ?? null,
},
connection: {
...baseConnection,
configuredAuthMode: snapshot.preferredAuthMode,
apiKeyConfigured: snapshot.apiKey.available,
apiKeySource: snapshot.apiKey.source,
apiKeySourceLabel: snapshot.apiKey.sourceLabel,
codex: {
preferredAuthMode: snapshot.preferredAuthMode,
effectiveAuthMode: snapshot.effectiveAuthMode,
launchAllowed: snapshot.launchAllowed,
launchIssueMessage: snapshot.launchIssueMessage,
launchReadinessState: snapshot.launchReadinessState,
appServerState: snapshot.appServerState,
appServerStatusMessage: snapshot.appServerStatusMessage,
managedAccount: snapshot.managedAccount,
requiresOpenaiAuth: snapshot.requiresOpenaiAuth,
localAccountArtifactsPresent: snapshot.localAccountArtifactsPresent,
localActiveChatgptAccountPresent: snapshot.localActiveChatgptAccountPresent,
login: snapshot.login,
rateLimits: snapshot.rateLimits,
},
},
};
}

View file

@ -0,0 +1,129 @@
import type { CodexRateLimitSnapshotDto } from '../contracts';
export function normalizeCodexResetTimestamp(resetAt: number | null | undefined): number | null {
if (typeof resetAt !== 'number' || !Number.isFinite(resetAt) || resetAt <= 0) {
return null;
}
return resetAt < 1_000_000_000_000 ? resetAt * 1000 : resetAt;
}
export function formatCodexWindowDuration(
windowDurationMins: number | null | undefined
): string | null {
if (
typeof windowDurationMins !== 'number' ||
!Number.isFinite(windowDurationMins) ||
windowDurationMins <= 0
) {
return null;
}
if (windowDurationMins % 10_080 === 0) {
return `${windowDurationMins / 10_080}w`;
}
if (windowDurationMins % 1_440 === 0) {
return `${windowDurationMins / 1_440}d`;
}
if (windowDurationMins % 60 === 0) {
return `${windowDurationMins / 60}h`;
}
return `${windowDurationMins}m`;
}
export function formatCodexWindowDurationLong(
windowDurationMins: number | null | undefined
): string | null {
if (
typeof windowDurationMins !== 'number' ||
!Number.isFinite(windowDurationMins) ||
windowDurationMins <= 0
) {
return null;
}
if (windowDurationMins % 10_080 === 0) {
const weeks = windowDurationMins / 10_080;
return weeks === 1 ? '7-day' : `${weeks}-week`;
}
if (windowDurationMins % 1_440 === 0) {
const days = windowDurationMins / 1_440;
return days === 1 ? '1-day' : `${days}-day`;
}
if (windowDurationMins % 60 === 0) {
const hours = windowDurationMins / 60;
return hours === 1 ? '1-hour' : `${hours}-hour`;
}
return `${windowDurationMins}-minute`;
}
export function formatCodexUsageWindowLabel(
title: 'Primary used' | 'Secondary used' | 'Weekly used',
windowDurationMins: number | null | undefined
): string {
const duration = formatCodexWindowDuration(windowDurationMins);
return duration ? `${title} (${duration})` : title;
}
export function formatCodexResetWindowLabel(
title: 'Primary reset' | 'Secondary reset' | 'Weekly reset',
windowDurationMins: number | null | undefined
): string {
const duration = formatCodexWindowDuration(windowDurationMins);
return duration ? `${title} (${duration})` : title;
}
export function formatCodexUsagePercent(usedPercent: number | null | undefined): string {
return typeof usedPercent === 'number' && Number.isFinite(usedPercent)
? `${usedPercent}%`
: 'Unknown';
}
export function formatCodexRemainingPercent(usedPercent: number | null | undefined): string | null {
if (typeof usedPercent !== 'number' || !Number.isFinite(usedPercent)) {
return null;
}
const remaining = Math.max(0, Math.min(100, 100 - usedPercent));
return `${remaining}%`;
}
export function formatCodexUsageExplanation(
usedPercent: number | null | undefined,
windowDurationMins: number | null | undefined
): string {
const windowLabel = formatCodexWindowDurationLong(windowDurationMins);
const remaining = formatCodexRemainingPercent(usedPercent);
if (windowLabel && remaining) {
return `${formatCodexUsagePercent(usedPercent)} used - about ${remaining} left in the current ${windowLabel} window.`;
}
if (windowLabel) {
return `Shows used quota in the current ${windowLabel} window, not remaining quota.`;
}
return 'Shows used quota, not remaining quota.';
}
export function formatCodexCreditsValue(credits: CodexRateLimitSnapshotDto['credits']): string {
if (!credits) {
return 'Unknown';
}
if (credits.unlimited) {
return 'Unlimited';
}
if (!credits.hasCredits) {
return 'Not available';
}
return credits.balance ?? 'Unknown';
}

View file

@ -9,12 +9,14 @@ import { ClaudeRecentProjectsSourceAdapter } from '../adapters/output/sources/Cl
import { CodexRecentProjectsSourceAdapter } from '../adapters/output/sources/CodexRecentProjectsSourceAdapter'; import { CodexRecentProjectsSourceAdapter } from '../adapters/output/sources/CodexRecentProjectsSourceAdapter';
import { InMemoryRecentProjectsCache } from '../infrastructure/cache/InMemoryRecentProjectsCache'; import { InMemoryRecentProjectsCache } from '../infrastructure/cache/InMemoryRecentProjectsCache';
import { CodexAppServerClient } from '../infrastructure/codex/CodexAppServerClient'; import { CodexAppServerClient } from '../infrastructure/codex/CodexAppServerClient';
import { CodexBinaryResolver } from '../infrastructure/codex/CodexBinaryResolver';
import { JsonRpcStdioClient } from '../infrastructure/codex/JsonRpcStdioClient';
import { RecentProjectIdentityResolver } from '../infrastructure/identity/RecentProjectIdentityResolver'; import { RecentProjectIdentityResolver } from '../infrastructure/identity/RecentProjectIdentityResolver';
import type { ClockPort } from '../../core/application/ports/ClockPort'; import type { ClockPort } from '../../core/application/ports/ClockPort';
import type { LoggerPort } from '../../core/application/ports/LoggerPort'; import type { LoggerPort } from '../../core/application/ports/LoggerPort';
import {
CodexBinaryResolver,
JsonRpcStdioClient,
} from '@main/services/infrastructure/codexAppServer';
import type { ServiceContext } from '@main/services'; import type { ServiceContext } from '@main/services';
export interface RecentProjectsFeatureFacade { export interface RecentProjectsFeatureFacade {

View file

@ -1,4 +1,7 @@
import type { JsonRpcSession, JsonRpcStdioClient } from './JsonRpcStdioClient'; import type {
JsonRpcSession,
JsonRpcStdioClient,
} from '@main/services/infrastructure/codexAppServer';
const DEFAULT_REQUEST_TIMEOUT_MS = 3_000; const DEFAULT_REQUEST_TIMEOUT_MS = 3_000;
const DEFAULT_TOTAL_TIMEOUT_MS = 8_000; const DEFAULT_TOTAL_TIMEOUT_MS = 8_000;

View file

@ -19,6 +19,12 @@ process.env.UV_THREADPOOL_SIZE ??= '16';
// Sentry must be the first import to capture early errors. // Sentry must be the first import to capture early errors.
import './sentry'; import './sentry';
import {
createCodexAccountFeature,
type CodexAccountFeatureFacade,
registerCodexAccountIpc,
removeCodexAccountIpc,
} from '@features/codex-account/main';
import { import {
createRecentProjectsFeature, createRecentProjectsFeature,
type RecentProjectsFeatureFacade, type RecentProjectsFeatureFacade,
@ -39,6 +45,7 @@ import { TeamConfigReader } from '@main/services/team/TeamConfigReader';
import { TeamInboxWriter } from '@main/services/team/TeamInboxWriter'; import { TeamInboxWriter } from '@main/services/team/TeamInboxWriter';
import { TeamMcpConfigBuilder } from '@main/services/team/TeamMcpConfigBuilder'; import { TeamMcpConfigBuilder } from '@main/services/team/TeamMcpConfigBuilder';
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import { providerConnectionService } from '@main/services/runtime/ProviderConnectionService';
import { import {
CONTEXT_CHANGED, CONTEXT_CHANGED,
SCHEDULE_CHANGE, SCHEDULE_CHANGE,
@ -414,6 +421,7 @@ let contextRegistry: ServiceContextRegistry;
let notificationManager: NotificationManager; let notificationManager: NotificationManager;
let updaterService: UpdaterService; let updaterService: UpdaterService;
let sshConnectionManager: SshConnectionManager; let sshConnectionManager: SshConnectionManager;
let codexAccountFeature: CodexAccountFeatureFacade | null = null;
let recentProjectsFeature: RecentProjectsFeatureFacade; let recentProjectsFeature: RecentProjectsFeatureFacade;
let teamDataService: TeamDataService; let teamDataService: TeamDataService;
let teamProvisioningService: TeamProvisioningService; let teamProvisioningService: TeamProvisioningService;
@ -975,6 +983,11 @@ async function initializeServices(): Promise<void> {
getLocalContext: () => contextRegistry.get('local'), getLocalContext: () => contextRegistry.get('local'),
logger: createLogger('Feature:RecentProjects'), logger: createLogger('Feature:RecentProjects'),
}); });
codexAccountFeature = createCodexAccountFeature({
logger: createLogger('Feature:CodexAccount'),
configManager,
});
providerConnectionService.setCodexAccountFeature(codexAccountFeature);
// startProcessHealthPolling() is deferred to after window creation // startProcessHealthPolling() is deferred to after window creation
// (did-finish-load handler) to avoid thread pool contention at startup. // (did-finish-load handler) to avoid thread pool contention at startup.
@ -1029,6 +1042,7 @@ async function initializeServices(): Promise<void> {
crossTeamService, crossTeamService,
teamBackupService ?? undefined teamBackupService ?? undefined
); );
registerCodexAccountIpc(ipcMain, codexAccountFeature);
registerRecentProjectsIpc(ipcMain, recentProjectsFeature); registerRecentProjectsIpc(ipcMain, recentProjectsFeature);
// Forward SSH state changes to renderer and HTTP SSE clients // Forward SSH state changes to renderer and HTTP SSE clients
@ -1171,6 +1185,9 @@ function shutdownServices(): void {
} }
void skillsWatcherService?.stopAll(); void skillsWatcherService?.stopAll();
providerConnectionService.setCodexAccountFeature(null);
void codexAccountFeature?.dispose();
codexAccountFeature = null;
// Kill all PTY processes // Kill all PTY processes
if (ptyTerminalService) { if (ptyTerminalService) {
@ -1179,6 +1196,7 @@ function shutdownServices(): void {
// Remove IPC handlers // Remove IPC handlers
removeIpcHandlers(); removeIpcHandlers();
removeCodexAccountIpc(ipcMain);
removeRecentProjectsIpc(ipcMain); removeRecentProjectsIpc(ipcMain);
// Dispose backup service timers // Dispose backup service timers
@ -1458,6 +1476,7 @@ function createWindow(): void {
if (teamProvisioningService) { if (teamProvisioningService) {
teamProvisioningService.setMainWindow(null); teamProvisioningService.setMainWindow(null);
} }
codexAccountFeature?.setMainWindow(null);
setEditorMainWindow(null); setEditorMainWindow(null);
setReviewMainWindow(null); setReviewMainWindow(null);
cleanupEditorState(); cleanupEditorState();
@ -1492,6 +1511,7 @@ function createWindow(): void {
if (teamProvisioningService) { if (teamProvisioningService) {
teamProvisioningService.setMainWindow(mainWindow); teamProvisioningService.setMainWindow(mainWindow);
} }
codexAccountFeature?.setMainWindow(mainWindow);
setEditorMainWindow(mainWindow); setEditorMainWindow(mainWindow);
setReviewMainWindow(mainWindow); setReviewMainWindow(mainWindow);

View file

@ -529,6 +529,23 @@ function validateProviderConnectionsSection(
continue; continue;
} }
if (connectionKey === 'preferredAuthMode') {
if (
connectionValue !== 'auto' &&
connectionValue !== 'chatgpt' &&
connectionValue !== 'api_key'
) {
return {
valid: false,
error:
'providerConnections.codex.preferredAuthMode must be one of: auto, chatgpt, api_key',
};
}
codexUpdate.preferredAuthMode = connectionValue;
continue;
}
return { return {
valid: false, valid: false,
error: `providerConnections.codex.${connectionKey} is not a valid setting`, error: `providerConnections.codex.${connectionKey} is not a valid setting`,

View file

@ -71,6 +71,9 @@ const CLI_INSTALLER_PROGRESS_CHANNEL = 'cliInstaller:progress';
/** Timeout for `claude --version` (ms) */ /** Timeout for `claude --version` (ms) */
const VERSION_TIMEOUT_MS = 10_000; const VERSION_TIMEOUT_MS = 10_000;
const VERSION_RETRY_ATTEMPTS = 2;
const VERSION_RETRY_DELAY_MS = 350;
const HEALTHY_STATUS_FALLBACK_TTL_MS = 60_000;
/** Timeout for `claude install` (ms) — can take a while on slow disks */ /** Timeout for `claude install` (ms) — can take a while on slow disks */
const INSTALL_TIMEOUT_MS = 120_000; const INSTALL_TIMEOUT_MS = 120_000;
@ -134,6 +137,10 @@ function clipTailForDiag(s: string, maxLen: number): string {
return stripControlForDiag(s).slice(-maxLen); return stripControlForDiag(s).slice(-maxLen);
} }
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const DIAG_PATH_HEAD = 400; const DIAG_PATH_HEAD = 400;
const DIAG_HOME_PREVIEW = 120; const DIAG_HOME_PREVIEW = 120;
const DIAG_AUTH_STDOUT_TAIL = 160; const DIAG_AUTH_STDOUT_TAIL = 160;
@ -355,8 +362,36 @@ export class CliInstallerService {
} }
); );
private latestStatusSnapshot: CliInstallationStatus | null = null; private latestStatusSnapshot: CliInstallationStatus | null = null;
private lastHealthyStatusSnapshot: CliInstallationStatus | null = null;
private lastHealthyStatusObservedAt = 0;
private readonly latestProviderSignatures = new Map<CliProviderId, string | null>(); private readonly latestProviderSignatures = new Map<CliProviderId, string | null>();
private rememberHealthyStatus(status: CliInstallationStatus): void {
if (!status.installed || !status.binaryPath || status.launchError) {
return;
}
this.lastHealthyStatusSnapshot = cloneCliInstallationStatus(status);
this.lastHealthyStatusObservedAt = Date.now();
}
private getRecoverableHealthyStatus(binaryPath: string): CliInstallationStatus | null {
if (
!this.lastHealthyStatusSnapshot ||
!this.lastHealthyStatusSnapshot.installed ||
!this.lastHealthyStatusSnapshot.binaryPath ||
this.lastHealthyStatusSnapshot.binaryPath !== binaryPath
) {
return null;
}
if (Date.now() - this.lastHealthyStatusObservedAt > HEALTHY_STATUS_FALLBACK_TTL_MS) {
return null;
}
return cloneCliInstallationStatus(this.lastHealthyStatusSnapshot);
}
private electronMetaForDiag(): Record<string, unknown> { private electronMetaForDiag(): Record<string, unknown> {
try { try {
// eslint-disable-next-line @typescript-eslint/no-require-imports // eslint-disable-next-line @typescript-eslint/no-require-imports
@ -764,6 +799,7 @@ export class CliInstallerService {
r.installedVersion = versionProbe.version; r.installedVersion = versionProbe.version;
r.launchError = null; r.launchError = null;
r.authStatusChecking = true; r.authStatusChecking = true;
this.rememberHealthyStatus(r);
this.publishStatusSnapshot(r); this.publishStatusSnapshot(r);
// Auth and GCS version check are independent — run in parallel. // Auth and GCS version check are independent — run in parallel.
@ -772,8 +808,21 @@ export class CliInstallerService {
this.checkAuthStatus(binaryPath, r, diag), this.checkAuthStatus(binaryPath, r, diag),
r.supportsSelfUpdate ? this.fetchLatestVersion(r) : Promise.resolve(), r.supportsSelfUpdate ? this.fetchLatestVersion(r) : Promise.resolve(),
]); ]);
this.rememberHealthyStatus(r);
this.publishStatusSnapshot(r); this.publishStatusSnapshot(r);
} else { } else {
const recoveredHealthyStatus = this.getRecoverableHealthyStatus(binaryPath);
if (recoveredHealthyStatus) {
logger.warn(
`CLI version probe failed for ${binaryPath}, reusing last healthy status snapshot: ${versionProbe.error}`
);
Object.assign(r, recoveredHealthyStatus, {
launchError: null,
});
this.publishStatusSnapshot(r);
return;
}
diag.versionError = versionProbe.error; diag.versionError = versionProbe.error;
r.installed = false; r.installed = false;
r.installedVersion = null; r.installedVersion = null;
@ -806,37 +855,50 @@ export class CliInstallerService {
private async probeCliVersion( private async probeCliVersion(
binaryPath: string binaryPath: string
): Promise<{ ok: true; version: string | null } | { ok: false; error: string }> { ): Promise<{ ok: true; version: string | null } | { ok: false; error: string }> {
try { let lastError: string | null = null;
const { stdout } = await execCli(binaryPath, ['--version'], {
timeout: VERSION_TIMEOUT_MS,
env: this.envForCli(binaryPath),
});
const version = normalizeVersion(stdout);
if (!version) {
return { ok: false, error: 'CLI returned an empty version string.' };
}
if (isSemverVersion(version)) { for (let attempt = 1; attempt <= VERSION_RETRY_ATTEMPTS; attempt += 1) {
logger.info(`Installed CLI version: "${stdout.trim()}" → normalized: "${version}"`); try {
return { ok: true, version }; const { stdout } = await execCli(binaryPath, ['--version'], {
} timeout: VERSION_TIMEOUT_MS,
env: this.envForCli(binaryPath),
});
const version = normalizeVersion(stdout);
if (!version) {
return { ok: false, error: 'CLI returned an empty version string.' };
}
const inferredVersion = await this.inferInstalledCliVersionFromPath(binaryPath); if (isSemverVersion(version)) {
if (inferredVersion) { logger.info(`Installed CLI version: "${stdout.trim()}" → normalized: "${version}"`);
logger.info( return { ok: true, version };
`Installed CLI version was inferred from installer path: "${stdout.trim()}" → "${inferredVersion}"` }
const inferredVersion = await this.inferInstalledCliVersionFromPath(binaryPath);
if (inferredVersion) {
logger.info(
`Installed CLI version was inferred from installer path: "${stdout.trim()}" → "${inferredVersion}"`
);
return { ok: true, version: inferredVersion };
}
logger.warn(
`Installed CLI returned a non-semver version string: "${stdout.trim()}". ` +
'Treating the binary as healthy, but omitting version details.'
); );
return { ok: true, version: inferredVersion }; return { ok: true, version: null };
} catch (err) {
lastError = getErrorMessage(err);
if (attempt < VERSION_RETRY_ATTEMPTS) {
logger.warn(
`CLI version probe failed (attempt ${attempt}/${VERSION_RETRY_ATTEMPTS}), retrying after ${VERSION_RETRY_DELAY_MS}ms: ${lastError}`
);
await sleep(VERSION_RETRY_DELAY_MS);
continue;
}
} }
logger.warn(
`Installed CLI returned a non-semver version string: "${stdout.trim()}". ` +
'Treating the binary as healthy, but omitting version details.'
);
return { ok: true, version: null };
} catch (err) {
return { ok: false, error: getErrorMessage(err) };
} }
return { ok: false, error: lastError ?? 'Failed to run runtime version probe.' };
} }
private async inferInstalledCliVersionFromPath(binaryPath: string): Promise<string | null> { private async inferInstalledCliVersionFromPath(binaryPath: string): Promise<string | null> {

View file

@ -11,14 +11,15 @@
import { getClaudeBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder'; import { getClaudeBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder';
import { validateRegexPattern } from '@main/utils/regexValidation'; import { validateRegexPattern } from '@main/utils/regexValidation';
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
import * as fs from 'fs'; import * as fs from 'fs';
import * as fsp from 'fs/promises'; import * as fsp from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
import { DEFAULT_TRIGGERS, TriggerManager } from './TriggerManager'; import { DEFAULT_TRIGGERS, TriggerManager } from './TriggerManager';
import type { CodexAccountAuthMode } from '@features/codex-account/contracts';
import type { TriggerColor } from '@shared/constants/triggerColors'; import type { TriggerColor } from '@shared/constants/triggerColors';
import type { SshConnectionProfile } from '@shared/types/api'; import type { SshConnectionProfile } from '@shared/types/api';
@ -237,7 +238,9 @@ export interface ProviderConnectionsConfig {
anthropic: { anthropic: {
authMode: ProviderConnectionAuthMode; authMode: ProviderConnectionAuthMode;
}; };
codex: Record<string, never>; codex: {
preferredAuthMode: CodexAccountAuthMode;
};
} }
export interface DisplayConfig { export interface DisplayConfig {
@ -331,7 +334,9 @@ const DEFAULT_CONFIG: AppConfig = {
anthropic: { anthropic: {
authMode: 'auto', authMode: 'auto',
}, },
codex: {}, codex: {
preferredAuthMode: 'auto',
},
}, },
runtime: { runtime: {
providerBackends: { providerBackends: {
@ -392,6 +397,27 @@ function normalizeConfiguredClaudeRootPath(value: unknown): string | null {
return resolved.slice(0, end); return resolved.slice(0, end);
} }
function normalizeCodexPreferredAuthMode(
currentValue: unknown,
legacyValue?: unknown
): CodexAccountAuthMode {
const candidate = currentValue ?? legacyValue;
if (candidate === 'chatgpt' || candidate === 'api_key' || candidate === 'auto') {
return candidate;
}
if (candidate === 'oauth') {
return 'chatgpt';
}
return DEFAULT_CONFIG.providerConnections.codex.preferredAuthMode;
}
function shouldPersistNormalizedConfig(loaded: Partial<AppConfig>, normalized: AppConfig): boolean {
return JSON.stringify(loaded) !== JSON.stringify(normalized);
}
// =========================================================================== // ===========================================================================
// ConfigManager Class // ConfigManager Class
// =========================================================================== // ===========================================================================
@ -443,9 +469,14 @@ export class ConfigManager {
try { try {
const content = fs.readFileSync(this.configPath, 'utf8'); const content = fs.readFileSync(this.configPath, 'utf8');
const parsed = JSON.parse(content) as Partial<AppConfig>; const parsed = JSON.parse(content) as Partial<AppConfig>;
const merged = this.mergeWithDefaults(parsed);
if (shouldPersistNormalizedConfig(parsed, merged)) {
this.persistConfig(merged);
}
// Merge with defaults to ensure all fields exist // Merge with defaults to ensure all fields exist
return this.mergeWithDefaults(parsed); return merged;
} catch (error) { } catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') { if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
logger.info('No config file found, using defaults'); logger.info('No config file found, using defaults');
@ -560,7 +591,12 @@ export class ConfigManager {
...DEFAULT_CONFIG.providerConnections.anthropic, ...DEFAULT_CONFIG.providerConnections.anthropic,
...(loaded.providerConnections?.anthropic ?? {}), ...(loaded.providerConnections?.anthropic ?? {}),
}, },
codex: {}, codex: {
preferredAuthMode: normalizeCodexPreferredAuthMode(
loaded.providerConnections?.codex?.preferredAuthMode,
(loaded.providerConnections?.codex as { authMode?: unknown } | undefined)?.authMode
),
},
}, },
runtime: { runtime: {
providerBackends: { providerBackends: {
@ -655,6 +691,10 @@ export class ConfigManager {
providerBackends: { providerBackends: {
...this.config.runtime.providerBackends, ...this.config.runtime.providerBackends,
...runtimeUpdate.providerBackends, ...runtimeUpdate.providerBackends,
codex: migrateProviderBackendId(
'codex',
runtimeUpdate.providerBackends?.codex ?? this.config.runtime.providerBackends.codex
) as RuntimeConfig['providerBackends']['codex'],
}, },
} as unknown as Partial<AppConfig[K]>; } as unknown as Partial<AppConfig[K]>;
} }
@ -670,6 +710,10 @@ export class ConfigManager {
codex: { codex: {
...this.config.providerConnections.codex, ...this.config.providerConnections.codex,
...(connectionUpdate.codex ?? {}), ...(connectionUpdate.codex ?? {}),
preferredAuthMode: normalizeCodexPreferredAuthMode(
connectionUpdate.codex?.preferredAuthMode,
(connectionUpdate.codex as { authMode?: unknown } | undefined)?.authMode
),
}, },
} as unknown as Partial<AppConfig[K]>; } as unknown as Partial<AppConfig[K]>;
} }

View file

@ -0,0 +1,135 @@
import type { JsonRpcSession, JsonRpcStdioClient } from './JsonRpcStdioClient';
import type { CodexAppServerInitializeResponse } from './protocol';
const DEFAULT_INITIALIZE_TIMEOUT_MS = 6_000;
const DEFAULT_REQUEST_TIMEOUT_MS = 3_000;
const DEFAULT_TOTAL_TIMEOUT_MS = 8_000;
export const DEFAULT_CODEX_APP_SERVER_SUPPRESSED_NOTIFICATION_METHODS = [
'thread/started',
'thread/status/changed',
'thread/archived',
'thread/unarchived',
'thread/closed',
'thread/name/updated',
'turn/started',
'turn/completed',
'item/agentMessage/delta',
'item/agentReasoning/delta',
'item/execCommandOutputDelta',
];
export interface CodexAppServerSession extends JsonRpcSession {
readonly initializeResponse: CodexAppServerInitializeResponse;
}
export class CodexAppServerSessionFactory {
constructor(private readonly rpcClient: JsonRpcStdioClient) {}
async withSession<T>(
options: {
binaryPath: string;
env?: NodeJS.ProcessEnv;
requestTimeoutMs?: number;
initializeTimeoutMs?: number;
totalTimeoutMs?: number;
label: string;
experimentalApi?: boolean;
optOutNotificationMethods?: string[];
},
handler: (session: CodexAppServerSession) => Promise<T>
): Promise<T> {
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
const initializeTimeoutMs = Math.max(
options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS,
requestTimeoutMs
);
return this.rpcClient.withSession(
{
binaryPath: options.binaryPath,
args: ['app-server'],
env: options.env,
requestTimeoutMs,
totalTimeoutMs: options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS,
label: options.label,
},
async (session) => {
const initializedSession = await this.initializeSession(session, {
initializeTimeoutMs,
experimentalApi: options.experimentalApi ?? false,
optOutNotificationMethods:
options.optOutNotificationMethods ??
DEFAULT_CODEX_APP_SERVER_SUPPRESSED_NOTIFICATION_METHODS,
});
return handler(initializedSession);
}
);
}
async openSession(options: {
binaryPath: string;
env?: NodeJS.ProcessEnv;
requestTimeoutMs?: number;
initializeTimeoutMs?: number;
experimentalApi?: boolean;
optOutNotificationMethods?: string[];
}): Promise<CodexAppServerSession> {
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
const initializeTimeoutMs = Math.max(
options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS,
requestTimeoutMs
);
const session = await this.rpcClient.openSession({
binaryPath: options.binaryPath,
args: ['app-server'],
env: options.env,
requestTimeoutMs,
});
try {
return await this.initializeSession(session, {
initializeTimeoutMs,
experimentalApi: options.experimentalApi ?? false,
optOutNotificationMethods:
options.optOutNotificationMethods ??
DEFAULT_CODEX_APP_SERVER_SUPPRESSED_NOTIFICATION_METHODS,
});
} catch (error) {
await session.close().catch(() => undefined);
throw error;
}
}
private async initializeSession(
session: JsonRpcSession,
options: {
initializeTimeoutMs: number;
experimentalApi: boolean;
optOutNotificationMethods: string[];
}
): Promise<CodexAppServerSession> {
const initializeResponse = await session.request<CodexAppServerInitializeResponse>(
'initialize',
{
clientInfo: {
name: 'claude-agent-teams-ui',
title: 'Agent Teams UI',
version: '0.1.0',
},
capabilities: {
experimentalApi: options.experimentalApi,
optOutNotificationMethods: options.optOutNotificationMethods,
},
},
options.initializeTimeoutMs
);
await session.notify('initialized');
return {
...session,
initializeResponse,
};
}
}

View file

@ -3,10 +3,9 @@ import readline from 'node:readline';
import { killProcessTree, spawnCli } from '@main/utils/childProcess'; import { killProcessTree, spawnCli } from '@main/utils/childProcess';
import type { LoggerPort } from '../../../core/application/ports/LoggerPort'; interface JsonRpcLogger {
warn: (message: string, meta?: Record<string, unknown>) => void;
const DEFAULT_REQUEST_TIMEOUT_MS = 3_000; }
const DEFAULT_TOTAL_TIMEOUT_MS = 8_000;
interface JsonRpcErrorPayload { interface JsonRpcErrorPayload {
code?: number; code?: number;
@ -19,9 +18,16 @@ interface JsonRpcResponse<T> {
error?: JsonRpcErrorPayload; error?: JsonRpcErrorPayload;
} }
interface JsonRpcNotificationMessage {
method?: string;
params?: unknown;
}
export interface JsonRpcSession { export interface JsonRpcSession {
request<TResult>(method: string, params?: unknown, timeoutMs?: number): Promise<TResult>; request<TResult>(method: string, params?: unknown, timeoutMs?: number): Promise<TResult>;
notify(method: string, params?: unknown): Promise<void>; notify(method: string, params?: unknown): Promise<void>;
onNotification(listener: (method: string, params: unknown) => void): () => void;
close(): Promise<void>;
} }
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> { function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
@ -40,42 +46,48 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string):
}) as Promise<T>; }) as Promise<T>;
} }
const DEFAULT_REQUEST_TIMEOUT_MS = 3_000;
const DEFAULT_TOTAL_TIMEOUT_MS = 8_000;
export class JsonRpcStdioClient { export class JsonRpcStdioClient {
constructor(private readonly logger: LoggerPort) {} constructor(private readonly logger: JsonRpcLogger) {}
async withSession<T>( async withSession<T>(
options: { options: {
binaryPath: string; binaryPath: string;
args: string[]; args: string[];
env?: NodeJS.ProcessEnv;
requestTimeoutMs?: number; requestTimeoutMs?: number;
totalTimeoutMs?: number; totalTimeoutMs?: number;
label: string; label: string;
}, },
handler: (session: JsonRpcSession) => Promise<T> handler: (session: JsonRpcSession) => Promise<T>
): Promise<T> { ): Promise<T> {
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; const session = await this.openSession(options);
const totalTimeoutMs = options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS; const totalTimeoutMs = options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS;
return withTimeout( try {
this.#runSession(options.binaryPath, options.args, requestTimeoutMs, handler), return await withTimeout(handler(session), totalTimeoutMs, options.label);
totalTimeoutMs, } finally {
options.label await session.close();
); }
} }
async #runSession<T>( async openSession(options: {
binaryPath: string, binaryPath: string;
args: string[], args: string[];
requestTimeoutMs: number, env?: NodeJS.ProcessEnv;
handler: (session: JsonRpcSession) => Promise<T> requestTimeoutMs?: number;
): Promise<T> { }): Promise<JsonRpcSession> {
const child = spawnCli(binaryPath, args, { const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
const child = spawnCli(options.binaryPath, options.args, {
env: options.env,
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true, windowsHide: true,
}); });
const lineReader = readline.createInterface({ input: child.stdout! }); const lineReader = readline.createInterface({ input: child.stdout! });
child.stderr?.on('data', () => { child.stderr?.on('data', () => {
// Keep stderr drained so process warnings do not block the pipe. // Keep stderr drained so warnings never block the pipe.
}); });
const pending = new Map< const pending = new Map<
@ -86,8 +98,10 @@ export class JsonRpcStdioClient {
timeoutId: ReturnType<typeof setTimeout>; timeoutId: ReturnType<typeof setTimeout>;
} }
>(); >();
const notificationListeners = new Set<(method: string, params: unknown) => void>();
let nextRequestId = 1; let nextRequestId = 1;
let closed = false;
const rejectAll = (error: Error): void => { const rejectAll = (error: Error): void => {
for (const [id, entry] of pending) { for (const [id, entry] of pending) {
@ -97,10 +111,27 @@ export class JsonRpcStdioClient {
} }
}; };
const handleNotification = (message: JsonRpcNotificationMessage): void => {
if (typeof message.method !== 'string' || message.method.length === 0) {
return;
}
for (const listener of notificationListeners) {
try {
listener(message.method, message.params);
} catch (error) {
this.logger.warn('json-rpc notification listener failed', {
error: error instanceof Error ? error.message : String(error),
method: message.method,
});
}
}
};
lineReader.on('line', (line) => { lineReader.on('line', (line) => {
let message: JsonRpcResponse<unknown>; let message: JsonRpcResponse<unknown> & JsonRpcNotificationMessage;
try { try {
message = JSON.parse(line) as JsonRpcResponse<unknown>; message = JSON.parse(line) as JsonRpcResponse<unknown> & JsonRpcNotificationMessage;
} catch (error) { } catch (error) {
this.logger.warn('json-rpc stdio emitted non-json line', { this.logger.warn('json-rpc stdio emitted non-json line', {
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
@ -108,24 +139,25 @@ export class JsonRpcStdioClient {
return; return;
} }
if (typeof message.id !== 'number') { if (typeof message.id === 'number') {
const entry = pending.get(message.id);
if (!entry) {
return;
}
clearTimeout(entry.timeoutId);
pending.delete(message.id);
if (message.error) {
entry.reject(new Error(message.error.message ?? 'Unknown JSON-RPC error'));
return;
}
entry.resolve(message.result);
return; return;
} }
const entry = pending.get(message.id); handleNotification(message);
if (!entry) {
return;
}
clearTimeout(entry.timeoutId);
pending.delete(message.id);
if (message.error) {
entry.reject(new Error(message.error.message ?? 'Unknown JSON-RPC error'));
return;
}
entry.resolve(message.result);
}); });
child.once('error', (error) => { child.once('error', (error) => {
@ -144,14 +176,42 @@ export class JsonRpcStdioClient {
); );
}); });
const session: JsonRpcSession = { const close = async (): Promise<void> => {
if (closed) {
return;
}
closed = true;
rejectAll(new Error('JSON-RPC session closed'));
notificationListeners.clear();
lineReader.close();
if (child.stdin && !child.stdin.destroyed && !child.stdin.writableEnded) {
await new Promise<void>((resolve) => {
try {
child.stdin!.end(() => resolve());
} catch {
resolve();
}
});
}
killProcessTree(child);
try {
await once(child, 'close');
} catch {
this.logger.warn('json-rpc close wait failed');
}
};
return {
request: <TResult>( request: <TResult>(
method: string, method: string,
params?: unknown, params?: unknown,
timeoutMs = requestTimeoutMs timeoutMs = requestTimeoutMs
): Promise<TResult> => ): Promise<TResult> =>
new Promise<TResult>((resolve, reject) => { new Promise<TResult>((resolve, reject) => {
if (!child.stdin) { if (!child.stdin || child.stdin.destroyed || child.stdin.writableEnded) {
reject(new Error('JSON-RPC stdin is not available')); reject(new Error('JSON-RPC stdin is not available'));
return; return;
} }
@ -176,7 +236,7 @@ export class JsonRpcStdioClient {
}), }),
notify: async (method: string, params?: unknown): Promise<void> => { notify: async (method: string, params?: unknown): Promise<void> => {
if (!child.stdin) { if (!child.stdin || child.stdin.destroyed || child.stdin.writableEnded) {
throw new Error('JSON-RPC stdin is not available'); throw new Error('JSON-RPC stdin is not available');
} }
@ -190,22 +250,15 @@ export class JsonRpcStdioClient {
}); });
}); });
}, },
};
try { onNotification: (listener) => {
return await handler(session); notificationListeners.add(listener);
} finally { return (): void => {
rejectAll(new Error('JSON-RPC session closed')); notificationListeners.delete(listener);
lineReader.close(); };
if (child.stdin && !child.stdin.destroyed) { },
child.stdin.end();
} close,
killProcessTree(child); };
try {
await once(child, 'close');
} catch {
this.logger.warn('json-rpc close wait failed');
}
}
} }
} }

View file

@ -0,0 +1,29 @@
export type { CodexAppServerSession } from './CodexAppServerSessionFactory';
export {
CodexAppServerSessionFactory,
DEFAULT_CODEX_APP_SERVER_SUPPRESSED_NOTIFICATION_METHODS,
} from './CodexAppServerSessionFactory';
export { CodexBinaryResolver } from './CodexBinaryResolver';
export type { JsonRpcSession } from './JsonRpcStdioClient';
export { JsonRpcStdioClient } from './JsonRpcStdioClient';
export type {
CodexAppServerAccount,
CodexAppServerAccountLoginCompletedNotification,
CodexAppServerAccountRateLimitsUpdatedNotification,
CodexAppServerAccountUpdatedNotification,
CodexAppServerAuthMode,
CodexAppServerCancelLoginAccountParams,
CodexAppServerCancelLoginAccountResponse,
CodexAppServerCancelLoginAccountStatus,
CodexAppServerCreditsSnapshot,
CodexAppServerGetAccountParams,
CodexAppServerGetAccountRateLimitsResponse,
CodexAppServerGetAccountResponse,
CodexAppServerInitializeResponse,
CodexAppServerLoginAccountParams,
CodexAppServerLoginAccountResponse,
CodexAppServerLogoutAccountResponse,
CodexAppServerPlanType,
CodexAppServerRateLimitSnapshot,
CodexAppServerRateLimitWindow,
} from './protocol';

View file

@ -0,0 +1,113 @@
export type CodexAppServerPlanType =
| 'free'
| 'go'
| 'plus'
| 'pro'
| 'team'
| 'business'
| 'enterprise'
| 'edu'
| 'unknown';
export type CodexAppServerAuthMode = 'apikey' | 'chatgpt' | 'chatgptAuthTokens';
export interface CodexAppServerInitializeResponse {
userAgent: string;
codexHome: string;
platformFamily: string;
platformOs: string;
}
export type CodexAppServerAccount =
| { type: 'apiKey' }
| {
type: 'chatgpt';
email: string;
planType: CodexAppServerPlanType;
};
export interface CodexAppServerGetAccountResponse {
account: CodexAppServerAccount | null;
requiresOpenaiAuth: boolean;
}
export interface CodexAppServerGetAccountParams {
refreshToken: boolean;
}
export type CodexAppServerLoginAccountParams =
| {
type: 'apiKey';
apiKey: string;
}
| {
type: 'chatgpt';
}
| {
type: 'chatgptAuthTokens';
accessToken: string;
chatgptAccountId: string;
chatgptPlanType?: string | null;
};
export type CodexAppServerLoginAccountResponse =
| { type: 'apiKey' }
| {
type: 'chatgpt';
loginId: string;
authUrl: string;
}
| { type: 'chatgptAuthTokens' };
export type CodexAppServerLogoutAccountResponse = Record<string, never>;
export interface CodexAppServerRateLimitWindow {
usedPercent: number;
windowDurationMins: number | null;
resetsAt: number | null;
}
export interface CodexAppServerCreditsSnapshot {
hasCredits: boolean;
unlimited: boolean;
balance: string | null;
}
export interface CodexAppServerRateLimitSnapshot {
limitId: string | null;
limitName: string | null;
primary: CodexAppServerRateLimitWindow | null;
secondary: CodexAppServerRateLimitWindow | null;
credits: CodexAppServerCreditsSnapshot | null;
planType: CodexAppServerPlanType | null;
}
export interface CodexAppServerGetAccountRateLimitsResponse {
rateLimits: CodexAppServerRateLimitSnapshot;
rateLimitsByLimitId: Record<string, CodexAppServerRateLimitSnapshot | undefined> | null;
}
export interface CodexAppServerAccountLoginCompletedNotification {
loginId: string | null;
success: boolean;
error: string | null;
}
export interface CodexAppServerAccountUpdatedNotification {
authMode: CodexAppServerAuthMode | null;
planType: CodexAppServerPlanType | null;
}
export interface CodexAppServerAccountRateLimitsUpdatedNotification {
rateLimits: CodexAppServerRateLimitSnapshot;
}
export interface CodexAppServerCancelLoginAccountParams {
loginId: string;
}
export type CodexAppServerCancelLoginAccountStatus = 'canceled' | 'notFound';
export interface CodexAppServerCancelLoginAccountResponse {
status: CodexAppServerCancelLoginAccountStatus;
}

View file

@ -1,8 +1,16 @@
import path from 'node:path';
import { evaluateCodexLaunchReadiness } from '@features/codex-account';
import { getCachedShellEnv } from '@main/utils/shellEnv'; import { getCachedShellEnv } from '@main/utils/shellEnv';
import { ApiKeyService } from '../extensions/apikeys/ApiKeyService'; import { ApiKeyService } from '../extensions/apikeys/ApiKeyService';
import { ConfigManager } from '../infrastructure/ConfigManager'; import { ConfigManager } from '../infrastructure/ConfigManager';
import type {
CodexAccountAuthMode,
CodexAccountSnapshotDto,
} from '@features/codex-account/contracts';
import type { CodexAccountFeatureFacade } from '@features/codex-account/main';
import type { import type {
CliProviderAuthMode, CliProviderAuthMode,
CliProviderConnectionInfo, CliProviderConnectionInfo,
@ -27,7 +35,7 @@ const PROVIDER_CAPABILITIES: Record<
codex: { codex: {
supportsOAuth: false, supportsOAuth: false,
supportsApiKey: true, supportsApiKey: true,
configurableAuthModes: [], configurableAuthModes: ['auto', 'chatgpt', 'api_key'],
}, },
gemini: { gemini: {
supportsOAuth: false, supportsOAuth: false,
@ -45,8 +53,30 @@ const PROVIDER_API_KEY_ENV_VARS: Partial<Record<CliProviderId, string>> = {
const CODEX_NATIVE_API_KEY_ENV_VAR = 'CODEX_API_KEY'; const CODEX_NATIVE_API_KEY_ENV_VAR = 'CODEX_API_KEY';
const CODEX_NATIVE_BACKEND_ID = 'codex-native'; const CODEX_NATIVE_BACKEND_ID = 'codex-native';
function isCodexExecBinary(binaryPath?: string | null): boolean {
const binaryName = path.basename(binaryPath?.trim() ?? '').toLowerCase();
return (
binaryName === 'codex' ||
binaryName === 'codex.exe' ||
binaryName === 'codex-cli' ||
binaryName === 'codex-cli.exe'
);
}
function buildCodexForcedLoginLaunchArgs(
binaryPath: string | null | undefined,
loginMethod: 'chatgpt' | 'api'
): string[] {
if (isCodexExecBinary(binaryPath)) {
return ['-c', `forced_login_method="${loginMethod}"`];
}
return ['--settings', JSON.stringify({ codex: { forced_login_method: loginMethod } })];
}
export class ProviderConnectionService { export class ProviderConnectionService {
private static instance: ProviderConnectionService | null = null; private static instance: ProviderConnectionService | null = null;
private codexAccountFeature: Pick<CodexAccountFeatureFacade, 'getSnapshot'> | null = null;
constructor( constructor(
private readonly apiKeyService = new ApiKeyService(), private readonly apiKeyService = new ApiKeyService(),
@ -58,13 +88,17 @@ export class ProviderConnectionService {
return ProviderConnectionService.instance; return ProviderConnectionService.instance;
} }
setCodexAccountFeature(feature: Pick<CodexAccountFeatureFacade, 'getSnapshot'> | null): void {
this.codexAccountFeature = feature;
}
getConfiguredAuthMode(providerId: CliProviderId): CliProviderAuthMode | null { getConfiguredAuthMode(providerId: CliProviderId): CliProviderAuthMode | null {
if (providerId === 'anthropic') { if (providerId === 'anthropic') {
return this.configManager.getConfig().providerConnections.anthropic.authMode; return this.configManager.getConfig().providerConnections.anthropic.authMode;
} }
if (providerId === 'codex') { if (providerId === 'codex') {
return null; return this.configManager.getConfig().providerConnections.codex.preferredAuthMode;
} }
return null; return null;
@ -107,23 +141,24 @@ export class ProviderConnectionService {
return env; return env;
} }
const codexRuntimeBackend = this.getConfiguredCodexRuntimeBackend(runtimeBackendOverride); const snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env);
const storedKey = await this.apiKeyService.lookupPreferred('OPENAI_API_KEY'); const readiness = evaluateCodexLaunchReadiness({
const existingOpenAiKey = preferredAuthMode: snapshot.preferredAuthMode,
typeof env.OPENAI_API_KEY === 'string' && env.OPENAI_API_KEY.trim() managedAccount: snapshot.managedAccount,
? env.OPENAI_API_KEY apiKey: snapshot.apiKey,
: null; appServerState: snapshot.appServerState,
const existingNativeKey = appServerStatusMessage: snapshot.appServerStatusMessage,
typeof env[CODEX_NATIVE_API_KEY_ENV_VAR] === 'string' && localActiveChatgptAccountPresent: snapshot.localActiveChatgptAccountPresent,
env[CODEX_NATIVE_API_KEY_ENV_VAR]?.trim() });
? env[CODEX_NATIVE_API_KEY_ENV_VAR]
: null;
const resolvedApiKey =
storedKey?.value.trim() ||
existingOpenAiKey ||
(codexRuntimeBackend === CODEX_NATIVE_BACKEND_ID ? existingNativeKey : null);
if (resolvedApiKey) { if (readiness.effectiveAuthMode === 'chatgpt') {
delete env.OPENAI_API_KEY;
delete env[CODEX_NATIVE_API_KEY_ENV_VAR];
return env;
}
const resolvedApiKey = await this.resolveCodexApiKeyValue(env, runtimeBackendOverride);
if (readiness.effectiveAuthMode === 'api_key' && resolvedApiKey) {
env.OPENAI_API_KEY = resolvedApiKey; env.OPENAI_API_KEY = resolvedApiKey;
env[CODEX_NATIVE_API_KEY_ENV_VAR] = resolvedApiKey; env[CODEX_NATIVE_API_KEY_ENV_VAR] = resolvedApiKey;
return env; return env;
@ -166,33 +201,25 @@ export class ProviderConnectionService {
return env; return env;
} }
const codexRuntimeBackend = this.getConfiguredCodexRuntimeBackend(runtimeBackendOverride); const snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env);
const storedKey = await this.apiKeyService.lookupPreferred('OPENAI_API_KEY'); const readiness = evaluateCodexLaunchReadiness({
const existingOpenAiKey = preferredAuthMode: snapshot.preferredAuthMode,
typeof env.OPENAI_API_KEY === 'string' && env.OPENAI_API_KEY.trim() managedAccount: snapshot.managedAccount,
? env.OPENAI_API_KEY apiKey: snapshot.apiKey,
: null; appServerState: snapshot.appServerState,
const existingNativeKey = appServerStatusMessage: snapshot.appServerStatusMessage,
typeof env[CODEX_NATIVE_API_KEY_ENV_VAR] === 'string' && localActiveChatgptAccountPresent: snapshot.localActiveChatgptAccountPresent,
env[CODEX_NATIVE_API_KEY_ENV_VAR]?.trim() });
? env[CODEX_NATIVE_API_KEY_ENV_VAR]
: null;
const resolvedApiKey =
storedKey?.value.trim() ||
existingOpenAiKey ||
(codexRuntimeBackend === CODEX_NATIVE_BACKEND_ID ? existingNativeKey : null);
if (storedKey?.value.trim()) { if (readiness.effectiveAuthMode === 'chatgpt') {
env.OPENAI_API_KEY = storedKey.value; delete env.OPENAI_API_KEY;
} else if ( delete env[CODEX_NATIVE_API_KEY_ENV_VAR];
!existingOpenAiKey && return env;
existingNativeKey &&
codexRuntimeBackend === CODEX_NATIVE_BACKEND_ID
) {
env.OPENAI_API_KEY = existingNativeKey;
} }
if (codexRuntimeBackend === CODEX_NATIVE_BACKEND_ID && resolvedApiKey) { const resolvedApiKey = await this.resolveCodexApiKeyValue(env, runtimeBackendOverride);
if (readiness.effectiveAuthMode === 'api_key' && resolvedApiKey) {
env.OPENAI_API_KEY = resolvedApiKey;
env[CODEX_NATIVE_API_KEY_ENV_VAR] = resolvedApiKey; env[CODEX_NATIVE_API_KEY_ENV_VAR] = resolvedApiKey;
} }
@ -210,7 +237,7 @@ export class ProviderConnectionService {
async getConfiguredConnectionIssue( async getConfiguredConnectionIssue(
env: NodeJS.ProcessEnv, env: NodeJS.ProcessEnv,
providerId: CliProviderId, providerId: CliProviderId,
runtimeBackendOverride?: string | null _runtimeBackendOverride?: string | null
): Promise<string | null> { ): Promise<string | null> {
if (providerId === 'anthropic') { if (providerId === 'anthropic') {
if (this.getConfiguredAuthMode(providerId) !== 'api_key') { if (this.getConfiguredAuthMode(providerId) !== 'api_key') {
@ -231,16 +258,42 @@ export class ProviderConnectionService {
return null; return null;
} }
const codexRuntimeBackend = this.getConfiguredCodexRuntimeBackend(runtimeBackendOverride); const snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env);
if ( const readiness = evaluateCodexLaunchReadiness({
(typeof env.OPENAI_API_KEY === 'string' && env.OPENAI_API_KEY.trim()) || preferredAuthMode: snapshot.preferredAuthMode,
(typeof env[CODEX_NATIVE_API_KEY_ENV_VAR] === 'string' && managedAccount: snapshot.managedAccount,
env[CODEX_NATIVE_API_KEY_ENV_VAR]?.trim()) apiKey: snapshot.apiKey,
) { appServerState: snapshot.appServerState,
appServerStatusMessage: snapshot.appServerStatusMessage,
localActiveChatgptAccountPresent: snapshot.localActiveChatgptAccountPresent,
});
if (readiness.launchAllowed) {
return null; return null;
} }
return 'Codex native requires OPENAI_API_KEY or CODEX_API_KEY. Add a stored or environment API key before launching Codex.'; if (readiness.state === 'missing_auth') {
if (snapshot.preferredAuthMode === 'chatgpt') {
return snapshot.requiresOpenaiAuth
? snapshot.localActiveChatgptAccountPresent
? 'Codex ChatGPT account mode is selected, and Codex has a locally selected ChatGPT account, but the current session needs reconnect. Reconnect ChatGPT or switch Codex auth mode to API key.'
: snapshot.localAccountArtifactsPresent
? 'Codex ChatGPT account mode is selected, but Codex CLI reports no active ChatGPT login. Local Codex account data exists, but no active managed session is selected. Connect ChatGPT again or switch Codex auth mode to API key.'
: 'Codex ChatGPT account mode is selected, but Codex CLI reports no active ChatGPT login. Connect ChatGPT again or switch Codex auth mode to API key.'
: 'Codex ChatGPT account mode is selected, but no managed ChatGPT account is available. Connect ChatGPT again or switch Codex auth mode to API key.';
}
if (snapshot.preferredAuthMode === 'api_key') {
return 'Codex API key mode is selected, but no OPENAI_API_KEY or CODEX_API_KEY credential is available. Add one before launching Codex.';
}
return 'Codex native requires OPENAI_API_KEY or CODEX_API_KEY, or a connected ChatGPT account. Add one before launching Codex.';
}
return (
readiness.issueMessage ??
'Codex native is not ready. Connect a ChatGPT account or add an API key before launching.'
);
} }
async getConfiguredConnectionIssues( async getConfiguredConnectionIssues(
@ -264,6 +317,41 @@ export class ProviderConnectionService {
return issues; return issues;
} }
async getConfiguredConnectionLaunchArgs(
env: NodeJS.ProcessEnv,
providerId: CliProviderId,
runtimeBackendOverride?: string | null,
binaryPath?: string | null
): Promise<string[]> {
if (providerId !== 'codex') {
return [];
}
if (this.getConfiguredCodexRuntimeBackend(runtimeBackendOverride) !== CODEX_NATIVE_BACKEND_ID) {
return [];
}
const snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env);
const readiness = evaluateCodexLaunchReadiness({
preferredAuthMode: snapshot.preferredAuthMode,
managedAccount: snapshot.managedAccount,
apiKey: snapshot.apiKey,
appServerState: snapshot.appServerState,
appServerStatusMessage: snapshot.appServerStatusMessage,
localActiveChatgptAccountPresent: snapshot.localActiveChatgptAccountPresent,
});
if (readiness.effectiveAuthMode === 'chatgpt') {
return buildCodexForcedLoginLaunchArgs(binaryPath, 'chatgpt');
}
if (readiness.effectiveAuthMode === 'api_key') {
return buildCodexForcedLoginLaunchArgs(binaryPath, 'api');
}
return [];
}
async enrichProviderStatus(provider: CliProviderStatus): Promise<CliProviderStatus> { async enrichProviderStatus(provider: CliProviderStatus): Promise<CliProviderStatus> {
return { return {
...provider, ...provider,
@ -278,27 +366,57 @@ export class ProviderConnectionService {
async getConnectionInfo(providerId: CliProviderId): Promise<CliProviderConnectionInfo> { async getConnectionInfo(providerId: CliProviderId): Promise<CliProviderConnectionInfo> {
const capabilities = PROVIDER_CAPABILITIES[providerId]; const capabilities = PROVIDER_CAPABILITIES[providerId];
const storedApiKey = await this.getStoredApiKey(providerId); const storedApiKey = await this.getStoredApiKey(providerId);
const codexRuntimeBackend = const externalCredential = this.getExternalCredential(providerId);
providerId === 'codex' ? this.getConfiguredCodexRuntimeBackend() : null; const codexSnapshot = providerId === 'codex' ? await this.getCodexAccountSnapshot() : null;
const externalCredential = this.getExternalCredential(providerId, codexRuntimeBackend); const configurableAuthModes = capabilities.configurableAuthModes;
const configurableAuthModes =
providerId === 'codex' ? ([] as CliProviderAuthMode[]) : capabilities.configurableAuthModes;
const configuredAuthMode = const configuredAuthMode =
providerId === 'codex' ? null : this.getConfiguredAuthMode(providerId); providerId === 'codex'
? (codexSnapshot?.preferredAuthMode ?? this.getConfiguredAuthMode(providerId))
: this.getConfiguredAuthMode(providerId);
const apiKeyConfigured =
providerId === 'codex'
? (codexSnapshot?.apiKey.available ?? false)
: Boolean(storedApiKey?.value.trim() || externalCredential?.value.trim());
const apiKeySource =
providerId === 'codex'
? (codexSnapshot?.apiKey.source ?? null)
: storedApiKey?.value.trim()
? 'stored'
: externalCredential?.value.trim()
? 'environment'
: null;
const apiKeySourceLabel =
providerId === 'codex'
? (codexSnapshot?.apiKey.sourceLabel ?? null)
: storedApiKey?.value.trim()
? 'Stored in app'
: (externalCredential?.label ?? null);
return { return {
...capabilities, ...capabilities,
configurableAuthModes, configurableAuthModes,
configuredAuthMode, configuredAuthMode,
apiKeyConfigured: Boolean(storedApiKey?.value.trim() || externalCredential?.value.trim()), apiKeyConfigured,
apiKeySource: storedApiKey?.value.trim() apiKeySource,
? 'stored' apiKeySourceLabel,
: externalCredential?.value.trim() codex:
? 'environment' providerId === 'codex' && codexSnapshot
? {
preferredAuthMode: codexSnapshot.preferredAuthMode,
effectiveAuthMode: codexSnapshot.effectiveAuthMode,
appServerState: codexSnapshot.appServerState,
appServerStatusMessage: codexSnapshot.appServerStatusMessage,
managedAccount: codexSnapshot.managedAccount,
requiresOpenaiAuth: codexSnapshot.requiresOpenaiAuth,
localAccountArtifactsPresent: codexSnapshot.localAccountArtifactsPresent,
localActiveChatgptAccountPresent: codexSnapshot.localActiveChatgptAccountPresent,
login: codexSnapshot.login,
rateLimits: codexSnapshot.rateLimits,
launchAllowed: codexSnapshot.launchAllowed,
launchIssueMessage: codexSnapshot.launchIssueMessage,
launchReadinessState: codexSnapshot.launchReadinessState,
}
: null, : null,
apiKeySourceLabel: storedApiKey?.value.trim()
? 'Stored in app'
: (externalCredential?.label ?? null),
}; };
} }
@ -320,10 +438,111 @@ export class ProviderConnectionService {
return CODEX_NATIVE_BACKEND_ID; return CODEX_NATIVE_BACKEND_ID;
} }
private getExternalCredential( private async getCodexAccountSnapshot(): Promise<CodexAccountSnapshotDto> {
providerId: CliProviderId, if (this.codexAccountFeature) {
codexRuntimeBackend: 'codex-native' | null = null return this.codexAccountFeature.getSnapshot();
): ExternalCredential { }
const preferredAuthMode =
(this.configManager.getConfig().providerConnections.codex.preferredAuthMode as
| CodexAccountAuthMode
| undefined) ?? 'auto';
const storedKey = await this.apiKeyService.lookupPreferred('OPENAI_API_KEY');
const externalCredential = this.getExternalCredential('codex');
const apiKeyAvailable = Boolean(storedKey?.value.trim() || externalCredential?.value.trim());
const apiKey = {
available: apiKeyAvailable,
source: storedKey?.value.trim()
? 'stored'
: externalCredential?.value.trim()
? 'environment'
: null,
sourceLabel: storedKey?.value.trim() ? 'Stored in app' : (externalCredential?.label ?? null),
} satisfies CodexAccountSnapshotDto['apiKey'];
const readiness = evaluateCodexLaunchReadiness({
preferredAuthMode,
managedAccount: null,
apiKey,
appServerState: 'degraded',
appServerStatusMessage: 'Codex account management has not been initialized yet.',
localActiveChatgptAccountPresent: false,
});
return {
preferredAuthMode,
effectiveAuthMode: readiness.effectiveAuthMode,
launchAllowed: readiness.launchAllowed,
launchIssueMessage: readiness.issueMessage,
launchReadinessState: readiness.state,
appServerState: 'degraded',
appServerStatusMessage: 'Codex account management has not been initialized yet.',
managedAccount: null,
apiKey,
requiresOpenaiAuth: null,
localAccountArtifactsPresent: false,
localActiveChatgptAccountPresent: false,
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: null,
updatedAt: new Date().toISOString(),
};
}
private async resolveCodexApiKeyValue(
env: NodeJS.ProcessEnv,
runtimeBackendOverride?: string | null
): Promise<string | null> {
const codexRuntimeBackend = this.getConfiguredCodexRuntimeBackend(runtimeBackendOverride);
const storedKey = await this.apiKeyService.lookupPreferred('OPENAI_API_KEY');
const existingOpenAiKey =
typeof env.OPENAI_API_KEY === 'string' && env.OPENAI_API_KEY.trim()
? env.OPENAI_API_KEY
: null;
const existingNativeKey =
typeof env[CODEX_NATIVE_API_KEY_ENV_VAR] === 'string' &&
env[CODEX_NATIVE_API_KEY_ENV_VAR]?.trim()
? env[CODEX_NATIVE_API_KEY_ENV_VAR]
: null;
return (
storedKey?.value.trim() ||
existingOpenAiKey ||
(codexRuntimeBackend === CODEX_NATIVE_BACKEND_ID ? existingNativeKey : null)
);
}
private mergeCodexApiKeyAvailability(
snapshot: CodexAccountSnapshotDto,
env: NodeJS.ProcessEnv
): CodexAccountSnapshotDto {
const openAiApiKey =
typeof env.OPENAI_API_KEY === 'string' && env.OPENAI_API_KEY.trim()
? env.OPENAI_API_KEY
: null;
const codexApiKey =
typeof env[CODEX_NATIVE_API_KEY_ENV_VAR] === 'string' &&
env[CODEX_NATIVE_API_KEY_ENV_VAR]?.trim()
? env[CODEX_NATIVE_API_KEY_ENV_VAR]
: null;
if (!openAiApiKey && !codexApiKey) {
return snapshot;
}
return {
...snapshot,
apiKey: {
available: true,
source: 'environment',
sourceLabel: codexApiKey ? 'Detected from CODEX_API_KEY' : 'Detected from OPENAI_API_KEY',
},
};
}
private getExternalCredential(providerId: CliProviderId): ExternalCredential {
const shellEnv = getCachedShellEnv() ?? {}; const shellEnv = getCachedShellEnv() ?? {};
const sources = [shellEnv, process.env]; const sources = [shellEnv, process.env];

View file

@ -0,0 +1,86 @@
import { buildEnrichedEnv } from '@main/utils/cliEnv';
import { getShellPreferredHome } from '@main/utils/shellEnv';
import { configManager } from '../infrastructure/ConfigManager';
import {
applyConfiguredRuntimeBackendsEnv,
applyProviderRuntimeEnv,
resolveTeamProviderId,
} from './providerRuntimeEnv';
import type { CliProviderId, TeamProviderId } from '@shared/types';
type ProviderEnvTargetId = CliProviderId | TeamProviderId | undefined;
export interface BuildRuntimeBaseEnvOptions {
binaryPath?: string | null;
providerId?: ProviderEnvTargetId;
providerBackendId?: string | null;
shellEnv?: NodeJS.ProcessEnv | null;
env?: NodeJS.ProcessEnv;
}
function getFirstNonEmptyEnvValue(...values: (string | null | undefined)[]): string | undefined {
for (const value of values) {
if (typeof value === 'string' && value.trim().length > 0) {
return value;
}
}
return undefined;
}
export function buildRuntimeBaseEnv(options: BuildRuntimeBaseEnvOptions = {}): {
env: NodeJS.ProcessEnv;
resolvedProviderId: CliProviderId | null;
} {
const shellEnv = options.shellEnv ?? {};
const env = {
...buildEnrichedEnv(options.binaryPath),
...shellEnv,
};
applyConfiguredRuntimeBackendsEnv(env, configManager.getConfig().runtime);
Object.assign(env, options.env ?? {});
const explicitHome = getFirstNonEmptyEnvValue(options.env?.HOME, options.env?.USERPROFILE);
const fallbackHome = getFirstNonEmptyEnvValue(
env.HOME,
env.USERPROFILE,
getShellPreferredHome(),
shellEnv.HOME,
process.env.HOME,
process.env.USERPROFILE
);
if (explicitHome) {
env.HOME = getFirstNonEmptyEnvValue(options.env?.HOME, explicitHome);
env.USERPROFILE = getFirstNonEmptyEnvValue(options.env?.USERPROFILE, explicitHome);
} else if (fallbackHome) {
env.HOME = getFirstNonEmptyEnvValue(env.HOME, fallbackHome);
env.USERPROFILE = getFirstNonEmptyEnvValue(env.USERPROFILE, fallbackHome);
}
if (!options.providerId) {
return {
env,
resolvedProviderId: null,
};
}
const resolvedProviderId = resolveTeamProviderId(options.providerId);
applyProviderRuntimeEnv(env, options.providerId);
if (resolvedProviderId === 'codex' && options.providerBackendId?.trim()) {
env.CLAUDE_CODE_CODEX_BACKEND = options.providerBackendId.trim();
}
if (resolvedProviderId === 'gemini' && options.providerBackendId?.trim()) {
env.CLAUDE_CODE_GEMINI_BACKEND = options.providerBackendId.trim();
}
return {
env,
resolvedProviderId,
};
}

View file

@ -1,14 +1,7 @@
import { buildEnrichedEnv } from '@main/utils/cliEnv'; import { getCachedShellEnv } from '@main/utils/shellEnv';
import { getCachedShellEnv, getShellPreferredHome } from '@main/utils/shellEnv';
import { configManager } from '../infrastructure/ConfigManager';
import { buildRuntimeBaseEnv } from './buildRuntimeBaseEnv';
import { providerConnectionService } from './ProviderConnectionService'; import { providerConnectionService } from './ProviderConnectionService';
import {
applyConfiguredRuntimeBackendsEnv,
applyProviderRuntimeEnv,
resolveTeamProviderId,
} from './providerRuntimeEnv';
import type { CliProviderId, TeamProviderId } from '@shared/types'; import type { CliProviderId, TeamProviderId } from '@shared/types';
@ -26,15 +19,7 @@ export interface ProviderAwareCliEnvOptions {
export interface ProviderAwareCliEnvResult { export interface ProviderAwareCliEnvResult {
env: NodeJS.ProcessEnv; env: NodeJS.ProcessEnv;
connectionIssues: Partial<Record<CliProviderId, string>>; connectionIssues: Partial<Record<CliProviderId, string>>;
} providerArgs: string[];
function getFirstNonEmptyEnvValue(...values: (string | null | undefined)[]): string | undefined {
for (const value of values) {
if (typeof value === 'string' && value.trim().length > 0) {
return value;
}
}
return undefined;
} }
export async function buildProviderAwareCliEnv( export async function buildProviderAwareCliEnv(
@ -42,41 +27,17 @@ export async function buildProviderAwareCliEnv(
): Promise<ProviderAwareCliEnvResult> { ): Promise<ProviderAwareCliEnvResult> {
const connectionMode = options.connectionMode ?? 'strict'; const connectionMode = options.connectionMode ?? 'strict';
const shellEnv = options.shellEnv ?? getCachedShellEnv() ?? {}; const shellEnv = options.shellEnv ?? getCachedShellEnv() ?? {};
const env = { const { env, resolvedProviderId } = buildRuntimeBaseEnv({
...buildEnrichedEnv(options.binaryPath), binaryPath: options.binaryPath,
...shellEnv, providerId: options.providerId,
}; providerBackendId: options.providerBackendId,
shellEnv,
applyConfiguredRuntimeBackendsEnv(env, configManager.getConfig().runtime); env: options.env,
});
Object.assign(env, options.env ?? {});
const explicitHome = getFirstNonEmptyEnvValue(options.env?.HOME, options.env?.USERPROFILE);
const fallbackHome = getFirstNonEmptyEnvValue(
env.HOME,
env.USERPROFILE,
getShellPreferredHome(),
shellEnv.HOME,
process.env.HOME,
process.env.USERPROFILE
);
if (explicitHome) {
env.HOME = getFirstNonEmptyEnvValue(options.env?.HOME, explicitHome);
env.USERPROFILE = getFirstNonEmptyEnvValue(options.env?.USERPROFILE, explicitHome);
} else if (fallbackHome) {
env.HOME = getFirstNonEmptyEnvValue(env.HOME, fallbackHome);
env.USERPROFILE = getFirstNonEmptyEnvValue(env.USERPROFILE, fallbackHome);
}
if (options.providerId) { if (options.providerId) {
const resolvedProviderId = resolveTeamProviderId(options.providerId); if (!resolvedProviderId) {
applyProviderRuntimeEnv(env, options.providerId); throw new Error('Resolved provider id is required when providerId is set');
if (resolvedProviderId === 'codex' && options.providerBackendId?.trim()) {
env.CLAUDE_CODE_CODEX_BACKEND = options.providerBackendId.trim();
}
if (resolvedProviderId === 'gemini' && options.providerBackendId?.trim()) {
env.CLAUDE_CODE_GEMINI_BACKEND = options.providerBackendId.trim();
} }
if (connectionMode === 'augment') { if (connectionMode === 'augment') {
await providerConnectionService.augmentConfiguredConnectionEnv( await providerConnectionService.augmentConfiguredConnectionEnv(
@ -87,6 +48,7 @@ export async function buildProviderAwareCliEnv(
return { return {
env, env,
connectionIssues: {}, connectionIssues: {},
providerArgs: [],
}; };
} }
@ -98,6 +60,12 @@ export async function buildProviderAwareCliEnv(
return { return {
env, env,
providerArgs: await providerConnectionService.getConfiguredConnectionLaunchArgs(
env,
resolvedProviderId,
options.providerBackendId,
options.binaryPath
),
connectionIssues: await providerConnectionService.getConfiguredConnectionIssues( connectionIssues: await providerConnectionService.getConfiguredConnectionIssues(
env, env,
[resolvedProviderId], [resolvedProviderId],
@ -113,6 +81,7 @@ export async function buildProviderAwareCliEnv(
return { return {
env, env,
connectionIssues: {}, connectionIssues: {},
providerArgs: [],
}; };
} }
@ -120,5 +89,6 @@ export async function buildProviderAwareCliEnv(
return { return {
env, env,
connectionIssues: await providerConnectionService.getConfiguredConnectionIssues(env), connectionIssues: await providerConnectionService.getConfiguredConnectionIssues(env),
providerArgs: [],
}; };
} }

View file

@ -105,7 +105,7 @@ export class ScheduledTaskExecutor {
request.config.providerId === 'codex' || request.config.providerId === 'gemini' request.config.providerId === 'codex' || request.config.providerId === 'gemini'
? request.config.providerId ? request.config.providerId
: 'anthropic'; : 'anthropic';
const { env, connectionIssues } = await buildProviderAwareCliEnv({ const { env, connectionIssues, providerArgs } = await buildProviderAwareCliEnv({
binaryPath, binaryPath,
providerId, providerId,
shellEnv, shellEnv,
@ -119,6 +119,8 @@ export class ScheduledTaskExecutor {
throw new Error(connectionIssue); throw new Error(connectionIssue);
} }
args.push(...providerArgs);
const child = spawnCli(binaryPath, args, { const child = spawnCli(binaryPath, args, {
cwd: request.config.cwd, cwd: request.config.cwd,
// shellEnv spread after buildEnrichedEnv ensures freshly-resolved values // shellEnv spread after buildEnrichedEnv ensures freshly-resolved values

View file

@ -2424,12 +2424,11 @@ export class TeamDataService {
: undefined, : undefined,
agentType: 'general-purpose' as const, agentType: 'general-purpose' as const,
joinedAt, joinedAt,
})), }))
{
providerBackendId: request.providerBackendId,
}
); );
await this.membersMetaStore.writeMembers(request.teamName, membersToWrite); await this.membersMetaStore.writeMembers(request.teamName, membersToWrite, {
providerBackendId: request.providerBackendId,
});
} }
async reconcileTeamArtifacts( async reconcileTeamArtifacts(

View file

@ -773,6 +773,7 @@ interface ProvisioningEnvResolution {
env: NodeJS.ProcessEnv; env: NodeJS.ProcessEnv;
authSource: ProvisioningAuthSource; authSource: ProvisioningAuthSource;
geminiRuntimeAuth: GeminiRuntimeAuthState | null; geminiRuntimeAuth: GeminiRuntimeAuthState | null;
providerArgs?: string[];
warning?: string; warning?: string;
} }
@ -6201,7 +6202,12 @@ export class TeamProvisioningService {
request.providerId, request.providerId,
request.providerBackendId request.providerBackendId
); );
const { env: shellEnv, geminiRuntimeAuth, warning: envWarning } = provisioningEnv; const {
env: shellEnv,
geminiRuntimeAuth,
providerArgs = [],
warning: envWarning,
} = provisioningEnv;
if (envWarning) { if (envWarning) {
throw new Error(envWarning); throw new Error(envWarning);
} }
@ -6380,6 +6386,7 @@ export class TeamProvisioningService {
...(request.effort ? ['--effort', request.effort] : []), ...(request.effort ? ['--effort', request.effort] : []),
...(request.worktree ? ['--worktree', request.worktree] : []), ...(request.worktree ? ['--worktree', request.worktree] : []),
...parseCliArgs(request.extraCliArgs), ...parseCliArgs(request.extraCliArgs),
...providerArgs,
]; ];
const runtimeWarning = buildRuntimeLaunchWarning(request, shellEnv, { const runtimeWarning = buildRuntimeLaunchWarning(request, shellEnv, {
geminiRuntimeAuth, geminiRuntimeAuth,
@ -6427,12 +6434,11 @@ export class TeamProvisioningService {
: undefined, : undefined,
agentType: 'general-purpose' as const, agentType: 'general-purpose' as const,
joinedAt: Date.now(), joinedAt: Date.now(),
})), }))
{
providerBackendId: request.providerBackendId,
}
); );
await this.membersMetaStore.writeMembers(request.teamName, membersToWrite); await this.membersMetaStore.writeMembers(request.teamName, membersToWrite, {
providerBackendId: request.providerBackendId,
});
if (request.skipPermissions === false) { if (request.skipPermissions === false) {
await this.seedTeammateOperationalPermissionRules(request.teamName, request.cwd); await this.seedTeammateOperationalPermissionRules(request.teamName, request.cwd);
} }
@ -6772,7 +6778,12 @@ export class TeamProvisioningService {
request.providerId, request.providerId,
request.providerBackendId request.providerBackendId
); );
const { env: shellEnv, geminiRuntimeAuth, warning: envWarning } = provisioningEnv; const {
env: shellEnv,
geminiRuntimeAuth,
providerArgs = [],
warning: envWarning,
} = provisioningEnv;
if (envWarning) { if (envWarning) {
throw new Error(envWarning); throw new Error(envWarning);
} }
@ -7009,6 +7020,7 @@ export class TeamProvisioningService {
launchArgs.push('--worktree', request.worktree); launchArgs.push('--worktree', request.worktree);
} }
launchArgs.push(...parseCliArgs(request.extraCliArgs)); launchArgs.push(...parseCliArgs(request.extraCliArgs));
launchArgs.push(...providerArgs);
const runtimeWarning = buildRuntimeLaunchWarning(request, shellEnv, { const runtimeWarning = buildRuntimeLaunchWarning(request, shellEnv, {
geminiRuntimeAuth, geminiRuntimeAuth,
promptSize, promptSize,
@ -12888,12 +12900,18 @@ export class TeamProvisioningService {
env: providerEnv, env: providerEnv,
authSource: 'configured_api_key_missing', authSource: 'configured_api_key_missing',
geminiRuntimeAuth: null, geminiRuntimeAuth: null,
providerArgs: providerEnvResult.providerArgs,
warning: providerConnectionIssue, warning: providerConnectionIssue,
}; };
} }
if (resolvedProviderId === 'codex') { if (resolvedProviderId === 'codex') {
return { env: providerEnv, authSource: 'codex_runtime', geminiRuntimeAuth: null }; return {
env: providerEnv,
authSource: 'codex_runtime',
geminiRuntimeAuth: null,
providerArgs: providerEnvResult.providerArgs,
};
} }
if (resolvedProviderId === 'gemini') { if (resolvedProviderId === 'gemini') {
@ -12901,6 +12919,7 @@ export class TeamProvisioningService {
env: providerEnv, env: providerEnv,
authSource: 'gemini_runtime', authSource: 'gemini_runtime',
geminiRuntimeAuth: await resolveGeminiRuntimeAuth(providerEnv), geminiRuntimeAuth: await resolveGeminiRuntimeAuth(providerEnv),
providerArgs: providerEnvResult.providerArgs,
}; };
} }
@ -12909,7 +12928,12 @@ export class TeamProvisioningService {
typeof providerEnv.ANTHROPIC_API_KEY === 'string' && typeof providerEnv.ANTHROPIC_API_KEY === 'string' &&
providerEnv.ANTHROPIC_API_KEY.trim().length > 0 providerEnv.ANTHROPIC_API_KEY.trim().length > 0
) { ) {
return { env: providerEnv, authSource: 'anthropic_api_key', geminiRuntimeAuth: null }; return {
env: providerEnv,
authSource: 'anthropic_api_key',
geminiRuntimeAuth: null,
providerArgs: providerEnvResult.providerArgs,
};
} }
// 2. Proxy token (ANTHROPIC_AUTH_TOKEN) — `-p` mode does NOT read this var, // 2. Proxy token (ANTHROPIC_AUTH_TOKEN) — `-p` mode does NOT read this var,
@ -12919,7 +12943,12 @@ export class TeamProvisioningService {
providerEnv.ANTHROPIC_AUTH_TOKEN.trim().length > 0 providerEnv.ANTHROPIC_AUTH_TOKEN.trim().length > 0
) { ) {
providerEnv.ANTHROPIC_API_KEY = providerEnv.ANTHROPIC_AUTH_TOKEN; providerEnv.ANTHROPIC_API_KEY = providerEnv.ANTHROPIC_AUTH_TOKEN;
return { env: providerEnv, authSource: 'anthropic_auth_token', geminiRuntimeAuth: null }; return {
env: providerEnv,
authSource: 'anthropic_auth_token',
geminiRuntimeAuth: null,
providerArgs: providerEnvResult.providerArgs,
};
} }
// 3. No explicit API key — let the CLI handle its own OAuth auth. // 3. No explicit API key — let the CLI handle its own OAuth auth.
@ -12927,7 +12956,12 @@ export class TeamProvisioningService {
// tokens in-memory. Injecting CLAUDE_CODE_OAUTH_TOKEN from the // tokens in-memory. Injecting CLAUDE_CODE_OAUTH_TOKEN from the
// credentials file causes 401 errors because the stored token is // credentials file causes 401 errors because the stored token is
// often stale (CLI refreshes in-memory but rarely writes back). // often stale (CLI refreshes in-memory but rarely writes back).
return { env: providerEnv, authSource: 'none', geminiRuntimeAuth: null }; return {
env: providerEnv,
authSource: 'none',
geminiRuntimeAuth: null,
providerArgs: providerEnvResult.providerArgs,
};
} }
private async resolveControlApiBaseUrl(): Promise<string | null> { private async resolveControlApiBaseUrl(): Promise<string | null> {
@ -13681,12 +13715,11 @@ export class TeamProvisioningService {
: undefined, : undefined,
agentType: 'general-purpose' as const, agentType: 'general-purpose' as const,
joinedAt, joinedAt,
})), }))
{
providerBackendId: request.providerBackendId,
}
); );
await this.membersMetaStore.writeMembers(teamName, membersToWrite); await this.membersMetaStore.writeMembers(teamName, membersToWrite, {
providerBackendId: request.providerBackendId,
});
} catch (error) { } catch (error) {
logger.warn( logger.warn(
`[${teamName}] Failed to persist members.meta.json: ${ `[${teamName}] Failed to persist members.meta.json: ${

View file

@ -26,7 +26,7 @@ export function getCliFlavorUiOptions(flavor: CliFlavor): CliFlavorUiOptions {
switch (flavor) { switch (flavor) {
case 'agent_teams_orchestrator': case 'agent_teams_orchestrator':
return { return {
displayName: 'agent_teams_orchestrator', displayName: 'Multimodel runtime',
supportsSelfUpdate: false, supportsSelfUpdate: false,
showVersionDetails: false, showVersionDetails: false,
showBinaryPath: false, showBinaryPath: false,

View file

@ -1,3 +1,4 @@
import { createCodexAccountBridge } from '@features/codex-account/preload';
import { createRecentProjectsBridge } from '@features/recent-projects/preload'; import { createRecentProjectsBridge } from '@features/recent-projects/preload';
import { createTmuxInstallerBridge } from '@features/tmux-installer/preload'; import { createTmuxInstallerBridge } from '@features/tmux-installer/preload';
import { WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL } from '@shared/constants'; import { WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL } from '@shared/constants';
@ -121,6 +122,7 @@ import {
TEAM_DELETE_DRAFT, TEAM_DELETE_DRAFT,
TEAM_DELETE_TASK_ATTACHMENT, TEAM_DELETE_TASK_ATTACHMENT,
TEAM_DELETE_TEAM, TEAM_DELETE_TEAM,
TEAM_GET_AGENT_RUNTIME,
TEAM_GET_ALL_TASKS, TEAM_GET_ALL_TASKS,
TEAM_GET_ATTACHMENTS, TEAM_GET_ATTACHMENTS,
TEAM_GET_CLAUDE_LOGS, TEAM_GET_CLAUDE_LOGS,
@ -147,7 +149,6 @@ import {
TEAM_LEAD_CONTEXT, TEAM_LEAD_CONTEXT,
TEAM_LIST, TEAM_LIST,
TEAM_MEMBER_SPAWN_STATUSES, TEAM_MEMBER_SPAWN_STATUSES,
TEAM_GET_AGENT_RUNTIME,
TEAM_PERMANENTLY_DELETE, TEAM_PERMANENTLY_DELETE,
TEAM_PREPARE_PROVISIONING, TEAM_PREPARE_PROVISIONING,
TEAM_PROCESS_ALIVE, TEAM_PROCESS_ALIVE,
@ -165,9 +166,9 @@ import {
TEAM_SAVE_TASK_ATTACHMENT, TEAM_SAVE_TASK_ATTACHMENT,
TEAM_SEND_MESSAGE, TEAM_SEND_MESSAGE,
TEAM_SET_CHANGE_PRESENCE_TRACKING, TEAM_SET_CHANGE_PRESENCE_TRACKING,
TEAM_SET_TASK_LOG_STREAM_TRACKING,
TEAM_SET_PROJECT_BRANCH_TRACKING, TEAM_SET_PROJECT_BRANCH_TRACKING,
TEAM_SET_TASK_CLARIFICATION, TEAM_SET_TASK_CLARIFICATION,
TEAM_SET_TASK_LOG_STREAM_TRACKING,
TEAM_SET_TOOL_ACTIVITY_TRACKING, TEAM_SET_TOOL_ACTIVITY_TRACKING,
TEAM_SHOW_MESSAGE_NOTIFICATION, TEAM_SHOW_MESSAGE_NOTIFICATION,
TEAM_SOFT_DELETE_TASK, TEAM_SOFT_DELETE_TASK,
@ -270,7 +271,6 @@ import type {
LeadContextUsageSnapshot, LeadContextUsageSnapshot,
MemberFullStats, MemberFullStats,
MemberLogSummary, MemberLogSummary,
TeamAgentRuntimeSnapshot,
MemberSpawnStatusesSnapshot, MemberSpawnStatusesSnapshot,
MessagesPage, MessagesPage,
NotificationTrigger, NotificationTrigger,
@ -293,6 +293,7 @@ import type {
TaskChangePresenceState, TaskChangePresenceState,
TaskChangeSetV2, TaskChangeSetV2,
TaskComment, TaskComment,
TeamAgentRuntimeSnapshot,
TeamChangeEvent, TeamChangeEvent,
TeamClaudeLogsQuery, TeamClaudeLogsQuery,
TeamClaudeLogsResponse, TeamClaudeLogsResponse,
@ -458,6 +459,9 @@ ipcRenderer.on(
// Expose protected methods that allow the renderer process to use // Expose protected methods that allow the renderer process to use
// the ipcRenderer without exposing the entire object // the ipcRenderer without exposing the entire object
const electronAPI: ElectronAPI = { const electronAPI: ElectronAPI = {
...createCodexAccountBridge({
ipcRenderer,
}),
...createRecentProjectsBridge(), ...createRecentProjectsBridge(),
getAppVersion: () => ipcRenderer.invoke('get-app-version'), getAppVersion: () => ipcRenderer.invoke('get-app-version'),
getProjects: () => ipcRenderer.invoke('get-projects'), getProjects: () => ipcRenderer.invoke('get-projects'),

View file

@ -6,6 +6,7 @@
* to run in a regular browser connected to an HTTP server. * to run in a regular browser connected to an HTTP server.
*/ */
import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
import type { DashboardRecentProjectsPayload } from '@features/recent-projects/contracts'; import type { DashboardRecentProjectsPayload } from '@features/recent-projects/contracts';
import type { import type {
AppConfig, AppConfig,
@ -219,6 +220,29 @@ export class HttpAPIClient implements ElectronAPI {
getAppVersion = (): Promise<string> => this.get<string>('/api/version'); getAppVersion = (): Promise<string> => this.get<string>('/api/version');
getCodexAccountSnapshot = (): Promise<CodexAccountSnapshotDto> =>
Promise.reject(new Error('Codex account bridge is unavailable in browser mode'));
refreshCodexAccountSnapshot = (_options?: {
includeRateLimits?: boolean;
forceRefreshToken?: boolean;
}): Promise<CodexAccountSnapshotDto> =>
Promise.reject(new Error('Codex account bridge is unavailable in browser mode'));
startCodexChatgptLogin = (): Promise<CodexAccountSnapshotDto> =>
Promise.reject(new Error('Codex account bridge is unavailable in browser mode'));
cancelCodexChatgptLogin = (): Promise<CodexAccountSnapshotDto> =>
Promise.reject(new Error('Codex account bridge is unavailable in browser mode'));
logoutCodexAccount = (): Promise<CodexAccountSnapshotDto> =>
Promise.reject(new Error('Codex account bridge is unavailable in browser mode'));
onCodexAccountSnapshotChanged =
(_callback: (event: unknown, snapshot: CodexAccountSnapshotDto) => void): (() => void) =>
() =>
undefined;
getDashboardRecentProjects = (): Promise<DashboardRecentProjectsPayload> => getDashboardRecentProjects = (): Promise<DashboardRecentProjectsPayload> =>
this.get<DashboardRecentProjectsPayload>('/api/dashboard/recent-projects'); this.get<DashboardRecentProjectsPayload>('/api/dashboard/recent-projects');

View file

@ -1,6 +1,6 @@
/** /**
* CliInstallWarningBanner Global warning strip shown below the tab bar * CliInstallWarningBanner Global warning strip shown below the tab bar
* when Claude Code CLI is not installed. * when the configured runtime is unavailable.
* *
* Hidden on Dashboard pages (which have their own detailed CliStatusBanner). * Hidden on Dashboard pages (which have their own detailed CliStatusBanner).
* Only rendered in Electron mode. * Only rendered in Electron mode.
@ -13,6 +13,7 @@ import { useShallow } from 'zustand/react/shallow';
export const CliInstallWarningBanner = (): React.JSX.Element | null => { export const CliInstallWarningBanner = (): React.JSX.Element | null => {
const cliStatus = useStore(useShallow((s) => s.cliStatus)); const cliStatus = useStore(useShallow((s) => s.cliStatus));
const cliStatusLoading = useStore((s) => s.cliStatusLoading);
const openDashboard = useStore((s) => s.openDashboard); const openDashboard = useStore((s) => s.openDashboard);
// Returns a primitive boolean — minimizes re-renders // Returns a primitive boolean — minimizes re-renders
@ -24,7 +25,13 @@ export const CliInstallWarningBanner = (): React.JSX.Element | null => {
}); });
// Hide when: not Electron, status not loaded yet, CLI installed, or dashboard is focused // Hide when: not Electron, status not loaded yet, CLI installed, or dashboard is focused
if (!isElectronMode() || !cliStatus || cliStatus.installed || isDashboardFocused) { if (
!isElectronMode() ||
cliStatusLoading ||
!cliStatus ||
cliStatus.installed ||
isDashboardFocused
) {
return null; return null;
} }
@ -40,8 +47,8 @@ export const CliInstallWarningBanner = (): React.JSX.Element | null => {
<AlertTriangle className="size-3.5 shrink-0" /> <AlertTriangle className="size-3.5 shrink-0" />
<span className="text-xs"> <span className="text-xs">
{cliStatus.binaryPath && cliStatus.launchError {cliStatus.binaryPath && cliStatus.launchError
? 'Claude Code was found but failed to start. Open the Dashboard to repair or reinstall it.' ? `The configured ${cliStatus.displayName} runtime was found but failed to start. Open the Dashboard to repair or reinstall it.`
: 'Claude Code is not installed. Install it from the Dashboard to enable all features.'} : `The configured ${cliStatus.displayName} runtime is not installed. Install it from the Dashboard to enable all features.`}
</span> </span>
<button <button
onClick={openDashboard} onClick={openDashboard}

View file

@ -9,6 +9,13 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import {
formatCodexRemainingPercent,
formatCodexWindowDuration,
mergeCodexProviderStatusWithSnapshot,
normalizeCodexResetTimestamp,
useCodexAccountSnapshot,
} from '@features/codex-account/renderer';
import { api, isElectronMode } from '@renderer/api'; import { api, isElectronMode } from '@renderer/api';
import { confirm } from '@renderer/components/common/ConfirmDialog'; import { confirm } from '@renderer/components/common/ConfirmDialog';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
@ -33,6 +40,8 @@ import { useStore } from '@renderer/store';
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice'; import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
import { formatBytes } from '@renderer/utils/formatters'; import { formatBytes } from '@renderer/utils/formatters';
import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze'; import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze';
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
import { getRuntimeDisplayName as getHumanRuntimeDisplayName } from '@renderer/utils/runtimeDisplayName';
import { isMultimodelRuntimeStatus } from '@renderer/utils/multimodelProviderVisibility'; import { isMultimodelRuntimeStatus } from '@renderer/utils/multimodelProviderVisibility';
import { import {
AlertTriangle, AlertTriangle,
@ -69,6 +78,45 @@ const VARIANT_STYLES: Record<BannerVariant, { border: string; bg: string }> = {
/** Minimum banner height — prevents layout shift between states (loading → installed → checking). */ /** Minimum banner height — prevents layout shift between states (loading → installed → checking). */
const BANNER_MIN_H = 'min-h-[4.25rem]'; const BANNER_MIN_H = 'min-h-[4.25rem]';
interface CodexDashboardRateLimitItem {
label: string;
remaining: string;
resetsAt: string;
}
function getCodexDashboardHint(provider: CliProviderStatus): string | null {
if (provider.providerId !== 'codex') {
return null;
}
const codex = provider.connection?.codex;
if (!codex || codex.managedAccount?.type === 'chatgpt') {
return null;
}
if (codex.login.status === 'starting' || codex.login.status === 'pending') {
return null;
}
const usageHint = codex.localActiveChatgptAccountPresent
? 'Usage limits appear only after Codex refreshes the currently selected ChatGPT session. Right now the local session needs reconnect.'
: codex.localAccountArtifactsPresent
? 'Usage limits appear only after Codex CLI sees an active ChatGPT account. Local Codex account data exists, but no active managed session is selected right now.'
: 'Usage limits appear only after Codex CLI sees an active ChatGPT account. Right now it reports no active ChatGPT login.';
if (
provider.connection?.configuredAuthMode === 'chatgpt' &&
provider.connection.apiKeyConfigured
) {
return `${usageHint} API key fallback is available if you switch auth mode.`;
}
if (provider.connection?.configuredAuthMode === 'auto' && provider.connection.apiKeyConfigured) {
return `${usageHint} Auto will keep using the API key until ChatGPT is connected.`;
}
return provider.connection?.configuredAuthMode === 'chatgpt' ? usageHint : null;
}
// ============================================================================= // =============================================================================
// Sub-components // Sub-components
// ============================================================================= // =============================================================================
@ -83,7 +131,13 @@ const DetailLine = ({ text }: { text: string | null }): React.JSX.Element | null
); );
}; };
const InstallCompletedNotice = ({ version }: { version: string | null }): React.JSX.Element => ( const InstallCompletedNotice = ({
version,
runtimeDisplayName,
}: {
version: string | null;
runtimeDisplayName: string;
}): React.JSX.Element => (
<div <div
className={`mb-6 flex items-center gap-3 rounded-lg border-l-4 px-4 py-3 ${BANNER_MIN_H}`} className={`mb-6 flex items-center gap-3 rounded-lg border-l-4 px-4 py-3 ${BANNER_MIN_H}`}
style={{ style={{
@ -93,7 +147,7 @@ const InstallCompletedNotice = ({ version }: { version: string | null }): React.
> >
<CheckCircle className="size-4 shrink-0" style={{ color: '#4ade80' }} /> <CheckCircle className="size-4 shrink-0" style={{ color: '#4ade80' }} />
<span className="text-sm" style={{ color: '#4ade80' }}> <span className="text-sm" style={{ color: '#4ade80' }}>
Successfully installed Claude CLI v{version ?? 'latest'} Successfully installed {runtimeDisplayName} v{version ?? 'latest'}
</span> </span>
</div> </div>
); );
@ -199,8 +253,10 @@ const CliCheckingSpinner = ({
interface InstalledBannerProps { interface InstalledBannerProps {
cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']>; cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']>;
sourceProviderMap: Map<CliProviderId, CliProviderStatus>;
cliStatusLoading: boolean; cliStatusLoading: boolean;
cliProviderStatusLoading: Partial<Record<CliProviderId, boolean>>; cliProviderStatusLoading: Partial<Record<CliProviderId, boolean>>;
codexSnapshotPending: boolean;
cliStatusError: string | null; cliStatusError: string | null;
isBusy: boolean; isBusy: boolean;
multimodelEnabled: boolean; multimodelEnabled: boolean;
@ -316,6 +372,34 @@ function isProviderCardLoading(provider: CliProviderStatus, providerLoading: boo
); );
} }
function isCodexSnapshotPending(
provider: CliProviderStatus,
codexSnapshotPending: boolean
): boolean {
return provider.providerId === 'codex' && codexSnapshotPending;
}
function shouldMaskCodexNegativeBootstrapState(
sourceProvider: CliProviderStatus | null,
mergedProvider: CliProviderStatus
): boolean {
return (
sourceProvider?.providerId === 'codex' &&
sourceProvider.statusMessage === 'Checking...' &&
mergedProvider.providerId === 'codex' &&
mergedProvider.connection?.codex?.launchReadinessState === 'missing_auth' &&
mergedProvider.connection.codex.login.status === 'idle'
);
}
function getProviderStatusColor(statusText: string, authenticated: boolean): string {
if (statusText === 'Checking...') {
return 'var(--color-text-secondary)';
}
return authenticated ? '#4ade80' : 'var(--color-text-muted)';
}
function getApiKeyActionRequiredProviders( function getApiKeyActionRequiredProviders(
providers: readonly CliProviderStatus[] providers: readonly CliProviderStatus[]
): CliProviderStatus[] { ): CliProviderStatus[] {
@ -331,9 +415,75 @@ function formatRuntimeLabel(
return null; return null;
} }
const runtimeLabel = getHumanRuntimeDisplayName(cliStatus);
return cliStatus.showVersionDetails && cliStatus.installedVersion return cliStatus.showVersionDetails && cliStatus.installedVersion
? `${cliStatus.displayName} v${cliStatus.installedVersion ?? 'unknown'}` ? `${runtimeLabel} v${cliStatus.installedVersion ?? 'unknown'}`
: cliStatus.displayName; : runtimeLabel;
}
function isCodexSubscriptionActive(
connection: CliProviderStatus['connection'] | null | undefined
): boolean {
return (
connection?.codex?.effectiveAuthMode === 'chatgpt' &&
(connection.codex.managedAccount?.type === 'chatgpt' || connection.codex.launchAllowed)
);
}
function buildCodexRateLimitLabel(
fallbackTitle: 'Primary left' | 'Secondary left' | 'Weekly left',
windowDurationMins: number | null | undefined
): string {
const duration = formatCodexWindowDuration(windowDurationMins);
return duration ? `${duration} left` : fallbackTitle;
}
function formatCodexDashboardResetTime(timestampSeconds: number | null | undefined): string {
const normalized = normalizeCodexResetTimestamp(timestampSeconds);
if (!normalized) {
return 'reset unknown';
}
return new Date(normalized).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
function getCodexDashboardRateLimits(
provider: CliProviderStatus
): CodexDashboardRateLimitItem[] | null {
if (provider.providerId !== 'codex' || !isCodexSubscriptionActive(provider.connection)) {
return null;
}
const rateLimits = provider.connection?.codex?.rateLimits;
if (!rateLimits?.primary) {
return null;
}
const items: CodexDashboardRateLimitItem[] = [];
const primaryRemaining = formatCodexRemainingPercent(rateLimits.primary.usedPercent) ?? 'Unknown';
items.push({
label: buildCodexRateLimitLabel('Primary left', rateLimits.primary.windowDurationMins),
remaining: primaryRemaining,
resetsAt: formatCodexDashboardResetTime(rateLimits.primary.resetsAt),
});
if (rateLimits.secondary) {
items.push({
label: buildCodexRateLimitLabel(
rateLimits.secondary.windowDurationMins === 10_080 ? 'Weekly left' : 'Secondary left',
rateLimits.secondary.windowDurationMins
),
remaining: formatCodexRemainingPercent(rateLimits.secondary.usedPercent) ?? 'Unknown',
resetsAt: formatCodexDashboardResetTime(rateLimits.secondary.resetsAt),
});
}
return items;
} }
function formatRuntimeAuthSummary( function formatRuntimeAuthSummary(
@ -390,8 +540,10 @@ function hasVisibleAuthenticatedMultimodelProvider(
const InstalledBanner = ({ const InstalledBanner = ({
cliStatus, cliStatus,
sourceProviderMap,
cliStatusLoading, cliStatusLoading,
cliProviderStatusLoading, cliProviderStatusLoading,
codexSnapshotPending,
cliStatusError, cliStatusError,
isBusy, isBusy,
multimodelEnabled, multimodelEnabled,
@ -520,16 +672,31 @@ const InstalledBanner = ({
style={{ borderColor: 'var(--color-border-subtle)' }} style={{ borderColor: 'var(--color-border-subtle)' }}
> >
{visibleProviders.map((provider) => { {visibleProviders.map((provider) => {
const statusText = formatProviderStatusText(provider);
const actionDisabled = isBusy || !cliStatus.binaryPath; const actionDisabled = isBusy || !cliStatus.binaryPath;
const runtimeSummary = isConnectionManagedRuntimeProvider(provider) const runtimeSummary = isConnectionManagedRuntimeProvider(provider)
? getProviderCurrentRuntimeSummary(provider) ? getProviderCurrentRuntimeSummary(provider)
: getProviderRuntimeBackendSummary(provider); : getProviderRuntimeBackendSummary(provider);
const connectionModeSummary = getProviderConnectionModeSummary(provider); const connectionModeSummary = getProviderConnectionModeSummary(provider);
const credentialSummary = getProviderCredentialSummary(provider); const credentialSummary = getProviderCredentialSummary(provider);
const codexDashboardRateLimits = getCodexDashboardRateLimits(provider);
const codexDashboardHint = getCodexDashboardHint(provider);
const disconnectAction = getProviderDisconnectAction(provider); const disconnectAction = getProviderDisconnectAction(provider);
const providerLoading = cliProviderStatusLoading[provider.providerId] === true; const providerLoading = cliProviderStatusLoading[provider.providerId] === true;
const showSkeleton = isProviderCardLoading(provider, providerLoading); const sourceProvider = sourceProviderMap.get(provider.providerId) ?? null;
const maskNegativeBootstrapState = shouldMaskCodexNegativeBootstrapState(
sourceProvider,
provider
);
const showSkeleton =
isProviderCardLoading(provider, providerLoading) ||
isCodexSnapshotPending(provider, codexSnapshotPending) ||
maskNegativeBootstrapState;
const showInlineCodexAccessoryRow =
!showSkeleton &&
provider.providerId === 'codex' &&
provider.models.length > 0 &&
Boolean(codexDashboardRateLimits?.length);
const statusText = showSkeleton ? 'Checking...' : formatProviderStatusText(provider);
const hasDetailContent = Boolean( const hasDetailContent = Boolean(
(provider.backend?.label && !runtimeSummary) || (provider.backend?.label && !runtimeSummary) ||
runtimeSummary || runtimeSummary ||
@ -562,7 +729,7 @@ const InstalledBanner = ({
<span <span
className="text-xs" className="text-xs"
style={{ style={{
color: provider.authenticated ? '#4ade80' : 'var(--color-text-muted)', color: getProviderStatusColor(statusText, provider.authenticated),
}} }}
> >
{statusText} {statusText}
@ -592,6 +759,90 @@ const InstalledBanner = ({
)} )}
</div> </div>
) : null} ) : null}
{showInlineCodexAccessoryRow ? (
<div className="mt-2 flex flex-wrap items-center gap-2">
<ProviderModelBadges
providerId={provider.providerId}
models={provider.models}
modelAvailability={provider.modelAvailability}
providerStatus={provider}
/>
{codexDashboardRateLimits!.map((item) => (
<div
key={`${provider.providerId}-${item.label}`}
className="rounded-md border px-2 py-1.5"
style={{
borderColor: 'rgba(74, 222, 128, 0.2)',
backgroundColor: 'rgba(74, 222, 128, 0.06)',
}}
>
<div className="flex items-baseline gap-1.5 whitespace-nowrap">
<span
className="text-[10px] uppercase tracking-[0.06em]"
style={{ color: 'var(--color-text-muted)' }}
>
{item.label}
</span>
<span className="text-xs font-medium" style={{ color: '#86efac' }}>
{item.remaining}
</span>
<span
className="min-w-0 truncate text-[10px]"
style={{ color: 'var(--color-text-secondary)' }}
title={item.resetsAt}
>
resets {item.resetsAt}
</span>
</div>
</div>
))}
</div>
) : !showSkeleton &&
codexDashboardRateLimits &&
codexDashboardRateLimits.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-2">
{codexDashboardRateLimits.map((item) => (
<div
key={`${provider.providerId}-${item.label}`}
className="rounded-md border px-2 py-1.5"
style={{
borderColor: 'rgba(74, 222, 128, 0.2)',
backgroundColor: 'rgba(74, 222, 128, 0.06)',
}}
>
<div className="flex items-baseline gap-1.5 whitespace-nowrap">
<span
className="text-[10px] uppercase tracking-[0.06em]"
style={{ color: 'var(--color-text-muted)' }}
>
{item.label}
</span>
<span className="text-xs font-medium" style={{ color: '#86efac' }}>
{item.remaining}
</span>
<span
className="min-w-0 truncate text-[10px]"
style={{ color: 'var(--color-text-secondary)' }}
title={item.resetsAt}
>
resets {item.resetsAt}
</span>
</div>
</div>
))}
</div>
) : !showSkeleton && codexDashboardHint ? (
<div
className="mt-2 rounded-md border px-2.5 py-2 text-[11px]"
style={{
borderColor: 'rgba(255, 255, 255, 0.08)',
backgroundColor: 'rgba(255, 255, 255, 0.025)',
color: 'var(--color-text-secondary)',
}}
>
{codexDashboardHint}
</div>
) : null}
</div> </div>
<div className="flex shrink-0 items-start gap-2"> <div className="flex shrink-0 items-start gap-2">
<button <button
@ -619,7 +870,7 @@ const InstalledBanner = ({
<LogOut className="size-3" /> <LogOut className="size-3" />
{disconnectAction.label} {disconnectAction.label}
</button> </button>
) : shouldShowProviderConnectAction(provider) ? ( ) : !showSkeleton && shouldShowProviderConnectAction(provider) ? (
<button <button
onClick={() => onProviderLogin(provider.providerId)} onClick={() => onProviderLogin(provider.providerId)}
disabled={actionDisabled} disabled={actionDisabled}
@ -635,7 +886,7 @@ const InstalledBanner = ({
) : null} ) : null}
<button <button
onClick={() => onProviderRefresh(provider.providerId)} onClick={() => onProviderRefresh(provider.providerId)}
disabled={cliStatusLoading || providerLoading} disabled={providerLoading}
className="flex items-center gap-1 rounded-md border px-1.5 py-[3px] text-[10px] transition-colors hover:bg-white/5 disabled:opacity-50" className="flex items-center gap-1 rounded-md border px-1.5 py-[3px] text-[10px] transition-colors hover:bg-white/5 disabled:opacity-50"
style={{ style={{
borderColor: 'var(--color-border)', borderColor: 'var(--color-border)',
@ -644,16 +895,12 @@ const InstalledBanner = ({
title={`Re-check ${provider.displayName}`} title={`Re-check ${provider.displayName}`}
> >
<RefreshCw <RefreshCw
className={ className={providerLoading ? 'size-[11px] animate-spin' : 'size-[11px]'}
cliStatusLoading || providerLoading
? 'size-[11px] animate-spin'
: 'size-[11px]'
}
/> />
</button> </button>
</div> </div>
</div> </div>
{!showSkeleton && provider.models.length > 0 && ( {!showSkeleton && provider.models.length > 0 && !showInlineCodexAccessoryRow && (
<div className="col-span-2"> <div className="col-span-2">
<ProviderModelBadges <ProviderModelBadges
providerId={provider.providerId} providerId={provider.providerId}
@ -712,10 +959,56 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false); const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false);
const [showTroubleshoot, setShowTroubleshoot] = useState(false); const [showTroubleshoot, setShowTroubleshoot] = useState(false);
const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true; const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true;
const visibleCliProviders = useMemo( const loadingCliStatus = useMemo(
() => filterMainScreenCliProviders(cliStatus?.providers ?? []), () =>
[cliStatus?.providers] !cliStatus && cliStatusLoading && multimodelEnabled
? createLoadingMultimodelCliStatus()
: cliStatus,
[cliStatus, cliStatusLoading, multimodelEnabled]
); );
const codexAccount = useCodexAccountSnapshot({
enabled:
isElectron &&
multimodelEnabled &&
loadingCliStatus?.flavor === 'agent_teams_orchestrator' &&
Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')),
includeRateLimits: true,
});
const visibleCliProviders = useMemo(
() =>
filterMainScreenCliProviders(loadingCliStatus?.providers ?? []).map((provider) =>
provider.providerId === 'codex'
? mergeCodexProviderStatusWithSnapshot(provider, codexAccount.snapshot)
: provider
),
[loadingCliStatus?.providers, codexAccount.snapshot]
);
const loadingCliProviderMap = useMemo(
() =>
new Map(
filterMainScreenCliProviders(loadingCliStatus?.providers ?? []).map((provider) => [
provider.providerId,
provider,
])
),
[loadingCliStatus?.providers]
);
const codexSnapshotPending =
codexAccount.loading &&
Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')) &&
!codexAccount.snapshot;
const effectiveCliStatus = useMemo(
() =>
loadingCliStatus
? {
...loadingCliStatus,
providers: visibleCliProviders,
}
: loadingCliStatus,
[loadingCliStatus, visibleCliProviders]
);
const renderCliStatus = effectiveCliStatus;
const runtimeDisplayName = getHumanRuntimeDisplayName(renderCliStatus, multimodelEnabled);
useEffect(() => { useEffect(() => {
if (!isElectron) return; if (!isElectron) return;
@ -729,24 +1022,28 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
const interval = setInterval( const interval = setInterval(
() => { () => {
void fetchCliStatus(); void refreshCliStatusForCurrentMode({
multimodelEnabled,
bootstrapCliStatus,
fetchCliStatus,
});
}, },
10 * 60 * 1000 10 * 60 * 1000
); );
return () => clearInterval(interval); return () => clearInterval(interval);
}, [isElectron, cliStatus, fetchCliStatus]); }, [bootstrapCliStatus, cliStatus, fetchCliStatus, isElectron, multimodelEnabled]);
const handleInstall = useCallback(() => { const handleInstall = useCallback(() => {
installCli(); installCli();
}, [installCli]); }, [installCli]);
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
if (multimodelEnabled) { void refreshCliStatusForCurrentMode({
void bootstrapCliStatus({ multimodelEnabled: true }); multimodelEnabled,
return; bootstrapCliStatus,
} fetchCliStatus,
void fetchCliStatus(); });
}, [bootstrapCliStatus, fetchCliStatus, multimodelEnabled]); }, [bootstrapCliStatus, fetchCliStatus, multimodelEnabled]);
const handleMultimodelToggle = useCallback( const handleMultimodelToggle = useCallback(
@ -785,12 +1082,16 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
void (async () => { void (async () => {
try { try {
await invalidateCliStatus(); await invalidateCliStatus();
await fetchCliStatus(); await refreshCliStatusForCurrentMode({
multimodelEnabled,
bootstrapCliStatus,
fetchCliStatus,
});
} finally { } finally {
setIsVerifyingAuth(false); setIsVerifyingAuth(false);
} }
})(); })();
}, [fetchCliStatus, invalidateCliStatus]); }, [bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, multimodelEnabled]);
const handleProviderLogin = useCallback((providerId: CliProviderId) => { const handleProviderLogin = useCallback((providerId: CliProviderId) => {
setProviderTerminal({ providerId, action: 'login' }); setProviderTerminal({ providerId, action: 'login' });
@ -800,7 +1101,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
(providerId: CliProviderId) => { (providerId: CliProviderId) => {
void (async () => { void (async () => {
const provider = const provider =
cliStatus?.providers.find((entry) => entry.providerId === providerId) ?? null; effectiveCliStatus?.providers.find((entry) => entry.providerId === providerId) ?? null;
const disconnectAction = provider ? getProviderDisconnectAction(provider) : null; const disconnectAction = provider ? getProviderDisconnectAction(provider) : null;
if (!disconnectAction) { if (!disconnectAction) {
return; return;
@ -821,7 +1122,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
setProviderTerminal({ providerId, action: 'logout' }); setProviderTerminal({ providerId, action: 'logout' });
})(); })();
}, },
[cliStatus?.providers] [effectiveCliStatus?.providers]
); );
const handleProviderManage = useCallback((providerId: CliProviderId) => { const handleProviderManage = useCallback((providerId: CliProviderId) => {
@ -870,29 +1171,29 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
if (installerState === 'error') return 'error'; if (installerState === 'error') return 'error';
if (installerState === 'completed') return 'success'; if (installerState === 'completed') return 'success';
if (installerState !== 'idle') return 'info'; if (installerState !== 'idle') return 'info';
if (!cliStatus) return 'loading'; if (!renderCliStatus) return 'loading';
if (isCheckingMultimodelStatus(cliStatus, visibleCliProviders)) return 'info'; if (isCheckingMultimodelStatus(renderCliStatus, visibleCliProviders)) return 'info';
if (cliStatus.authStatusChecking) return 'info'; if (renderCliStatus.authStatusChecking) return 'info';
if (!cliStatus.installed) return 'error'; if (!renderCliStatus.installed) return 'error';
if (isMultimodelRuntimeStatus(cliStatus) && visibleCliProviders.length === 0) { if (isMultimodelRuntimeStatus(renderCliStatus) && visibleCliProviders.length === 0) {
return 'warning'; return 'warning';
} }
if ( if (
isMultimodelRuntimeStatus(cliStatus) && isMultimodelRuntimeStatus(renderCliStatus) &&
visibleCliProviders.length > 0 && visibleCliProviders.length > 0 &&
!hasVisibleAuthenticatedMultimodelProvider(visibleCliProviders) !hasVisibleAuthenticatedMultimodelProvider(visibleCliProviders)
) { ) {
return 'warning'; return 'warning';
} }
if (cliStatus.installed && !cliStatus.authLoggedIn) return 'warning'; if (renderCliStatus.installed && !renderCliStatus.authLoggedIn) return 'warning';
if (cliStatus.updateAvailable) return 'info'; if (renderCliStatus.updateAvailable) return 'info';
return 'success'; return 'success';
}; };
const variant = getVariant(); const variant = getVariant();
const styles = VARIANT_STYLES[variant]; const styles = VARIANT_STYLES[variant];
const activeTerminalProvider = providerTerminal const activeTerminalProvider = providerTerminal
? (cliStatus?.providers.find( ? (effectiveCliStatus?.providers.find(
(provider) => provider.providerId === providerTerminal.providerId (provider) => provider.providerId === providerTerminal.providerId
) ?? null) ) ?? null)
: null; : null;
@ -903,7 +1204,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
: getProviderTerminalLogoutCommand(activeTerminalProvider) : getProviderTerminalLogoutCommand(activeTerminalProvider)
: null; : null;
const installedAuxiliaryUi = const installedAuxiliaryUi =
cliStatus !== null ? ( renderCliStatus !== null ? (
<> <>
<ProviderRuntimeSettingsDialog <ProviderRuntimeSettingsDialog
open={manageDialogOpen} open={manageDialogOpen}
@ -915,17 +1216,17 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
: (visibleCliProviders[0]?.providerId ?? 'anthropic') : (visibleCliProviders[0]?.providerId ?? 'anthropic')
} }
providerStatusLoading={cliProviderStatusLoading} providerStatusLoading={cliProviderStatusLoading}
disabled={isBusy || cliStatusLoading || !cliStatus.binaryPath} disabled={isBusy || cliStatusLoading || !renderCliStatus.binaryPath}
onSelectBackend={handleProviderBackendChange} onSelectBackend={handleProviderBackendChange}
onRefreshProvider={(providerId) => fetchCliProviderStatus(providerId)} onRefreshProvider={(providerId) => fetchCliProviderStatus(providerId)}
onRequestLogin={(providerId) => setProviderTerminal({ providerId, action: 'login' })} onRequestLogin={(providerId) => setProviderTerminal({ providerId, action: 'login' })}
/> />
{providerTerminal && cliStatus.binaryPath && ( {providerTerminal && renderCliStatus.binaryPath && (
<TerminalModal <TerminalModal
title={`${cliStatus.displayName} ${providerTerminal.action === 'login' ? 'Login' : 'Logout'}: ${getProviderLabel( title={`${getHumanRuntimeDisplayName(renderCliStatus, multimodelEnabled)} ${
providerTerminal.providerId providerTerminal.action === 'login' ? 'Login' : 'Logout'
)}`} }: ${getProviderLabel(providerTerminal.providerId)}`}
command={cliStatus.binaryPath} command={renderCliStatus.binaryPath}
args={providerTerminalCommand?.args} args={providerTerminalCommand?.args}
env={providerTerminalCommand?.env} env={providerTerminalCommand?.env}
onClose={() => { onClose={() => {
@ -948,7 +1249,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
) : null; ) : null;
// ── Loading / fetch error state ──────────────────────────────────────── // ── Loading / fetch error state ────────────────────────────────────────
if (!cliStatus && installerState === 'idle') { if (!renderCliStatus && installerState === 'idle') {
// Fetch failed — show error with retry // Fetch failed — show error with retry
if (cliStatusError && !cliStatusLoading) { if (cliStatusError && !cliStatusLoading) {
return ( return (
@ -988,7 +1289,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
style={{ borderColor: styles.border, backgroundColor: styles.bg }} style={{ borderColor: styles.border, backgroundColor: styles.bg }}
> >
<span className="text-sm" style={{ color: 'var(--color-text-muted)' }}> <span className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
Claude CLI status will be checked in the background. {runtimeDisplayName} status will be checked in the background.
</span> </span>
<button <button
onClick={handleRefresh} onClick={handleRefresh}
@ -1006,9 +1307,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
if (multimodelEnabled) { if (multimodelEnabled) {
return ( return (
<InstalledBanner <InstalledBanner
cliStatus={createLoadingMultimodelCliStatus()} cliStatus={renderCliStatus ?? createLoadingMultimodelCliStatus()}
sourceProviderMap={loadingCliProviderMap}
cliStatusLoading={cliStatusLoading} cliStatusLoading={cliStatusLoading}
cliProviderStatusLoading={cliProviderStatusLoading} cliProviderStatusLoading={cliProviderStatusLoading}
codexSnapshotPending={codexSnapshotPending}
cliStatusError={cliStatusError ?? null} cliStatusError={cliStatusError ?? null}
isBusy={isBusy} isBusy={isBusy}
multimodelEnabled={multimodelEnabled} multimodelEnabled={multimodelEnabled}
@ -1045,7 +1348,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Loader2 className="size-4 shrink-0 animate-spin text-blue-600 dark:text-blue-400" /> <Loader2 className="size-4 shrink-0 animate-spin text-blue-600 dark:text-blue-400" />
<span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}> <span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
Downloading Claude CLI... Downloading {runtimeDisplayName}...
</span> </span>
</div> </div>
<span className="text-xs tabular-nums" style={{ color: 'var(--color-text-muted)' }}> <span className="text-xs tabular-nums" style={{ color: 'var(--color-text-muted)' }}>
@ -1104,7 +1407,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Loader2 className="size-4 shrink-0 animate-spin text-blue-600 dark:text-blue-400" /> <Loader2 className="size-4 shrink-0 animate-spin text-blue-600 dark:text-blue-400" />
<span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}> <span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
Installing Claude CLI... Installing {runtimeDisplayName}...
</span> </span>
</div> </div>
<TerminalLogPanel chunks={installerRawChunks} /> <TerminalLogPanel chunks={installerRawChunks} />
@ -1115,10 +1418,12 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
// ── Completed ────────────────────────────────────────────────────────── // ── Completed ──────────────────────────────────────────────────────────
if ( if (
installerState === 'completed' && installerState === 'completed' &&
!cliStatus?.installed && !renderCliStatus?.installed &&
!(cliStatus?.binaryPath && cliStatus?.launchError) !(renderCliStatus?.binaryPath && renderCliStatus?.launchError)
) { ) {
return <InstallCompletedNotice version={completedVersion} />; return (
<InstallCompletedNotice version={completedVersion} runtimeDisplayName={runtimeDisplayName} />
);
} }
// ── Error ────────────────────────────────────────────────────────────── // ── Error ──────────────────────────────────────────────────────────────
@ -1134,12 +1439,13 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
} }
// ── Idle state with status ───────────────────────────────────────────── // ── Idle state with status ─────────────────────────────────────────────
if (!cliStatus) return null; if (!renderCliStatus) return null;
const cliLaunchIssue = const cliLaunchIssue =
!cliStatus.installed && Boolean(cliStatus.binaryPath && cliStatus.launchError); !renderCliStatus.installed &&
Boolean(renderCliStatus.binaryPath && renderCliStatus.launchError);
// Not installed — red error banner // Not installed — red error banner
if (!cliStatus.installed) { if (!renderCliStatus.installed) {
return ( return (
<div <div
className="mb-6 rounded-lg border-l-4 p-4" className="mb-6 rounded-lg border-l-4 p-4"
@ -1151,23 +1457,23 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
<div> <div>
<p className="text-sm font-medium" style={{ color: '#f87171' }}> <p className="text-sm font-medium" style={{ color: '#f87171' }}>
{cliLaunchIssue {cliLaunchIssue
? 'Claude CLI was found but failed to start' ? `${runtimeDisplayName} was found but failed to start`
: 'Claude CLI is required'} : `${runtimeDisplayName} is required`}
</p> </p>
<p className="mt-1 text-xs" style={{ color: 'var(--color-text-muted)' }}> <p className="mt-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
{cliLaunchIssue {cliLaunchIssue
? 'The app found a Claude CLI binary, but its startup health check failed. Repair or reinstall it, then retry.' ? `The app found the configured ${runtimeDisplayName}, but its startup health check failed. Repair or reinstall it, then retry.`
: 'Claude CLI is required for team provisioning and session management. Install it to get started.'} : `${runtimeDisplayName} is required for team provisioning and session management. Install it to get started.`}
</p> </p>
{cliStatus.showBinaryPath && cliStatus.binaryPath && ( {renderCliStatus.showBinaryPath && renderCliStatus.binaryPath && (
<p <p
className="mt-2 break-all font-mono text-[11px]" className="mt-2 break-all font-mono text-[11px]"
style={{ color: 'var(--color-text-muted)' }} style={{ color: 'var(--color-text-muted)' }}
> >
{cliStatus.binaryPath} {renderCliStatus.binaryPath}
</p> </p>
)} )}
{cliLaunchIssue && cliStatus.launchError && ( {cliLaunchIssue && renderCliStatus.launchError && (
<div <div
className="mt-2 rounded border px-2 py-1.5 font-mono text-[11px]" className="mt-2 rounded border px-2 py-1.5 font-mono text-[11px]"
style={{ style={{
@ -1176,7 +1482,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
color: 'var(--color-text-muted)', color: 'var(--color-text-muted)',
}} }}
> >
{cliStatus.launchError} {renderCliStatus.launchError}
</div> </div>
)} )}
</div> </div>
@ -1190,7 +1496,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
<RefreshCw className="size-4" /> <RefreshCw className="size-4" />
Re-check Re-check
</button> </button>
{cliStatus.supportsSelfUpdate ? ( {renderCliStatus.supportsSelfUpdate ? (
<button <button
onClick={handleInstall} onClick={handleInstall}
disabled={isBusy} disabled={isBusy}
@ -1198,13 +1504,15 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
style={{ backgroundColor: '#3b82f6' }} style={{ backgroundColor: '#3b82f6' }}
> >
<Download className="size-4" /> <Download className="size-4" />
{cliLaunchIssue ? 'Reinstall Claude CLI' : 'Install Claude CLI'} {cliLaunchIssue
? `Reinstall ${runtimeDisplayName}`
: `Install ${runtimeDisplayName}`}
</button> </button>
) : ( ) : (
<p className="max-w-40 text-xs" style={{ color: 'var(--color-text-muted)' }}> <p className="max-w-40 text-xs" style={{ color: 'var(--color-text-muted)' }}>
{cliLaunchIssue {cliLaunchIssue
? `The configured ${cliStatus.displayName} runtime failed its startup health check.` ? `The configured ${runtimeDisplayName} failed its startup health check.`
: `The configured ${cliStatus.displayName} runtime was not found.`} : `The configured ${runtimeDisplayName} was not found.`}
</p> </p>
)} )}
</div> </div>
@ -1215,17 +1523,19 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
// Installed but not logged in — yellow warning banner // Installed but not logged in — yellow warning banner
if ( if (
cliStatus.installed && renderCliStatus.installed &&
cliStatus.flavor !== 'agent_teams_orchestrator' && renderCliStatus.flavor !== 'agent_teams_orchestrator' &&
(cliStatus.authStatusChecking || isVerifyingAuth) (renderCliStatus.authStatusChecking || isVerifyingAuth)
) { ) {
if (cliStatus.authStatusChecking || isVerifyingAuth) { if (renderCliStatus.authStatusChecking || isVerifyingAuth) {
return ( return (
<> <>
<InstalledBanner <InstalledBanner
cliStatus={cliStatus} cliStatus={renderCliStatus}
sourceProviderMap={loadingCliProviderMap}
cliStatusLoading={cliStatusLoading} cliStatusLoading={cliStatusLoading}
cliProviderStatusLoading={cliProviderStatusLoading} cliProviderStatusLoading={cliProviderStatusLoading}
codexSnapshotPending={codexSnapshotPending}
cliStatusError={cliStatusError ?? null} cliStatusError={cliStatusError ?? null}
isBusy={isBusy} isBusy={isBusy}
multimodelEnabled={multimodelEnabled} multimodelEnabled={multimodelEnabled}
@ -1246,12 +1556,14 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
} }
if ( if (
cliStatus.installed && renderCliStatus.installed &&
cliStatus.flavor !== 'agent_teams_orchestrator' && renderCliStatus.flavor !== 'agent_teams_orchestrator' &&
!cliStatus.authStatusChecking && !renderCliStatus.authStatusChecking &&
!cliStatus.authLoggedIn !renderCliStatus.authLoggedIn
) { ) {
const apiKeyActionRequiredProviders = getApiKeyActionRequiredProviders(cliStatus.providers); const apiKeyActionRequiredProviders = getApiKeyActionRequiredProviders(
renderCliStatus.providers
);
const hasApiKeyModeIssue = apiKeyActionRequiredProviders.length > 0; const hasApiKeyModeIssue = apiKeyActionRequiredProviders.length > 0;
const primaryApiKeyProvider = apiKeyActionRequiredProviders[0] ?? null; const primaryApiKeyProvider = apiKeyActionRequiredProviders[0] ?? null;
const apiKeyMissingProviders = apiKeyActionRequiredProviders.filter( const apiKeyMissingProviders = apiKeyActionRequiredProviders.filter(
@ -1272,14 +1584,16 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
: apiKeyActionRequiredProviders.length === 1 && primaryApiKeyProvider : apiKeyActionRequiredProviders.length === 1 && primaryApiKeyProvider
? `${primaryApiKeyProvider.displayName} is set to API key mode, but it is not connected. Open Manage Providers to review the saved key or switch the connection mode.` ? `${primaryApiKeyProvider.displayName} is set to API key mode, but it is not connected. Open Manage Providers to review the saved key or switch the connection mode.`
: 'One or more providers are set to API key mode and need attention. Open Manage Providers to review saved keys or switch the connection mode.' : 'One or more providers are set to API key mode and need attention. Open Manage Providers to review saved keys or switch the connection mode.'
: `${cliStatus.displayName} is installed but you are not authenticated. Login is required for team provisioning and AI features.`; : `${runtimeDisplayName} is installed but you are not authenticated. Login is required for team provisioning and AI features.`;
return ( return (
<> <>
<InstalledBanner <InstalledBanner
cliStatus={cliStatus} cliStatus={renderCliStatus}
sourceProviderMap={loadingCliProviderMap}
cliStatusLoading={cliStatusLoading} cliStatusLoading={cliStatusLoading}
cliProviderStatusLoading={cliProviderStatusLoading} cliProviderStatusLoading={cliProviderStatusLoading}
codexSnapshotPending={codexSnapshotPending}
cliStatusError={cliStatusError ?? null} cliStatusError={cliStatusError ?? null}
isBusy={isBusy} isBusy={isBusy}
multimodelEnabled={multimodelEnabled} multimodelEnabled={multimodelEnabled}
@ -1403,8 +1717,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
<li> <li>
Open your terminal and run:{' '} Open your terminal and run:{' '}
<code className="rounded bg-white/5 px-1.5 py-0.5 font-mono text-[11px]"> <code className="rounded bg-white/5 px-1.5 py-0.5 font-mono text-[11px]">
{cliStatus.showBinaryPath && cliStatus.binaryPath {renderCliStatus.showBinaryPath && renderCliStatus.binaryPath
? `"${cliStatus.binaryPath}" auth status` ? `"${renderCliStatus.binaryPath}" auth status`
: 'your configured CLI auth status command'} : 'your configured CLI auth status command'}
</code>{' '} </code>{' '}
check if it shows &quot;Logged in&quot; check if it shows &quot;Logged in&quot;
@ -1412,25 +1726,25 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
<li> <li>
If it says logged in but the app doesn&apos;t see it, try:{' '} If it says logged in but the app doesn&apos;t see it, try:{' '}
<code className="rounded bg-white/5 px-1.5 py-0.5 font-mono text-[11px]"> <code className="rounded bg-white/5 px-1.5 py-0.5 font-mono text-[11px]">
{cliStatus.showBinaryPath && cliStatus.binaryPath {renderCliStatus.showBinaryPath && renderCliStatus.binaryPath
? `"${cliStatus.binaryPath}" auth logout` ? `"${renderCliStatus.binaryPath}" auth logout`
: 'the runtime logout command'} : 'the runtime logout command'}
</code>{' '} </code>{' '}
then{' '} then{' '}
<code className="rounded bg-white/5 px-1.5 py-0.5 font-mono text-[11px]"> <code className="rounded bg-white/5 px-1.5 py-0.5 font-mono text-[11px]">
{cliStatus.showBinaryPath && cliStatus.binaryPath {renderCliStatus.showBinaryPath && renderCliStatus.binaryPath
? `"${cliStatus.binaryPath}" auth login` ? `"${renderCliStatus.binaryPath}" auth login`
: 'the runtime login command'} : 'the runtime login command'}
</code>{' '} </code>{' '}
again again
</li> </li>
<li> <li>
Make sure the CLI in your terminal is the same runtime the app uses Make sure the CLI in your terminal is the same runtime the app uses
{cliStatus.showBinaryPath && cliStatus.binaryPath && ( {renderCliStatus.showBinaryPath && renderCliStatus.binaryPath && (
<span> <span>
:{' '} :{' '}
<code className="rounded bg-white/5 px-1.5 py-0.5 font-mono text-[11px]"> <code className="rounded bg-white/5 px-1.5 py-0.5 font-mono text-[11px]">
{cliStatus.binaryPath} {renderCliStatus.binaryPath}
</code> </code>
</span> </span>
)} )}
@ -1444,10 +1758,10 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
)} )}
</div> </div>
{installedAuxiliaryUi} {installedAuxiliaryUi}
{showLoginTerminal && cliStatus.binaryPath && ( {showLoginTerminal && renderCliStatus.binaryPath && (
<TerminalModal <TerminalModal
title={`${cliStatus.displayName} Login`} title={`${getHumanRuntimeDisplayName(renderCliStatus, multimodelEnabled)} Login`}
command={cliStatus.binaryPath} command={renderCliStatus.binaryPath}
args={['auth', 'login']} args={['auth', 'login']}
onClose={() => { onClose={() => {
setShowLoginTerminal(false); setShowLoginTerminal(false);
@ -1493,9 +1807,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
return ( return (
<> <>
<InstalledBanner <InstalledBanner
cliStatus={cliStatus} cliStatus={renderCliStatus}
sourceProviderMap={loadingCliProviderMap}
cliStatusLoading={cliStatusLoading} cliStatusLoading={cliStatusLoading}
cliProviderStatusLoading={cliProviderStatusLoading} cliProviderStatusLoading={cliProviderStatusLoading}
codexSnapshotPending={codexSnapshotPending}
cliStatusError={cliStatusError ?? null} cliStatusError={cliStatusError ?? null}
isBusy={isBusy} isBusy={isBusy}
multimodelEnabled={multimodelEnabled} multimodelEnabled={multimodelEnabled}

View file

@ -6,7 +6,11 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '@renderer/api'; import {
mergeCodexProviderStatusWithSnapshot,
useCodexAccountSnapshot,
} from '@features/codex-account/renderer';
import { api, isElectronMode } from '@renderer/api';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import { Badge } from '@renderer/components/ui/badge'; import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
@ -20,12 +24,15 @@ import {
import { useTabIdOptional } from '@renderer/contexts/useTabUIContext'; import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState'; import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
import { import {
formatCliExtensionCapabilityStatus, formatCliExtensionCapabilityStatus,
getVisibleMultimodelProviders, getVisibleMultimodelProviders,
isMultimodelRuntimeStatus, isMultimodelRuntimeStatus,
} from '@renderer/utils/multimodelProviderVisibility'; } from '@renderer/utils/multimodelProviderVisibility';
import { resolveProjectPathById } from '@renderer/utils/projectLookup'; import { resolveProjectPathById } from '@renderer/utils/projectLookup';
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
import { getRuntimeDisplayName } from '@renderer/utils/runtimeDisplayName';
import { getExtensionActionDisableReason } from '@shared/utils/extensionNormalizers'; import { getExtensionActionDisableReason } from '@shared/utils/extensionNormalizers';
import { getCliProviderExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities'; import { getCliProviderExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
import { import {
@ -97,10 +104,19 @@ function isProviderCapabilityCardLoading(
); );
} }
function isCodexSnapshotPending(
provider: CliProviderStatus,
codexSnapshotPending: boolean
): boolean {
return provider.providerId === 'codex' && codexSnapshotPending;
}
export const ExtensionStoreView = (): React.JSX.Element => { export const ExtensionStoreView = (): React.JSX.Element => {
const isElectron = useMemo(() => isElectronMode(), []);
const tabId = useTabIdOptional(); const tabId = useTabIdOptional();
const { const {
fetchPluginCatalog, fetchPluginCatalog,
bootstrapCliStatus,
fetchCliStatus, fetchCliStatus,
fetchApiKeys, fetchApiKeys,
fetchSkillsCatalog, fetchSkillsCatalog,
@ -113,6 +129,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
cliStatus, cliStatus,
cliStatusLoading, cliStatusLoading,
cliProviderStatusLoading, cliProviderStatusLoading,
appConfig,
openDashboard, openDashboard,
sessions, sessions,
projects, projects,
@ -120,6 +137,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
} = useStore( } = useStore(
useShallow((s) => ({ useShallow((s) => ({
fetchPluginCatalog: s.fetchPluginCatalog, fetchPluginCatalog: s.fetchPluginCatalog,
bootstrapCliStatus: s.bootstrapCliStatus,
fetchCliStatus: s.fetchCliStatus, fetchCliStatus: s.fetchCliStatus,
fetchApiKeys: s.fetchApiKeys, fetchApiKeys: s.fetchApiKeys,
fetchSkillsCatalog: s.fetchSkillsCatalog, fetchSkillsCatalog: s.fetchSkillsCatalog,
@ -132,13 +150,58 @@ export const ExtensionStoreView = (): React.JSX.Element => {
cliStatus: s.cliStatus, cliStatus: s.cliStatus,
cliStatusLoading: s.cliStatusLoading, cliStatusLoading: s.cliStatusLoading,
cliProviderStatusLoading: s.cliProviderStatusLoading, cliProviderStatusLoading: s.cliProviderStatusLoading,
appConfig: s.appConfig,
openDashboard: s.openDashboard, openDashboard: s.openDashboard,
sessions: s.sessions, sessions: s.sessions,
projects: s.projects, projects: s.projects,
repositoryGroups: s.repositoryGroups, repositoryGroups: s.repositoryGroups,
})) }))
); );
const cliInstalled = cliStatus?.installed ?? true; const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true;
const loadingCliStatus = useMemo(
() =>
!cliStatus && cliStatusLoading && multimodelEnabled
? createLoadingMultimodelCliStatus()
: cliStatus,
[cliStatus, cliStatusLoading, multimodelEnabled]
);
const codexAccount = useCodexAccountSnapshot({
enabled:
isElectron &&
multimodelEnabled &&
loadingCliStatus?.flavor === 'agent_teams_orchestrator' &&
Boolean(
loadingCliStatus?.providers.some(
(provider: CliProviderStatus) => provider.providerId === 'codex'
)
),
includeRateLimits: true,
});
const codexSnapshotPending =
codexAccount.loading &&
Boolean(
loadingCliStatus?.providers.some(
(provider: CliProviderStatus) => provider.providerId === 'codex'
)
) &&
!codexAccount.snapshot;
const effectiveCliStatus = useMemo(
() =>
loadingCliStatus
? {
...loadingCliStatus,
providers: loadingCliStatus.providers.map((provider: CliProviderStatus) =>
provider.providerId === 'codex'
? mergeCodexProviderStatusWithSnapshot(provider, codexAccount.snapshot)
: provider
),
}
: loadingCliStatus,
[loadingCliStatus, codexAccount.snapshot]
);
const effectiveCliStatusLoading = cliStatusLoading && effectiveCliStatus === null;
const runtimeDisplayName = getRuntimeDisplayName(effectiveCliStatus, multimodelEnabled);
const cliInstalled = effectiveCliStatus?.installed ?? true;
const hasOngoingSessions = sessions.some((sess) => sess.isOngoing); const hasOngoingSessions = sessions.some((sess) => sess.isOngoing);
const extensionsTabProjectId = useStore((s) => const extensionsTabProjectId = useStore((s) =>
tabId tabId
@ -195,8 +258,12 @@ export const ExtensionStoreView = (): React.JSX.Element => {
}, [fetchPluginCatalog, projectPath]); }, [fetchPluginCatalog, projectPath]);
useEffect(() => { useEffect(() => {
void fetchCliStatus(); void refreshCliStatusForCurrentMode({
}, [fetchCliStatus]); multimodelEnabled,
bootstrapCliStatus,
fetchCliStatus,
});
}, [bootstrapCliStatus, fetchCliStatus, multimodelEnabled]);
// Fetch MCP installed state on mount // Fetch MCP installed state on mount
useEffect(() => { useEffect(() => {
@ -215,42 +282,55 @@ export const ExtensionStoreView = (): React.JSX.Element => {
// Refresh all data (plugins + MCP browse + installed + skills) // Refresh all data (plugins + MCP browse + installed + skills)
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
void fetchCliStatus(); void refreshCliStatusForCurrentMode({
multimodelEnabled,
bootstrapCliStatus,
fetchCliStatus,
});
void fetchApiKeys(); void fetchApiKeys();
void fetchPluginCatalog(projectPath ?? undefined, true); void fetchPluginCatalog(projectPath ?? undefined, true);
void mcpBrowse(); // re-fetch first page void mcpBrowse(); // re-fetch first page
void mcpFetchInstalled(projectPath ?? undefined); void mcpFetchInstalled(projectPath ?? undefined);
void fetchSkillsCatalog(projectPath ?? undefined); void fetchSkillsCatalog(projectPath ?? undefined);
}, [ }, [
bootstrapCliStatus,
fetchApiKeys, fetchApiKeys,
fetchCliStatus, fetchCliStatus,
fetchPluginCatalog, fetchPluginCatalog,
fetchSkillsCatalog, fetchSkillsCatalog,
multimodelEnabled,
mcpBrowse, mcpBrowse,
mcpFetchInstalled, mcpFetchInstalled,
projectPath, projectPath,
]); ]);
const isRefreshing = const isRefreshing =
cliStatusLoading || apiKeysLoading || pluginCatalogLoading || mcpBrowseLoading || skillsLoading; effectiveCliStatusLoading ||
apiKeysLoading ||
pluginCatalogLoading ||
mcpBrowseLoading ||
skillsLoading;
const mcpMutationDisableReason = useMemo( const mcpMutationDisableReason = useMemo(
() => () =>
getExtensionActionDisableReason({ getExtensionActionDisableReason({
isInstalled: false, isInstalled: false,
cliStatus, cliStatus: effectiveCliStatus,
cliStatusLoading, cliStatusLoading: effectiveCliStatusLoading,
section: 'mcp', section: 'mcp',
}), }),
[cliStatus, cliStatusLoading] [effectiveCliStatus, effectiveCliStatusLoading]
); );
const cliStatusBanner = useMemo(() => { const cliStatusBanner = useMemo(() => {
const providers = cliStatus?.providers ?? []; const providers = effectiveCliStatus?.providers ?? [];
const visibleProviders = getVisibleMultimodelProviders(providers); const visibleProviders = getVisibleMultimodelProviders(providers);
const isMultimodel = isMultimodelRuntimeStatus(cliStatus); const isMultimodel = isMultimodelRuntimeStatus(effectiveCliStatus);
const shouldShowMultimodelProviderCards = const shouldShowMultimodelProviderCards =
isMultimodel && visibleProviders.length > 0 && cliStatus !== null; isMultimodel && visibleProviders.length > 0 && effectiveCliStatus !== null;
if ((cliStatusLoading || cliStatus === null) && !shouldShowMultimodelProviderCards) { if (
(effectiveCliStatusLoading || effectiveCliStatus === null) &&
!shouldShowMultimodelProviderCards
) {
return ( return (
<div className="bg-surface/70 mx-4 mt-3 flex items-start gap-3 rounded-md border border-border px-4 py-3"> <div className="bg-surface/70 mx-4 mt-3 flex items-start gap-3 rounded-md border border-border px-4 py-3">
<Info className="mt-0.5 size-4 shrink-0 text-text-secondary" /> <Info className="mt-0.5 size-4 shrink-0 text-text-secondary" />
@ -267,8 +347,10 @@ export const ExtensionStoreView = (): React.JSX.Element => {
); );
} }
if (!cliStatus.installed) { if (!effectiveCliStatus.installed) {
const cliLaunchIssue = Boolean(cliStatus.binaryPath && cliStatus.launchError); const cliLaunchIssue = Boolean(
effectiveCliStatus.binaryPath && effectiveCliStatus.launchError
);
return ( return (
<div className="mx-4 mt-3 flex items-start gap-3 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3"> <div className="mx-4 mt-3 flex items-start gap-3 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" /> <AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
@ -283,9 +365,9 @@ export const ExtensionStoreView = (): React.JSX.Element => {
? 'Extensions are disabled until the runtime passes its startup health check. Open the Dashboard to repair or reinstall it.' ? 'Extensions are disabled until the runtime passes its startup health check. Open the Dashboard to repair or reinstall it.'
: 'Extensions are disabled until the runtime is installed. Open the Dashboard to install it and retry.'} : 'Extensions are disabled until the runtime is installed. Open the Dashboard to install it and retry.'}
</p> </p>
{cliLaunchIssue && cliStatus.launchError && ( {cliLaunchIssue && effectiveCliStatus.launchError && (
<p className="mt-2 break-all font-mono text-[11px] text-text-muted"> <p className="mt-2 break-all font-mono text-[11px] text-text-muted">
{cliStatus.launchError} {effectiveCliStatus.launchError}
</p> </p>
)} )}
</div> </div>
@ -296,16 +378,18 @@ export const ExtensionStoreView = (): React.JSX.Element => {
); );
} }
if (!isMultimodel && !cliStatus.authLoggedIn) { if (!isMultimodel && !effectiveCliStatus.authLoggedIn) {
return ( return (
<div className="mx-4 mt-3 flex items-start gap-3 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3"> <div className="mx-4 mt-3 flex items-start gap-3 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" /> <AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-sm font-medium text-amber-300">Claude CLI needs sign-in</p> <p className="text-sm font-medium text-amber-300">{runtimeDisplayName} needs sign-in</p>
<p className="mt-0.5 text-xs text-text-muted"> <p className="mt-0.5 text-xs text-text-muted">
Claude CLI was found {runtimeDisplayName} was found
{cliStatus.installedVersion ? ` (${cliStatus.installedVersion})` : ''}, but plugin {effectiveCliStatus.installedVersion
installs are disabled until you sign in from the Dashboard. ? ` (${effectiveCliStatus.installedVersion})`
: ''}
, but plugin installs are disabled until you sign in from the Dashboard.
</p> </p>
</div> </div>
<Button size="sm" variant="outline" onClick={openDashboard}> <Button size="sm" variant="outline" onClick={openDashboard}>
@ -332,7 +416,10 @@ export const ExtensionStoreView = (): React.JSX.Element => {
<div className="mt-3 grid gap-2 md:grid-cols-2"> <div className="mt-3 grid gap-2 md:grid-cols-2">
{visibleProviders.map((provider) => { {visibleProviders.map((provider) => {
const providerLoading = cliProviderStatusLoading[provider.providerId] === true; const providerLoading = cliProviderStatusLoading[provider.providerId] === true;
if (isProviderCapabilityCardLoading(provider, providerLoading)) { if (
isProviderCapabilityCardLoading(provider, providerLoading) ||
isCodexSnapshotPending(provider, codexSnapshotPending)
) {
return ( return (
<ProviderCapabilityCardSkeleton <ProviderCapabilityCardSkeleton
key={provider.providerId} key={provider.providerId}
@ -410,15 +497,24 @@ export const ExtensionStoreView = (): React.JSX.Element => {
<div className="mx-4 mt-3 flex items-start gap-3 rounded-md border border-emerald-500/30 bg-emerald-500/5 px-4 py-3"> <div className="mx-4 mt-3 flex items-start gap-3 rounded-md border border-emerald-500/30 bg-emerald-500/5 px-4 py-3">
<Info className="mt-0.5 size-4 shrink-0 text-emerald-300" /> <Info className="mt-0.5 size-4 shrink-0 text-emerald-300" />
<div> <div>
<p className="text-sm font-medium text-emerald-300">Claude CLI is ready</p> <p className="text-sm font-medium text-emerald-300">{runtimeDisplayName} is ready</p>
<p className="mt-0.5 text-xs text-text-muted"> <p className="mt-0.5 text-xs text-text-muted">
Plugins can be installed from this page Plugins can be installed from this page
{cliStatus.installedVersion ? ` using Claude CLI ${cliStatus.installedVersion}` : ''}. {effectiveCliStatus.installedVersion
? ` using ${runtimeDisplayName} ${effectiveCliStatus.installedVersion}`
: ''}
.
</p> </p>
</div> </div>
</div> </div>
); );
}, [cliProviderStatusLoading, cliStatus, cliStatusLoading, openDashboard]); }, [
cliProviderStatusLoading,
codexSnapshotPending,
effectiveCliStatus,
effectiveCliStatusLoading,
openDashboard,
]);
// Browser mode guard // Browser mode guard
if (!api.plugins && !api.mcpRegistry && !api.skills) { if (!api.plugins && !api.mcpRegistry && !api.skills) {
@ -526,6 +622,8 @@ export const ExtensionStoreView = (): React.JSX.Element => {
clearFilters={tabState.clearFilters} clearFilters={tabState.clearFilters}
hasActiveFilters={tabState.hasActiveFilters} hasActiveFilters={tabState.hasActiveFilters}
setPluginSort={tabState.setPluginSort} setPluginSort={tabState.setPluginSort}
cliStatus={effectiveCliStatus}
cliStatusLoading={effectiveCliStatusLoading}
/> />
</TabsContent> </TabsContent>
@ -539,6 +637,8 @@ export const ExtensionStoreView = (): React.JSX.Element => {
mcpSearchWarnings={tabState.mcpSearchWarnings} mcpSearchWarnings={tabState.mcpSearchWarnings}
selectedMcpServerId={tabState.selectedMcpServerId} selectedMcpServerId={tabState.selectedMcpServerId}
setSelectedMcpServerId={tabState.setSelectedMcpServerId} setSelectedMcpServerId={tabState.setSelectedMcpServerId}
cliStatus={effectiveCliStatus}
cliStatusLoading={effectiveCliStatusLoading}
/> />
</TabsContent> </TabsContent>
@ -565,6 +665,8 @@ export const ExtensionStoreView = (): React.JSX.Element => {
open={customMcpDialogOpen} open={customMcpDialogOpen}
onClose={() => setCustomMcpDialogOpen(false)} onClose={() => setCustomMcpDialogOpen(false)}
projectPath={projectPath} projectPath={projectPath}
cliStatus={effectiveCliStatus}
cliStatusLoading={effectiveCliStatusLoading}
/> />
</div> </div>
</div> </div>

View file

@ -4,9 +4,15 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import {
mergeCodexProviderStatusWithSnapshot,
useCodexAccountSnapshot,
} from '@features/codex-account/renderer';
import { isElectronMode } from '@renderer/api';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
import { AlertTriangle, Info, Key, Plus } from 'lucide-react'; import { AlertTriangle, Info, Key, Plus } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
@ -24,17 +30,56 @@ export const ApiKeysPanel = ({
projectPath, projectPath,
projectLabel, projectLabel,
}: ApiKeysPanelProps): React.JSX.Element => { }: ApiKeysPanelProps): React.JSX.Element => {
const { apiKeys, apiKeysLoading, apiKeysError, storageStatus, fetchStorageStatus, cliStatus } = const isElectron = useMemo(() => isElectronMode(), []);
useStore( const {
useShallow((s) => ({ apiKeys,
apiKeys: s.apiKeys, apiKeysLoading,
apiKeysLoading: s.apiKeysLoading, apiKeysError,
apiKeysError: s.apiKeysError, storageStatus,
storageStatus: s.apiKeyStorageStatus, fetchStorageStatus,
fetchStorageStatus: s.fetchApiKeyStorageStatus, cliStatus,
cliStatus: s.cliStatus, cliStatusLoading,
})) appConfig,
); } = useStore(
useShallow((s) => ({
apiKeys: s.apiKeys,
apiKeysLoading: s.apiKeysLoading,
apiKeysError: s.apiKeysError,
storageStatus: s.apiKeyStorageStatus,
fetchStorageStatus: s.fetchApiKeyStorageStatus,
cliStatus: s.cliStatus,
cliStatusLoading: s.cliStatusLoading,
appConfig: s.appConfig,
}))
);
const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true;
const loadingCliStatus = useMemo(
() =>
!cliStatus && cliStatusLoading && multimodelEnabled
? createLoadingMultimodelCliStatus()
: cliStatus,
[cliStatus, cliStatusLoading, multimodelEnabled]
);
const codexAccount = useCodexAccountSnapshot({
enabled:
isElectron &&
loadingCliStatus?.flavor === 'agent_teams_orchestrator' &&
Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')),
});
const effectiveCliStatus = useMemo(
() =>
loadingCliStatus
? {
...loadingCliStatus,
providers: loadingCliStatus.providers.map((provider) =>
provider.providerId === 'codex'
? mergeCodexProviderStatusWithSnapshot(provider, codexAccount.snapshot)
: provider
),
}
: loadingCliStatus,
[loadingCliStatus, codexAccount.snapshot]
);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editingKey, setEditingKey] = useState<ApiKeyEntry | null>(null); const [editingKey, setEditingKey] = useState<ApiKeyEntry | null>(null);
@ -60,7 +105,7 @@ export const ApiKeysPanel = ({
const isOsKeychain = storageStatus?.encryptionMethod === 'os-keychain'; const isOsKeychain = storageStatus?.encryptionMethod === 'os-keychain';
const providerKeyCards = useMemo(() => { const providerKeyCards = useMemo(() => {
if (!cliStatus?.providers?.length) { if (!effectiveCliStatus?.providers?.length) {
return []; return [];
} }
@ -78,7 +123,9 @@ export const ApiKeysPanel = ({
}, },
] as const ] as const
).flatMap((item) => { ).flatMap((item) => {
const provider = cliStatus.providers.find((entry) => entry.providerId === item.providerId); const provider = effectiveCliStatus.providers.find(
(entry) => entry.providerId === item.providerId
);
if (!provider) { if (!provider) {
return []; return [];
} }
@ -93,7 +140,7 @@ export const ApiKeysPanel = ({
}, },
]; ];
}); });
}, [cliStatus]); }, [effectiveCliStatus]);
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">

View file

@ -17,6 +17,7 @@ import { getExtensionActionDisableReason } from '@shared/utils/extensionNormaliz
import { Check, Loader2, Trash2 } from 'lucide-react'; import { Check, Loader2, Trash2 } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import type { CliInstallationStatus } from '@shared/types';
import type { ExtensionOperationState } from '@shared/types/extensions'; import type { ExtensionOperationState } from '@shared/types/extensions';
interface InstallButtonProps { interface InstallButtonProps {
@ -28,6 +29,11 @@ interface InstallButtonProps {
disabled?: boolean; disabled?: boolean;
size?: 'sm' | 'default'; size?: 'sm' | 'default';
errorMessage?: string; errorMessage?: string;
cliStatus?: Pick<
CliInstallationStatus,
'installed' | 'authLoggedIn' | 'binaryPath' | 'launchError' | 'flavor' | 'providers'
> | null;
cliStatusLoading?: boolean;
} }
export const InstallButton = ({ export const InstallButton = ({
@ -39,13 +45,17 @@ export const InstallButton = ({
disabled, disabled,
size = 'sm', size = 'sm',
errorMessage, errorMessage,
cliStatus: cliStatusOverride,
cliStatusLoading: cliStatusLoadingOverride,
}: InstallButtonProps) => { }: InstallButtonProps) => {
const { cliStatus, cliStatusLoading } = useStore( const { cliStatus: storedCliStatus, cliStatusLoading: storedCliStatusLoading } = useStore(
useShallow((s) => ({ useShallow((s) => ({
cliStatus: s.cliStatus, cliStatus: s.cliStatus,
cliStatusLoading: s.cliStatusLoading, cliStatusLoading: s.cliStatusLoading,
})) }))
); );
const cliStatus = cliStatusOverride ?? storedCliStatus;
const cliStatusLoading = cliStatusLoadingOverride ?? storedCliStatusLoading;
const disableReason = getExtensionActionDisableReason({ const disableReason = getExtensionActionDisableReason({
isInstalled, isInstalled,
cliStatus, cliStatus,

View file

@ -33,6 +33,7 @@ import {
} from '@shared/utils/mcpScopes'; } from '@shared/utils/mcpScopes';
import { Plus, Server, Trash2 } from 'lucide-react'; import { Plus, Server, Trash2 } from 'lucide-react';
import type { CliInstallationStatus } from '@shared/types';
import type { import type {
McpCustomInstallRequest, McpCustomInstallRequest,
McpHeaderDef, McpHeaderDef,
@ -45,6 +46,11 @@ interface CustomMcpServerDialogProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
projectPath: string | null; projectPath: string | null;
cliStatus?: Pick<
CliInstallationStatus,
'installed' | 'authLoggedIn' | 'binaryPath' | 'launchError' | 'flavor' | 'providers'
> | null;
cliStatusLoading?: boolean;
} }
type TransportMode = 'stdio' | 'http'; type TransportMode = 'stdio' | 'http';
@ -66,10 +72,14 @@ export const CustomMcpServerDialog = ({
open, open,
onClose, onClose,
projectPath, projectPath,
cliStatus: cliStatusOverride,
cliStatusLoading: cliStatusLoadingOverride,
}: CustomMcpServerDialogProps): React.JSX.Element => { }: CustomMcpServerDialogProps): React.JSX.Element => {
const installCustomMcpServer = useStore((s) => s.installCustomMcpServer); const installCustomMcpServer = useStore((s) => s.installCustomMcpServer);
const cliStatus = useStore((s) => s.cliStatus); const storedCliStatus = useStore((s) => s.cliStatus);
const cliStatusLoading = useStore((s) => s.cliStatusLoading); const storedCliStatusLoading = useStore((s) => s.cliStatusLoading);
const cliStatus = cliStatusOverride ?? storedCliStatus;
const cliStatusLoading = cliStatusLoadingOverride ?? storedCliStatusLoading;
const defaultSharedScope = getDefaultMcpSharedScope(cliStatus?.flavor); const defaultSharedScope = getDefaultMcpSharedScope(cliStatus?.flavor);
const scopeOptions: { value: Scope; label: string }[] = [ const scopeOptions: { value: Scope; label: string }[] = [
{ value: defaultSharedScope, label: getMcpScopeLabel(defaultSharedScope, cliStatus?.flavor) }, { value: defaultSharedScope, label: getMcpScopeLabel(defaultSharedScope, cliStatus?.flavor) },

View file

@ -23,6 +23,7 @@ import { Github as GithubIcon } from 'lucide-react';
import { InstallButton } from '../common/InstallButton'; import { InstallButton } from '../common/InstallButton';
import { SourceBadge } from '../common/SourceBadge'; import { SourceBadge } from '../common/SourceBadge';
import type { CliInstallationStatus } from '@shared/types';
import type { import type {
InstalledMcpEntry, InstalledMcpEntry,
McpCatalogItem, McpCatalogItem,
@ -37,6 +38,11 @@ interface McpServerCardProps {
diagnostic?: McpServerDiagnostic | null; diagnostic?: McpServerDiagnostic | null;
diagnosticsLoading?: boolean; diagnosticsLoading?: boolean;
onClick: (serverId: string) => void; onClick: (serverId: string) => void;
cliStatus?: Pick<
CliInstallationStatus,
'installed' | 'authLoggedIn' | 'binaryPath' | 'launchError' | 'flavor' | 'providers'
> | null;
cliStatusLoading?: boolean;
} }
export const McpServerCard = ({ export const McpServerCard = ({
@ -47,8 +53,11 @@ export const McpServerCard = ({
diagnostic, diagnostic,
diagnosticsLoading, diagnosticsLoading,
onClick, onClick,
cliStatus: cliStatusOverride,
cliStatusLoading,
}: McpServerCardProps): React.JSX.Element => { }: McpServerCardProps): React.JSX.Element => {
const cliStatus = useStore((s) => s.cliStatus); const storedCliStatus = useStore((s) => s.cliStatus);
const cliStatus = cliStatusOverride ?? storedCliStatus;
const sharedScope = getDefaultMcpSharedScope(cliStatus?.flavor); const sharedScope = getDefaultMcpSharedScope(cliStatus?.flavor);
const operationKey = getMcpOperationKey(server.id, sharedScope); const operationKey = getMcpOperationKey(server.id, sharedScope);
const installProgress = useStore((s) => s.mcpInstallProgress[operationKey] ?? 'idle'); const installProgress = useStore((s) => s.mcpInstallProgress[operationKey] ?? 'idle');
@ -262,6 +271,8 @@ export const McpServerCard = ({
state={installProgress} state={installProgress}
isInstalled={isInstalled} isInstalled={isInstalled}
section="mcp" section="mcp"
cliStatus={cliStatus}
cliStatusLoading={cliStatusLoading}
onInstall={() => onInstall={() =>
installMcpServer({ installMcpServer({
registryId: server.id, registryId: server.id,

View file

@ -42,6 +42,7 @@ import { ExternalLink, Lock, Plus, Star, Trash2, Wrench } from 'lucide-react';
import { InstallButton } from '../common/InstallButton'; import { InstallButton } from '../common/InstallButton';
import { SourceBadge } from '../common/SourceBadge'; import { SourceBadge } from '../common/SourceBadge';
import type { CliInstallationStatus } from '@shared/types';
import type { import type {
InstalledMcpEntry, InstalledMcpEntry,
McpCatalogItem, McpCatalogItem,
@ -59,6 +60,11 @@ interface McpServerDetailDialogProps {
projectPath: string | null; projectPath: string | null;
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
cliStatus?: Pick<
CliInstallationStatus,
'installed' | 'authLoggedIn' | 'binaryPath' | 'launchError' | 'flavor' | 'providers'
> | null;
cliStatusLoading?: boolean;
} }
type Scope = 'local' | 'user' | 'project' | 'global'; type Scope = 'local' | 'user' | 'project' | 'global';
@ -73,8 +79,11 @@ export const McpServerDetailDialog = ({
projectPath, projectPath,
open, open,
onClose, onClose,
cliStatus: cliStatusOverride,
cliStatusLoading,
}: McpServerDetailDialogProps): React.JSX.Element => { }: McpServerDetailDialogProps): React.JSX.Element => {
const cliStatus = useStore((s) => s.cliStatus); const storedCliStatus = useStore((s) => s.cliStatus);
const cliStatus = cliStatusOverride ?? storedCliStatus;
const defaultSharedScope = getDefaultMcpSharedScope(cliStatus?.flavor); const defaultSharedScope = getDefaultMcpSharedScope(cliStatus?.flavor);
const [scope, setScope] = useState<Scope>(defaultSharedScope); const [scope, setScope] = useState<Scope>(defaultSharedScope);
const operationKey = server ? getMcpOperationKey(server.id, scope, projectPath) : null; const operationKey = server ? getMcpOperationKey(server.id, scope, projectPath) : null;
@ -587,6 +596,8 @@ export const McpServerDetailDialog = ({
state={installProgress} state={installProgress}
isInstalled={isInstalledForScope} isInstalled={isInstalledForScope}
section="mcp" section="mcp"
cliStatus={cliStatus}
cliStatusLoading={cliStatusLoading}
onInstall={handleInstall} onInstall={handleInstall}
onUninstall={handleUninstall} onUninstall={handleUninstall}
disabled={installDisabled} disabled={installDisabled}

View file

@ -15,6 +15,7 @@ import {
} from '@renderer/components/ui/select'; } from '@renderer/components/ui/select';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { formatRelativeTime } from '@renderer/utils/formatters'; import { formatRelativeTime } from '@renderer/utils/formatters';
import { getRuntimeDisplayName } from '@renderer/utils/runtimeDisplayName';
import { CLI_NOT_FOUND_MARKER } from '@shared/constants/cli'; import { CLI_NOT_FOUND_MARKER } from '@shared/constants/cli';
import { import {
getMcpDiagnosticKey, getMcpDiagnosticKey,
@ -30,6 +31,7 @@ import { SearchInput } from '../common/SearchInput';
import { McpServerCard } from './McpServerCard'; import { McpServerCard } from './McpServerCard';
import { McpServerDetailDialog } from './McpServerDetailDialog'; import { McpServerDetailDialog } from './McpServerDetailDialog';
import type { CliInstallationStatus } from '@shared/types';
import type { import type {
InstalledMcpEntry, InstalledMcpEntry,
McpCatalogItem, McpCatalogItem,
@ -68,6 +70,17 @@ interface McpServersPanelProps {
mcpSearchWarnings: string[]; mcpSearchWarnings: string[];
selectedMcpServerId: string | null; selectedMcpServerId: string | null;
setSelectedMcpServerId: (id: string | null) => void; setSelectedMcpServerId: (id: string | null) => void;
cliStatus?: Pick<
CliInstallationStatus,
| 'installed'
| 'authLoggedIn'
| 'binaryPath'
| 'launchError'
| 'flavor'
| 'displayName'
| 'providers'
> | null;
cliStatusLoading?: boolean;
} }
export const McpServersPanel = ({ export const McpServersPanel = ({
@ -79,6 +92,8 @@ export const McpServersPanel = ({
mcpSearchWarnings, mcpSearchWarnings,
selectedMcpServerId, selectedMcpServerId,
setSelectedMcpServerId, setSelectedMcpServerId,
cliStatus: cliStatusOverride,
cliStatusLoading: cliStatusLoadingOverride,
}: McpServersPanelProps): React.JSX.Element => { }: McpServersPanelProps): React.JSX.Element => {
const projectStateKey = getMcpProjectStateKey(projectPath); const projectStateKey = getMcpProjectStateKey(projectPath);
const { const {
@ -99,8 +114,6 @@ export const McpServersPanel = ({
mcpDiagnosticsLastCheckedAtByProjectPath, mcpDiagnosticsLastCheckedAtByProjectPath,
mcpDiagnosticsLastCheckedAtFallback, mcpDiagnosticsLastCheckedAtFallback,
runMcpDiagnostics, runMcpDiagnostics,
cliStatus,
cliStatusLoading,
} = useStore( } = useStore(
useShallow((s) => ({ useShallow((s) => ({
browseCatalog: s.mcpBrowseCatalog, browseCatalog: s.mcpBrowseCatalog,
@ -120,10 +133,12 @@ export const McpServersPanel = ({
mcpDiagnosticsLastCheckedAtByProjectPath: s.mcpDiagnosticsLastCheckedAtByProjectPath, mcpDiagnosticsLastCheckedAtByProjectPath: s.mcpDiagnosticsLastCheckedAtByProjectPath,
mcpDiagnosticsLastCheckedAtFallback: s.mcpDiagnosticsLastCheckedAt, mcpDiagnosticsLastCheckedAtFallback: s.mcpDiagnosticsLastCheckedAt,
runMcpDiagnostics: s.runMcpDiagnostics, runMcpDiagnostics: s.runMcpDiagnostics,
cliStatus: s.cliStatus,
cliStatusLoading: s.cliStatusLoading,
})) }))
); );
const storedCliStatus = useStore((s) => s.cliStatus);
const storedCliStatusLoading = useStore((s) => s.cliStatusLoading);
const cliStatus = cliStatusOverride ?? storedCliStatus;
const cliStatusLoading = cliStatusLoadingOverride ?? storedCliStatusLoading;
const installedServers = const installedServers =
installedServersByProjectPath?.[projectStateKey] ?? installedServersFallback ?? []; installedServersByProjectPath?.[projectStateKey] ?? installedServersFallback ?? [];
const mcpDiagnostics = const mcpDiagnostics =
@ -147,12 +162,8 @@ export const McpServersPanel = ({
}, [browseCatalog.length, browseError, browseLoading, mcpBrowse]); }, [browseCatalog.length, browseError, browseLoading, mcpBrowse]);
const diagnosticsDisableReason = useMemo(() => { const diagnosticsDisableReason = useMemo(() => {
if (cliStatusLoading) {
return 'Checking runtime status...';
}
if (cliStatus === null || typeof cliStatus === 'undefined') { if (cliStatus === null || typeof cliStatus === 'undefined') {
return 'Checking runtime availability...'; return cliStatusLoading ? 'Checking runtime status...' : 'Checking runtime availability...';
} }
if (cliStatus?.installed === false) { if (cliStatus?.installed === false) {
@ -241,8 +252,7 @@ export const McpServersPanel = ({
// Sort displayed servers // Sort displayed servers
const displayServers = useMemo(() => sortMcpServers(rawServers, mcpSort), [rawServers, mcpSort]); const displayServers = useMemo(() => sortMcpServers(rawServers, mcpSort), [rawServers, mcpSort]);
const runtimeLabel = const runtimeLabel = getRuntimeDisplayName(cliStatus, true);
cliStatus?.flavor === 'agent_teams_orchestrator' ? 'multimodel runtime' : 'Claude CLI';
// Find selected server (search in both lists to avoid losing selection during search toggle) // Find selected server (search in both lists to avoid losing selection during search toggle)
const selectedServer = useMemo(() => { const selectedServer = useMemo(() => {
@ -411,13 +421,12 @@ export const McpServersPanel = ({
<div> <div>
<p className="text-sm font-medium text-amber-300"> <p className="text-sm font-medium text-amber-300">
{cliStatus?.flavor === 'agent_teams_orchestrator' {cliStatus?.flavor === 'agent_teams_orchestrator'
? 'Configured runtime not available' ? `${runtimeLabel} not available`
: 'Claude CLI not installed'} : `${runtimeLabel} not installed`}
</p> </p>
<p className="mt-0.5 text-xs text-text-muted"> <p className="mt-0.5 text-xs text-text-muted">
{cliStatus?.flavor === 'agent_teams_orchestrator' MCP health checks require {runtimeLabel}. Go to the Dashboard to install or repair
? 'MCP health checks require the configured runtime. Go to the Dashboard to install or repair it.' it.
: 'MCP health checks require Claude CLI. Go to the Dashboard to install or repair it.'}
</p> </p>
</div> </div>
</div> </div>
@ -458,6 +467,8 @@ export const McpServersPanel = ({
diagnostic={getDiagnostic(server)} diagnostic={getDiagnostic(server)}
diagnosticsLoading={mcpDiagnosticsLoading} diagnosticsLoading={mcpDiagnosticsLoading}
onClick={setSelectedMcpServerId} onClick={setSelectedMcpServerId}
cliStatus={cliStatus}
cliStatusLoading={cliStatusLoading}
/> />
))} ))}
</div> </div>
@ -488,6 +499,8 @@ export const McpServersPanel = ({
projectPath={projectPath} projectPath={projectPath}
open={selectedMcpServerId !== null} open={selectedMcpServerId !== null}
onClose={() => setSelectedMcpServerId(null)} onClose={() => setSelectedMcpServerId(null)}
cliStatus={cliStatus}
cliStatusLoading={cliStatusLoading}
/> />
</div> </div>
); );

View file

@ -17,15 +17,27 @@ import { Tag } from 'lucide-react';
import { InstallButton } from '../common/InstallButton'; import { InstallButton } from '../common/InstallButton';
import { InstallCountBadge } from '../common/InstallCountBadge'; import { InstallCountBadge } from '../common/InstallCountBadge';
import type { CliInstallationStatus } from '@shared/types';
import type { EnrichedPlugin } from '@shared/types/extensions'; import type { EnrichedPlugin } from '@shared/types/extensions';
interface PluginCardProps { interface PluginCardProps {
plugin: EnrichedPlugin; plugin: EnrichedPlugin;
index: number; index: number;
onClick: (pluginId: string) => void; onClick: (pluginId: string) => void;
cliStatus?: Pick<
CliInstallationStatus,
'installed' | 'authLoggedIn' | 'binaryPath' | 'launchError' | 'flavor' | 'providers'
> | null;
cliStatusLoading?: boolean;
} }
export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.JSX.Element => { export const PluginCard = ({
plugin,
index,
onClick,
cliStatus,
cliStatusLoading,
}: PluginCardProps): React.JSX.Element => {
const capabilities = inferCapabilities(plugin); const capabilities = inferCapabilities(plugin);
const category = normalizeCategory(plugin.category); const category = normalizeCategory(plugin.category);
const operationKey = getPluginOperationKey(plugin.pluginId, 'user'); const operationKey = getPluginOperationKey(plugin.pluginId, 'user');
@ -120,6 +132,8 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J
state={installProgress} state={installProgress}
isInstalled={isUserInstalled} isInstalled={isUserInstalled}
section="plugins" section="plugins"
cliStatus={cliStatus}
cliStatusLoading={cliStatusLoading}
onInstall={() => installPlugin({ pluginId: plugin.pluginId, scope: 'user' })} onInstall={() => installPlugin({ pluginId: plugin.pluginId, scope: 'user' })}
onUninstall={() => uninstallPlugin(plugin.pluginId, 'user')} onUninstall={() => uninstallPlugin(plugin.pluginId, 'user')}
size="sm" size="sm"

View file

@ -39,6 +39,7 @@ import { InstallButton } from '../common/InstallButton';
import { InstallCountBadge } from '../common/InstallCountBadge'; import { InstallCountBadge } from '../common/InstallCountBadge';
import { SourceBadge } from '../common/SourceBadge'; import { SourceBadge } from '../common/SourceBadge';
import type { CliInstallationStatus } from '@shared/types';
import type { EnrichedPlugin, InstallScope } from '@shared/types/extensions'; import type { EnrichedPlugin, InstallScope } from '@shared/types/extensions';
interface PluginDetailDialogProps { interface PluginDetailDialogProps {
@ -46,6 +47,11 @@ interface PluginDetailDialogProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
projectPath: string | null; projectPath: string | null;
cliStatus?: Pick<
CliInstallationStatus,
'installed' | 'authLoggedIn' | 'binaryPath' | 'launchError' | 'flavor' | 'providers'
> | null;
cliStatusLoading?: boolean;
} }
const SCOPE_OPTIONS: { value: InstallScope; label: string }[] = [ const SCOPE_OPTIONS: { value: InstallScope; label: string }[] = [
@ -59,6 +65,8 @@ export const PluginDetailDialog = ({
open, open,
onClose, onClose,
projectPath, projectPath,
cliStatus,
cliStatusLoading,
}: PluginDetailDialogProps): React.JSX.Element => { }: PluginDetailDialogProps): React.JSX.Element => {
const { fetchPluginReadme, readmes, readmeLoading, installPlugin, uninstallPlugin } = useStore( const { fetchPluginReadme, readmes, readmeLoading, installPlugin, uninstallPlugin } = useStore(
useShallow((s) => ({ useShallow((s) => ({
@ -198,6 +206,8 @@ export const PluginDetailDialog = ({
state={installProgress} state={installProgress}
isInstalled={isInstalledForScope} isInstalled={isInstalledForScope}
section="plugins" section="plugins"
cliStatus={cliStatus}
cliStatusLoading={cliStatusLoading}
onInstall={() => onInstall={() =>
installPlugin({ installPlugin({
pluginId: plugin.pluginId, pluginId: plugin.pluginId,

View file

@ -28,6 +28,7 @@ import { CategoryChips } from './CategoryChips';
import { PluginCard } from './PluginCard'; import { PluginCard } from './PluginCard';
import { PluginDetailDialog } from './PluginDetailDialog'; import { PluginDetailDialog } from './PluginDetailDialog';
import type { CliInstallationStatus } from '@shared/types';
import type { import type {
EnrichedPlugin, EnrichedPlugin,
PluginCapability, PluginCapability,
@ -48,6 +49,11 @@ interface PluginsPanelProps {
clearFilters: () => void; clearFilters: () => void;
hasActiveFilters: boolean; hasActiveFilters: boolean;
setPluginSort: (sort: { field: PluginSortField; order: 'asc' | 'desc' }) => void; setPluginSort: (sort: { field: PluginSortField; order: 'asc' | 'desc' }) => void;
cliStatus?: Pick<
CliInstallationStatus,
'installed' | 'authLoggedIn' | 'binaryPath' | 'launchError' | 'flavor' | 'providers'
> | null;
cliStatusLoading?: boolean;
} }
const SORT_OPTIONS: { value: string; label: string }[] = [ const SORT_OPTIONS: { value: string; label: string }[] = [
@ -125,8 +131,15 @@ export const PluginsPanel = ({
clearFilters, clearFilters,
hasActiveFilters, hasActiveFilters,
setPluginSort, setPluginSort,
cliStatus: cliStatusOverride,
cliStatusLoading,
}: PluginsPanelProps): React.JSX.Element => { }: PluginsPanelProps): React.JSX.Element => {
const { catalog, loading, error, cliStatus } = useStore( const {
catalog,
loading,
error,
cliStatus: storedCliStatus,
} = useStore(
useShallow((s) => ({ useShallow((s) => ({
catalog: s.pluginCatalog, catalog: s.pluginCatalog,
loading: s.pluginCatalogLoading, loading: s.pluginCatalogLoading,
@ -134,6 +147,7 @@ export const PluginsPanel = ({
cliStatus: s.cliStatus, cliStatus: s.cliStatus,
})) }))
); );
const cliStatus = cliStatusOverride ?? storedCliStatus;
const filtered = useMemo( const filtered = useMemo(
() => selectFilteredPlugins(catalog, pluginFilters, pluginSort), () => selectFilteredPlugins(catalog, pluginFilters, pluginSort),
@ -192,8 +206,9 @@ export const PluginsPanel = ({
return ( return (
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-300"> <div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-300">
In the multimodel runtime, plugins currently apply only to Anthropic sessions. Broader In the multimodel runtime, plugins are currently guaranteed only for Anthropic
plugin support across providers is in development. sessions. We are actively building broader plugin support for all agents, including
both universal plugins and agent-specific plugins.
{capability.reason ? ` ${capability.reason}` : ''} {capability.reason ? ` ${capability.reason}` : ''}
</div> </div>
); );
@ -407,6 +422,8 @@ export const PluginsPanel = ({
plugin={plugin} plugin={plugin}
index={index} index={index}
onClick={setSelectedPluginId} onClick={setSelectedPluginId}
cliStatus={cliStatus}
cliStatusLoading={cliStatusLoading}
/> />
))} ))}
</div> </div>
@ -418,6 +435,8 @@ export const PluginsPanel = ({
open={selectedPluginId !== null} open={selectedPluginId !== null}
onClose={() => setSelectedPluginId(null)} onClose={() => setSelectedPluginId(null)}
projectPath={projectPath} projectPath={projectPath}
cliStatus={cliStatus}
cliStatusLoading={cliStatusLoading}
/> />
</div> </div>
); );

View file

@ -1,11 +1,16 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import {
mergeCodexProviderStatusWithSnapshot,
useCodexAccountSnapshot,
} from '@features/codex-account/renderer';
import { api } from '@renderer/api'; import { api } from '@renderer/api';
import { Badge } from '@renderer/components/ui/badge'; import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
import { getVisibleMultimodelProviders } from '@renderer/utils/multimodelProviderVisibility'; import { getVisibleMultimodelProviders } from '@renderer/utils/multimodelProviderVisibility';
import { import {
getCliProviderExtensionCapability, getCliProviderExtensionCapability,
@ -149,6 +154,8 @@ export const SkillsPanel = ({
const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog); const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog);
const fetchSkillDetail = useStore((s) => s.fetchSkillDetail); const fetchSkillDetail = useStore((s) => s.fetchSkillDetail);
const cliStatus = useStore((s) => s.cliStatus); const cliStatus = useStore((s) => s.cliStatus);
const cliStatusLoading = useStore((s) => s.cliStatusLoading);
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
const skillsLoading = useStore((s) => s.skillsCatalogLoadingByProjectPath[catalogKey] ?? false); const skillsLoading = useStore((s) => s.skillsCatalogLoadingByProjectPath[catalogKey] ?? false);
const skillsError = useStore((s) => s.skillsCatalogErrorByProjectPath[catalogKey] ?? null); const skillsError = useStore((s) => s.skillsCatalogErrorByProjectPath[catalogKey] ?? null);
const detailById = useStore(useShallow((s) => s.skillsDetailsById)); const detailById = useStore(useShallow((s) => s.skillsDetailsById));
@ -167,28 +174,54 @@ export const SkillsPanel = ({
const selectedSkillIdRef = useRef<string | null>(selectedSkillId); const selectedSkillIdRef = useRef<string | null>(selectedSkillId);
const selectedSkillItemRef = useRef<SkillCatalogItem | null>(null); const selectedSkillItemRef = useRef<SkillCatalogItem | null>(null);
selectedSkillIdRef.current = selectedSkillId; selectedSkillIdRef.current = selectedSkillId;
const loadingCliStatus = useMemo(
() =>
!cliStatus && cliStatusLoading && multimodelEnabled
? createLoadingMultimodelCliStatus()
: cliStatus,
[cliStatus, cliStatusLoading, multimodelEnabled]
);
const codexAccount = useCodexAccountSnapshot({
enabled:
loadingCliStatus?.flavor === 'agent_teams_orchestrator' &&
Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')),
});
const effectiveCliStatus = useMemo(
() =>
loadingCliStatus
? {
...loadingCliStatus,
providers: loadingCliStatus.providers.map((provider) =>
provider.providerId === 'codex'
? mergeCodexProviderStatusWithSnapshot(provider, codexAccount.snapshot)
: provider
),
}
: loadingCliStatus,
[loadingCliStatus, codexAccount.snapshot]
);
const mergedSkills = useMemo( const mergedSkills = useMemo(
() => [...projectSkills, ...userSkills], () => [...projectSkills, ...userSkills],
[projectSkills, userSkills] [projectSkills, userSkills]
); );
const codexSkillOverlayAvailable = useMemo( const codexSkillOverlayAvailable = useMemo(
() => isCodexSkillOverlayAvailable(cliStatus), () => isCodexSkillOverlayAvailable(effectiveCliStatus),
[cliStatus] [effectiveCliStatus]
); );
const skillsAudienceLabel = useMemo(() => { const skillsAudienceLabel = useMemo(() => {
if (cliStatus?.flavor !== 'agent_teams_orchestrator') { if (effectiveCliStatus?.flavor !== 'agent_teams_orchestrator') {
return null; return null;
} }
const providerNames = getVisibleMultimodelProviders(cliStatus.providers ?? []) const providerNames = getVisibleMultimodelProviders(effectiveCliStatus.providers ?? [])
.filter((provider) => .filter((provider) =>
isCliExtensionCapabilityAvailable(getCliProviderExtensionCapability(provider, 'skills')) isCliExtensionCapabilityAvailable(getCliProviderExtensionCapability(provider, 'skills'))
) )
.map((provider) => provider.displayName); .map((provider) => provider.displayName);
return formatRuntimeAudienceLabel(providerNames); return formatRuntimeAudienceLabel(providerNames);
}, [cliStatus]); }, [effectiveCliStatus]);
const codexOnlySkillsCount = useMemo( const codexOnlySkillsCount = useMemo(
() => mergedSkills.filter((skill) => getSkillAudience(skill.rootKind) === 'codex').length, () => mergedSkills.filter((skill) => getSkillAudience(skill.rootKind) === 'codex').length,
[mergedSkills] [mergedSkills]
@ -314,7 +347,7 @@ export const SkillsPanel = ({
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{cliStatus?.flavor === 'agent_teams_orchestrator' && ( {effectiveCliStatus?.flavor === 'agent_teams_orchestrator' && (
<div className="rounded-md border border-blue-500/30 bg-blue-500/5 px-4 py-3 text-sm text-blue-300"> <div className="rounded-md border border-blue-500/30 bg-blue-500/5 px-4 py-3 text-sm text-blue-300">
Shared skills in `.claude`, `.cursor`, and `.agents` are available to{' '} Shared skills in `.claude`, `.cursor`, and `.agents` are available to{' '}
{skillsAudienceLabel ?? 'the configured runtime'}. Skills stored in `.codex` stay {skillsAudienceLabel ?? 'the configured runtime'}. Skills stored in `.codex` stay

View file

@ -1,5 +1,17 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import {
formatCodexCreditsValue,
formatCodexRemainingPercent,
formatCodexResetWindowLabel,
formatCodexUsageExplanation,
formatCodexUsagePercent,
formatCodexUsageWindowLabel,
formatCodexWindowDurationLong,
mergeCodexProviderStatusWithSnapshot,
normalizeCodexResetTimestamp,
useCodexAccountSnapshot,
} from '@features/codex-account/renderer';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { import {
@ -30,8 +42,8 @@ import {
isConnectionManagedRuntimeProvider, isConnectionManagedRuntimeProvider,
} from './providerConnectionUi'; } from './providerConnectionUi';
import { import {
getVisibleProviderRuntimeBackendOptions,
getProviderRuntimeBackendSummary, getProviderRuntimeBackendSummary,
getVisibleProviderRuntimeBackendOptions,
ProviderRuntimeBackendSelector, ProviderRuntimeBackendSelector,
} from './ProviderRuntimeBackendSelector'; } from './ProviderRuntimeBackendSelector';
@ -39,7 +51,8 @@ import type { CliProviderAuthMode, CliProviderId, CliProviderStatus } from '@sha
import type { ApiKeyEntry } from '@shared/types/extensions'; import type { ApiKeyEntry } from '@shared/types/extensions';
type ApiKeyProviderId = 'anthropic' | 'codex' | 'gemini'; type ApiKeyProviderId = 'anthropic' | 'codex' | 'gemini';
type PendingConnectionAction = 'auto' | 'oauth' | 'api_key' | null; type PendingConnectionAction = 'auto' | 'oauth' | 'chatgpt' | 'api_key' | null;
interface ConnectionMethodCardOption { interface ConnectionMethodCardOption {
readonly authMode: CliProviderAuthMode; readonly authMode: CliProviderAuthMode;
readonly title: string; readonly title: string;
@ -81,7 +94,7 @@ const API_KEY_PROVIDER_CONFIG: Record<
name: 'Codex API Key', name: 'Codex API Key',
title: 'API key', title: 'API key',
description: description:
'Codex native requires API-key credentials. Save OPENAI_API_KEY here and the app will mirror it into the native CODEX_API_KEY environment when launching Codex.', 'Use an OpenAI API key as a secondary Codex auth path. If you switch Codex to API key mode, the app will mirror OPENAI_API_KEY into CODEX_API_KEY for native launches.',
placeholder: 'sk-proj-...', placeholder: 'sk-proj-...',
}, },
gemini: { gemini: {
@ -98,13 +111,6 @@ function isApiKeyProviderId(providerId: CliProviderId): providerId is ApiKeyProv
return providerId === 'anthropic' || providerId === 'codex' || providerId === 'gemini'; return providerId === 'anthropic' || providerId === 'codex' || providerId === 'gemini';
} }
function isCodexNativeLane(provider: CliProviderStatus): boolean {
return (
provider.providerId === 'codex' &&
(provider.selectedBackendId === 'codex-native' || provider.resolvedBackendId === 'codex-native')
);
}
function findPreferredApiKeyEntry(apiKeys: ApiKeyEntry[], envVarName: string): ApiKeyEntry | null { function findPreferredApiKeyEntry(apiKeys: ApiKeyEntry[], envVarName: string): ApiKeyEntry | null {
const matches = apiKeys.filter((entry) => entry.envVarName === envVarName); const matches = apiKeys.filter((entry) => entry.envVarName === envVarName);
return matches.find((entry) => entry.scope === 'user') ?? null; return matches.find((entry) => entry.scope === 'user') ?? null;
@ -115,7 +121,7 @@ function getConnectionDescription(provider: CliProviderStatus): string {
case 'anthropic': case 'anthropic':
return 'Choose how app-launched Anthropic sessions authenticate.'; return 'Choose how app-launched Anthropic sessions authenticate.';
case 'codex': case 'codex':
return 'Codex launches always use the native runtime now. Manage API-key credentials here before launching teams or one-shot Codex runs.'; return 'Choose whether Codex should prefer your ChatGPT subscription or an API key when the native runtime launches.';
case 'gemini': case 'gemini':
return 'Configure optional API access. CLI SDK and ADC are still discovered automatically.'; return 'Configure optional API access. CLI SDK and ADC are still discovered automatically.';
} }
@ -145,7 +151,16 @@ function getAuthModeDescription(providerId: CliProviderId, authMode: CliProvider
} }
if (providerId === 'codex') { if (providerId === 'codex') {
return 'Codex always launches through the native runtime and requires API-key credentials.'; switch (authMode) {
case 'auto':
return 'Prefer your ChatGPT account when it is available. Fall back to API key mode only when needed.';
case 'chatgpt':
return 'Force native Codex launches to use your connected ChatGPT account and subscription.';
case 'api_key':
return 'Force native Codex launches to use OPENAI_API_KEY / CODEX_API_KEY billing.';
default:
return '';
}
} }
return ''; return '';
@ -180,8 +195,51 @@ function getConnectionAlert(provider: CliProviderStatus): string | null {
return 'A saved API key is available, but app-launched Anthropic sessions use it only after you switch to API key mode.'; return 'A saved API key is available, but app-launched Anthropic sessions use it only after you switch to API key mode.';
} }
if (provider.providerId === 'codex' && !provider.connection?.apiKeyConfigured) { if (provider.providerId === 'codex') {
return 'No OPENAI_API_KEY or CODEX_API_KEY credential is available yet.'; const codex = provider.connection?.codex;
if (codex?.login.status === 'starting') {
return 'Starting ChatGPT login...';
}
if (codex?.login.status === 'pending') {
return 'Waiting for ChatGPT account login to finish...';
}
if (codex?.login.status === 'failed' && codex.login.error) {
return codex.login.error;
}
if (provider.connection?.configuredAuthMode === 'api_key') {
if (!provider.connection?.apiKeyConfigured) {
return 'API key mode is selected, but no OPENAI_API_KEY or CODEX_API_KEY credential is available yet.';
}
return null;
}
if (provider.connection?.configuredAuthMode === 'chatgpt' && !codex?.managedAccount) {
const missingChatgptMessage = codex?.localActiveChatgptAccountPresent
? 'Codex has a locally selected ChatGPT account, but the current session needs reconnect.'
: codex?.localAccountArtifactsPresent
? 'Codex CLI currently has no active ChatGPT account. Local Codex account data exists, but no active managed session is selected.'
: 'Codex CLI currently has no active ChatGPT account. Connect ChatGPT to use your subscription.';
return provider.connection.apiKeyConfigured
? `${missingChatgptMessage} Switch to API key mode to use the detected API key.`
: missingChatgptMessage;
}
if (!codex?.launchAllowed && codex?.launchIssueMessage) {
return codex.launchIssueMessage;
}
if (codex?.appServerState === 'degraded' && codex.appServerStatusMessage) {
return codex.appServerStatusMessage;
}
if (!provider.connection?.apiKeyConfigured && !codex?.managedAccount) {
return 'No ChatGPT account or API key is available yet.';
}
return null;
} }
if ( if (
@ -194,6 +252,147 @@ function getConnectionAlert(provider: CliProviderStatus): string | null {
return null; return null;
} }
function getCodexAccountPanelHint(
provider: CliProviderStatus | null,
configuredAuthMode: CliProviderAuthMode | undefined
): string | null {
if (provider?.providerId !== 'codex') {
return null;
}
const codex = provider.connection?.codex;
if (!codex || codex.login.status === 'starting' || codex.login.status === 'pending') {
return null;
}
if (codex.managedAccount?.type === 'chatgpt') {
if (!codex.rateLimits) {
return 'Usage limits appear here after Codex reports them for the connected ChatGPT account.';
}
return null;
}
const usageSentence = codex.localActiveChatgptAccountPresent
? 'Codex has a locally selected ChatGPT account, but the current session needs reconnect before usage limits can load here.'
: codex.localAccountArtifactsPresent
? 'Codex CLI currently reports no active ChatGPT account. Local Codex account data exists, but no active managed session is selected. Usage limits appear here only after Codex CLI sees one.'
: 'Codex CLI currently reports no active ChatGPT account. Usage limits appear here only after Codex CLI sees one.';
if (configuredAuthMode === 'chatgpt' && provider.connection?.apiKeyConfigured) {
return `${usageSentence} The detected API key is only used after you switch Codex to API key mode.`;
}
if (configuredAuthMode === 'auto' && provider.connection?.apiKeyConfigured) {
return `${usageSentence} Auto will keep using the detected API key until ChatGPT is connected.`;
}
return usageSentence;
}
function getCheckingStatusColor(): string {
return 'var(--color-text-secondary)';
}
function getProviderStatusColor(statusText: string | null, authenticated: boolean): string {
if (statusText === 'Checking...') {
return getCheckingStatusColor();
}
return authenticated ? '#4ade80' : 'var(--color-text-muted)';
}
function formatCodexResetDateTime(timestampSeconds: number | null | undefined): string {
const normalized = normalizeCodexResetTimestamp(timestampSeconds);
return normalized ? new Date(normalized).toLocaleString() : 'Unknown';
}
function CodexRateLimitWindowCard({
title,
usedLabel,
usedValue,
remainingValue,
resetLabel,
resetValue,
accent,
}: Readonly<{
title: string;
usedLabel: string;
usedValue: string;
remainingValue: string;
resetLabel: string;
resetValue: string;
accent: 'primary' | 'secondary';
}>): React.JSX.Element {
const accentStyles =
accent === 'primary'
? {
borderColor: 'rgba(74, 222, 128, 0.24)',
backgroundColor: 'rgba(74, 222, 128, 0.05)',
badgeColor: '#86efac',
badgeBackground: 'rgba(74, 222, 128, 0.14)',
}
: {
borderColor: 'rgba(125, 211, 252, 0.22)',
backgroundColor: 'rgba(125, 211, 252, 0.04)',
badgeColor: '#bae6fd',
badgeBackground: 'rgba(125, 211, 252, 0.14)',
};
return (
<div
className="rounded-lg border px-4 py-3"
style={{
borderColor: accentStyles.borderColor,
backgroundColor: accentStyles.backgroundColor,
}}
>
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
{title}
</div>
<span
className="rounded-full px-2 py-0.5 text-[11px] font-medium"
style={{
color: accentStyles.badgeColor,
backgroundColor: accentStyles.badgeBackground,
}}
>
{remainingValue}
</span>
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
<div className="space-y-1">
<div className="text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
{usedLabel}
</div>
<div
className="text-3xl font-semibold leading-none"
style={{ color: 'var(--color-text)' }}
>
{usedValue}
</div>
<div className="text-[11px]" style={{ color: 'var(--color-text-secondary)' }}>
{remainingValue} left
</div>
</div>
<div
className="rounded-md border px-3 py-2"
style={{ borderColor: 'var(--color-border-subtle)' }}
>
<div className="text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
{resetLabel}
</div>
<div className="mt-1 text-sm font-medium" style={{ color: 'var(--color-text)' }}>
{resetValue}
</div>
</div>
</div>
</div>
);
}
function getConnectionMethodCardOptions( function getConnectionMethodCardOptions(
provider: CliProviderStatus provider: CliProviderStatus
): ConnectionMethodCardOption[] | null { ): ConnectionMethodCardOption[] | null {
@ -217,7 +416,24 @@ function getConnectionMethodCardOptions(
}, },
]; ];
case 'codex': case 'codex':
return null; return [
{
authMode: 'auto',
title: 'Auto',
description:
'Prefer your ChatGPT account and subscription. Use API key mode only if needed.',
},
{
authMode: 'chatgpt',
title: 'ChatGPT account',
description: 'Use your connected ChatGPT account and Codex subscription.',
},
{
authMode: 'api_key',
title: 'API key',
description: 'Use OPENAI_API_KEY and CODEX_API_KEY billing for native Codex launches.',
},
];
default: default:
return null; return null;
} }
@ -225,7 +441,7 @@ function getConnectionMethodCardOptions(
function getConnectionMethodCardsHint(provider: CliProviderStatus): string | null { function getConnectionMethodCardsHint(provider: CliProviderStatus): string | null {
if (provider.providerId === 'codex') { if (provider.providerId === 'codex') {
return 'Codex uses saved or environment API-key credentials for the native runtime.'; return 'Codex always runs through the native runtime. Auto prefers your ChatGPT account before falling back to API-key credentials.';
} }
if (provider.providerId === 'anthropic') { if (provider.providerId === 'anthropic') {
@ -342,6 +558,10 @@ export const ProviderRuntimeSettingsDialog = ({
const deleteApiKey = useStore((s) => s.deleteApiKey); const deleteApiKey = useStore((s) => s.deleteApiKey);
const updateConfig = useStore((s) => s.updateConfig); const updateConfig = useStore((s) => s.updateConfig);
const appConfig = useStore((s) => s.appConfig); const appConfig = useStore((s) => s.appConfig);
const codexAccount = useCodexAccountSnapshot({
enabled: open && selectedProviderId === 'codex',
includeRateLimits: true,
});
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
@ -374,6 +594,12 @@ export const ProviderRuntimeSettingsDialog = ({
setRuntimeError(null); setRuntimeError(null);
}, [selectedProviderId]); }, [selectedProviderId]);
useEffect(() => {
if (selectedProviderId === 'codex' && codexAccount.error) {
setConnectionError(codexAccount.error);
}
}, [codexAccount.error, selectedProviderId]);
const statusSelectedProvider = useMemo(() => { const statusSelectedProvider = useMemo(() => {
return ( return (
providers.find((provider) => provider.providerId === selectedProviderId) ?? providers.find((provider) => provider.providerId === selectedProviderId) ??
@ -394,18 +620,29 @@ export const ProviderRuntimeSettingsDialog = ({
: null; : null;
const selectedProvider = useMemo(() => { const selectedProvider = useMemo(() => {
if (!statusSelectedProvider?.connection) { const mergedStatusProvider =
return statusSelectedProvider; statusSelectedProvider?.providerId === 'codex'
? mergeCodexProviderStatusWithSnapshot(statusSelectedProvider, codexAccount.snapshot)
: statusSelectedProvider;
if (!mergedStatusProvider?.connection) {
return mergedStatusProvider;
} }
const nextConnection = { const nextConnection = {
...statusSelectedProvider.connection, ...mergedStatusProvider.connection,
}; };
if (statusSelectedProvider.providerId === 'anthropic') { if (mergedStatusProvider.providerId === 'anthropic') {
nextConnection.configuredAuthMode = nextConnection.configuredAuthMode =
appConfig?.providerConnections?.anthropic.authMode ?? appConfig?.providerConnections?.anthropic.authMode ??
statusSelectedProvider.connection.configuredAuthMode; mergedStatusProvider.connection.configuredAuthMode;
}
if (mergedStatusProvider.providerId === 'codex') {
nextConnection.configuredAuthMode =
appConfig?.providerConnections?.codex.preferredAuthMode ??
mergedStatusProvider.connection.configuredAuthMode;
} }
if (statusApiKeyConfig) { if (statusApiKeyConfig) {
@ -421,11 +658,13 @@ export const ProviderRuntimeSettingsDialog = ({
} }
return { return {
...statusSelectedProvider, ...mergedStatusProvider,
connection: nextConnection, connection: nextConnection,
}; };
}, [ }, [
appConfig?.providerConnections?.anthropic.authMode, appConfig?.providerConnections?.anthropic.authMode,
appConfig?.providerConnections?.codex.preferredAuthMode,
codexAccount.snapshot,
selectedApiKey, selectedApiKey,
statusApiKeyConfig, statusApiKeyConfig,
statusSelectedProvider, statusSelectedProvider,
@ -437,6 +676,10 @@ export const ProviderRuntimeSettingsDialog = ({
const runtimeSummary = selectedProvider const runtimeSummary = selectedProvider
? getProviderRuntimeBackendSummary(selectedProvider) ? getProviderRuntimeBackendSummary(selectedProvider)
: null; : null;
const codexConnection =
selectedProvider?.providerId === 'codex' ? (selectedProvider.connection?.codex ?? null) : null;
const codexLoginPending =
codexConnection?.login.status === 'starting' || codexConnection?.login.status === 'pending';
const configurableAuthModes = selectedProvider?.connection?.configurableAuthModes ?? []; const configurableAuthModes = selectedProvider?.connection?.configurableAuthModes ?? [];
const configuredAuthMode: CliProviderAuthMode | undefined = const configuredAuthMode: CliProviderAuthMode | undefined =
selectedProvider?.connection?.configuredAuthMode ?? configurableAuthModes[0] ?? undefined; selectedProvider?.connection?.configuredAuthMode ?? configurableAuthModes[0] ?? undefined;
@ -471,19 +714,30 @@ export const ProviderRuntimeSettingsDialog = ({
(selectedProvider?.providerId !== 'codex' || !selectedProvider.connection?.supportsOAuth) (selectedProvider?.providerId !== 'codex' || !selectedProvider.connection?.supportsOAuth)
); );
const connectionAlert = selectedProvider ? getConnectionAlert(selectedProvider) : null; const connectionAlert = selectedProvider ? getConnectionAlert(selectedProvider) : null;
const connectionLoading = selectedProviderLoading || connectionSaving; const connectionLoading =
selectedProviderLoading ||
connectionSaving ||
Boolean(selectedProvider?.providerId === 'codex' && codexAccount.loading && !codexConnection);
const connectionBusy = disabled || connectionLoading; const connectionBusy = disabled || connectionLoading;
const codexActionBusy =
disabled || selectedProviderLoading || connectionSaving || codexAccount.loading;
const runtimeBusy = disabled || selectedProviderLoading || runtimeSaving; const runtimeBusy = disabled || selectedProviderLoading || runtimeSaving;
const connectionMethodCardsHint = selectedProvider const connectionMethodCardsHint = selectedProvider
? getConnectionMethodCardsHint(selectedProvider) ? getConnectionMethodCardsHint(selectedProvider)
: null; : null;
const codexAccountPanelHint = getCodexAccountPanelHint(
selectedProvider ?? null,
configuredAuthMode
);
const hasSubscriptionSession = const hasSubscriptionSession =
selectedProvider?.providerId === 'anthropic' selectedProvider?.providerId === 'anthropic'
? selectedProvider.authMethod === 'oauth_token' || selectedProvider.authMethod === 'claude.ai' ? selectedProvider.authMethod === 'oauth_token' || selectedProvider.authMethod === 'claude.ai'
: false; : false;
const canRequestSubscriptionLogin = const canRequestSubscriptionLogin =
Boolean(selectedProvider?.connection?.supportsOAuth && onRequestLogin) && selectedProvider?.providerId === 'anthropic' &&
Boolean(selectedProvider.connection?.supportsOAuth && onRequestLogin) &&
configuredAuthMode !== 'api_key' && configuredAuthMode !== 'api_key' &&
selectedProvider.statusMessage !== 'Checking...' &&
(!selectedProvider?.authenticated || hasSubscriptionSession || configuredAuthMode === 'oauth'); (!selectedProvider?.authenticated || hasSubscriptionSession || configuredAuthMode === 'oauth');
let connectionStatusLabel: string | null = null; let connectionStatusLabel: string | null = null;
if (selectedProvider) { if (selectedProvider) {
@ -517,6 +771,19 @@ export const ProviderRuntimeSettingsDialog = ({
} }
} }
if (selectedProvider.providerId === 'codex') {
switch (pendingConnectionAction) {
case 'chatgpt':
return 'Switching to ChatGPT account mode...';
case 'api_key':
return 'Switching to API key mode...';
case 'auto':
return 'Switching to Auto...';
default:
return 'Applying connection changes...';
}
}
return 'Applying connection changes...'; return 'Applying connection changes...';
} }
@ -601,7 +868,7 @@ export const ProviderRuntimeSettingsDialog = ({
}; };
const handleAuthModeChange = async (authMode: string): Promise<void> => { const handleAuthModeChange = async (authMode: string): Promise<void> => {
if (selectedProvider?.providerId !== 'anthropic') { if (selectedProvider?.providerId !== 'anthropic' && selectedProvider?.providerId !== 'codex') {
return; return;
} }
@ -615,11 +882,21 @@ export const ProviderRuntimeSettingsDialog = ({
setConnectionError(null); setConnectionError(null);
let updateSucceeded = false; let updateSucceeded = false;
try { try {
await updateConfig('providerConnections', { if (selectedProvider.providerId === 'anthropic') {
anthropic: { await updateConfig('providerConnections', {
authMode: nextAuthMode, anthropic: {
}, authMode: nextAuthMode,
}); },
});
} else if (nextAuthMode !== 'oauth') {
await updateConfig('providerConnections', {
codex: {
preferredAuthMode: nextAuthMode,
},
});
await codexAccount.refresh({ includeRateLimits: true, forceRefreshToken: true });
}
updateSucceeded = true; updateSucceeded = true;
} catch (error) { } catch (error) {
setConnectionError(error instanceof Error ? error.message : 'Failed to update connection'); setConnectionError(error instanceof Error ? error.message : 'Failed to update connection');
@ -637,6 +914,46 @@ export const ProviderRuntimeSettingsDialog = ({
} }
}; };
const handleCodexAccountRefresh = async (): Promise<void> => {
setConnectionError(null);
try {
await codexAccount.refresh({ includeRateLimits: true, forceRefreshToken: true });
await onRefreshProvider?.('codex');
} catch (error) {
setConnectionError(
error instanceof Error ? error.message : 'Failed to refresh Codex account'
);
}
};
const handleCodexStartLogin = async (): Promise<void> => {
setConnectionError(null);
const success = await codexAccount.startChatgptLogin();
if (!success && codexAccount.error) {
setConnectionError(codexAccount.error);
}
};
const handleCodexCancelLogin = async (): Promise<void> => {
setConnectionError(null);
const success = await codexAccount.cancelChatgptLogin();
if (success) {
await onRefreshProvider?.('codex');
} else if (codexAccount.error) {
setConnectionError(codexAccount.error);
}
};
const handleCodexLogout = async (): Promise<void> => {
setConnectionError(null);
const success = await codexAccount.logout();
if (success) {
await onRefreshProvider?.('codex');
} else if (codexAccount.error) {
setConnectionError(codexAccount.error);
}
};
const handleRuntimeBackendSelect = async ( const handleRuntimeBackendSelect = async (
providerId: CliProviderId, providerId: CliProviderId,
backendId: string backendId: string
@ -712,7 +1029,15 @@ export const ProviderRuntimeSettingsDialog = ({
<span <span
className="text-xs" className="text-xs"
style={{ style={{
color: selectedProvider.authenticated ? '#4ade80' : 'var(--color-text-muted)', color: getProviderStatusColor(
selectedProvider.authenticated
? `Using ${formatProviderAuthMethodLabelForProvider(
selectedProvider.providerId,
selectedProvider.authMethod
)}`
: selectedProvider.statusMessage || 'Not connected',
selectedProvider.authenticated
),
}} }}
> >
{selectedProvider.authenticated {selectedProvider.authenticated
@ -863,6 +1188,281 @@ export const ProviderRuntimeSettingsDialog = ({
) : null} ) : null}
</div> </div>
{selectedProvider.providerId === 'codex' ? (
<div
className="space-y-3 rounded-md border p-3"
style={{ borderColor: 'var(--color-border-subtle)' }}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
ChatGPT account
</div>
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
Manage the local Codex app-server account session that powers
subscription-backed native launches.
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="ghost"
disabled={codexActionBusy}
onClick={() => void handleCodexAccountRefresh()}
>
Refresh
</Button>
{codexLoginPending ? (
<Button
size="sm"
variant="outline"
disabled={codexActionBusy}
onClick={() => void handleCodexCancelLogin()}
>
Cancel login
</Button>
) : codexConnection?.managedAccount?.type === 'chatgpt' ? (
<Button
size="sm"
variant="outline"
disabled={codexActionBusy}
onClick={() => void handleCodexLogout()}
>
Disconnect account
</Button>
) : (
<Button
size="sm"
variant="outline"
disabled={codexActionBusy}
onClick={() => void handleCodexStartLogin()}
>
<Link2 className="mr-1 size-3.5" />
Connect ChatGPT
</Button>
)}
</div>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs">
<span
className="rounded-full px-2 py-0.5"
style={{
color:
codexConnection?.managedAccount?.type === 'chatgpt'
? '#86efac'
: 'var(--color-text-muted)',
backgroundColor:
codexConnection?.managedAccount?.type === 'chatgpt'
? 'rgba(74, 222, 128, 0.14)'
: 'rgba(255, 255, 255, 0.05)',
}}
>
{codexConnection?.managedAccount?.type === 'chatgpt'
? 'Connected'
: codexLoginPending
? 'Login in progress'
: 'Not connected'}
</span>
{codexConnection ? (
<span
className="rounded-full px-2 py-0.5"
style={{
color:
codexConnection.appServerState === 'healthy'
? '#86efac'
: codexConnection.appServerState === 'degraded'
? '#fbbf24'
: '#fca5a5',
backgroundColor:
codexConnection.appServerState === 'healthy'
? 'rgba(74, 222, 128, 0.14)'
: codexConnection.appServerState === 'degraded'
? 'rgba(245, 158, 11, 0.12)'
: 'rgba(248, 113, 113, 0.08)',
}}
>
App-server: {codexConnection.appServerState}
</span>
) : null}
{codexConnection?.managedAccount?.planType ? (
<span style={{ color: 'var(--color-text-secondary)' }}>
Plan: {codexConnection.managedAccount.planType}
</span>
) : null}
{codexConnection?.managedAccount?.email ? (
<span style={{ color: 'var(--color-text-secondary)' }}>
{codexConnection.managedAccount.email}
</span>
) : null}
</div>
{codexAccountPanelHint ? (
<div
className="rounded-md border px-3 py-2 text-xs"
style={{
borderColor: 'var(--color-border-subtle)',
color: 'var(--color-text-secondary)',
}}
>
{codexAccountPanelHint}
</div>
) : null}
{codexConnection?.rateLimits ? (
<div className="space-y-2">
<div
className="rounded-md border px-3 py-2 text-xs"
style={{
borderColor: 'var(--color-border-subtle)',
color: 'var(--color-text-secondary)',
}}
>
These percentages show used quota, not remaining quota.{' '}
{formatCodexUsageExplanation(
codexConnection.rateLimits.primary?.usedPercent,
codexConnection.rateLimits.primary?.windowDurationMins
)}
{codexConnection.rateLimits.secondary
? ` Weekly limits are shown separately in the ${
formatCodexWindowDurationLong(
codexConnection.rateLimits.secondary.windowDurationMins
) ?? 'secondary'
} window.`
: ''}
</div>
<div className="space-y-3">
<div className="grid gap-3 md:grid-cols-2">
<CodexRateLimitWindowCard
title="Primary window"
usedLabel={formatCodexUsageWindowLabel(
'Primary used',
codexConnection.rateLimits.primary?.windowDurationMins
)}
usedValue={formatCodexUsagePercent(
codexConnection.rateLimits.primary?.usedPercent
)}
remainingValue={
formatCodexRemainingPercent(
codexConnection.rateLimits.primary?.usedPercent
) ?? 'Remaining unknown'
}
resetLabel={formatCodexResetWindowLabel(
'Primary reset',
codexConnection.rateLimits.primary?.windowDurationMins
)}
resetValue={formatCodexResetDateTime(
codexConnection.rateLimits.primary?.resetsAt
)}
accent="primary"
/>
{codexConnection.rateLimits.secondary ? (
<CodexRateLimitWindowCard
title={
codexConnection.rateLimits.secondary.windowDurationMins === 10_080
? 'Weekly window'
: 'Secondary window'
}
usedLabel={formatCodexUsageWindowLabel(
codexConnection.rateLimits.secondary.windowDurationMins === 10_080
? 'Weekly used'
: 'Secondary used',
codexConnection.rateLimits.secondary.windowDurationMins
)}
usedValue={formatCodexUsagePercent(
codexConnection.rateLimits.secondary.usedPercent
)}
remainingValue={
formatCodexRemainingPercent(
codexConnection.rateLimits.secondary.usedPercent
) ?? 'Remaining unknown'
}
resetLabel={formatCodexResetWindowLabel(
codexConnection.rateLimits.secondary.windowDurationMins === 10_080
? 'Weekly reset'
: 'Secondary reset',
codexConnection.rateLimits.secondary.windowDurationMins
)}
resetValue={formatCodexResetDateTime(
codexConnection.rateLimits.secondary.resetsAt
)}
accent="secondary"
/>
) : (
<div
className="rounded-lg border px-4 py-3"
style={{
borderColor: 'var(--color-border-subtle)',
backgroundColor: 'rgba(255, 255, 255, 0.02)',
}}
>
<div
className="text-sm font-medium"
style={{ color: 'var(--color-text)' }}
>
Weekly window
</div>
<div
className="mt-3 text-[11px]"
style={{ color: 'var(--color-text-muted)' }}
>
Weekly used (1w)
</div>
<div
className="mt-1 text-sm font-medium"
style={{ color: 'var(--color-text)' }}
>
Not reported
</div>
<div
className="mt-1 text-[11px]"
style={{ color: 'var(--color-text-secondary)' }}
>
Codex did not return a secondary window for this account snapshot.
</div>
</div>
)}
</div>
<div
className="rounded-lg border px-4 py-3"
style={{
borderColor: 'var(--color-border-subtle)',
backgroundColor: 'rgba(255, 255, 255, 0.02)',
}}
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div
className="text-[11px]"
style={{ color: 'var(--color-text-muted)' }}
>
Credits
</div>
<div
className="mt-1 text-sm font-medium"
style={{ color: 'var(--color-text)' }}
>
{formatCodexCreditsValue(codexConnection.rateLimits.credits)}
</div>
</div>
<div
className="max-w-md text-[11px]"
style={{ color: 'var(--color-text-secondary)' }}
>
Credits are shown separately from window-based subscription usage and
may be unavailable for plan-backed ChatGPT sessions.
</div>
</div>
</div>
</div>
</div>
) : null}
</div>
) : null}
{showApiKeySection && apiKeyConfig ? ( {showApiKeySection && apiKeyConfig ? (
<div <div
className="space-y-3 rounded-md border p-3" className="space-y-3 rounded-md border p-3"

View file

@ -6,6 +6,7 @@ const ANTHROPIC_SUBSCRIPTION_LABEL = 'Anthropic subscription';
const AUTH_MODE_LABELS: Record<CliProviderAuthMode, string> = { const AUTH_MODE_LABELS: Record<CliProviderAuthMode, string> = {
auto: 'Auto', auto: 'Auto',
oauth: 'Subscription / OAuth', oauth: 'Subscription / OAuth',
chatgpt: 'ChatGPT account',
api_key: 'API key', api_key: 'API key',
}; };
@ -86,11 +87,53 @@ function getSelectedRuntimeBackendOption(
} }
export function isConnectionManagedRuntimeProvider(provider: CliProviderStatus): boolean { export function isConnectionManagedRuntimeProvider(provider: CliProviderStatus): boolean {
return false; return provider.providerId === 'codex';
} }
function getCodexCurrentRuntimeLabel(provider: CliProviderStatus): string { function getCodexCurrentRuntimeLabel(provider: CliProviderStatus): string {
return provider.backend?.label ?? CODEX_NATIVE_LABEL; return CODEX_NATIVE_LABEL;
}
function getCodexApiKeyAvailabilitySummary(provider: CliProviderStatus): string | null {
if (provider.providerId !== 'codex' || !provider.connection?.apiKeyConfigured) {
return null;
}
if (provider.connection.apiKeySource === 'stored') {
return 'Saved API key available in Manage';
}
return provider.connection.apiKeySourceLabel ?? 'API key is configured';
}
function getCodexMissingManagedAccountStatus(provider: CliProviderStatus): string | null {
if (provider.providerId !== 'codex') {
return null;
}
const codexConnection = provider.connection?.codex;
if (!codexConnection || codexConnection.managedAccount?.type === 'chatgpt') {
return null;
}
if (provider.connection?.configuredAuthMode !== 'chatgpt') {
return null;
}
if (codexConnection.requiresOpenaiAuth) {
if (codexConnection.localActiveChatgptAccountPresent) {
return 'Codex has a locally selected ChatGPT account, but the current session needs reconnect.';
}
return codexConnection.localAccountArtifactsPresent
? 'Codex CLI reports no active ChatGPT login. Local Codex account data exists, but no active managed session is selected.'
: 'Codex CLI reports no active ChatGPT login';
}
return (
codexConnection.launchIssueMessage ??
'Connect a ChatGPT account to use your Codex subscription.'
);
} }
export function getProviderCurrentRuntimeSummary(provider: CliProviderStatus): string | null { export function getProviderCurrentRuntimeSummary(provider: CliProviderStatus): string | null {
@ -106,6 +149,51 @@ export function formatProviderStatusText(provider: CliProviderStatus): string {
const selectedBackendOption = getSelectedRuntimeBackendOption(provider); const selectedBackendOption = getSelectedRuntimeBackendOption(provider);
if (provider.providerId === 'codex') { if (provider.providerId === 'codex') {
if (provider.connection?.codex?.login.status === 'starting') {
return 'Starting ChatGPT login...';
}
if (provider.connection?.codex?.login.status === 'pending') {
return 'Waiting for ChatGPT account login...';
}
if (
provider.connection?.codex?.login.status === 'failed' &&
provider.connection.codex.login.error
) {
return provider.connection.codex.login.error;
}
if (
provider.connection?.codex?.appServerState === 'degraded' &&
provider.connection.codex.effectiveAuthMode === 'chatgpt' &&
provider.connection.codex.launchAllowed
) {
return (
provider.connection.codex.launchIssueMessage ??
'ChatGPT account detected - account verification is currently degraded.'
);
}
if (provider.connection?.codex?.launchAllowed) {
if (provider.connection.codex.effectiveAuthMode === 'chatgpt') {
return 'ChatGPT account ready';
}
if (provider.connection.codex.effectiveAuthMode === 'api_key') {
return 'API key ready';
}
}
const missingManagedAccountStatus = getCodexMissingManagedAccountStatus(provider);
if (missingManagedAccountStatus) {
return missingManagedAccountStatus;
}
if (provider.connection?.codex?.launchIssueMessage) {
return provider.connection.codex.launchIssueMessage;
}
if (selectedBackendOption?.statusMessage) { if (selectedBackendOption?.statusMessage) {
return selectedBackendOption.statusMessage; return selectedBackendOption.statusMessage;
} }
@ -156,11 +244,17 @@ export function getProviderConnectionModeSummary(provider: CliProviderStatus): s
return null; return null;
} }
if (provider.authenticated) { if (provider.providerId === 'anthropic') {
return null; if (provider.authenticated) {
return null;
}
if (provider.connection?.configuredAuthMode === 'auto') {
return null;
}
} }
if (provider.providerId === 'anthropic' && provider.connection?.configuredAuthMode === 'auto') { if (provider.providerId === 'codex' && provider.connection?.configuredAuthMode === 'auto') {
return null; return null;
} }
@ -168,7 +262,13 @@ export function getProviderConnectionModeSummary(provider: CliProviderStatus): s
provider.providerId, provider.providerId,
provider.connection?.configuredAuthMode ?? null provider.connection?.configuredAuthMode ?? null
); );
return authModeLabel ? `Preferred auth: ${authModeLabel}` : null; if (!authModeLabel) {
return null;
}
return provider.providerId === 'codex'
? `Selected auth: ${authModeLabel}`
: `Preferred auth: ${authModeLabel}`;
} }
export function getProviderCredentialSummary(provider: CliProviderStatus): string | null { export function getProviderCredentialSummary(provider: CliProviderStatus): string | null {
@ -197,9 +297,31 @@ export function getProviderCredentialSummary(provider: CliProviderStatus): strin
} }
if (provider.providerId === 'codex') { if (provider.providerId === 'codex') {
return provider.connection.apiKeySource === 'stored' const apiKeyAvailabilitySummary = getCodexApiKeyAvailabilitySummary(provider);
? 'Saved API key available in Manage' if (!apiKeyAvailabilitySummary) {
: (provider.connection.apiKeySourceLabel ?? 'API key is configured'); return null;
}
if (
provider.connection.codex?.managedAccount?.type === 'chatgpt' ||
provider.connection.codex?.effectiveAuthMode === 'chatgpt'
) {
return provider.connection.apiKeySource === 'stored'
? 'API key also available in Manage as fallback'
: `${apiKeyAvailabilitySummary} - available as fallback`;
}
if (provider.connection.configuredAuthMode === 'chatgpt') {
return provider.connection.apiKeySource === 'stored'
? 'Saved API key available in Manage if you switch to API key mode'
: `${apiKeyAvailabilitySummary} - available if you switch to API key mode`;
}
if (provider.connection.configuredAuthMode === 'auto') {
return `${apiKeyAvailabilitySummary} - Auto will use this until ChatGPT is connected`;
}
return apiKeyAvailabilitySummary;
} }
return provider.connection.apiKeySourceLabel ?? null; return provider.connection.apiKeySourceLabel ?? null;
@ -249,7 +371,7 @@ export function getProviderConnectLabel(provider: CliProviderStatus): string {
} }
if (provider.providerId === 'codex') { if (provider.providerId === 'codex') {
return 'Configure API key'; return 'Connect ChatGPT';
} }
if (provider.providerId === 'gemini') { if (provider.providerId === 'gemini') {

View file

@ -332,7 +332,9 @@ export function useSettingsHandlers({
anthropic: { anthropic: {
authMode: 'auto', authMode: 'auto',
}, },
codex: {}, codex: {
preferredAuthMode: 'auto',
},
}, },
runtime: { runtime: {
providerBackends: { providerBackends: {

View file

@ -7,6 +7,10 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import {
mergeCodexProviderStatusWithSnapshot,
useCodexAccountSnapshot,
} from '@features/codex-account/renderer';
import { isElectronMode } from '@renderer/api'; import { isElectronMode } from '@renderer/api';
import { confirm } from '@renderer/components/common/ConfirmDialog'; import { confirm } from '@renderer/components/common/ConfirmDialog';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
@ -29,6 +33,8 @@ import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice'; import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
import { formatBytes } from '@renderer/utils/formatters'; import { formatBytes } from '@renderer/utils/formatters';
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
import { getRuntimeDisplayName } from '@renderer/utils/runtimeDisplayName';
import { import {
AlertTriangle, AlertTriangle,
CheckCircle, CheckCircle,
@ -80,6 +86,34 @@ function isProviderCardLoading(provider: CliProviderStatus, providerLoading: boo
); );
} }
function isCodexSnapshotPending(
provider: CliProviderStatus,
codexSnapshotPending: boolean
): boolean {
return provider.providerId === 'codex' && codexSnapshotPending;
}
function shouldMaskCodexNegativeBootstrapState(
sourceProvider: CliProviderStatus | null,
mergedProvider: CliProviderStatus
): boolean {
return (
sourceProvider?.providerId === 'codex' &&
sourceProvider.statusMessage === 'Checking...' &&
mergedProvider.providerId === 'codex' &&
mergedProvider.connection?.codex?.launchReadinessState === 'missing_auth' &&
mergedProvider.connection.codex.login.status === 'idle'
);
}
function getProviderStatusColor(statusText: string, authenticated: boolean): string {
if (statusText === 'Checking...') {
return 'var(--color-text-secondary)';
}
return authenticated ? '#4ade80' : 'var(--color-text-muted)';
}
function getProviderLabel(providerId: CliProviderId): string { function getProviderLabel(providerId: CliProviderId): string {
switch (providerId) { switch (providerId) {
case 'anthropic': case 'anthropic':
@ -177,10 +211,43 @@ export const CliStatusSection = (): React.JSX.Element | null => {
const [manageDialogOpen, setManageDialogOpen] = useState(false); const [manageDialogOpen, setManageDialogOpen] = useState(false);
const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false); const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false);
const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true; const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true;
const effectiveCliStatus = const loadingCliStatus =
!cliStatus && cliStatusLoading && multimodelEnabled !cliStatus && cliStatusLoading && multimodelEnabled
? createLoadingMultimodelCliStatus() ? createLoadingMultimodelCliStatus()
: cliStatus; : cliStatus;
const codexAccount = useCodexAccountSnapshot({
enabled:
isElectron &&
multimodelEnabled &&
loadingCliStatus?.flavor === 'agent_teams_orchestrator' &&
Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')),
includeRateLimits: true,
});
const codexSnapshotPending =
codexAccount.loading &&
Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')) &&
!codexAccount.snapshot;
const effectiveCliStatus = useMemo(
() =>
loadingCliStatus
? {
...loadingCliStatus,
providers: loadingCliStatus.providers.map((provider) =>
provider.providerId === 'codex'
? mergeCodexProviderStatusWithSnapshot(provider, codexAccount.snapshot)
: provider
),
}
: loadingCliStatus,
[codexAccount.snapshot, loadingCliStatus]
);
const loadingCliProviderMap = useMemo(
() =>
new Map(
(loadingCliStatus?.providers ?? []).map((provider) => [provider.providerId, provider])
),
[loadingCliStatus?.providers]
);
const canOpenExtensions = effectiveCliStatus?.installed === true; const canOpenExtensions = effectiveCliStatus?.installed === true;
const showInstalledControls = const showInstalledControls =
effectiveCliStatus !== null && (installerState === 'idle' || installerState === 'completed'); effectiveCliStatus !== null && (installerState === 'idle' || installerState === 'completed');
@ -202,8 +269,12 @@ export const CliStatusSection = (): React.JSX.Element | null => {
}, [installCli]); }, [installCli]);
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
void fetchCliStatus(); void refreshCliStatusForCurrentMode({
}, [fetchCliStatus]); multimodelEnabled,
bootstrapCliStatus,
fetchCliStatus,
});
}, [bootstrapCliStatus, fetchCliStatus, multimodelEnabled]);
const handleProviderLogout = useCallback( const handleProviderLogout = useCallback(
async (providerId: CliProviderId) => { async (providerId: CliProviderId) => {
@ -242,9 +313,13 @@ export const CliStatusSection = (): React.JSX.Element | null => {
const recheckStatus = useCallback(() => { const recheckStatus = useCallback(() => {
void (async () => { void (async () => {
await invalidateCliStatus(); await invalidateCliStatus();
await fetchCliStatus(); await refreshCliStatusForCurrentMode({
multimodelEnabled,
bootstrapCliStatus,
fetchCliStatus,
});
})(); })();
}, [fetchCliStatus, invalidateCliStatus]); }, [bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, multimodelEnabled]);
const handleMultimodelToggle = useCallback( const handleMultimodelToggle = useCallback(
async (enabled: boolean) => { async (enabled: boolean) => {
@ -306,14 +381,15 @@ export const CliStatusSection = (): React.JSX.Element | null => {
if (!isElectron) return null; if (!isElectron) return null;
const runtimeDisplayName = getRuntimeDisplayName(effectiveCliStatus, multimodelEnabled);
const runtimeLabel = const runtimeLabel =
effectiveCliStatus?.flavor === 'agent_teams_orchestrator' effectiveCliStatus?.flavor === 'agent_teams_orchestrator'
? null ? null
: effectiveCliStatus && : effectiveCliStatus &&
effectiveCliStatus.showVersionDetails && effectiveCliStatus.showVersionDetails &&
effectiveCliStatus.installedVersion effectiveCliStatus.installedVersion
? `${effectiveCliStatus.displayName} v${effectiveCliStatus.installedVersion ?? 'unknown'}` ? `${runtimeDisplayName} v${effectiveCliStatus.installedVersion ?? 'unknown'}`
: (effectiveCliStatus?.displayName ?? 'Claude CLI'); : runtimeDisplayName;
const activeTerminalProvider = providerTerminal const activeTerminalProvider = providerTerminal
? (effectiveCliStatus?.providers.find( ? (effectiveCliStatus?.providers.find(
@ -463,11 +539,22 @@ export const CliStatusSection = (): React.JSX.Element | null => {
{(() => { {(() => {
const providerLoading = const providerLoading =
cliProviderStatusLoading[provider.providerId] === true; cliProviderStatusLoading[provider.providerId] === true;
const showSkeleton = isProviderCardLoading(provider, providerLoading); const showSkeleton =
isProviderCardLoading(provider, providerLoading) ||
isCodexSnapshotPending(provider, codexSnapshotPending);
const runtimeSummary = isConnectionManagedRuntimeProvider(provider) const runtimeSummary = isConnectionManagedRuntimeProvider(provider)
? getProviderCurrentRuntimeSummary(provider) ? getProviderCurrentRuntimeSummary(provider)
: getProviderRuntimeBackendSummary(provider); : getProviderRuntimeBackendSummary(provider);
const statusText = formatProviderStatusText(provider); const sourceProvider =
loadingCliProviderMap.get(provider.providerId) ?? null;
const maskNegativeBootstrapState = shouldMaskCodexNegativeBootstrapState(
sourceProvider,
provider
);
const effectiveShowSkeleton = showSkeleton || maskNegativeBootstrapState;
const statusText = effectiveShowSkeleton
? 'Checking...'
: formatProviderStatusText(provider);
const connectionModeSummary = getProviderConnectionModeSummary(provider); const connectionModeSummary = getProviderConnectionModeSummary(provider);
const credentialSummary = getProviderCredentialSummary(provider); const credentialSummary = getProviderCredentialSummary(provider);
const disconnectAction = getProviderDisconnectAction(provider); const disconnectAction = getProviderDisconnectAction(provider);
@ -498,15 +585,16 @@ export const CliStatusSection = (): React.JSX.Element | null => {
</span> </span>
<span <span
style={{ style={{
color: provider.authenticated color: getProviderStatusColor(
? '#4ade80' statusText,
: 'var(--color-text-muted)', provider.authenticated
),
}} }}
> >
{statusText} {statusText}
</span> </span>
</div> </div>
{showSkeleton ? ( {effectiveShowSkeleton ? (
<ProviderDetailSkeleton /> <ProviderDetailSkeleton />
) : hasDetailContent ? ( ) : hasDetailContent ? (
<div <div
@ -561,7 +649,8 @@ export const CliStatusSection = (): React.JSX.Element | null => {
<LogOut className="size-3" /> <LogOut className="size-3" />
{disconnectAction.label} {disconnectAction.label}
</button> </button>
) : shouldShowProviderConnectAction(provider) ? ( ) : !effectiveShowSkeleton &&
shouldShowProviderConnectAction(provider) ? (
<button <button
type="button" type="button"
onClick={() => onClick={() =>
@ -583,7 +672,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
) : null} ) : null}
</div> </div>
</div> </div>
{!showSkeleton && provider.models.length > 0 && ( {!effectiveShowSkeleton && provider.models.length > 0 && (
<div className="col-span-2"> <div className="col-span-2">
<ProviderModelBadges <ProviderModelBadges
providerId={provider.providerId} providerId={provider.providerId}
@ -619,8 +708,8 @@ export const CliStatusSection = (): React.JSX.Element | null => {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<AlertTriangle className="size-4 shrink-0" style={{ color: '#fbbf24' }} /> <AlertTriangle className="size-4 shrink-0" style={{ color: '#fbbf24' }} />
{effectiveCliStatus.binaryPath && effectiveCliStatus.launchError {effectiveCliStatus.binaryPath && effectiveCliStatus.launchError
? 'Claude CLI was found but failed to start' ? `${runtimeDisplayName} was found but failed to start`
: 'Claude CLI not installed'} : `${runtimeDisplayName} not installed`}
</div> </div>
{effectiveCliStatus.showBinaryPath && effectiveCliStatus.binaryPath && ( {effectiveCliStatus.showBinaryPath && effectiveCliStatus.binaryPath && (
<div className="break-all font-mono text-xs text-text-muted"> <div className="break-all font-mono text-xs text-text-muted">
@ -664,16 +753,16 @@ export const CliStatusSection = (): React.JSX.Element | null => {
> >
<Download className="size-3.5" /> <Download className="size-3.5" />
{effectiveCliStatus.binaryPath && effectiveCliStatus.launchError {effectiveCliStatus.binaryPath && effectiveCliStatus.launchError
? 'Reinstall Claude CLI' ? `Reinstall ${runtimeDisplayName}`
: 'Install Claude CLI'} : `Install ${runtimeDisplayName}`}
</button> </button>
</div> </div>
)} )}
{!effectiveCliStatus.installed && !effectiveCliStatus.supportsSelfUpdate && ( {!effectiveCliStatus.installed && !effectiveCliStatus.supportsSelfUpdate && (
<p className="text-xs" style={{ color: 'var(--color-text-muted)' }}> <p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
{effectiveCliStatus.binaryPath && effectiveCliStatus.launchError {effectiveCliStatus.binaryPath && effectiveCliStatus.launchError
? `The configured ${effectiveCliStatus.displayName} runtime failed its startup health check.` ? `The configured ${runtimeDisplayName} failed its startup health check.`
: `The configured ${effectiveCliStatus.displayName} runtime was not found.`} : `The configured ${runtimeDisplayName} was not found.`}
</p> </p>
)} )}
</div> </div>
@ -779,9 +868,9 @@ export const CliStatusSection = (): React.JSX.Element | null => {
</div> </div>
{providerTerminal && cliStatus?.binaryPath && ( {providerTerminal && cliStatus?.binaryPath && (
<TerminalModal <TerminalModal
title={`${cliStatus.displayName} ${providerTerminal.action === 'login' ? 'Login' : 'Logout'}: ${getProviderLabel( title={`${getRuntimeDisplayName(cliStatus, multimodelEnabled)} ${
providerTerminal.providerId providerTerminal.action === 'login' ? 'Login' : 'Logout'
)}`} }: ${getProviderLabel(providerTerminal.providerId)}`}
command={cliStatus.binaryPath} command={cliStatus.binaryPath}
args={providerTerminalCommand?.args} args={providerTerminalCommand?.args}
env={providerTerminalCommand?.env} env={providerTerminalCommand?.env}

View file

@ -157,9 +157,7 @@ export const ProvisioningProgressBlock = ({
className, className,
}: ProvisioningProgressBlockProps): React.JSX.Element => { }: ProvisioningProgressBlockProps): React.JSX.Element => {
const elapsed = useElapsedTimer(startedAt, loading); const elapsed = useElapsedTimer(startedAt, loading);
const [logsOpen, setLogsOpen] = useState( const [logsOpen, setLogsOpen] = useState(() => defaultLogsOpen ?? false);
() => defaultLogsOpen ?? (Boolean(cliLogsTail) && loading)
);
const [liveOutputOpen, setLiveOutputOpen] = useState(defaultLiveOutputOpen); const [liveOutputOpen, setLiveOutputOpen] = useState(defaultLiveOutputOpen);
const outputScrollRef = useRef<HTMLDivElement>(null); const outputScrollRef = useRef<HTMLDivElement>(null);
const isError = tone === 'error'; const isError = tone === 'error';
@ -192,29 +190,6 @@ export const ProvisioningProgressBlock = ({
} }
}, [isError, cliLogsTail]); }, [isError, cliLogsTail]);
// Open CLI logs while loading, collapse when done (unless error).
const prevLoadingRef = useRef(loading);
const hadLogsRef = useRef(Boolean(cliLogsTail));
useEffect(() => {
if (!isError) {
const hasLogs = Boolean(cliLogsTail);
if (loading && hasLogs && !hadLogsRef.current) {
// Logs just appeared while loading → open
setLogsOpen(true);
} else if (loading && !prevLoadingRef.current && hasLogs) {
// Started loading with logs already present → open
setLogsOpen(true);
} else if (!loading && prevLoadingRef.current) {
// Finished loading → collapse
setLogsOpen(false);
}
hadLogsRef.current = hasLogs;
}
prevLoadingRef.current = loading;
}, [loading, cliLogsTail, isError]);
return ( return (
<div <div
className={cn( className={cn(

View file

@ -1,5 +1,9 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
mergeCodexCliStatusWithSnapshot,
useCodexAccountSnapshot,
} from '@features/codex-account/renderer';
import { api } from '@renderer/api'; import { api } from '@renderer/api';
import { import {
buildMemberDraftColorMap, buildMemberDraftColorMap,
@ -36,12 +40,14 @@ import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
import { import {
isGeminiUiFrozen, isGeminiUiFrozen,
normalizeCreateLaunchProviderForUi, normalizeCreateLaunchProviderForUi,
} from '@renderer/utils/geminiUiFreeze'; } from '@renderer/utils/geminiUiFreeze';
import { normalizePath } from '@renderer/utils/pathNormalize'; import { normalizePath } from '@renderer/utils/pathNormalize';
import { resolveUiOwnedProviderBackendId } from '@renderer/utils/providerBackendIdentity'; import { resolveUiOwnedProviderBackendId } from '@renderer/utils/providerBackendIdentity';
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
import { import {
getTeamModelSelectionError, getTeamModelSelectionError,
normalizeExplicitTeamModelForUi, normalizeExplicitTeamModelForUi,
@ -309,7 +315,25 @@ export const CreateTeamDialog = ({
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true); const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
const cliStatus = useStore((s) => s.cliStatus); const cliStatus = useStore((s) => s.cliStatus);
const cliStatusLoading = useStore((s) => s.cliStatusLoading); const cliStatusLoading = useStore((s) => s.cliStatusLoading);
const bootstrapCliStatus = useStore((s) => s.bootstrapCliStatus);
const fetchCliStatus = useStore((s) => s.fetchCliStatus); const fetchCliStatus = useStore((s) => s.fetchCliStatus);
const loadingCliStatus = useMemo(
() =>
!cliStatus && cliStatusLoading && multimodelEnabled
? createLoadingMultimodelCliStatus()
: cliStatus,
[cliStatus, cliStatusLoading, multimodelEnabled]
);
const codexAccount = useCodexAccountSnapshot({
enabled:
multimodelEnabled &&
loadingCliStatus?.flavor === 'agent_teams_orchestrator' &&
Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')),
});
const effectiveCliStatus = useMemo(
() => mergeCodexCliStatusWithSnapshot(loadingCliStatus, codexAccount.snapshot),
[loadingCliStatus, codexAccount.snapshot]
);
// ── Persisted draft state (survives tab navigation) ────────────────── // ── Persisted draft state (survives tab navigation) ──────────────────
const { const {
@ -508,7 +532,9 @@ export const CreateTeamDialog = ({
}, [members, multimodelEnabled, selectedProviderId, soloTeam, syncModelsWithLead]); }, [members, multimodelEnabled, selectedProviderId, soloTeam, syncModelsWithLead]);
const runtimeBackendSummaryByProvider = useMemo(() => { const runtimeBackendSummaryByProvider = useMemo(() => {
const entries: (readonly [TeamProviderId, string | null])[] = (cliStatus?.providers ?? []).map( const entries: (readonly [TeamProviderId, string | null])[] = (
effectiveCliStatus?.providers ?? []
).map(
(provider) => (provider) =>
[ [
provider.providerId as TeamProviderId, provider.providerId as TeamProviderId,
@ -516,7 +542,7 @@ export const CreateTeamDialog = ({
] as const ] as const
); );
return new Map<TeamProviderId, string | null>(entries); return new Map<TeamProviderId, string | null>(entries);
}, [cliStatus?.providers]); }, [effectiveCliStatus?.providers]);
const runtimeBackendSummaryByProviderRef = useRef(runtimeBackendSummaryByProvider); const runtimeBackendSummaryByProviderRef = useRef(runtimeBackendSummaryByProvider);
const prepareChecksRef = useRef<ProvisioningProviderCheck[]>([]); const prepareChecksRef = useRef<ProvisioningProviderCheck[]>([]);
const prepareModelResultsCacheRef = useRef( const prepareModelResultsCacheRef = useRef(
@ -556,8 +582,12 @@ export const CreateTeamDialog = ({
if (!open || cliStatus || cliStatusLoading) { if (!open || cliStatus || cliStatusLoading) {
return; return;
} }
void fetchCliStatus(); void refreshCliStatusForCurrentMode({
}, [open, cliStatus, cliStatusLoading, fetchCliStatus]); multimodelEnabled,
bootstrapCliStatus,
fetchCliStatus,
});
}, [bootstrapCliStatus, cliStatus, cliStatusLoading, fetchCliStatus, multimodelEnabled, open]);
useEffect(() => { useEffect(() => {
if (!open || !canCreate || !launchTeam) { if (!open || !canCreate || !launchTeam) {
@ -961,9 +991,11 @@ export const CreateTeamDialog = ({
const runtimeProviderStatusById = useMemo( const runtimeProviderStatusById = useMemo(
() => () =>
new Map( new Map(
(cliStatus?.providers ?? []).map((provider) => [provider.providerId, provider] as const) (effectiveCliStatus?.providers ?? []).map(
(provider) => [provider.providerId, provider] as const
)
), ),
[cliStatus?.providers] [effectiveCliStatus?.providers]
); );
const sanitizedTeamName = sanitizeTeamName(teamName.trim()); const sanitizedTeamName = sanitizeTeamName(teamName.trim());

View file

@ -1,5 +1,9 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
mergeCodexCliStatusWithSnapshot,
useCodexAccountSnapshot,
} from '@features/codex-account/renderer';
import { api } from '@renderer/api'; import { api } from '@renderer/api';
import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox'; import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox';
import { import {
@ -36,6 +40,7 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
import { import {
isTeamProvisioningActive, isTeamProvisioningActive,
selectResolvedMembersForTeamName, selectResolvedMembersForTeamName,
@ -46,6 +51,7 @@ import {
} from '@renderer/utils/geminiUiFreeze'; } from '@renderer/utils/geminiUiFreeze';
import { normalizePath } from '@renderer/utils/pathNormalize'; import { normalizePath } from '@renderer/utils/pathNormalize';
import { resolveUiOwnedProviderBackendId } from '@renderer/utils/providerBackendIdentity'; import { resolveUiOwnedProviderBackendId } from '@renderer/utils/providerBackendIdentity';
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
import { nameColorSet } from '@renderer/utils/projectColor'; import { nameColorSet } from '@renderer/utils/projectColor';
import { import {
getTeamModelSelectionError, getTeamModelSelectionError,
@ -249,9 +255,27 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true); const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
const cliStatus = useStore((s) => s.cliStatus); const cliStatus = useStore((s) => s.cliStatus);
const cliStatusLoading = useStore((s) => s.cliStatusLoading); const cliStatusLoading = useStore((s) => s.cliStatusLoading);
const bootstrapCliStatus = useStore((s) => s.bootstrapCliStatus);
const fetchCliStatus = useStore((s) => s.fetchCliStatus); const fetchCliStatus = useStore((s) => s.fetchCliStatus);
const isLaunchMode = props.mode === 'launch' || props.mode === 'relaunch'; const isLaunchMode = props.mode === 'launch' || props.mode === 'relaunch';
const isRelaunch = props.mode === 'relaunch'; const isRelaunch = props.mode === 'relaunch';
const loadingCliStatus = useMemo(
() =>
!cliStatus && cliStatusLoading && multimodelEnabled
? createLoadingMultimodelCliStatus()
: cliStatus,
[cliStatus, cliStatusLoading, multimodelEnabled]
);
const codexAccount = useCodexAccountSnapshot({
enabled:
multimodelEnabled &&
loadingCliStatus?.flavor === 'agent_teams_orchestrator' &&
Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')),
});
const effectiveCliStatus = useMemo(
() => mergeCodexCliStatusWithSnapshot(loadingCliStatus, codexAccount.snapshot),
[loadingCliStatus, codexAccount.snapshot]
);
const isSchedule = props.mode === 'schedule'; const isSchedule = props.mode === 'schedule';
const schedule = isSchedule ? (props.schedule ?? null) : null; const schedule = isSchedule ? (props.schedule ?? null) : null;
const isEditing = isSchedule && !!schedule; const isEditing = isSchedule && !!schedule;
@ -385,7 +409,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
); );
const runtimeBackendSummaryByProvider = useMemo(() => { const runtimeBackendSummaryByProvider = useMemo(() => {
const entries: (readonly [TeamProviderId, string | null])[] = (cliStatus?.providers ?? []).map( const entries: (readonly [TeamProviderId, string | null])[] = (
effectiveCliStatus?.providers ?? []
).map(
(provider) => (provider) =>
[ [
provider.providerId as TeamProviderId, provider.providerId as TeamProviderId,
@ -393,7 +419,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
] as const ] as const
); );
return new Map<TeamProviderId, string | null>(entries); return new Map<TeamProviderId, string | null>(entries);
}, [cliStatus?.providers]); }, [effectiveCliStatus?.providers]);
const runtimeBackendSummaryByProviderRef = useRef(runtimeBackendSummaryByProvider); const runtimeBackendSummaryByProviderRef = useRef(runtimeBackendSummaryByProvider);
const prepareChecksRef = useRef<ProvisioningProviderCheck[]>([]); const prepareChecksRef = useRef<ProvisioningProviderCheck[]>([]);
const prepareModelResultsCacheRef = useRef( const prepareModelResultsCacheRef = useRef(
@ -414,9 +440,11 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
const runtimeProviderStatusById = useMemo( const runtimeProviderStatusById = useMemo(
() => () =>
new Map( new Map(
(cliStatus?.providers ?? []).map((provider) => [provider.providerId, provider] as const) (effectiveCliStatus?.providers ?? []).map(
(provider) => [provider.providerId, provider] as const
)
), ),
[cliStatus?.providers] [effectiveCliStatus?.providers]
); );
useEffect(() => { useEffect(() => {
@ -442,8 +470,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
if (!open || cliStatus || cliStatusLoading) { if (!open || cliStatus || cliStatusLoading) {
return; return;
} }
void fetchCliStatus(); void refreshCliStatusForCurrentMode({
}, [open, cliStatus, cliStatusLoading, fetchCliStatus]); multimodelEnabled,
bootstrapCliStatus,
fetchCliStatus,
});
}, [bootstrapCliStatus, cliStatus, cliStatusLoading, fetchCliStatus, multimodelEnabled, open]);
// Schedule store actions // Schedule store actions
const createSchedule = useStore((s) => s.createSchedule); const createSchedule = useStore((s) => s.createSchedule);

View file

@ -1,5 +1,9 @@
import React, { useEffect, useMemo } from 'react'; import React, { useEffect, useMemo } from 'react';
import {
mergeCodexCliStatusWithSnapshot,
useCodexAccountSnapshot,
} from '@features/codex-account/renderer';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import { Label } from '@renderer/components/ui/label'; import { Label } from '@renderer/components/ui/label';
import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
@ -10,6 +14,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from '@renderer/components/ui/tooltip'; } from '@renderer/components/ui/tooltip';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { import {
GEMINI_UI_DISABLED_BADGE_LABEL, GEMINI_UI_DISABLED_BADGE_LABEL,
@ -136,10 +141,26 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
const cliStatus = useStore((s) => s.cliStatus); const cliStatus = useStore((s) => s.cliStatus);
const cliStatusLoading = useStore((s) => s.cliStatusLoading); const cliStatusLoading = useStore((s) => s.cliStatusLoading);
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true); const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
const multimodelAvailable = multimodelEnabled || cliStatus?.flavor === 'agent_teams_orchestrator'; const loadingCliStatus = useMemo(
() =>
!cliStatus && cliStatusLoading && multimodelEnabled
? createLoadingMultimodelCliStatus()
: cliStatus,
[cliStatus, cliStatusLoading, multimodelEnabled]
);
const effectiveProviderId = const effectiveProviderId =
disableGeminiOption && isGeminiUiFrozen() && providerId === 'gemini' ? 'anthropic' : providerId; disableGeminiOption && isGeminiUiFrozen() && providerId === 'gemini' ? 'anthropic' : providerId;
const codexAccount = useCodexAccountSnapshot({
enabled: multimodelEnabled && effectiveProviderId === 'codex',
});
const effectiveCliStatus = useMemo(
() => mergeCodexCliStatusWithSnapshot(loadingCliStatus, codexAccount.snapshot),
[codexAccount.snapshot, loadingCliStatus]
);
const effectiveCliStatusLoading = cliStatusLoading && effectiveCliStatus === null;
const multimodelAvailable =
multimodelEnabled || effectiveCliStatus?.flavor === 'agent_teams_orchestrator';
const defaultModelTooltip = useMemo(() => { const defaultModelTooltip = useMemo(() => {
if (effectiveProviderId === 'anthropic') { if (effectiveProviderId === 'anthropic') {
return 'Uses the Claude team default model.\nResolves to Opus 4.7 with 1M context, or Opus 4.7 with 200K context when Limit context is enabled.'; return 'Uses the Claude team default model.\nResolves to Opus 4.7 with 1M context, or Opus 4.7 with 200K context when Limit context is enabled.';
@ -190,12 +211,14 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
}; };
const runtimeProviderStatus = useMemo( const runtimeProviderStatus = useMemo(
() => () =>
cliStatus?.providers.find((provider) => provider.providerId === effectiveProviderId) ?? null, effectiveCliStatus?.providers.find(
[cliStatus?.providers, effectiveProviderId] (provider) => provider.providerId === effectiveProviderId
) ?? null,
[effectiveCliStatus?.providers, effectiveProviderId]
); );
const shouldAwaitRuntimeModelList = const shouldAwaitRuntimeModelList =
effectiveProviderId !== 'anthropic' && effectiveProviderId !== 'anthropic' &&
(cliStatus == null || cliStatusLoading) && (effectiveCliStatus == null || effectiveCliStatusLoading) &&
runtimeProviderStatus == null; runtimeProviderStatus == null;
const normalizedValue = normalizeTeamModelForUi( const normalizedValue = normalizeTeamModelForUi(
effectiveProviderId, effectiveProviderId,

View file

@ -6,6 +6,7 @@ import { api } from '@renderer/api';
import { syncRendererTelemetry } from '@renderer/sentry'; import { syncRendererTelemetry } from '@renderer/sentry';
import { cleanupStale as cleanupCommentReadState } from '@renderer/services/commentReadStorage'; import { cleanupStale as cleanupCommentReadState } from '@renderer/services/commentReadStorage';
import { normalizePath } from '@renderer/utils/pathNormalize'; import { normalizePath } from '@renderer/utils/pathNormalize';
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
import { import {
buildTaskChangePresenceKey, buildTaskChangePresenceKey,
buildTaskChangeRequestOptions, buildTaskChangeRequestOptions,
@ -1424,13 +1425,21 @@ export function initializeNotificationListeners(): () => void {
break; break;
} }
case 'completed': case 'completed':
{
const multimodelEnabled =
useStore.getState().appConfig?.general?.multimodelEnabled ?? true;
void refreshCliStatusForCurrentMode({
multimodelEnabled,
bootstrapCliStatus: useStore.getState().bootstrapCliStatus,
fetchCliStatus: useStore.getState().fetchCliStatus,
});
}
useStore.setState({ useStore.setState({
cliInstallerState: 'completed', cliInstallerState: 'completed',
cliCompletedVersion: progress.version ?? null, cliCompletedVersion: progress.version ?? null,
cliInstallerDetail: null, cliInstallerDetail: null,
}); });
// Re-fetch status after install and auto-revert to idle after 3s // Re-fetch status after install and auto-revert to idle after 3s
void useStore.getState().fetchCliStatus();
cliCompletedRevertTimer = setTimeout(() => { cliCompletedRevertTimer = setTimeout(() => {
cliCompletedRevertTimer = null; cliCompletedRevertTimer = null;
// Only revert if still in 'completed' state (not overwritten by a new install) // Only revert if still in 'completed' state (not overwritten by a new install)

View file

@ -44,7 +44,7 @@ export function createLoadingMultimodelCliStatus(): CliInstallationStatus {
return { return {
flavor: 'agent_teams_orchestrator', flavor: 'agent_teams_orchestrator',
displayName: 'agent_teams_orchestrator', displayName: 'Multimodel runtime',
supportsSelfUpdate: false, supportsSelfUpdate: false,
showVersionDetails: false, showVersionDetails: false,
showBinaryPath: false, showBinaryPath: false,
@ -61,6 +61,24 @@ export function createLoadingMultimodelCliStatus(): CliInstallationStatus {
}; };
} }
function isHydratedMultimodelProviderStatus(provider: CliProviderStatus | undefined): boolean {
if (!provider) {
return false;
}
return !(
provider.supported === false &&
provider.authenticated === false &&
provider.authMethod === null &&
provider.verificationState === 'unknown' &&
provider.statusMessage === 'Checking...' &&
provider.models.length === 0 &&
provider.backend == null &&
(provider.availableBackends?.length ?? 0) === 0 &&
provider.connection == null
);
}
// ============================================================================= // =============================================================================
// Slice Interface // Slice Interface
// ============================================================================= // =============================================================================
@ -164,6 +182,18 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
return; return;
} }
const nextProviderLoading = Object.fromEntries(
MULTIMODEL_PROVIDER_IDS.map((providerId) => [
providerId,
!isHydratedMultimodelProviderStatus(
metadata.providers.find((provider) => provider.providerId === providerId)
),
])
) as Partial<Record<CliProviderId, boolean>>;
const pendingProviderIds = MULTIMODEL_PROVIDER_IDS.filter(
(providerId) => nextProviderLoading[providerId] === true
);
set((state) => { set((state) => {
if (epoch !== cliStatusEpoch || !state.cliStatus) { if (epoch !== cliStatusEpoch || !state.cliStatus) {
return {}; return {};
@ -171,37 +201,37 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
return { return {
cliStatus: { cliStatus: {
...state.cliStatus, ...metadata,
flavor: metadata.flavor,
displayName: metadata.displayName,
supportsSelfUpdate: metadata.supportsSelfUpdate,
showVersionDetails: metadata.showVersionDetails,
showBinaryPath: metadata.showBinaryPath,
installed: metadata.installed,
installedVersion: metadata.installedVersion,
binaryPath: metadata.binaryPath,
launchError: metadata.launchError ?? null, launchError: metadata.launchError ?? null,
latestVersion: metadata.latestVersion, authStatusChecking: metadata.installed && pendingProviderIds.length > 0,
updateAvailable: metadata.updateAvailable,
authStatusChecking:
metadata.installed &&
state.cliStatus.providers.some(
(provider) => provider.statusMessage === 'Checking...'
),
providers: metadata.installed ? state.cliStatus.providers : metadata.providers,
}, },
cliStatusLoading: false,
cliProviderStatusLoading: nextProviderLoading,
}; };
}); });
if (!metadata.installed) { if (!metadata.installed) {
if (epoch === cliStatusEpoch) { if (epoch === cliStatusEpoch) {
set({ set({
cliStatusLoading: false,
cliProviderStatusLoading: {}, cliProviderStatusLoading: {},
}); });
} }
return; return;
} }
if (pendingProviderIds.length === 0) {
return;
}
await Promise.allSettled(
pendingProviderIds.map((providerId) =>
get().fetchCliProviderStatus(providerId, {
silent: false,
epoch,
})
)
);
return;
} catch (error) { } catch (error) {
logger.warn('Failed to hydrate CLI metadata during provider-first bootstrap:', error); logger.warn('Failed to hydrate CLI metadata during provider-first bootstrap:', error);
} }

View file

@ -4,6 +4,7 @@
*/ */
import { api } from '@renderer/api'; import { api } from '@renderer/api';
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
import { import {
getExtensionActionDisableReason, getExtensionActionDisableReason,
getMcpDiagnosticKey, getMcpDiagnosticKey,
@ -345,6 +346,26 @@ function upsertApiKeyEntry(entries: ApiKeyEntry[], entry: ApiKeyEntry): ApiKeyEn
const SUCCESS_DISPLAY_MS = 2_000; const SUCCESS_DISPLAY_MS = 2_000;
const PROJECT_SCOPE_REQUIRED_MESSAGE = const PROJECT_SCOPE_REQUIRED_MESSAGE =
'Project- and local-scoped plugins require an active project in the Extensions tab.'; 'Project- and local-scoped plugins require an active project in the Extensions tab.';
function refreshConfiguredCliStatus(
state: Pick<AppState, 'appConfig' | 'bootstrapCliStatus' | 'fetchCliStatus'>
): Promise<void> {
return refreshCliStatusForCurrentMode({
multimodelEnabled: state.appConfig?.general?.multimodelEnabled ?? true,
bootstrapCliStatus: state.bootstrapCliStatus,
fetchCliStatus: state.fetchCliStatus,
});
}
function getExtensionActionCliStatusState(
state: Pick<AppState, 'cliStatus' | 'cliStatusLoading'>
): Pick<Parameters<typeof getExtensionActionDisableReason>[0], 'cliStatus' | 'cliStatusLoading'> {
return {
cliStatus: state.cliStatus,
cliStatusLoading: state.cliStatus === null && state.cliStatusLoading,
};
}
export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSlice> = ( export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSlice> = (
set, set,
get get
@ -886,22 +907,21 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
: { ...request, projectPath: effectiveProjectPath }; : { ...request, projectPath: effectiveProjectPath };
const preflightState = get(); const preflightState = get();
if (preflightState.cliStatus === null || preflightState.cliStatusLoading) { if (preflightState.cliStatus === null) {
try { try {
await preflightState.fetchCliStatus(); await refreshConfiguredCliStatus(preflightState);
} catch { } catch {
// fetchCliStatus stores the error in cliStatusError; map to a user-facing install error below. // fetchCliStatus stores the error in cliStatusError; map to a user-facing install error below.
} }
} }
const cliStatus = get().cliStatus; const extensionCliStatusState = getExtensionActionCliStatusState(get());
const preflightError = const preflightError =
effectiveRequest.scope !== 'user' && !effectiveRequest.projectPath effectiveRequest.scope !== 'user' && !effectiveRequest.projectPath
? PROJECT_SCOPE_REQUIRED_MESSAGE ? PROJECT_SCOPE_REQUIRED_MESSAGE
: getExtensionActionDisableReason({ : getExtensionActionDisableReason({
isInstalled: false, isInstalled: false,
cliStatus, ...extensionCliStatusState,
cliStatusLoading: get().cliStatusLoading,
section: 'plugins', section: 'plugins',
}); });
@ -977,9 +997,9 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
} }
const preflightState = get(); const preflightState = get();
if (preflightState.cliStatus === null || preflightState.cliStatusLoading) { if (preflightState.cliStatus === null) {
try { try {
await preflightState.fetchCliStatus(); await refreshConfiguredCliStatus(preflightState);
} catch { } catch {
// fetchCliStatus stores the error in cliStatusError; map to a user-facing uninstall error below. // fetchCliStatus stores the error in cliStatusError; map to a user-facing uninstall error below.
} }
@ -987,8 +1007,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
const uninstallDisableReason = getExtensionActionDisableReason({ const uninstallDisableReason = getExtensionActionDisableReason({
isInstalled: true, isInstalled: true,
cliStatus: get().cliStatus, ...getExtensionActionCliStatusState(get()),
cliStatusLoading: get().cliStatusLoading,
section: 'plugins', section: 'plugins',
}); });
if (uninstallDisableReason) { if (uninstallDisableReason) {
@ -1055,9 +1074,9 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
} }
const preflightState = get(); const preflightState = get();
if (preflightState.cliStatus === null || preflightState.cliStatusLoading) { if (preflightState.cliStatus === null) {
try { try {
await preflightState.fetchCliStatus(); await refreshConfiguredCliStatus(preflightState);
} catch { } catch {
// fetchCliStatus stores the error in cliStatusError; map to a user-facing install error below. // fetchCliStatus stores the error in cliStatusError; map to a user-facing install error below.
} }
@ -1065,8 +1084,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
const installDisableReason = getExtensionActionDisableReason({ const installDisableReason = getExtensionActionDisableReason({
isInstalled: false, isInstalled: false,
cliStatus: get().cliStatus, ...getExtensionActionCliStatusState(get()),
cliStatusLoading: get().cliStatusLoading,
section: 'mcp', section: 'mcp',
}); });
if (installDisableReason) { if (installDisableReason) {
@ -1130,9 +1148,9 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
} }
const preflightState = get(); const preflightState = get();
if (preflightState.cliStatus === null || preflightState.cliStatusLoading) { if (preflightState.cliStatus === null) {
try { try {
await preflightState.fetchCliStatus(); await refreshConfiguredCliStatus(preflightState);
} catch { } catch {
// fetchCliStatus stores the error in cliStatusError; map to a user-facing install error below. // fetchCliStatus stores the error in cliStatusError; map to a user-facing install error below.
} }
@ -1140,8 +1158,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
const installDisableReason = getExtensionActionDisableReason({ const installDisableReason = getExtensionActionDisableReason({
isInstalled: false, isInstalled: false,
cliStatus: get().cliStatus, ...getExtensionActionCliStatusState(get()),
cliStatusLoading: get().cliStatusLoading,
section: 'mcp', section: 'mcp',
}); });
if (installDisableReason) { if (installDisableReason) {
@ -1199,9 +1216,9 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
} }
const preflightState = get(); const preflightState = get();
if (preflightState.cliStatus === null || preflightState.cliStatusLoading) { if (preflightState.cliStatus === null) {
try { try {
await preflightState.fetchCliStatus(); await refreshConfiguredCliStatus(preflightState);
} catch { } catch {
// fetchCliStatus stores the error in cliStatusError; map to a user-facing uninstall error below. // fetchCliStatus stores the error in cliStatusError; map to a user-facing uninstall error below.
} }
@ -1209,8 +1226,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
const uninstallDisableReason = getExtensionActionDisableReason({ const uninstallDisableReason = getExtensionActionDisableReason({
isInstalled: true, isInstalled: true,
cliStatus: get().cliStatus, ...getExtensionActionCliStatusState(get()),
cliStatusLoading: get().cliStatusLoading,
section: 'mcp', section: 'mcp',
}); });
if (uninstallDisableReason) { if (uninstallDisableReason) {
@ -1309,7 +1325,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
})); }));
} }
await get().fetchCliStatus(); await refreshConfiguredCliStatus(get());
const refreshError = get().cliStatusError; const refreshError = get().cliStatusError;
if (refreshError) { if (refreshError) {
warnings.push(`API key saved, but failed to refresh provider status. ${refreshError}`); warnings.push(`API key saved, but failed to refresh provider status. ${refreshError}`);
@ -1333,7 +1349,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
set((prev) => ({ set((prev) => ({
apiKeys: prev.apiKeys.filter((k) => k.id !== id), apiKeys: prev.apiKeys.filter((k) => k.id !== id),
})); }));
await get().fetchCliStatus(); await refreshConfiguredCliStatus(get());
const refreshError = get().cliStatusError; const refreshError = get().cliStatusError;
set({ set({
apiKeysError: refreshError apiKeysError: refreshError

View file

@ -11,6 +11,21 @@ import type {
TeamProviderId, TeamProviderId,
} from '@shared/types'; } from '@shared/types';
function normalizeMemberBackendLabel(
providerId: TeamProviderId,
backendLabel: string | undefined
): string | undefined {
if (!backendLabel) {
return undefined;
}
if (providerId === 'codex' && backendLabel === 'Codex native') {
return 'Codex';
}
return backendLabel;
}
function isMemberLaunchPending(spawnEntry: MemberSpawnStatusEntry | undefined): boolean { function isMemberLaunchPending(spawnEntry: MemberSpawnStatusEntry | undefined): boolean {
if (!spawnEntry) { if (!spawnEntry) {
return false; return false;
@ -35,9 +50,9 @@ export function resolveMemberRuntimeSummary(
const configuredModel = member.model?.trim() || launchParams?.model?.trim() || ''; const configuredModel = member.model?.trim() || launchParams?.model?.trim() || '';
const configuredEffort = member.effort ?? launchParams?.effort; const configuredEffort = member.effort ?? launchParams?.effort;
const runtimeModel = spawnEntry?.runtimeModel?.trim() || runtimeEntry?.runtimeModel?.trim(); const runtimeModel = spawnEntry?.runtimeModel?.trim() || runtimeEntry?.runtimeModel?.trim();
const backendLabel = formatTeamProviderBackendLabel( const backendLabel = normalizeMemberBackendLabel(
configuredProvider, configuredProvider,
launchParams?.providerBackendId formatTeamProviderBackendLabel(configuredProvider, launchParams?.providerBackendId)
); );
const memorySuffix = const memorySuffix =
typeof runtimeEntry?.rssBytes === 'number' && runtimeEntry.rssBytes > 0 typeof runtimeEntry?.rssBytes === 'number' && runtimeEntry.rssBytes > 0

View file

@ -0,0 +1,17 @@
interface RefreshCliStatusOptions {
multimodelEnabled: boolean;
bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise<void>;
fetchCliStatus: () => Promise<void>;
}
export function refreshCliStatusForCurrentMode({
multimodelEnabled,
bootstrapCliStatus,
fetchCliStatus,
}: RefreshCliStatusOptions): Promise<void> {
if (multimodelEnabled) {
return bootstrapCliStatus({ multimodelEnabled: true });
}
return fetchCliStatus();
}

View file

@ -0,0 +1,26 @@
import type { CliFlavor, CliInstallationStatus } from '@shared/types';
const MULTIMODEL_RUNTIME_LABEL = 'Multimodel runtime';
export function getRuntimeDisplayName(
cliStatus: Pick<CliInstallationStatus, 'flavor' | 'displayName'> | null | undefined,
multimodelEnabledFallback = false
): string {
if (cliStatus?.flavor === 'agent_teams_orchestrator') {
if (!cliStatus.displayName || cliStatus.displayName === 'agent_teams_orchestrator') {
return MULTIMODEL_RUNTIME_LABEL;
}
return cliStatus.displayName;
}
if (cliStatus?.displayName) {
return cliStatus.displayName;
}
return multimodelEnabledFallback ? MULTIMODEL_RUNTIME_LABEL : 'Claude CLI';
}
export function getRuntimeCommandLabel(flavor: CliFlavor): string {
return flavor === 'agent_teams_orchestrator' ? MULTIMODEL_RUNTIME_LABEL : 'Claude CLI';
}

View file

@ -323,8 +323,14 @@ export function sortTeamProviderModels(
export function isCodexChatGptSubscriptionProviderStatus( export function isCodexChatGptSubscriptionProviderStatus(
providerStatus?: RuntimeAwareProviderStatus | null providerStatus?: RuntimeAwareProviderStatus | null
): boolean { ): boolean {
void providerStatus; if (providerStatus?.providerId !== 'codex') {
return false; return false;
}
return (
providerStatus.authMethod === 'chatgpt' ||
providerStatus.backend?.authMethodDetail === 'chatgpt'
);
} }
function isRuntimeHiddenTeamModel( function isRuntimeHiddenTeamModel(

View file

@ -318,7 +318,7 @@ export function buildTeamProvisioningPresentation({
panelMessage: panelMessage:
failedSpawnCount > 0 ? (failedSpawnPanelMessage ?? progress.message) : progress.message, failedSpawnCount > 0 ? (failedSpawnPanelMessage ?? progress.message) : progress.message,
panelMessageSeverity: failedSpawnCount > 0 ? 'warning' : progress.messageSeverity, panelMessageSeverity: failedSpawnCount > 0 ? 'warning' : progress.messageSeverity,
defaultLiveOutputOpen: true, defaultLiveOutputOpen: false,
compactTitle: 'Launching team', compactTitle: 'Launching team',
compactDetail: compactDetail:
failedSpawnCount > 0 failedSpawnCount > 0

View file

@ -107,6 +107,7 @@ import type {
SessionsPaginationOptions, SessionsPaginationOptions,
SubagentDetail, SubagentDetail,
} from '@main/types'; } from '@main/types';
import type { CodexAccountElectronApi } from '@features/codex-account/contracts';
// ============================================================================= // =============================================================================
// Cost Calculation Types // Cost Calculation Types
@ -732,7 +733,7 @@ export interface ReviewAPI {
/** /**
* Complete Electron API exposed to the renderer process via preload script. * Complete Electron API exposed to the renderer process via preload script.
*/ */
export interface ElectronAPI extends RecentProjectsElectronApi { export interface ElectronAPI extends RecentProjectsElectronApi, CodexAccountElectronApi {
getAppVersion: () => Promise<string>; getAppVersion: () => Promise<string>;
getProjects: () => Promise<Project[]>; getProjects: () => Promise<Project[]>;
getSessions: (projectId: string) => Promise<Session[]>; getSessions: (projectId: string) => Promise<Session[]>;

View file

@ -4,6 +4,16 @@
* Used for detecting, downloading, verifying, and installing Claude Code CLI binary. * Used for detecting, downloading, verifying, and installing Claude Code CLI binary.
*/ */
import type {
CodexAccountAppServerState,
CodexAccountAuthMode,
CodexAccountEffectiveAuthMode,
CodexLoginStateDto,
CodexLaunchReadinessState,
CodexManagedAccountDto,
CodexRateLimitSnapshotDto,
} from '@features/codex-account/contracts';
// ============================================================================= // =============================================================================
// Platform Detection // Platform Detection
// ============================================================================= // =============================================================================
@ -24,7 +34,7 @@ export type CliPlatform =
export type CliFlavor = 'claude' | 'agent_teams_orchestrator'; export type CliFlavor = 'claude' | 'agent_teams_orchestrator';
export type CliProviderId = 'anthropic' | 'codex' | 'gemini'; export type CliProviderId = 'anthropic' | 'codex' | 'gemini';
export type CliProviderAuthMode = 'auto' | 'oauth' | 'api_key'; export type CliProviderAuthMode = 'auto' | 'oauth' | 'chatgpt' | 'api_key';
export interface CliProviderConnectionInfo { export interface CliProviderConnectionInfo {
supportsOAuth: boolean; supportsOAuth: boolean;
@ -34,6 +44,21 @@ export interface CliProviderConnectionInfo {
apiKeyConfigured: boolean; apiKeyConfigured: boolean;
apiKeySource: 'stored' | 'environment' | null; apiKeySource: 'stored' | 'environment' | null;
apiKeySourceLabel?: string | null; apiKeySourceLabel?: string | null;
codex?: {
preferredAuthMode: CodexAccountAuthMode;
effectiveAuthMode: CodexAccountEffectiveAuthMode;
appServerState: CodexAccountAppServerState;
appServerStatusMessage: string | null;
managedAccount: CodexManagedAccountDto | null;
requiresOpenaiAuth: boolean | null;
localAccountArtifactsPresent?: boolean;
localActiveChatgptAccountPresent?: boolean;
login: CodexLoginStateDto;
rateLimits: CodexRateLimitSnapshotDto | null;
launchAllowed: boolean;
launchIssueMessage: string | null;
launchReadinessState: CodexLaunchReadinessState;
} | null;
} }
export interface CliProviderBackendOption { export interface CliProviderBackendOption {

View file

@ -328,7 +328,9 @@ export interface AppConfig {
anthropic: { anthropic: {
authMode: 'auto' | 'oauth' | 'api_key'; authMode: 'auto' | 'oauth' | 'api_key';
}; };
codex: Record<string, never>; codex: {
preferredAuthMode: 'auto' | 'chatgpt' | 'api_key';
};
}; };
/** Runtime backend preferences for app-launched agent_teams_orchestrator sessions */ /** Runtime backend preferences for app-launched agent_teams_orchestrator sessions */
runtime: { runtime: {

View file

@ -237,12 +237,8 @@ export function getExtensionActionDisableReason(options: {
section?: 'plugins' | 'mcp'; section?: 'plugins' | 'mcp';
}): string | null { }): string | null {
const { isInstalled, cliStatus, cliStatusLoading, section = 'plugins' } = options; const { isInstalled, cliStatus, cliStatusLoading, section = 'plugins' } = options;
if (cliStatusLoading) {
return 'Checking runtime status...';
}
if (cliStatus === null) { if (cliStatus === null) {
return 'Checking runtime availability...'; return cliStatusLoading ? 'Checking runtime status...' : 'Checking runtime availability...';
} }
if (cliStatus.installed === false) { if (cliStatus.installed === false) {

View file

@ -0,0 +1,137 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest';
import { evaluateCodexLaunchReadiness } from '@features/codex-account/core/domain/evaluateCodexLaunchReadiness';
describe('evaluateCodexLaunchReadiness', () => {
it('prefers a managed ChatGPT account in auto mode when both auth sources are available', () => {
const readiness = evaluateCodexLaunchReadiness({
preferredAuthMode: 'auto',
managedAccount: {
type: 'chatgpt',
email: 'user@example.com',
planType: 'plus',
},
apiKey: {
available: true,
source: 'stored',
sourceLabel: 'Stored in app',
},
appServerState: 'healthy',
appServerStatusMessage: null,
});
expect(readiness).toEqual({
state: 'ready_both',
effectiveAuthMode: 'chatgpt',
launchAllowed: true,
issueMessage: null,
});
});
it('blocks launch when ChatGPT account mode is selected but no managed account is connected', () => {
const readiness = evaluateCodexLaunchReadiness({
preferredAuthMode: 'chatgpt',
managedAccount: null,
apiKey: {
available: true,
source: 'environment',
sourceLabel: 'Detected from OPENAI_API_KEY',
},
appServerState: 'healthy',
appServerStatusMessage: null,
});
expect(readiness.state).toBe('missing_auth');
expect(readiness.effectiveAuthMode).toBeNull();
expect(readiness.launchAllowed).toBe(false);
expect(readiness.issueMessage).toContain('Connect a ChatGPT account');
});
it('asks for reconnect instead of a fresh login when a locally selected ChatGPT account already exists', () => {
const readiness = evaluateCodexLaunchReadiness({
preferredAuthMode: 'chatgpt',
managedAccount: null,
apiKey: {
available: true,
source: 'environment',
sourceLabel: 'Detected from OPENAI_API_KEY',
},
appServerState: 'healthy',
appServerStatusMessage: null,
localActiveChatgptAccountPresent: true,
});
expect(readiness.state).toBe('missing_auth');
expect(readiness.effectiveAuthMode).toBeNull();
expect(readiness.launchAllowed).toBe(false);
expect(readiness.issueMessage).toContain('Reconnect ChatGPT');
});
it('allows API-key mode when an API key is available', () => {
const readiness = evaluateCodexLaunchReadiness({
preferredAuthMode: 'api_key',
managedAccount: null,
apiKey: {
available: true,
source: 'stored',
sourceLabel: 'Stored in app',
},
appServerState: 'healthy',
appServerStatusMessage: null,
});
expect(readiness).toEqual({
state: 'ready_api_key',
effectiveAuthMode: 'api_key',
launchAllowed: true,
issueMessage: null,
});
});
it('surfaces degraded-but-launchable state when the managed account is still usable', () => {
const readiness = evaluateCodexLaunchReadiness({
preferredAuthMode: 'auto',
managedAccount: {
type: 'chatgpt',
email: 'user@example.com',
planType: 'pro',
},
apiKey: {
available: false,
source: null,
sourceLabel: null,
},
appServerState: 'degraded',
appServerStatusMessage: 'Temporary app-server probe failure',
});
expect(readiness).toEqual({
state: 'warning_degraded_but_launchable',
effectiveAuthMode: 'chatgpt',
launchAllowed: true,
issueMessage: 'Temporary app-server probe failure',
});
});
it('fails fast when the Codex runtime is missing entirely', () => {
const readiness = evaluateCodexLaunchReadiness({
preferredAuthMode: 'auto',
managedAccount: null,
apiKey: {
available: true,
source: 'stored',
sourceLabel: 'Stored in app',
},
appServerState: 'runtime-missing',
appServerStatusMessage: 'Codex CLI not found',
});
expect(readiness).toEqual({
state: 'runtime_missing',
effectiveAuthMode: null,
launchAllowed: false,
issueMessage: 'Codex CLI not found',
});
});
});

View file

@ -0,0 +1,64 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest';
import { CodexAccountEnvBuilder } from '@features/codex-account/main/infrastructure/CodexAccountEnvBuilder';
describe('CodexAccountEnvBuilder', () => {
it('strips provider-routing flags and API keys from the control-plane env', () => {
const builder = new CodexAccountEnvBuilder();
const env = builder.buildControlPlaneEnv({
env: {
HOME: '/Users/tester',
USERPROFILE: '/Users/tester',
OPENAI_API_KEY: 'openai-key',
CODEX_API_KEY: 'codex-key',
CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: '1',
CLAUDE_CODE_CODEX_BACKEND: 'codex-native',
},
shellEnv: {
PATH: '/usr/local/bin',
},
});
expect(env.HOME).toBe('/Users/tester');
expect(env.USERPROFILE).toBe('/Users/tester');
expect(env.PATH).toBe('/usr/local/bin');
expect(env.OPENAI_API_KEY).toBeUndefined();
expect(env.CODEX_API_KEY).toBeUndefined();
expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBeUndefined();
expect(env.CLAUDE_CODE_CODEX_BACKEND).toBeUndefined();
});
it('removes API keys from execution env when ChatGPT mode is selected', () => {
const builder = new CodexAccountEnvBuilder();
const env = builder.applyExecutionAuthPolicy(
{
OPENAI_API_KEY: 'openai-key',
CODEX_API_KEY: 'codex-key',
},
{
effectiveAuthMode: 'chatgpt',
}
);
expect(env.OPENAI_API_KEY).toBeUndefined();
expect(env.CODEX_API_KEY).toBeUndefined();
});
it('injects both OPENAI_API_KEY and CODEX_API_KEY in API-key mode', () => {
const builder = new CodexAccountEnvBuilder();
const env = builder.applyExecutionAuthPolicy(
{},
{
effectiveAuthMode: 'api_key',
apiKeyValue: 'stored-key',
}
);
expect(env.OPENAI_API_KEY).toBe('stored-key');
expect(env.CODEX_API_KEY).toBe('stored-key');
});
});

View file

@ -0,0 +1,237 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const openExternalMock = vi.fn<(url: string) => Promise<void>>();
vi.mock('electron', () => ({
shell: {
openExternal: (url: string) => openExternalMock(url),
},
}));
import { CodexLoginSessionManager } from '@features/codex-account/main/infrastructure/CodexLoginSessionManager';
import type { CodexAppServerSession } from '@main/services/infrastructure/codexAppServer';
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((nextResolve, nextReject) => {
resolve = nextResolve;
reject = nextReject;
});
return { promise, resolve, reject };
}
function createSession(overrides?: {
request?: ReturnType<typeof vi.fn>;
close?: ReturnType<typeof vi.fn>;
}) {
const listeners = new Set<(method: string, params: unknown) => void>();
const request =
overrides?.request ??
vi.fn().mockResolvedValue({
type: 'chatgpt',
loginId: 'login-1',
authUrl: 'https://chatgpt.com/auth',
});
const close = overrides?.close ?? vi.fn().mockResolvedValue(undefined);
const session = {
initializeResponse: {
userAgent: 'codex-test',
codexHome: '/Users/tester/.codex',
platformFamily: 'darwin',
platformOs: 'macos',
},
request,
notify: vi.fn().mockResolvedValue(undefined),
onNotification: vi.fn((listener: (method: string, params: unknown) => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
}),
close,
} satisfies CodexAppServerSession;
return {
session,
request,
close,
emitNotification(method: string, params: unknown) {
for (const listener of listeners) {
listener(method, params);
}
},
};
}
describe('CodexLoginSessionManager', () => {
beforeEach(() => {
vi.clearAllMocks();
openExternalMock.mockResolvedValue(undefined);
});
afterEach(() => {
vi.useRealTimers();
});
it('ignores duplicate start requests while the first login session is still starting', async () => {
const deferredSession = createDeferred<CodexAppServerSession>();
const sessionFactory = {
openSession: vi.fn(() => deferredSession.promise),
};
const manager = new CodexLoginSessionManager(sessionFactory as never, {
warn: vi.fn(),
});
const firstStart = manager.start({
binaryPath: '/usr/local/bin/codex',
env: {},
});
const secondStart = manager.start({
binaryPath: '/usr/local/bin/codex',
env: {},
});
expect(sessionFactory.openSession).toHaveBeenCalledTimes(1);
const fakeSession = createSession();
deferredSession.resolve(fakeSession.session);
await Promise.all([firstStart, secondStart]);
expect(fakeSession.request).toHaveBeenCalledTimes(1);
expect(openExternalMock).toHaveBeenCalledTimes(1);
expect(manager.getState().status).toBe('pending');
});
it('cancels a login cleanly while the app-server session is still starting', async () => {
const deferredSession = createDeferred<CodexAppServerSession>();
const sessionFactory = {
openSession: vi.fn(() => deferredSession.promise),
};
const settledListener = vi.fn();
const manager = new CodexLoginSessionManager(sessionFactory as never, {
warn: vi.fn(),
});
manager.onSettled(settledListener);
const startPromise = manager.start({
binaryPath: '/usr/local/bin/codex',
env: {},
});
await manager.cancel();
const fakeSession = createSession();
deferredSession.resolve(fakeSession.session);
await startPromise;
expect(fakeSession.request).not.toHaveBeenCalled();
expect(fakeSession.close).toHaveBeenCalledTimes(1);
expect(openExternalMock).not.toHaveBeenCalled();
expect(settledListener).toHaveBeenCalledTimes(1);
expect(manager.getState()).toEqual({
status: 'cancelled',
error: null,
startedAt: null,
});
});
it('returns to idle after a successful login completion notification', async () => {
const fakeSession = createSession();
const sessionFactory = {
openSession: vi.fn().mockResolvedValue(fakeSession.session),
};
const settledListener = vi.fn();
const manager = new CodexLoginSessionManager(sessionFactory as never, {
warn: vi.fn(),
});
manager.onSettled(settledListener);
await manager.start({
binaryPath: '/usr/local/bin/codex',
env: {},
});
expect(manager.getState().status).toBe('pending');
fakeSession.emitNotification('account/login/completed', {
loginId: 'login-1',
success: true,
error: null,
});
await Promise.resolve();
await Promise.resolve();
expect(fakeSession.close).toHaveBeenCalledTimes(1);
expect(settledListener).toHaveBeenCalledTimes(1);
expect(manager.getState()).toEqual({
status: 'idle',
error: null,
startedAt: null,
});
});
it('marks the login as failed when the pending login times out', async () => {
vi.useFakeTimers();
const fakeSession = createSession();
const sessionFactory = {
openSession: vi.fn().mockResolvedValue(fakeSession.session),
};
const settledListener = vi.fn();
const manager = new CodexLoginSessionManager(sessionFactory as never, {
warn: vi.fn(),
});
manager.onSettled(settledListener);
await manager.start({
binaryPath: '/usr/local/bin/codex',
env: {},
});
await vi.advanceTimersByTimeAsync(10 * 60 * 1_000);
await Promise.resolve();
await Promise.resolve();
expect(fakeSession.close).toHaveBeenCalledTimes(1);
expect(settledListener).toHaveBeenCalledTimes(1);
expect(manager.getState()).toMatchObject({
status: 'failed',
error: 'Timed out while waiting for ChatGPT account login to finish.',
});
});
it('surfaces failed login completion notifications as a failed state', async () => {
const fakeSession = createSession();
const sessionFactory = {
openSession: vi.fn().mockResolvedValue(fakeSession.session),
};
const settledListener = vi.fn();
const manager = new CodexLoginSessionManager(sessionFactory as never, {
warn: vi.fn(),
});
manager.onSettled(settledListener);
await manager.start({
binaryPath: '/usr/local/bin/codex',
env: {},
});
fakeSession.emitNotification('account/login/completed', {
loginId: 'login-1',
success: false,
error: 'ChatGPT login was denied.',
});
await Promise.resolve();
await Promise.resolve();
expect(fakeSession.close).toHaveBeenCalledTimes(1);
expect(settledListener).toHaveBeenCalledTimes(1);
expect(manager.getState()).toMatchObject({
status: 'failed',
error: 'ChatGPT login was denied.',
});
});
});

View file

@ -0,0 +1,52 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest';
import { createCodexAccountFeature } from '../../../../src/features/codex-account/main/composition/createCodexAccountFeature';
import { detectCodexLocalAccountState } from '../../../../src/features/codex-account/main/infrastructure/detectCodexLocalAccountArtifacts';
const describeLive = process.env.LIVE_CODEX_ACCOUNT_SMOKE === '1' ? describe : describe.skip;
describeLive('createCodexAccountFeature live smoke', () => {
it('classifies the current local Codex account state consistently with local account artifacts', async () => {
const localState = await detectCodexLocalAccountState();
const feature = createCodexAccountFeature({
logger: {
info: () => undefined,
warn: () => undefined,
error: () => undefined,
},
configManager: {
getConfig: () => ({
providerConnections: {
codex: {
preferredAuthMode: 'chatgpt' as const,
},
},
}),
},
});
try {
const snapshot = await feature.refreshSnapshot({
includeRateLimits: true,
forceRefreshToken: true,
});
expect(snapshot.localAccountArtifactsPresent).toBe(localState.hasArtifacts);
expect(snapshot.localActiveChatgptAccountPresent).toBe(
localState.hasActiveChatgptAccount
);
if (localState.hasActiveChatgptAccount && snapshot.managedAccount?.type !== 'chatgpt') {
expect(snapshot.launchAllowed).toBe(false);
expect(snapshot.launchIssueMessage).toContain('Reconnect ChatGPT');
}
if (snapshot.managedAccount?.type === 'chatgpt') {
expect(snapshot.effectiveAuthMode).toBe('chatgpt');
}
} finally {
await feature.dispose();
}
});
});

View file

@ -0,0 +1,604 @@
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { createCodexAccountFeature } from '../../../../src/features/codex-account/main/composition/createCodexAccountFeature';
import type {
CodexAccountLoginStatus,
CodexAccountSnapshotDto,
CodexLoginStateDto,
} from '@features/codex-account/contracts';
const {
apiKeyLookupMock,
binaryResolveMock,
detectLocalAccountStateMock,
getCachedShellEnvMock,
loginCancelMock,
loginDisposeMock,
loginSettledListeners,
loginStartMock,
loginStateContainer,
loginStateListeners,
logoutMock,
readAccountMock,
readRateLimitsMock,
} = vi.hoisted(() => ({
binaryResolveMock: vi.fn(),
apiKeyLookupMock: vi.fn(),
detectLocalAccountStateMock: vi.fn(),
getCachedShellEnvMock: vi.fn(),
readAccountMock: vi.fn(),
readRateLimitsMock: vi.fn(),
logoutMock: vi.fn(),
loginStartMock: vi.fn(),
loginCancelMock: vi.fn(),
loginDisposeMock: vi.fn(),
loginStateContainer: {
current: {
status: 'idle' as CodexAccountLoginStatus,
error: null as string | null,
startedAt: null as string | null,
},
},
loginStateListeners: new Set<() => void>(),
loginSettledListeners: new Set<() => void>(),
}));
const originalOpenAiApiKey = process.env.OPENAI_API_KEY;
const originalCodexApiKey = process.env.CODEX_API_KEY;
function emitLoginState(nextState: CodexLoginStateDto): void {
loginStateContainer.current = structuredClone(nextState);
for (const listener of loginStateListeners) {
listener();
}
}
vi.mock('../../../../src/main/services/extensions', () => ({
ApiKeyService: class MockApiKeyService {
lookupPreferred = apiKeyLookupMock;
},
}));
vi.mock('../../../../src/main/utils/shellEnv', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../../src/main/utils/shellEnv')>();
return {
...actual,
getCachedShellEnv: getCachedShellEnvMock,
};
});
vi.mock('../../../../src/main/services/infrastructure/codexAppServer', () => ({
CodexBinaryResolver: {
resolve: binaryResolveMock,
},
CodexAppServerSessionFactory: class MockCodexAppServerSessionFactory {},
JsonRpcStdioClient: class MockJsonRpcStdioClient {},
}));
vi.mock(
'../../../../src/features/codex-account/main/infrastructure/detectCodexLocalAccountArtifacts',
() => ({
detectCodexLocalAccountState: detectLocalAccountStateMock,
detectCodexLocalAccountArtifacts: async () =>
(await detectLocalAccountStateMock()).hasArtifacts,
})
);
vi.mock(
'../../../../src/features/codex-account/main/infrastructure/CodexAccountAppServerClient',
() => ({
CodexAccountAppServerClient: class MockCodexAccountAppServerClient {
readAccount = readAccountMock;
readRateLimits = readRateLimitsMock;
logout = logoutMock;
},
})
);
vi.mock(
'../../../../src/features/codex-account/main/infrastructure/CodexLoginSessionManager',
() => ({
CodexLoginSessionManager: class MockCodexLoginSessionManager {
subscribe(listener: () => void): () => void {
loginStateListeners.add(listener);
return (): void => {
loginStateListeners.delete(listener);
};
}
onSettled(listener: () => void): () => void {
loginSettledListeners.add(listener);
return (): void => {
loginSettledListeners.delete(listener);
};
}
getState(): CodexLoginStateDto {
return structuredClone(loginStateContainer.current);
}
async start(): Promise<void> {
await loginStartMock();
}
async cancel(): Promise<void> {
await loginCancelMock();
}
async dispose(): Promise<void> {
await loginDisposeMock();
}
},
})
);
function createLoggerPort() {
return {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
}
function createConfigManager(preferredAuthMode: 'auto' | 'chatgpt' | 'api_key' = 'auto') {
return {
getConfig: () => ({
providerConnections: {
codex: {
preferredAuthMode,
},
},
}),
};
}
function createAccountResponse(overrides?: Partial<{
requiresOpenaiAuth: boolean;
account: { type: 'chatgpt'; email: string; planType: 'pro' | 'plus' } | null;
}>) {
return {
account:
overrides && 'account' in overrides
? overrides.account ?? null
: {
type: 'chatgpt' as const,
email: 'user@example.com',
planType: 'pro' as const,
},
requiresOpenaiAuth: overrides?.requiresOpenaiAuth ?? true,
};
}
function createRateLimitsResponse() {
return {
rateLimits: {
limitId: 'codex',
limitName: null,
primary: {
usedPercent: 77,
windowDurationMins: 300,
resetsAt: 1_776_678_034,
},
secondary: null,
credits: {
hasCredits: false,
unlimited: false,
balance: '0',
},
planType: 'pro' as const,
},
rateLimitsByLimitId: null,
};
}
describe('createCodexAccountFeature', () => {
beforeEach(() => {
vi.clearAllMocks();
delete process.env.OPENAI_API_KEY;
delete process.env.CODEX_API_KEY;
binaryResolveMock.mockResolvedValue('/usr/local/bin/codex');
apiKeyLookupMock.mockResolvedValue(null);
detectLocalAccountStateMock.mockResolvedValue({
hasArtifacts: false,
hasActiveChatgptAccount: false,
});
getCachedShellEnvMock.mockReturnValue({});
readAccountMock.mockReset();
readRateLimitsMock.mockReset();
logoutMock.mockReset();
loginStartMock.mockReset();
loginCancelMock.mockReset();
loginDisposeMock.mockReset();
loginStateContainer.current = {
status: 'idle',
error: null,
startedAt: null,
};
loginStateListeners.clear();
loginSettledListeners.clear();
});
afterAll(() => {
if (typeof originalOpenAiApiKey === 'string') {
process.env.OPENAI_API_KEY = originalOpenAiApiKey;
} else {
delete process.env.OPENAI_API_KEY;
}
if (typeof originalCodexApiKey === 'string') {
process.env.CODEX_API_KEY = originalCodexApiKey;
} else {
delete process.env.CODEX_API_KEY;
}
});
it('builds a healthy snapshot from app-server account truth, API-key availability, and rate limits', async () => {
getCachedShellEnvMock.mockReturnValue({
OPENAI_API_KEY: 'env-openai-key',
});
readAccountMock.mockResolvedValue({
account: createAccountResponse(),
initialize: {
codexHome: '/Users/test/.codex',
platformFamily: 'unix',
platformOs: 'macos',
},
});
readRateLimitsMock.mockResolvedValue(createRateLimitsResponse());
const feature = createCodexAccountFeature({
logger: createLoggerPort(),
configManager: createConfigManager('auto'),
});
try {
const snapshot = await feature.refreshSnapshot({ includeRateLimits: true });
expect(snapshot).toMatchObject<Partial<CodexAccountSnapshotDto>>({
preferredAuthMode: 'auto',
effectiveAuthMode: 'chatgpt',
appServerState: 'healthy',
managedAccount: {
type: 'chatgpt',
email: 'user@example.com',
planType: 'pro',
},
apiKey: {
available: true,
source: 'environment',
sourceLabel: 'Detected from OPENAI_API_KEY',
},
launchAllowed: true,
launchReadinessState: 'ready_both',
});
expect(snapshot.rateLimits?.planType).toBe('pro');
expect(snapshot.rateLimits?.primary?.usedPercent).toBe(77);
expect(readAccountMock).toHaveBeenCalledWith(
expect.objectContaining({
binaryPath: '/usr/local/bin/codex',
refreshToken: false,
})
);
expect(readRateLimitsMock).toHaveBeenCalledTimes(1);
} finally {
await feature.dispose();
}
});
it('keeps the last known managed account during a transient degraded read', async () => {
readAccountMock
.mockResolvedValueOnce({
account: createAccountResponse(),
initialize: {
codexHome: '/Users/test/.codex',
platformFamily: 'unix',
platformOs: 'macos',
},
})
.mockRejectedValueOnce(new Error('temporary app-server timeout'));
const logger = createLoggerPort();
const feature = createCodexAccountFeature({
logger,
configManager: createConfigManager('chatgpt'),
});
try {
const firstSnapshot = await feature.refreshSnapshot();
const degradedSnapshot = await feature.refreshSnapshot({ forceRefreshToken: true });
expect(firstSnapshot.managedAccount?.email).toBe('user@example.com');
expect(degradedSnapshot.appServerState).toBe('degraded');
expect(degradedSnapshot.appServerStatusMessage).toContain('temporary app-server timeout');
expect(degradedSnapshot.managedAccount).toMatchObject({
type: 'chatgpt',
email: 'user@example.com',
});
expect(degradedSnapshot.launchAllowed).toBe(true);
expect(logger.warn).not.toHaveBeenCalledWith(
expect.stringContaining('false logout'),
expect.anything()
);
} finally {
await feature.dispose();
}
});
it('keeps the last known ChatGPT managed account during a transient empty account read after HMR-style reconnect flicker', async () => {
detectLocalAccountStateMock.mockResolvedValue({
hasArtifacts: true,
hasActiveChatgptAccount: true,
});
readAccountMock
.mockResolvedValueOnce({
account: createAccountResponse(),
initialize: {
codexHome: '/Users/test/.codex',
platformFamily: 'unix',
platformOs: 'macos',
},
})
.mockResolvedValueOnce({
account: createAccountResponse({ account: null, requiresOpenaiAuth: true }),
initialize: {
codexHome: '/Users/test/.codex',
platformFamily: 'unix',
platformOs: 'macos',
},
});
const feature = createCodexAccountFeature({
logger: createLoggerPort(),
configManager: createConfigManager('chatgpt'),
});
try {
const firstSnapshot = await feature.refreshSnapshot();
const secondSnapshot = await feature.refreshSnapshot();
expect(firstSnapshot.managedAccount?.email).toBe('user@example.com');
expect(secondSnapshot.managedAccount).toMatchObject({
type: 'chatgpt',
email: 'user@example.com',
});
expect(secondSnapshot.launchAllowed).toBe(true);
expect(secondSnapshot.launchReadinessState).toBe('ready_chatgpt');
expect(secondSnapshot.launchIssueMessage).toBeNull();
} finally {
await feature.dispose();
}
});
it('classifies a locally selected ChatGPT account without a usable managed session as reconnect-needed', async () => {
detectLocalAccountStateMock.mockResolvedValue({
hasArtifacts: true,
hasActiveChatgptAccount: true,
});
readAccountMock.mockResolvedValue({
account: createAccountResponse({ account: null, requiresOpenaiAuth: true }),
initialize: {
codexHome: '/Users/test/.codex',
platformFamily: 'unix',
platformOs: 'macos',
},
});
const feature = createCodexAccountFeature({
logger: createLoggerPort(),
configManager: createConfigManager('chatgpt'),
});
try {
const snapshot = await feature.refreshSnapshot();
expect(snapshot.localAccountArtifactsPresent).toBe(true);
expect(snapshot.localActiveChatgptAccountPresent).toBe(true);
expect(snapshot.launchAllowed).toBe(false);
expect(snapshot.launchReadinessState).toBe('missing_auth');
expect(snapshot.launchIssueMessage).toContain('Reconnect ChatGPT');
} finally {
await feature.dispose();
}
});
it('runs a stronger queued refresh after a passive read is already in flight', async () => {
let resolveFirstRead: ((value: unknown) => void) | null = null;
readAccountMock
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveFirstRead = resolve;
})
)
.mockResolvedValueOnce({
account: createAccountResponse(),
initialize: {
codexHome: '/Users/test/.codex',
platformFamily: 'unix',
platformOs: 'macos',
},
});
readRateLimitsMock.mockResolvedValue(createRateLimitsResponse());
const feature = createCodexAccountFeature({
logger: createLoggerPort(),
configManager: createConfigManager('auto'),
});
try {
const firstRefresh = feature.refreshSnapshot();
const strongerRefresh = feature.refreshSnapshot({
includeRateLimits: true,
forceRefreshToken: true,
});
await vi.waitFor(() => {
expect(resolveFirstRead).not.toBeNull();
});
const completeFirstRead = resolveFirstRead as ((value: unknown) => void) | null;
if (!completeFirstRead) {
throw new Error('Expected the first account read to remain pending.');
}
completeFirstRead({
account: createAccountResponse(),
initialize: {
codexHome: '/Users/test/.codex',
platformFamily: 'unix',
platformOs: 'macos',
},
});
const [firstSnapshot, strongerSnapshot] = await Promise.all([firstRefresh, strongerRefresh]);
expect(firstSnapshot.managedAccount?.email).toBe('user@example.com');
expect(strongerSnapshot.rateLimits?.primary?.usedPercent).toBe(77);
expect(readAccountMock).toHaveBeenCalledTimes(2);
expect(readAccountMock.mock.calls[0]?.[0]).toMatchObject({
refreshToken: false,
});
expect(readAccountMock.mock.calls[1]?.[0]).toMatchObject({
refreshToken: true,
});
expect(readRateLimitsMock).toHaveBeenCalledTimes(1);
} finally {
await feature.dispose();
}
});
it('logs out and refreshes to the new logged-out truth instead of keeping stale account state', async () => {
readAccountMock
.mockResolvedValueOnce({
account: createAccountResponse(),
initialize: {
codexHome: '/Users/test/.codex',
platformFamily: 'unix',
platformOs: 'macos',
},
})
.mockResolvedValueOnce({
account: createAccountResponse({ account: null, requiresOpenaiAuth: false }),
initialize: {
codexHome: '/Users/test/.codex',
platformFamily: 'unix',
platformOs: 'macos',
},
});
readRateLimitsMock.mockResolvedValue(createRateLimitsResponse());
logoutMock.mockResolvedValue({});
const feature = createCodexAccountFeature({
logger: createLoggerPort(),
configManager: createConfigManager('chatgpt'),
});
try {
const initialSnapshot = await feature.refreshSnapshot();
const afterLogout = await feature.logout();
expect(initialSnapshot.managedAccount?.type).toBe('chatgpt');
expect(logoutMock).toHaveBeenCalledTimes(1);
expect(afterLogout.managedAccount).toBeNull();
expect(afterLogout.requiresOpenaiAuth).toBe(false);
expect(afterLogout.launchAllowed).toBe(false);
expect(afterLogout.launchReadinessState).toBe('missing_auth');
expect(readAccountMock.mock.calls.at(-1)?.[0]).toMatchObject({
refreshToken: true,
});
} finally {
await feature.dispose();
}
});
it('publishes the pending login state immediately after login start without waiting for a full refresh', async () => {
readAccountMock.mockResolvedValue({
account: createAccountResponse(),
initialize: {
codexHome: '/Users/test/.codex',
platformFamily: 'unix',
platformOs: 'macos',
},
});
loginStartMock.mockImplementation(() => {
emitLoginState({
status: 'pending',
error: null,
startedAt: '2026-04-20T12:00:00.000Z',
});
});
const feature = createCodexAccountFeature({
logger: createLoggerPort(),
configManager: createConfigManager('chatgpt'),
});
try {
await feature.refreshSnapshot();
const pendingSnapshot = await feature.startChatgptLogin();
expect(pendingSnapshot.login).toMatchObject({
status: 'pending',
startedAt: '2026-04-20T12:00:00.000Z',
});
expect(loginStartMock).toHaveBeenCalledTimes(1);
} finally {
await feature.dispose();
}
});
it('publishes a cancelled login snapshot immediately and then forces a settled refresh', async () => {
readAccountMock.mockResolvedValue({
account: createAccountResponse(),
initialize: {
codexHome: '/Users/test/.codex',
platformFamily: 'unix',
platformOs: 'macos',
},
});
readRateLimitsMock.mockResolvedValue(createRateLimitsResponse());
emitLoginState({
status: 'pending',
error: null,
startedAt: '2026-04-20T12:00:00.000Z',
});
loginCancelMock.mockImplementation(() => {
emitLoginState({
status: 'cancelled',
error: null,
startedAt: null,
});
for (const listener of loginSettledListeners) {
listener();
}
});
const feature = createCodexAccountFeature({
logger: createLoggerPort(),
configManager: createConfigManager('chatgpt'),
});
try {
await feature.refreshSnapshot();
const cancelledSnapshot = await feature.cancelLogin();
expect(loginCancelMock).toHaveBeenCalledTimes(1);
expect(cancelledSnapshot.login).toMatchObject({
status: 'cancelled',
error: null,
startedAt: null,
});
await vi.waitFor(() => {
expect(
readAccountMock.mock.calls.some(
(call) => (call[0] as { refreshToken?: boolean } | undefined)?.refreshToken === true
)
).toBe(true);
});
} finally {
await feature.dispose();
}
});
});

View file

@ -0,0 +1,90 @@
// @vitest-environment node
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises';
import os from 'os';
import path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import {
detectCodexLocalAccountArtifacts,
detectCodexLocalAccountState,
} from '../../../../../src/features/codex-account/main/infrastructure/detectCodexLocalAccountArtifacts';
const tempDirs: string[] = [];
async function makeTempDir(): Promise<string> {
const dir = await mkdtemp(path.join(os.tmpdir(), 'codex-artifacts-'));
tempDirs.push(dir);
return dir;
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
function encodeAccountKeyForAuthFilename(accountKey: string): string {
return Buffer.from(accountKey, 'utf8')
.toString('base64')
.replaceAll('+', '-')
.replaceAll('/', '_')
.replace(/=+$/u, '');
}
describe('detectCodexLocalAccountArtifacts', () => {
it('returns true when the Codex accounts registry exists', async () => {
const accountsDir = await makeTempDir();
await writeFile(path.join(accountsDir, 'registry.json'), '{}', 'utf8');
await expect(detectCodexLocalAccountArtifacts(accountsDir)).resolves.toBe(true);
});
it('returns true when auth artifacts exist without a registry file', async () => {
const accountsDir = await makeTempDir();
await writeFile(path.join(accountsDir, 'chatgpt.auth.json'), '{}', 'utf8');
await expect(detectCodexLocalAccountArtifacts(accountsDir)).resolves.toBe(true);
});
it('returns false when the accounts directory is missing or empty', async () => {
const missingDir = path.join(await makeTempDir(), 'missing');
const emptyDir = await makeTempDir();
await mkdir(emptyDir, { recursive: true });
await expect(detectCodexLocalAccountArtifacts(missingDir)).resolves.toBe(false);
await expect(detectCodexLocalAccountArtifacts(emptyDir)).resolves.toBe(false);
});
it('detects a locally selected ChatGPT account from the registry and active auth file', async () => {
const accountsDir = await makeTempDir();
const activeAccountKey = 'user-test::chatgpt-account';
await writeFile(
path.join(accountsDir, 'registry.json'),
JSON.stringify({ active_account_key: activeAccountKey }),
'utf8'
);
await writeFile(
path.join(accountsDir, `${encodeAccountKeyForAuthFilename(activeAccountKey)}.auth.json`),
JSON.stringify({ auth_mode: 'chatgpt' }),
'utf8'
);
await expect(detectCodexLocalAccountState(accountsDir)).resolves.toEqual({
hasArtifacts: true,
hasActiveChatgptAccount: true,
});
});
it('keeps artifact detection true but selected-account detection false when the active auth file is missing', async () => {
const accountsDir = await makeTempDir();
await writeFile(
path.join(accountsDir, 'registry.json'),
JSON.stringify({ active_account_key: 'user-test::missing-auth' }),
'utf8'
);
await expect(detectCodexLocalAccountState(accountsDir)).resolves.toEqual({
hasArtifacts: true,
hasActiveChatgptAccount: false,
});
});
});

View file

@ -0,0 +1,67 @@
import { describe, expect, it, vi } from 'vitest';
import {
CODEX_ACCOUNT_CANCEL_CHATGPT_LOGIN,
CODEX_ACCOUNT_GET_SNAPSHOT,
CODEX_ACCOUNT_LOGOUT,
CODEX_ACCOUNT_REFRESH_SNAPSHOT,
CODEX_ACCOUNT_SNAPSHOT_CHANGED,
CODEX_ACCOUNT_START_CHATGPT_LOGIN,
} from '../../../../src/features/codex-account/contracts';
import { createCodexAccountBridge } from '../../../../src/features/codex-account/preload/createCodexAccountBridge';
describe('createCodexAccountBridge', () => {
it('forwards Codex account IPC requests through raw ipcRenderer.invoke and returns raw payloads', async () => {
const snapshot = { ok: true };
const ipcRenderer = {
invoke: vi.fn().mockResolvedValue(snapshot),
on: vi.fn(),
removeListener: vi.fn(),
};
const bridge = createCodexAccountBridge({
ipcRenderer: ipcRenderer as never,
});
const refreshOptions = {
includeRateLimits: true,
forceRefreshToken: true,
};
await expect(bridge.getCodexAccountSnapshot()).resolves.toBe(snapshot);
await expect(bridge.refreshCodexAccountSnapshot(refreshOptions)).resolves.toBe(snapshot);
await expect(bridge.startCodexChatgptLogin()).resolves.toBe(snapshot);
await expect(bridge.cancelCodexChatgptLogin()).resolves.toBe(snapshot);
await expect(bridge.logoutCodexAccount()).resolves.toBe(snapshot);
expect(ipcRenderer.invoke.mock.calls).toEqual([
[CODEX_ACCOUNT_GET_SNAPSHOT],
[CODEX_ACCOUNT_REFRESH_SNAPSHOT, refreshOptions],
[CODEX_ACCOUNT_START_CHATGPT_LOGIN],
[CODEX_ACCOUNT_CANCEL_CHATGPT_LOGIN],
[CODEX_ACCOUNT_LOGOUT],
]);
});
it('subscribes and unsubscribes from Codex snapshot change notifications', () => {
const ipcRenderer = {
invoke: vi.fn(),
on: vi.fn(),
removeListener: vi.fn(),
};
const bridge = createCodexAccountBridge({
ipcRenderer: ipcRenderer as never,
});
const callback = vi.fn();
const unsubscribe = bridge.onCodexAccountSnapshotChanged(callback);
expect(ipcRenderer.on).toHaveBeenCalledWith(CODEX_ACCOUNT_SNAPSHOT_CHANGED, callback);
unsubscribe();
expect(ipcRenderer.removeListener).toHaveBeenCalledWith(
CODEX_ACCOUNT_SNAPSHOT_CHANGED,
callback
);
});
});

View file

@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest';
import { mergeCodexCliStatusWithSnapshot } from '../../../../src/features/codex-account/renderer/mergeCodexCliStatusWithSnapshot';
import { createDefaultCliExtensionCapabilities } from '../../../../src/shared/utils/providerExtensionCapabilities';
import type { CodexAccountSnapshotDto } from '../../../../src/features/codex-account/contracts';
import type { CliInstallationStatus } from '../../../../src/shared/types';
function createCliStatus(): CliInstallationStatus {
return {
flavor: 'agent_teams_orchestrator',
displayName: 'Multimodel runtime',
supportsSelfUpdate: false,
showVersionDetails: false,
showBinaryPath: true,
installed: true,
installedVersion: '0.0.3',
binaryPath: '/usr/local/bin/agent-teams',
launchError: null,
latestVersion: null,
updateAvailable: false,
authLoggedIn: true,
authStatusChecking: false,
authMethod: null,
providers: [
{
providerId: 'anthropic',
displayName: 'Anthropic',
supported: true,
authenticated: true,
authMethod: 'oauth',
verificationState: 'verified',
modelVerificationState: 'verified',
statusMessage: 'Connected',
models: ['claude-opus-4-7'],
modelAvailability: [],
canLoginFromUi: true,
capabilities: {
teamLaunch: true,
oneShot: true,
extensions: createDefaultCliExtensionCapabilities(),
},
selectedBackendId: null,
resolvedBackendId: null,
availableBackends: [],
externalRuntimeDiagnostics: [],
backend: null,
connection: null,
},
{
providerId: 'codex',
displayName: 'Codex',
supported: true,
authenticated: false,
authMethod: null,
verificationState: 'unknown',
modelVerificationState: 'idle',
statusMessage: 'Checking...',
models: ['gpt-5.4', 'gpt-5.1-codex-max'],
modelAvailability: [],
canLoginFromUi: true,
capabilities: {
teamLaunch: true,
oneShot: true,
extensions: createDefaultCliExtensionCapabilities(),
},
selectedBackendId: 'codex-native',
resolvedBackendId: null,
availableBackends: [],
externalRuntimeDiagnostics: [],
backend: null,
connection: null,
},
],
};
}
function createChatgptSnapshot(): CodexAccountSnapshotDto {
return {
preferredAuthMode: 'chatgpt',
effectiveAuthMode: 'chatgpt',
launchAllowed: true,
launchIssueMessage: null,
launchReadinessState: 'ready_chatgpt',
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount: {
type: 'chatgpt',
email: 'user@example.com',
planType: 'pro',
},
apiKey: {
available: false,
source: null,
sourceLabel: null,
},
requiresOpenaiAuth: false,
localAccountArtifactsPresent: false,
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: null,
updatedAt: '2026-04-20T12:00:00.000Z',
};
}
describe('mergeCodexCliStatusWithSnapshot', () => {
it('updates only the codex provider while preserving the rest of the runtime status', () => {
const merged = mergeCodexCliStatusWithSnapshot(createCliStatus(), createChatgptSnapshot());
expect(merged?.providers[0]?.providerId).toBe('anthropic');
expect(merged?.providers[0]?.statusMessage).toBe('Connected');
expect(merged?.providers[1]?.providerId).toBe('codex');
expect(merged?.providers[1]?.authMethod).toBe('chatgpt');
expect(merged?.providers[1]?.statusMessage).toBe('ChatGPT account ready');
expect(merged?.providers[1]?.backend?.authMethodDetail).toBe('chatgpt');
expect(merged?.providers[1]?.models).toEqual(['gpt-5.4', 'gpt-5.1-codex-max']);
});
});

View file

@ -0,0 +1,231 @@
import { describe, expect, it } from 'vitest';
import { mergeCodexProviderStatusWithSnapshot } from '../../../../src/features/codex-account/renderer/mergeCodexProviderStatusWithSnapshot';
import { createDefaultCliExtensionCapabilities } from '../../../../src/shared/utils/providerExtensionCapabilities';
import type { CodexAccountSnapshotDto } from '../../../../src/features/codex-account/contracts';
import type { CliProviderStatus } from '../../../../src/shared/types';
function createBaseCodexProvider(): CliProviderStatus {
return {
providerId: 'codex',
displayName: 'Codex',
supported: true,
authenticated: false,
authMethod: null,
verificationState: 'unknown',
statusMessage: 'Connect a ChatGPT account to use your Codex subscription.',
models: ['gpt-5.4'],
modelAvailability: [],
canLoginFromUi: false,
capabilities: {
teamLaunch: true,
oneShot: true,
extensions: createDefaultCliExtensionCapabilities(),
},
selectedBackendId: 'codex-native',
resolvedBackendId: null,
availableBackends: [
{
id: 'codex-native',
label: 'Codex native',
description: 'Use codex exec JSON mode.',
selectable: true,
recommended: true,
available: false,
state: 'authentication-required',
audience: 'general',
statusMessage: 'Connect a ChatGPT account to use your Codex subscription.',
detailMessage: null,
},
],
externalRuntimeDiagnostics: [],
backend: {
kind: 'codex-native',
label: 'Codex native',
endpointLabel: 'codex exec --json',
projectId: null,
authMethodDetail: null,
},
connection: {
supportsOAuth: false,
supportsApiKey: true,
configurableAuthModes: ['auto', 'chatgpt', 'api_key'],
configuredAuthMode: 'auto',
apiKeyConfigured: true,
apiKeySource: 'environment',
apiKeySourceLabel: 'Detected from OPENAI_API_KEY',
codex: {
preferredAuthMode: 'auto',
effectiveAuthMode: null,
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount: null,
requiresOpenaiAuth: false,
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: null,
launchAllowed: false,
launchIssueMessage: 'Connect a ChatGPT account to use your Codex subscription.',
launchReadinessState: 'missing_auth',
},
},
};
}
function createReadyChatgptSnapshot(): CodexAccountSnapshotDto {
return {
preferredAuthMode: 'auto',
effectiveAuthMode: 'chatgpt',
launchAllowed: true,
launchIssueMessage: null,
launchReadinessState: 'ready_chatgpt',
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount: {
type: 'chatgpt',
email: 'belief@example.com',
planType: 'pro',
},
apiKey: {
available: true,
source: 'environment',
sourceLabel: 'Detected from OPENAI_API_KEY',
},
requiresOpenaiAuth: false,
localAccountArtifactsPresent: true,
localActiveChatgptAccountPresent: true,
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: {
limitId: 'plan-pro',
limitName: 'Pro',
primary: {
usedPercent: 5,
windowDurationMins: 300,
resetsAt: 1_762_547_200,
},
secondary: {
usedPercent: 41,
windowDurationMins: 10_080,
resetsAt: 1_762_891_200,
},
credits: {
hasCredits: false,
unlimited: false,
balance: null,
},
planType: 'pro',
},
updatedAt: '2026-04-20T12:00:00.000Z',
};
}
describe('mergeCodexProviderStatusWithSnapshot', () => {
it('upgrades stale codex provider auth/runtime state from the live snapshot', () => {
const merged = mergeCodexProviderStatusWithSnapshot(
createBaseCodexProvider(),
createReadyChatgptSnapshot()
);
expect(merged.authenticated).toBe(true);
expect(merged.authMethod).toBe('chatgpt');
expect(merged.statusMessage).toBe('ChatGPT account ready');
expect(merged.resolvedBackendId).toBe('codex-native');
expect(merged.connection?.codex?.managedAccount?.email).toBe('belief@example.com');
expect(merged.connection?.codex?.rateLimits?.primary?.usedPercent).toBe(5);
expect(merged.connection?.codex?.localAccountArtifactsPresent).toBe(true);
expect(merged.connection?.codex?.localActiveChatgptAccountPresent).toBe(true);
expect(merged.availableBackends?.find((option) => option.id === 'codex-native')).toMatchObject({
available: true,
selectable: true,
state: 'ready',
statusMessage: 'Ready',
});
});
it('hydrates codex connection truth even when the stale provider payload had no connection block', () => {
const merged = mergeCodexProviderStatusWithSnapshot(
{
...createBaseCodexProvider(),
connection: null,
},
createReadyChatgptSnapshot()
);
expect(merged.authenticated).toBe(true);
expect(merged.statusMessage).toBe('ChatGPT account ready');
expect(merged.connection).toMatchObject({
supportsOAuth: false,
supportsApiKey: true,
configuredAuthMode: 'auto',
apiKeyConfigured: true,
apiKeySource: 'environment',
});
expect(merged.connection?.codex?.managedAccount?.planType).toBe('pro');
});
it('promotes stale bootstrap placeholders out of the unsupported state once live Codex snapshot truth arrives', () => {
const merged = mergeCodexProviderStatusWithSnapshot(
{
...createBaseCodexProvider(),
supported: false,
statusMessage: 'Checking...',
models: [],
backend: null,
connection: null,
},
{
...createReadyChatgptSnapshot(),
effectiveAuthMode: null,
launchAllowed: false,
launchIssueMessage: 'Connect a ChatGPT account to use your Codex subscription.',
launchReadinessState: 'missing_auth',
managedAccount: null,
}
);
expect(merged.supported).toBe(true);
expect(merged.statusMessage).toBe('Connect a ChatGPT account to use your Codex subscription.');
});
it('normalizes stale legacy backend truth back to codex-native even when the live snapshot is reconnect-needed', () => {
const merged = mergeCodexProviderStatusWithSnapshot(
{
...createBaseCodexProvider(),
selectedBackendId: 'auto',
resolvedBackendId: 'api',
backend: {
kind: 'adapter',
label: 'Default adapter',
endpointLabel: 'legacy adapter',
projectId: null,
authMethodDetail: null,
},
},
{
...createReadyChatgptSnapshot(),
effectiveAuthMode: null,
launchAllowed: false,
launchIssueMessage: 'Reconnect ChatGPT to refresh the current Codex subscription session.',
launchReadinessState: 'missing_auth',
managedAccount: null,
requiresOpenaiAuth: true,
}
);
expect(merged.selectedBackendId).toBe('codex-native');
expect(merged.resolvedBackendId).toBe('codex-native');
expect(merged.backend).toMatchObject({
kind: 'codex-native',
label: 'Codex native',
endpointLabel: 'codex exec --json',
});
});
});

View file

@ -0,0 +1,244 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useCodexAccountSnapshot } from '../../../../src/features/codex-account/renderer/hooks/useCodexAccountSnapshot';
import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
const apiMocks = vi.hoisted(() => ({
getCodexAccountSnapshot: vi.fn(),
refreshCodexAccountSnapshot: vi.fn(),
startCodexChatgptLogin: vi.fn(),
cancelCodexChatgptLogin: vi.fn(),
logoutCodexAccount: vi.fn(),
onCodexAccountSnapshotChanged: vi.fn(() => () => undefined),
}));
vi.mock('@renderer/api', () => ({
api: apiMocks,
isElectronMode: () => true,
}));
function createSnapshot(): CodexAccountSnapshotDto {
return {
preferredAuthMode: 'chatgpt',
effectiveAuthMode: 'chatgpt',
launchAllowed: true,
launchIssueMessage: null,
launchReadinessState: 'ready_chatgpt',
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount: {
type: 'chatgpt',
email: 'belief@example.com',
planType: 'pro',
},
apiKey: {
available: false,
source: null,
sourceLabel: null,
},
requiresOpenaiAuth: false,
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: {
limitId: 'codex',
limitName: null,
primary: {
usedPercent: 77,
windowDurationMins: 300,
resetsAt: 1_776_678_034,
},
secondary: null,
credits: {
hasCredits: false,
unlimited: false,
balance: '0',
},
planType: 'pro',
},
updatedAt: new Date().toISOString(),
};
}
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((nextResolve, nextReject) => {
resolve = nextResolve;
reject = nextReject;
});
return { promise, resolve, reject };
}
describe('useCodexAccountSnapshot', () => {
beforeEach(() => {
vi.clearAllMocks();
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
vi.useRealTimers();
});
afterEach(() => {
document.body.innerHTML = '';
});
it('loads the initial Codex snapshot through refresh when rate limits are requested', async () => {
const snapshot = createSnapshot();
const refreshDeferred = createDeferred<CodexAccountSnapshotDto>();
apiMocks.refreshCodexAccountSnapshot.mockReturnValue(refreshDeferred.promise);
apiMocks.getCodexAccountSnapshot.mockResolvedValue(snapshot);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
function Harness(): React.ReactElement {
const state = useCodexAccountSnapshot({
enabled: true,
includeRateLimits: true,
});
return React.createElement('div', null, state.snapshot?.managedAccount?.email ?? 'empty');
}
await act(async () => {
root.render(React.createElement(Harness));
refreshDeferred.resolve(snapshot);
await refreshDeferred.promise;
await Promise.resolve();
});
expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledWith({
includeRateLimits: true,
});
expect(apiMocks.getCodexAccountSnapshot).not.toHaveBeenCalled();
expect(host.textContent).toContain('belief@example.com');
act(() => {
root.unmount();
});
});
it('refreshes rate-limit snapshots more often while visible without flipping loading state during background polls', async () => {
vi.useFakeTimers();
let visibilityState: DocumentVisibilityState = 'visible';
Object.defineProperty(document, 'visibilityState', {
configurable: true,
get: () => visibilityState,
});
const snapshot = createSnapshot();
apiMocks.refreshCodexAccountSnapshot.mockResolvedValue(snapshot);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
function Harness(): React.ReactElement {
const state = useCodexAccountSnapshot({
enabled: true,
includeRateLimits: true,
});
return React.createElement(
'div',
{ 'data-loading': state.loading ? 'true' : 'false' },
state.snapshot?.managedAccount?.email ?? 'empty'
);
}
await act(async () => {
root.render(React.createElement(Harness));
await Promise.resolve();
});
expect(host.firstElementChild?.getAttribute('data-loading')).toBe('false');
expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledTimes(1);
apiMocks.refreshCodexAccountSnapshot.mockClear();
await act(async () => {
vi.advanceTimersByTime(10_000);
await Promise.resolve();
});
expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledTimes(1);
expect(host.firstElementChild?.getAttribute('data-loading')).toBe('false');
act(() => {
root.unmount();
});
});
it('slows background refreshes while hidden and refreshes immediately when the tab becomes visible again after staleness', async () => {
vi.useFakeTimers();
let visibilityState: DocumentVisibilityState = 'visible';
Object.defineProperty(document, 'visibilityState', {
configurable: true,
get: () => visibilityState,
});
const snapshot = createSnapshot();
apiMocks.refreshCodexAccountSnapshot.mockResolvedValue(snapshot);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
function Harness(): React.ReactElement {
useCodexAccountSnapshot({
enabled: true,
includeRateLimits: true,
});
return React.createElement('div', null, 'hook-mounted');
}
await act(async () => {
root.render(React.createElement(Harness));
await Promise.resolve();
});
apiMocks.refreshCodexAccountSnapshot.mockClear();
await act(async () => {
visibilityState = 'hidden';
document.dispatchEvent(new Event('visibilitychange'));
await Promise.resolve();
});
await act(async () => {
vi.advanceTimersByTime(10_000);
await Promise.resolve();
});
expect(apiMocks.refreshCodexAccountSnapshot).not.toHaveBeenCalled();
await act(async () => {
vi.advanceTimersByTime(50_000);
await Promise.resolve();
});
expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledTimes(1);
apiMocks.refreshCodexAccountSnapshot.mockClear();
await act(async () => {
vi.advanceTimersByTime(10_000);
visibilityState = 'visible';
document.dispatchEvent(new Event('visibilitychange'));
await Promise.resolve();
});
expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledTimes(1);
act(() => {
root.unmount();
});
});
});

View file

@ -2,7 +2,10 @@ import { describe, expect, it, vi } from 'vitest';
import { CodexAppServerClient } from '@features/recent-projects/main/infrastructure/codex/CodexAppServerClient'; import { CodexAppServerClient } from '@features/recent-projects/main/infrastructure/codex/CodexAppServerClient';
import type { JsonRpcSession, JsonRpcStdioClient } from '@features/recent-projects/main/infrastructure/codex/JsonRpcStdioClient'; import type {
JsonRpcSession,
JsonRpcStdioClient,
} from '@main/services/infrastructure/codexAppServer';
function createSession( function createSession(
request: JsonRpcSession['request'], request: JsonRpcSession['request'],
@ -11,6 +14,8 @@ function createSession(
return { return {
request, request,
notify, notify,
onNotification: vi.fn().mockReturnValue(() => undefined),
close: vi.fn().mockResolvedValue(undefined),
}; };
} }

View file

@ -139,6 +139,57 @@ describe('CliInstallerService', () => {
expect(status.installedVersion).toBeNull(); expect(status.installedVersion).toBeNull();
}); });
it('retries the version probe once before marking the runtime unhealthy', async () => {
allowConsoleLogs();
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');
vi.mocked(execCli)
.mockRejectedValueOnce(new Error('Command failed: /usr/local/bin/claude --version'))
.mockResolvedValueOnce({ stdout: '2.3.4', stderr: '' })
.mockResolvedValueOnce({
stdout: '{"loggedIn":true,"authMethod":"oauth_token"}',
stderr: '',
});
const status = await service.getStatus();
expect(status.installed).toBe(true);
expect(status.installedVersion).toBe('2.3.4');
expect(execCli).toHaveBeenNthCalledWith(
1,
'/usr/local/bin/claude',
['--version'],
expect.objectContaining({ timeout: expect.any(Number) })
);
expect(execCli).toHaveBeenNthCalledWith(
2,
'/usr/local/bin/claude',
['--version'],
expect.objectContaining({ timeout: expect.any(Number) })
);
});
it('reuses the last healthy runtime snapshot when a later version probe fails transiently', async () => {
allowConsoleLogs();
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');
vi.mocked(execCli)
.mockResolvedValueOnce({ stdout: '2.3.4', stderr: '' })
.mockResolvedValueOnce({
stdout: '{"loggedIn":true,"authMethod":"oauth_token"}',
stderr: '',
})
.mockRejectedValueOnce(new Error('Command failed: /usr/local/bin/claude --version'))
.mockRejectedValueOnce(new Error('Command failed: /usr/local/bin/claude --version'));
const firstStatus = await service.getStatus();
const secondStatus = await service.getStatus();
expect(firstStatus.installed).toBe(true);
expect(firstStatus.installedVersion).toBe('2.3.4');
expect(secondStatus.installed).toBe(true);
expect(secondStatus.installedVersion).toBe('2.3.4');
expect(secondStatus.launchError).toBeNull();
});
it('handles spawn EINVAL when binary path contains non-ASCII by falling back', async () => { it('handles spawn EINVAL when binary path contains non-ASCII by falling back', async () => {
allowConsoleLogs(); allowConsoleLogs();
const fakePath = 'C:\\Users\\Алексей\\AppData\\Roaming\\npm\\claude.cmd'; const fakePath = 'C:\\Users\\Алексей\\AppData\\Roaming\\npm\\claude.cmd';

View file

@ -0,0 +1,91 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
describe('ConfigManager Codex migration hardening', () => {
let tempRoot: string | null = null;
afterEach(async () => {
if (tempRoot) {
fs.rmSync(tempRoot, { recursive: true, force: true });
tempRoot = null;
}
vi.resetModules();
const pathDecoder = await import('../../../../src/main/utils/pathDecoder');
pathDecoder.setClaudeBasePathOverride(null);
});
it('persists the normalized Codex auth and runtime shape after loading a legacy config', async () => {
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'config-codex-migration-'));
const configPath = path.join(tempRoot, 'claude-devtools-config.json');
// eslint-disable-next-line security/detect-non-literal-fs-filename -- temp fixture path
fs.writeFileSync(
configPath,
JSON.stringify({
providerConnections: {
codex: {
authMode: 'oauth',
apiKeyBetaEnabled: true,
},
},
runtime: {
providerBackends: {
codex: 'api',
},
},
})
);
const { ConfigManager } = await import(
'../../../../src/main/services/infrastructure/ConfigManager'
);
const manager = new ConfigManager(configPath);
const config = manager.getConfig();
expect(config.providerConnections.codex.preferredAuthMode).toBe('chatgpt');
expect(config.runtime.providerBackends.codex).toBe('codex-native');
await vi.waitFor(() => {
// eslint-disable-next-line security/detect-non-literal-fs-filename -- temp fixture path
const persisted = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
providerConnections: { codex: Record<string, unknown> };
runtime: { providerBackends: { codex: string } };
};
expect(persisted.providerConnections.codex).toEqual({
preferredAuthMode: 'chatgpt',
});
expect(persisted.runtime.providerBackends.codex).toBe('codex-native');
});
});
it('normalizes legacy Codex runtime backend updates inside ConfigManager updateConfig', async () => {
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'config-codex-runtime-update-'));
const configPath = path.join(tempRoot, 'claude-devtools-config.json');
const { ConfigManager } = await import(
'../../../../src/main/services/infrastructure/ConfigManager'
);
const manager = new ConfigManager(configPath);
const updated = manager.updateConfig('runtime', {
providerBackends: {
codex: 'api' as never,
},
} as never);
expect(updated.runtime.providerBackends.codex).toBe('codex-native');
await vi.waitFor(() => {
// eslint-disable-next-line security/detect-non-literal-fs-filename -- temp fixture path
const persisted = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
runtime: { providerBackends: { codex: string } };
};
expect(persisted.runtime.providerBackends.codex).toBe('codex-native');
});
});
});

View file

@ -428,9 +428,9 @@ describe('ClaudeMultimodelBridgeService', () => {
expect(codex?.capabilities.extensions.plugins).toMatchObject({ expect(codex?.capabilities.extensions.plugins).toMatchObject({
status: 'unsupported', status: 'unsupported',
}); });
expect(isConnectionManagedRuntimeProvider(codex!)).toBe(false); expect(isConnectionManagedRuntimeProvider(codex!)).toBe(true);
expect(getProviderConnectionModeSummary(codex!)).toBeNull(); expect(getProviderConnectionModeSummary(codex!)).toBeNull();
expect(getProviderCurrentRuntimeSummary(codex!)).toBeNull(); expect(getProviderCurrentRuntimeSummary(codex!)).toBe('Current runtime: Codex native');
}); });
it('preserves codex-native ready truth from runtime status payloads', async () => { it('preserves codex-native ready truth from runtime status payloads', async () => {

View file

@ -17,7 +17,9 @@ describe('ProviderConnectionService', () => {
anthropic: { anthropic: {
authMode, authMode,
}, },
codex: {}, codex: {
preferredAuthMode: 'auto' as const,
},
}, },
runtime: { runtime: {
providerBackends: { providerBackends: {
@ -174,8 +176,8 @@ describe('ProviderConnectionService', () => {
expect(info).toMatchObject({ expect(info).toMatchObject({
supportsOAuth: false, supportsOAuth: false,
supportsApiKey: true, supportsApiKey: true,
configurableAuthModes: [], configurableAuthModes: ['auto', 'chatgpt', 'api_key'],
configuredAuthMode: null, configuredAuthMode: 'auto',
apiKeyConfigured: false, apiKeyConfigured: false,
apiKeySource: null, apiKeySource: null,
apiKeySourceLabel: null, apiKeySourceLabel: null,
@ -279,6 +281,209 @@ describe('ProviderConnectionService', () => {
expect(issue).toContain('Codex native requires OPENAI_API_KEY or CODEX_API_KEY'); expect(issue).toContain('Codex native requires OPENAI_API_KEY or CODEX_API_KEY');
}); });
it('reports a pinned Codex ChatGPT mode as a missing active CLI login instead of flattening it to generic auth advice', async () => {
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
lookupPreferred: vi.fn().mockResolvedValue(null),
} as never,
{
getConfig: () => createConfig('auto'),
} as never
);
service.setCodexAccountFeature({
getSnapshot: vi.fn().mockResolvedValue({
preferredAuthMode: 'chatgpt',
effectiveAuthMode: null,
launchAllowed: false,
launchIssueMessage: 'Connect a ChatGPT account to use your Codex subscription.',
launchReadinessState: 'missing_auth',
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount: null,
apiKey: {
available: true,
source: 'environment',
sourceLabel: 'Detected from OPENAI_API_KEY',
},
requiresOpenaiAuth: true,
localAccountArtifactsPresent: false,
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: null,
updatedAt: '2026-04-20T00:00:00.000Z',
}),
} as never);
const issue = await service.getConfiguredConnectionIssue(
{
OPENAI_API_KEY: 'env-key',
},
'codex'
);
expect(issue).toBe(
'Codex ChatGPT account mode is selected, but Codex CLI reports no active ChatGPT login. Connect ChatGPT again or switch Codex auth mode to API key.'
);
});
it('mentions local Codex account artifacts when pinned ChatGPT mode has no active managed session', async () => {
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
lookupPreferred: vi.fn().mockResolvedValue(null),
} as never,
{
getConfig: () => createConfig('auto'),
} as never
);
service.setCodexAccountFeature({
getSnapshot: vi.fn().mockResolvedValue({
preferredAuthMode: 'chatgpt',
effectiveAuthMode: null,
launchAllowed: false,
launchIssueMessage: 'Connect a ChatGPT account to use your Codex subscription.',
launchReadinessState: 'missing_auth',
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount: null,
apiKey: {
available: true,
source: 'environment',
sourceLabel: 'Detected from OPENAI_API_KEY',
},
requiresOpenaiAuth: true,
localAccountArtifactsPresent: true,
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: null,
updatedAt: '2026-04-20T00:00:00.000Z',
}),
} as never);
const issue = await service.getConfiguredConnectionIssue(
{
OPENAI_API_KEY: 'env-key',
},
'codex'
);
expect(issue).toBe(
'Codex ChatGPT account mode is selected, but Codex CLI reports no active ChatGPT login. Local Codex account data exists, but no active managed session is selected. Connect ChatGPT again or switch Codex auth mode to API key.'
);
});
it('asks for reconnect when pinned ChatGPT mode still has a locally selected Codex account', async () => {
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
lookupPreferred: vi.fn().mockResolvedValue(null),
} as never,
{
getConfig: () => createConfig('auto'),
} as never
);
service.setCodexAccountFeature({
getSnapshot: vi.fn().mockResolvedValue({
preferredAuthMode: 'chatgpt',
effectiveAuthMode: null,
launchAllowed: false,
launchIssueMessage: 'Reconnect ChatGPT to refresh the current Codex subscription session.',
launchReadinessState: 'missing_auth',
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount: null,
apiKey: {
available: true,
source: 'environment',
sourceLabel: 'Detected from OPENAI_API_KEY',
},
requiresOpenaiAuth: true,
localAccountArtifactsPresent: true,
localActiveChatgptAccountPresent: true,
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: null,
updatedAt: '2026-04-20T00:00:00.000Z',
}),
} as never);
const issue = await service.getConfiguredConnectionIssue(
{
OPENAI_API_KEY: 'env-key',
},
'codex'
);
expect(issue).toBe(
'Codex ChatGPT account mode is selected, and Codex has a locally selected ChatGPT account, but the current session needs reconnect. Reconnect ChatGPT or switch Codex auth mode to API key.'
);
});
it('reports a pinned Codex API-key mode as missing only the API key credential', async () => {
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
lookupPreferred: vi.fn().mockResolvedValue(null),
} as never,
{
getConfig: () => createConfig('auto'),
} as never
);
service.setCodexAccountFeature({
getSnapshot: vi.fn().mockResolvedValue({
preferredAuthMode: 'api_key',
effectiveAuthMode: null,
launchAllowed: false,
launchIssueMessage: 'Add OPENAI_API_KEY or CODEX_API_KEY to use Codex API key mode.',
launchReadinessState: 'missing_auth',
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount: null,
apiKey: {
available: false,
source: null,
sourceLabel: null,
},
requiresOpenaiAuth: true,
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: null,
updatedAt: '2026-04-20T00:00:00.000Z',
}),
} as never);
const issue = await service.getConfiguredConnectionIssue({}, 'codex');
expect(issue).toBe(
'Codex API key mode is selected, but no OPENAI_API_KEY or CODEX_API_KEY credential is available. Add one before launching Codex.'
);
});
it('augments PTY env for native Codex without dropping existing OpenAI credentials', async () => { it('augments PTY env for native Codex without dropping existing OpenAI credentials', async () => {
const { ProviderConnectionService } = const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService'); await import('@main/services/runtime/ProviderConnectionService');
@ -302,4 +507,118 @@ describe('ProviderConnectionService', () => {
expect(result.OPENAI_API_KEY).toBe('shell-key'); expect(result.OPENAI_API_KEY).toBe('shell-key');
expect(result.CODEX_API_KEY).toBe('shell-key'); expect(result.CODEX_API_KEY).toBe('shell-key');
}); });
it('returns a chatgpt forced_login_method override for managed Codex launches', async () => {
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
lookupPreferred: vi.fn().mockResolvedValue(null),
} as never,
{
getConfig: () => createConfig('auto'),
} as never
);
service.setCodexAccountFeature({
getSnapshot: vi.fn().mockResolvedValue({
preferredAuthMode: 'chatgpt',
effectiveAuthMode: 'chatgpt',
launchAllowed: true,
launchIssueMessage: null,
launchReadinessState: 'ready_chatgpt',
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount: {
type: 'chatgpt',
email: 'user@example.com',
planType: 'pro',
},
apiKey: {
available: true,
source: 'environment',
sourceLabel: 'Detected from OPENAI_API_KEY',
},
requiresOpenaiAuth: true,
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: null,
updatedAt: '2026-04-20T00:00:00.000Z',
}),
} as never);
const args = await service.getConfiguredConnectionLaunchArgs(
{
OPENAI_API_KEY: undefined,
CODEX_API_KEY: undefined,
},
'codex',
undefined,
'/mock/claude-multimodel'
);
expect(args).toEqual(['--settings', '{"codex":{"forced_login_method":"chatgpt"}}']);
});
it('returns an api forced_login_method override for Codex API-key launches', async () => {
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
lookupPreferred: vi.fn().mockResolvedValue({
envVarName: 'OPENAI_API_KEY',
value: 'stored-key',
}),
} as never,
{
getConfig: () => createConfig('auto'),
} as never
);
const args = await service.getConfiguredConnectionLaunchArgs(
{
OPENAI_API_KEY: 'stored-key',
CODEX_API_KEY: 'stored-key',
},
'codex',
undefined,
'/mock/claude-multimodel'
);
expect(args).toEqual(['--settings', '{"codex":{"forced_login_method":"api"}}']);
});
it('keeps codex exec style config overrides for direct Codex binary launches', async () => {
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
lookupPreferred: vi.fn().mockResolvedValue({
envVarName: 'OPENAI_API_KEY',
value: 'stored-key',
}),
} as never,
{
getConfig: () => createConfig('auto'),
} as never
);
const args = await service.getConfiguredConnectionLaunchArgs(
{
OPENAI_API_KEY: 'stored-key',
CODEX_API_KEY: 'stored-key',
},
'codex',
undefined,
'/usr/local/bin/codex'
);
expect(args).toEqual(['-c', 'forced_login_method="api"']);
});
}); });

View file

@ -9,6 +9,7 @@ const augmentConfiguredConnectionEnvMock = vi.fn();
const applyConfiguredConnectionEnvMock = vi.fn(); const applyConfiguredConnectionEnvMock = vi.fn();
const applyAllConfiguredConnectionEnvMock = vi.fn(); const applyAllConfiguredConnectionEnvMock = vi.fn();
const getConfiguredConnectionIssuesMock = vi.fn(); const getConfiguredConnectionIssuesMock = vi.fn();
const getConfiguredConnectionLaunchArgsMock = vi.fn();
vi.mock('@main/utils/cliEnv', () => ({ vi.mock('@main/utils/cliEnv', () => ({
buildEnrichedEnv: (...args: Parameters<typeof buildEnrichedEnvMock>) => buildEnrichedEnvMock(...args), buildEnrichedEnv: (...args: Parameters<typeof buildEnrichedEnvMock>) => buildEnrichedEnvMock(...args),
@ -42,6 +43,9 @@ vi.mock('../../../../src/main/services/runtime/ProviderConnectionService', () =>
applyConfiguredConnectionEnvMock(...args), applyConfiguredConnectionEnvMock(...args),
applyAllConfiguredConnectionEnv: (...args: Parameters<typeof applyAllConfiguredConnectionEnvMock>) => applyAllConfiguredConnectionEnv: (...args: Parameters<typeof applyAllConfiguredConnectionEnvMock>) =>
applyAllConfiguredConnectionEnvMock(...args), applyAllConfiguredConnectionEnvMock(...args),
getConfiguredConnectionLaunchArgs: (
...args: Parameters<typeof getConfiguredConnectionLaunchArgsMock>
) => getConfiguredConnectionLaunchArgsMock(...args),
getConfiguredConnectionIssues: (...args: Parameters<typeof getConfiguredConnectionIssuesMock>) => getConfiguredConnectionIssues: (...args: Parameters<typeof getConfiguredConnectionIssuesMock>) =>
getConfiguredConnectionIssuesMock(...args), getConfiguredConnectionIssuesMock(...args),
}, },
@ -70,6 +74,7 @@ describe('buildProviderAwareCliEnv', () => {
applyAllConfiguredConnectionEnvMock.mockImplementation((env: NodeJS.ProcessEnv) => applyAllConfiguredConnectionEnvMock.mockImplementation((env: NodeJS.ProcessEnv) =>
Promise.resolve(env) Promise.resolve(env)
); );
getConfiguredConnectionLaunchArgsMock.mockResolvedValue([]);
getConfiguredConnectionIssuesMock.mockResolvedValue({}); getConfiguredConnectionIssuesMock.mockResolvedValue({});
}); });
@ -104,6 +109,7 @@ describe('buildProviderAwareCliEnv', () => {
expect(result.connectionIssues).toEqual({ expect(result.connectionIssues).toEqual({
anthropic: 'missing key', anthropic: 'missing key',
}); });
expect(result.providerArgs).toEqual([]);
}); });
it('builds shared env for generic CLI launches when no provider is specified', async () => { it('builds shared env for generic CLI launches when no provider is specified', async () => {
@ -125,6 +131,7 @@ describe('buildProviderAwareCliEnv', () => {
}) })
); );
expect(result.connectionIssues).toEqual({}); expect(result.connectionIssues).toEqual({});
expect(result.providerArgs).toEqual([]);
}); });
it('uses non-destructive credential augmentation for PTY-style envs', async () => { it('uses non-destructive credential augmentation for PTY-style envs', async () => {
@ -145,6 +152,7 @@ describe('buildProviderAwareCliEnv', () => {
}) })
); );
expect(result.connectionIssues).toEqual({}); expect(result.connectionIssues).toEqual({});
expect(result.providerArgs).toEqual([]);
}); });
it('preserves caller-provided HOME and USERPROFILE overrides', async () => { it('preserves caller-provided HOME and USERPROFILE overrides', async () => {
@ -169,6 +177,7 @@ describe('buildProviderAwareCliEnv', () => {
); );
expect(result.env.HOME).toBe('/Users/electron-home'); expect(result.env.HOME).toBe('/Users/electron-home');
expect(result.env.USERPROFILE).toBe('/Users/electron-home'); expect(result.env.USERPROFILE).toBe('/Users/electron-home');
expect(result.providerArgs).toEqual([]);
}); });
it('preserves explicit backend overrides passed by the caller', async () => { it('preserves explicit backend overrides passed by the caller', async () => {
@ -190,6 +199,7 @@ describe('buildProviderAwareCliEnv', () => {
); );
expect(result.env.CLAUDE_CODE_GEMINI_BACKEND).toBe('api'); expect(result.env.CLAUDE_CODE_GEMINI_BACKEND).toBe('api');
expect(result.env.CLAUDE_CODE_CODEX_BACKEND).toBe('codex-native'); expect(result.env.CLAUDE_CODE_CODEX_BACKEND).toBe('codex-native');
expect(result.providerArgs).toEqual([]);
}); });
it('preserves codex-native backend env across provider-aware child env building', async () => { it('preserves codex-native backend env across provider-aware child env building', async () => {
@ -212,5 +222,34 @@ describe('buildProviderAwareCliEnv', () => {
undefined undefined
); );
expect(result.env.CLAUDE_CODE_CODEX_BACKEND).toBe('codex-native'); expect(result.env.CLAUDE_CODE_CODEX_BACKEND).toBe('codex-native');
expect(result.providerArgs).toEqual([]);
});
it('returns provider launch args for strict codex launches', async () => {
getConfiguredConnectionLaunchArgsMock.mockResolvedValue([
'--settings',
'{"codex":{"forced_login_method":"chatgpt"}}',
]);
const { buildProviderAwareCliEnv } = await import(
'../../../../src/main/services/runtime/providerAwareCliEnv'
);
const result = await buildProviderAwareCliEnv({
binaryPath: '/mock/claude-multimodel',
providerId: 'codex',
});
expect(getConfiguredConnectionLaunchArgsMock).toHaveBeenCalledWith(
expect.objectContaining({
CLAUDE_CODE_CODEX_BACKEND: 'codex-native',
}),
'codex',
undefined,
'/mock/claude-multimodel'
);
expect(result.providerArgs).toEqual([
'--settings',
'{"codex":{"forced_login_method":"chatgpt"}}',
]);
}); });
}); });

View file

@ -89,6 +89,7 @@ describe('ScheduledTaskExecutor', () => {
buildProviderAwareCliEnvMock.mockResolvedValue({ buildProviderAwareCliEnvMock.mockResolvedValue({
env: { ...process.env, SHELL: '/bin/zsh' }, env: { ...process.env, SHELL: '/bin/zsh' },
connectionIssues: {}, connectionIssues: {},
providerArgs: [],
}); });
const mod = await import('../../../../src/main/services/schedule/ScheduledTaskExecutor'); const mod = await import('../../../../src/main/services/schedule/ScheduledTaskExecutor');
@ -139,6 +140,37 @@ describe('ScheduledTaskExecutor', () => {
expect(result.stderr).toBe('Error: something broke'); expect(result.stderr).toBe('Error: something broke');
}); });
it('appends provider launch overrides returned by provider-aware env resolution', async () => {
buildProviderAwareCliEnvMock.mockResolvedValue({
env: { ...process.env, SHELL: '/bin/zsh' },
connectionIssues: {},
providerArgs: ['--settings', '{"codex":{"forced_login_method":"chatgpt"}}'],
});
const proc = createMockProcess();
mockSpawnCli.mockReturnValue(proc);
const executor = new ScheduledTaskExecutor();
const resultPromise = executor.execute(
makeRequest({
config: {
cwd: '/tmp/project',
prompt: 'Run the tests',
providerId: 'codex',
},
})
);
await flushAsync();
const spawnArgs = mockSpawnCli.mock.calls[0]?.[1] as string[];
expect(spawnArgs).toEqual(
expect.arrayContaining(['--settings', '{"codex":{"forced_login_method":"chatgpt"}}'])
);
proc.emit('close', 0);
await resultPromise;
});
it('rejects on process error event', async () => { it('rejects on process error event', async () => {
const proc = createMockProcess(); const proc = createMockProcess();
mockSpawnCli.mockReturnValue(proc); mockSpawnCli.mockReturnValue(proc);

View file

@ -189,6 +189,7 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
authSource: 'anthropic_api_key', authSource: 'anthropic_api_key',
})); }));
(svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {}); (svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {});
(svc as any).resolveProviderDefaultModel = vi.fn(async () => 'gpt-5.4');
(svc as any).updateConfigProjectPath = vi.fn(async () => {}); (svc as any).updateConfigProjectPath = vi.fn(async () => {});
(svc as any).restorePrelaunchConfig = vi.fn(async () => {}); (svc as any).restorePrelaunchConfig = vi.fn(async () => {});
(svc as any).persistLaunchStateSnapshot = vi.fn(async () => {}); (svc as any).persistLaunchStateSnapshot = vi.fn(async () => {});
@ -280,6 +281,39 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
await svc.cancelProvisioning(runId); await svc.cancelProvisioning(runId);
}); });
it('forwards codex provider launch overrides into createTeam runtime args', async () => {
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/codex');
const { child } = createFakeChild();
vi.mocked(spawnCli).mockReturnValue(child as any);
const svc = new TeamProvisioningService();
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
env: {},
authSource: 'codex_runtime',
providerArgs: ['--settings', '{"codex":{"forced_login_method":"chatgpt"}}'],
}));
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
(svc as any).startFilesystemMonitor = vi.fn();
(svc as any).pathExists = vi.fn(async () => false);
const { runId } = await svc.createTeam(
{
teamName: 'codex-team',
cwd: process.cwd(),
members: [],
providerId: 'codex',
},
() => {}
);
const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[];
expect(launchArgs).toEqual(
expect.arrayContaining(['--settings', '{"codex":{"forced_login_method":"chatgpt"}}'])
);
await svc.cancelProvisioning(runId);
});
it('restart teammate message keeps the exact teammate identity and avoids duplicate semantics', () => { it('restart teammate message keeps the exact teammate identity and avoids duplicate semantics', () => {
const message = buildRestartMemberSpawnMessage('forge-labs', 'Forge Labs', 'lead', { const message = buildRestartMemberSpawnMessage('forge-labs', 'Forge Labs', 'lead', {
name: 'alice', name: 'alice',
@ -428,6 +462,7 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
authSource: 'anthropic_api_key', authSource: 'anthropic_api_key',
})); }));
(svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {}); (svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {});
(svc as any).resolveProviderDefaultModel = vi.fn(async () => 'gpt-5.4');
(svc as any).updateConfigProjectPath = vi.fn(async () => {}); (svc as any).updateConfigProjectPath = vi.fn(async () => {});
(svc as any).restorePrelaunchConfig = vi.fn(async () => {}); (svc as any).restorePrelaunchConfig = vi.fn(async () => {});
(svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {}); (svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {});
@ -486,6 +521,7 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
authSource: 'anthropic_api_key', authSource: 'anthropic_api_key',
})); }));
(svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {}); (svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {});
(svc as any).resolveProviderDefaultModel = vi.fn(async () => 'gpt-5.4');
(svc as any).updateConfigProjectPath = vi.fn(async () => {}); (svc as any).updateConfigProjectPath = vi.fn(async () => {});
(svc as any).restorePrelaunchConfig = vi.fn(async () => {}); (svc as any).restorePrelaunchConfig = vi.fn(async () => {});
(svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {}); (svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {});
@ -569,6 +605,7 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
})); }));
(svc as any).resolveProviderDefaultModel = vi.fn(async () => 'gpt-5.4'); (svc as any).resolveProviderDefaultModel = vi.fn(async () => 'gpt-5.4');
(svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {}); (svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {});
(svc as any).resolveProviderDefaultModel = vi.fn(async () => 'gpt-5.4');
(svc as any).updateConfigProjectPath = vi.fn(async () => {}); (svc as any).updateConfigProjectPath = vi.fn(async () => {});
(svc as any).restorePrelaunchConfig = vi.fn(async () => {}); (svc as any).restorePrelaunchConfig = vi.fn(async () => {});
(svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {}); (svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {});
@ -603,4 +640,64 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
await svc.cancelProvisioning(runId); await svc.cancelProvisioning(runId);
}); });
it('forwards codex provider launch overrides into launchTeam runtime args', async () => {
const teamName = 'codex-launch-forced-login';
const teamDir = path.join(tempTeamsBase, teamName);
fs.mkdirSync(teamDir, { recursive: true });
fs.writeFileSync(
path.join(teamDir, 'config.json'),
JSON.stringify({
name: teamName,
members: [
{ name: 'team-lead', agentType: 'team-lead', providerId: 'codex' },
{ name: 'alice', agentType: 'teammate', role: 'developer', providerId: 'codex' },
],
}),
'utf8'
);
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/codex');
const { child } = createFakeChild();
vi.mocked(spawnCli).mockReturnValue(child as any);
const svc = new TeamProvisioningService();
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
env: {},
authSource: 'codex_runtime',
geminiRuntimeAuth: null,
providerArgs: ['--settings', '{"codex":{"forced_login_method":"chatgpt"}}'],
}));
(svc as any).resolveProviderDefaultModel = vi.fn(async () => 'gpt-5.4');
(svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {});
(svc as any).updateConfigProjectPath = vi.fn(async () => {});
(svc as any).restorePrelaunchConfig = vi.fn(async () => {});
(svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {});
(svc as any).persistLaunchStateSnapshot = vi.fn(async () => {});
(svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({
members: [{ name: 'alice', role: 'developer', providerId: 'codex' }],
source: 'config-fallback',
warning: undefined,
}));
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
(svc as any).pathExists = vi.fn(async () => false);
(svc as any).startFilesystemMonitor = vi.fn();
const { runId } = await svc.launchTeam(
{
teamName,
cwd: process.cwd(),
providerId: 'codex',
clearContext: true,
} as any,
() => {}
);
const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[];
expect(launchArgs).toEqual(
expect.arrayContaining(['--settings', '{"codex":{"forced_login_method":"chatgpt"}}'])
);
await svc.cancelProvisioning(runId);
});
}); });

View file

@ -0,0 +1,40 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { HttpAPIClient } from '../../../src/renderer/api/httpClient';
class MockEventSource {
onopen: (() => void) | null = null;
onerror: (() => void) | null = null;
addEventListener(): void {
// noop browser-mode stub
}
close(): void {
// noop browser-mode stub
}
}
describe('HttpAPIClient Codex account browser fallback', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('rejects Codex account actions with a consistent browser-mode error and returns a safe noop subscription', async () => {
vi.stubGlobal('EventSource', MockEventSource);
const client = new HttpAPIClient('http://localhost:9999');
const expectedMessage = 'Codex account bridge is unavailable in browser mode';
await expect(client.getCodexAccountSnapshot()).rejects.toThrow(expectedMessage);
await expect(
client.refreshCodexAccountSnapshot({
includeRateLimits: true,
forceRefreshToken: true,
})
).rejects.toThrow(expectedMessage);
await expect(client.startCodexChatgptLogin()).rejects.toThrow(expectedMessage);
await expect(client.cancelCodexChatgptLogin()).rejects.toThrow(expectedMessage);
await expect(client.logoutCodexAccount()).rejects.toThrow(expectedMessage);
expect(typeof client.onCodexAccountSnapshotChanged(() => undefined)).toBe('function');
});
});

View file

@ -2,6 +2,8 @@ import React, { act } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
interface StoreState { interface StoreState {
cliStatus: Record<string, unknown> | null; cliStatus: Record<string, unknown> | null;
cliStatusLoading: boolean; cliStatusLoading: boolean;
@ -45,6 +47,15 @@ let providerRuntimeSettingsDialogProps: {
open?: boolean; open?: boolean;
initialProviderId?: string; initialProviderId?: string;
} | null = null; } | null = null;
const codexAccountHookState = {
snapshot: null as CodexAccountSnapshotDto | null,
loading: false,
error: null as string | null,
refresh: vi.fn(() => Promise.resolve(undefined)),
startChatgptLogin: vi.fn(() => Promise.resolve(true)),
cancelChatgptLogin: vi.fn(() => Promise.resolve(true)),
logout: vi.fn(() => Promise.resolve(true)),
};
vi.mock('@renderer/api', () => ({ vi.mock('@renderer/api', () => ({
api: { api: {
@ -53,6 +64,14 @@ vi.mock('@renderer/api', () => ({
isElectronMode: () => true, isElectronMode: () => true,
})); }));
vi.mock('@features/codex-account/renderer', async (importOriginal) => {
const actual = await importOriginal<typeof import('@features/codex-account/renderer')>();
return {
...actual,
useCodexAccountSnapshot: () => codexAccountHookState,
};
});
vi.mock('@renderer/components/common/ConfirmDialog', () => ({ vi.mock('@renderer/components/common/ConfirmDialog', () => ({
confirm: vi.fn(() => Promise.resolve(true)), confirm: vi.fn(() => Promise.resolve(true)),
})); }));
@ -269,6 +288,13 @@ describe('CLI status visibility during completed install state', () => {
beforeEach(() => { beforeEach(() => {
providerRuntimeSettingsDialogProps = null; providerRuntimeSettingsDialogProps = null;
codexAccountHookState.snapshot = null;
codexAccountHookState.loading = false;
codexAccountHookState.error = null;
codexAccountHookState.refresh.mockClear();
codexAccountHookState.startChatgptLogin.mockClear();
codexAccountHookState.cancelChatgptLogin.mockClear();
codexAccountHookState.logout.mockClear();
storeState.cliStatus = createInstalledCliStatus(); storeState.cliStatus = createInstalledCliStatus();
storeState.cliStatusLoading = false; storeState.cliStatusLoading = false;
storeState.cliProviderStatusLoading = {}; storeState.cliProviderStatusLoading = {};
@ -437,6 +463,50 @@ describe('CLI status visibility during completed install state', () => {
}); });
}); });
it('does not render the Anthropic connect action while the provider card is still checking', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliInstallerState = 'idle';
storeState.cliStatus = createInstalledCliStatus({
flavor: 'agent_teams_orchestrator',
authLoggedIn: false,
providers: [
{
providerId: 'anthropic',
displayName: 'Anthropic',
supported: true,
authenticated: false,
authMethod: null,
verificationState: 'unknown',
statusMessage: 'Checking...',
models: [],
canLoginFromUi: true,
capabilities: {
teamLaunch: true,
oneShot: true,
},
backend: null,
},
],
});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(CliStatusBanner));
await Promise.resolve();
});
expect(host.textContent).toContain('Checking...');
expect(host.textContent).not.toContain('Connect Anthropic');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('does not fall back to direct-Claude auth copy when only hidden multimodel providers are available', async () => { it('does not fall back to direct-Claude auth copy when only hidden multimodel providers are available', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliInstallerState = 'idle'; storeState.cliInstallerState = 'idle';
@ -565,6 +635,11 @@ describe('CLI status visibility during completed install state', () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliInstallerState = 'idle'; storeState.cliInstallerState = 'idle';
storeState.cliStatus = createInstalledCliStatus({ storeState.cliStatus = createInstalledCliStatus({
flavor: 'agent_teams_orchestrator',
displayName: 'agent_teams_orchestrator',
supportsSelfUpdate: false,
showVersionDetails: false,
showBinaryPath: false,
installed: false, installed: false,
installedVersion: null, installedVersion: null,
binaryPath: '/Users/tester/.claude/local/node_modules/.bin/claude', binaryPath: '/Users/tester/.claude/local/node_modules/.bin/claude',
@ -581,7 +656,12 @@ describe('CLI status visibility during completed install state', () => {
}); });
expect(host.textContent).toContain('failed to start'); expect(host.textContent).toContain('failed to start');
expect(host.textContent).toContain('Reinstall Claude CLI'); expect(host.textContent).toContain('Multimodel runtime was found but failed to start');
expect(host.textContent).toContain('Re-check');
expect(host.textContent).toContain(
'The configured Multimodel runtime failed its startup health check.'
);
expect(host.textContent).not.toContain('Reinstall Claude CLI');
await act(async () => { await act(async () => {
root.unmount(); root.unmount();
@ -626,6 +706,46 @@ describe('CLI status visibility during completed install state', () => {
}); });
}); });
it('uses provider-first bootstrap when settings re-check runs in multimodel mode', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliInstallerState = 'idle';
storeState.cliStatus = createInstalledCliStatus({
flavor: 'agent_teams_orchestrator',
displayName: 'Multimodel runtime',
supportsSelfUpdate: true,
showVersionDetails: false,
installed: false,
authLoggedIn: false,
});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(CliStatusSection));
await Promise.resolve();
});
const refreshButton = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Re-check')
);
expect(refreshButton).not.toBeNull();
await act(async () => {
refreshButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(storeState.bootstrapCliStatus).toHaveBeenCalledWith({ multimodelEnabled: true });
expect(storeState.fetchCliStatus).not.toHaveBeenCalled();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('preserves settings runtime backend refresh errors for the manage dialog', async () => { it('preserves settings runtime backend refresh errors for the manage dialog', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliInstallerState = 'idle'; storeState.cliInstallerState = 'idle';
@ -864,7 +984,7 @@ describe('CLI status visibility during completed install state', () => {
}); });
expect(host.textContent).toContain('Ready'); expect(host.textContent).toContain('Ready');
expect(host.textContent).toContain('Runtime: Codex native'); expect(host.textContent).toContain('Current runtime: Codex native');
expect(host.textContent).not.toContain('Connected via API key'); expect(host.textContent).not.toContain('Connected via API key');
await act(async () => { await act(async () => {
@ -873,6 +993,723 @@ describe('CLI status visibility during completed install state', () => {
}); });
}); });
it('shows remaining Codex subscription limits on the dashboard card when ChatGPT mode is active', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliInstallerState = 'idle';
codexAccountHookState.snapshot = {
preferredAuthMode: 'auto',
effectiveAuthMode: 'chatgpt',
launchAllowed: true,
launchIssueMessage: null,
launchReadinessState: 'ready_chatgpt',
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount: {
type: 'chatgpt',
email: 'user@example.com',
planType: 'pro',
},
apiKey: {
available: true,
source: 'environment',
sourceLabel: 'Detected from OPENAI_API_KEY',
},
requiresOpenaiAuth: false,
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: {
limitId: 'plan-pro',
limitName: 'Pro',
primary: {
usedPercent: 5,
windowDurationMins: 300,
resetsAt: 1_762_547_200,
},
secondary: {
usedPercent: 41,
windowDurationMins: 10_080,
resetsAt: 1_762_891_200,
},
credits: {
hasCredits: false,
unlimited: false,
balance: null,
},
planType: 'pro',
},
updatedAt: new Date().toISOString(),
};
storeState.cliStatus = createInstalledCliStatus({
flavor: 'agent_teams_orchestrator',
displayName: 'agent_teams_orchestrator',
supportsSelfUpdate: false,
showVersionDetails: false,
showBinaryPath: false,
authLoggedIn: true,
providers: [
createCodexNativeRolloutProvider({
authenticated: false,
authMethod: null,
verificationState: 'unknown',
statusMessage: 'Connect a ChatGPT account to use your Codex subscription.',
connection: {
supportsOAuth: false,
supportsApiKey: true,
configurableAuthModes: ['auto', 'chatgpt', 'api_key'],
configuredAuthMode: 'auto',
apiKeyConfigured: true,
apiKeySource: 'environment',
apiKeySourceLabel: 'Detected from OPENAI_API_KEY',
codex: {
preferredAuthMode: 'auto',
effectiveAuthMode: null,
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount: null,
requiresOpenaiAuth: false,
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: null,
launchAllowed: false,
launchIssueMessage: 'Connect a ChatGPT account to use your Codex subscription.',
launchReadinessState: 'missing_auth',
},
},
backend: {
kind: 'codex-native',
label: 'Codex native',
endpointLabel: 'codex exec --json',
authMethodDetail: null,
},
}),
],
});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(CliStatusBanner));
await Promise.resolve();
});
expect(host.textContent).toContain('Providers: 1/1 connected');
expect(host.textContent).toContain('ChatGPT account ready');
expect(host.textContent).not.toContain('Connect a ChatGPT account to use your Codex subscription.');
expect(host.textContent).toContain('5h left');
expect(host.textContent).toContain('95%');
expect(host.textContent).toContain('1w left');
expect(host.textContent).toContain('59%');
expect(host.textContent).toContain('resets');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('uses the live Codex account snapshot in the settings runtime section too', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliInstallerState = 'idle';
codexAccountHookState.snapshot = {
preferredAuthMode: 'chatgpt',
effectiveAuthMode: 'chatgpt',
launchAllowed: true,
launchIssueMessage: null,
launchReadinessState: 'ready_chatgpt',
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount: {
type: 'chatgpt',
email: 'user@example.com',
planType: 'pro',
},
apiKey: {
available: true,
source: 'environment',
sourceLabel: 'Detected from OPENAI_API_KEY',
},
requiresOpenaiAuth: false,
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: null,
updatedAt: new Date().toISOString(),
};
storeState.cliStatus = createInstalledCliStatus({
flavor: 'agent_teams_orchestrator',
displayName: 'agent_teams_orchestrator',
supportsSelfUpdate: false,
showVersionDetails: false,
showBinaryPath: false,
authLoggedIn: true,
providers: [
createCodexNativeRolloutProvider({
authenticated: false,
authMethod: null,
verificationState: 'unknown',
statusMessage: 'Connect a ChatGPT account to use your Codex subscription.',
connection: {
supportsOAuth: false,
supportsApiKey: true,
configurableAuthModes: ['auto', 'chatgpt', 'api_key'],
configuredAuthMode: 'auto',
apiKeyConfigured: true,
apiKeySource: 'environment',
apiKeySourceLabel: 'Detected from OPENAI_API_KEY',
codex: {
preferredAuthMode: 'auto',
effectiveAuthMode: null,
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount: null,
requiresOpenaiAuth: false,
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: null,
launchAllowed: false,
launchIssueMessage: 'Connect a ChatGPT account to use your Codex subscription.',
launchReadinessState: 'missing_auth',
},
},
backend: {
kind: 'codex-native',
label: 'Codex native',
endpointLabel: 'codex exec --json',
authMethodDetail: null,
},
}),
],
});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(CliStatusSection));
await Promise.resolve();
});
expect(host.textContent).toContain('ChatGPT account ready');
expect(host.textContent).not.toContain('Connect a ChatGPT account to use your Codex subscription.');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('applies the live Codex snapshot even while the dashboard is still on multimodel loading placeholder state', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliInstallerState = 'idle';
storeState.cliStatus = null;
storeState.cliStatusLoading = true;
codexAccountHookState.snapshot = {
preferredAuthMode: 'auto',
effectiveAuthMode: 'chatgpt',
launchAllowed: true,
launchIssueMessage: null,
launchReadinessState: 'ready_chatgpt',
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount: {
type: 'chatgpt',
email: 'user@example.com',
planType: 'pro',
},
apiKey: {
available: true,
source: 'environment',
sourceLabel: 'Detected from OPENAI_API_KEY',
},
requiresOpenaiAuth: false,
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: {
limitId: 'plan-pro',
limitName: 'Pro',
primary: {
usedPercent: 5,
windowDurationMins: 300,
resetsAt: 1_762_547_200,
},
secondary: {
usedPercent: 41,
windowDurationMins: 10_080,
resetsAt: 1_762_891_200,
},
credits: {
hasCredits: false,
unlimited: false,
balance: null,
},
planType: 'pro',
},
updatedAt: new Date().toISOString(),
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(CliStatusBanner));
await Promise.resolve();
});
expect(host.textContent).toContain('Providers: 1/2 connected');
expect(host.textContent).toContain('5h left');
expect(host.textContent).toContain('1w left');
expect(host.textContent).toContain('resets');
expect(host.textContent).not.toContain('status will be checked in the background');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('keeps Codex on checking while the dashboard bootstrap is still on placeholder state and the live snapshot is only a negative auth result', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliInstallerState = 'idle';
storeState.cliStatus = null;
storeState.cliStatusLoading = true;
codexAccountHookState.snapshot = {
preferredAuthMode: 'chatgpt',
effectiveAuthMode: null,
launchAllowed: false,
launchIssueMessage: 'Reconnect ChatGPT to refresh the current Codex subscription session.',
launchReadinessState: 'missing_auth',
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount: null,
apiKey: {
available: true,
source: 'environment',
sourceLabel: 'Detected from OPENAI_API_KEY',
},
requiresOpenaiAuth: true,
localAccountArtifactsPresent: true,
localActiveChatgptAccountPresent: true,
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: null,
updatedAt: new Date().toISOString(),
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(CliStatusBanner));
await Promise.resolve();
});
expect(host.textContent).toContain('Checking...');
expect(host.textContent).not.toContain(
'Codex has a locally selected ChatGPT account, but the current session needs reconnect.'
);
expect(host.textContent).not.toContain(
'Usage limits appear only after Codex refreshes the currently selected ChatGPT session.'
);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('explains missing Codex limits when ChatGPT mode is selected but Codex is not logged in', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliInstallerState = 'idle';
codexAccountHookState.snapshot = {
preferredAuthMode: 'chatgpt',
effectiveAuthMode: null,
launchAllowed: false,
launchIssueMessage: 'Connect a ChatGPT account to use your Codex subscription.',
launchReadinessState: 'missing_auth',
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount: null,
apiKey: {
available: true,
source: 'environment',
sourceLabel: 'Detected from OPENAI_API_KEY',
},
requiresOpenaiAuth: true,
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: null,
updatedAt: new Date().toISOString(),
};
storeState.cliStatus = createInstalledCliStatus({
flavor: 'agent_teams_orchestrator',
displayName: 'agent_teams_orchestrator',
supportsSelfUpdate: false,
showVersionDetails: false,
showBinaryPath: false,
authLoggedIn: false,
providers: [
createCodexNativeRolloutProvider({
authenticated: false,
authMethod: null,
verificationState: 'unknown',
statusMessage: 'Connect a ChatGPT account to use your Codex subscription.',
connection: {
supportsOAuth: false,
supportsApiKey: true,
configurableAuthModes: ['auto', 'chatgpt', 'api_key'],
configuredAuthMode: 'chatgpt',
apiKeyConfigured: true,
apiKeySource: 'environment',
apiKeySourceLabel: 'Detected from OPENAI_API_KEY',
codex: {
preferredAuthMode: 'chatgpt',
effectiveAuthMode: null,
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount: null,
requiresOpenaiAuth: true,
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: null,
launchAllowed: false,
launchIssueMessage: 'Connect a ChatGPT account to use your Codex subscription.',
launchReadinessState: 'missing_auth',
},
},
backend: {
kind: 'codex-native',
label: 'Codex native',
endpointLabel: 'codex exec --json',
authMethodDetail: null,
},
}),
],
});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(CliStatusBanner));
await Promise.resolve();
});
expect(host.textContent).toContain('Codex CLI reports no active ChatGPT login');
expect(host.textContent).toContain('Selected auth: ChatGPT account');
expect(host.textContent).toContain(
'Detected from OPENAI_API_KEY - available if you switch to API key mode'
);
expect(host.textContent).toContain(
'Usage limits appear only after Codex CLI sees an active ChatGPT account. Right now it reports no active ChatGPT login. API key fallback is available if you switch auth mode.'
);
expect(host.textContent).not.toContain('5h left');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('explains reconnect when a local selected ChatGPT account exists but the current session is stale', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliInstallerState = 'idle';
codexAccountHookState.snapshot = {
preferredAuthMode: 'chatgpt',
effectiveAuthMode: null,
launchAllowed: false,
launchIssueMessage: 'Reconnect ChatGPT to refresh the current Codex subscription session.',
launchReadinessState: 'missing_auth',
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount: null,
apiKey: {
available: true,
source: 'environment',
sourceLabel: 'Detected from OPENAI_API_KEY',
},
requiresOpenaiAuth: true,
localAccountArtifactsPresent: true,
localActiveChatgptAccountPresent: true,
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: null,
updatedAt: new Date().toISOString(),
};
storeState.cliStatus = createInstalledCliStatus({
flavor: 'agent_teams_orchestrator',
displayName: 'agent_teams_orchestrator',
supportsSelfUpdate: false,
showVersionDetails: false,
showBinaryPath: false,
authLoggedIn: false,
providers: [
createCodexNativeRolloutProvider({
authenticated: false,
authMethod: null,
verificationState: 'unknown',
statusMessage: 'Reconnect ChatGPT to refresh the current Codex subscription session.',
connection: {
supportsOAuth: false,
supportsApiKey: true,
configurableAuthModes: ['auto', 'chatgpt', 'api_key'],
configuredAuthMode: 'chatgpt',
apiKeyConfigured: true,
apiKeySource: 'environment',
apiKeySourceLabel: 'Detected from OPENAI_API_KEY',
codex: {
preferredAuthMode: 'chatgpt',
effectiveAuthMode: null,
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount: null,
requiresOpenaiAuth: true,
localAccountArtifactsPresent: true,
localActiveChatgptAccountPresent: true,
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: null,
launchAllowed: false,
launchIssueMessage: 'Reconnect ChatGPT to refresh the current Codex subscription session.',
launchReadinessState: 'missing_auth',
},
},
backend: {
kind: 'codex-native',
label: 'Codex native',
endpointLabel: 'codex exec --json',
authMethodDetail: null,
},
}),
],
});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(CliStatusBanner));
await Promise.resolve();
});
expect(host.textContent).toContain(
'Codex has a locally selected ChatGPT account, but the current session needs reconnect.'
);
expect(host.textContent).toContain(
'Usage limits appear only after Codex refreshes the currently selected ChatGPT session. Right now the local session needs reconnect. API key fallback is available if you switch auth mode.'
);
expect(host.textContent).not.toContain('5h left');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('explains when Auto is using an API key while ChatGPT usage limits are still unavailable', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliInstallerState = 'idle';
codexAccountHookState.snapshot = {
preferredAuthMode: 'auto',
effectiveAuthMode: 'api_key',
launchAllowed: true,
launchIssueMessage: null,
launchReadinessState: 'ready_api_key',
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount: null,
apiKey: {
available: true,
source: 'environment',
sourceLabel: 'Detected from OPENAI_API_KEY',
},
requiresOpenaiAuth: true,
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: null,
updatedAt: new Date().toISOString(),
};
storeState.cliStatus = createInstalledCliStatus({
flavor: 'agent_teams_orchestrator',
displayName: 'agent_teams_orchestrator',
supportsSelfUpdate: false,
showVersionDetails: false,
showBinaryPath: false,
authLoggedIn: true,
providers: [
createCodexNativeRolloutProvider({
authenticated: true,
authMethod: 'api_key',
verificationState: 'verified',
statusMessage: 'API key ready',
connection: {
supportsOAuth: false,
supportsApiKey: true,
configurableAuthModes: ['auto', 'chatgpt', 'api_key'],
configuredAuthMode: 'auto',
apiKeyConfigured: true,
apiKeySource: 'environment',
apiKeySourceLabel: 'Detected from OPENAI_API_KEY',
codex: {
preferredAuthMode: 'auto',
effectiveAuthMode: 'api_key',
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount: null,
requiresOpenaiAuth: true,
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: null,
launchAllowed: true,
launchIssueMessage: null,
launchReadinessState: 'ready_api_key',
},
},
backend: {
kind: 'codex-native',
label: 'Codex native',
endpointLabel: 'codex exec --json',
authMethodDetail: 'api_key',
},
}),
],
});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(CliStatusBanner));
await Promise.resolve();
});
expect(host.textContent).toContain(
'Detected from OPENAI_API_KEY - Auto will use this until ChatGPT is connected'
);
expect(host.textContent).toContain(
'Usage limits appear only after Codex CLI sees an active ChatGPT account. Right now it reports no active ChatGPT login. Auto will keep using the API key until ChatGPT is connected.'
);
expect(host.textContent).not.toContain('5h left');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('does not spin the provider refresh control during a global CLI refresh once the provider card is already rendered', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliInstallerState = 'idle';
storeState.cliStatusLoading = true;
storeState.cliStatus = createInstalledCliStatus({
flavor: 'agent_teams_orchestrator',
displayName: 'agent_teams_orchestrator',
supportsSelfUpdate: false,
showVersionDetails: false,
showBinaryPath: false,
authLoggedIn: true,
providers: [
createCodexNativeRolloutProvider({
authenticated: true,
authMethod: 'api_key',
connection: {
supportsOAuth: false,
supportsApiKey: true,
configurableAuthModes: ['auto', 'chatgpt', 'api_key'],
configuredAuthMode: 'api_key',
apiKeyConfigured: true,
apiKeySource: 'stored',
apiKeySourceLabel: 'Stored in app',
codex: {
preferredAuthMode: 'api_key',
effectiveAuthMode: 'api_key',
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount: null,
requiresOpenaiAuth: false,
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: null,
launchAllowed: true,
launchIssueMessage: null,
launchReadinessState: 'ready_api_key',
},
},
backend: {
kind: 'codex-native',
label: 'Codex native',
endpointLabel: 'codex exec --json',
authMethodDetail: 'api_key',
},
}),
],
});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(CliStatusBanner));
await Promise.resolve();
});
const refreshButton = host.querySelector('[title="Re-check Codex"]');
expect(refreshButton).not.toBeNull();
const refreshIcon = refreshButton?.querySelector('svg');
expect(refreshIcon?.getAttribute('class')).not.toContain('animate-spin');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('keeps settings codex-native rollout truth explicit for runtime-missing lanes', async () => { it('keeps settings codex-native rollout truth explicit for runtime-missing lanes', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliInstallerState = 'idle'; storeState.cliInstallerState = 'idle';
@ -909,7 +1746,7 @@ describe('CLI status visibility during completed install state', () => {
}); });
expect(host.textContent).toContain('Codex CLI not found'); expect(host.textContent).toContain('Codex CLI not found');
expect(host.textContent).toContain('Runtime: Codex native - runtime missing'); expect(host.textContent).toContain('Selected runtime: Codex native');
expect(host.textContent).not.toContain('Connected via API key'); expect(host.textContent).not.toContain('Connected via API key');
await act(async () => { await act(async () => {

View file

@ -0,0 +1,104 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
const openDashboard = vi.fn();
const storeState = {
cliStatus: null as
| {
installed: boolean;
displayName: string;
binaryPath: string | null;
launchError: string | null;
}
| null,
cliStatusLoading: false,
paneLayout: {
focusedPaneId: 'pane-1',
panes: [
{
id: 'pane-1',
activeTabId: 'tab-1',
tabs: [
{
id: 'tab-1',
type: 'thread',
},
],
},
],
},
openDashboard,
};
vi.mock('@renderer/api', () => ({
isElectronMode: () => true,
}));
vi.mock('@renderer/store', () => ({
useStore: (selector: (state: typeof storeState) => unknown) => selector(storeState),
}));
import { CliInstallWarningBanner } from '@renderer/components/common/CliInstallWarningBanner';
describe('CliInstallWarningBanner', () => {
afterEach(() => {
document.body.innerHTML = '';
storeState.cliStatus = null;
storeState.cliStatusLoading = false;
openDashboard.mockReset();
});
it('hides stale runtime errors while status is still loading', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliStatus = {
installed: false,
displayName: 'Multimodel runtime',
binaryPath: '/tmp/runtime',
launchError: 'spawn EACCES',
};
storeState.cliStatusLoading = true;
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(CliInstallWarningBanner));
await Promise.resolve();
});
expect(host.textContent).toBe('');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('shows the banner after loading completes and allows opening the dashboard', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliStatus = {
installed: false,
displayName: 'Multimodel runtime',
binaryPath: '/tmp/runtime',
launchError: 'spawn EACCES',
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(CliInstallWarningBanner));
await Promise.resolve();
});
expect(host.textContent).toContain('failed to start');
host.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(openDashboard).toHaveBeenCalledTimes(1);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -2,10 +2,12 @@ import React, { act } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
import type { CliInstallationStatus } from '@shared/types'; import type { CliInstallationStatus } from '@shared/types';
interface StoreState { interface StoreState {
fetchPluginCatalog: ReturnType<typeof vi.fn>; fetchPluginCatalog: ReturnType<typeof vi.fn>;
bootstrapCliStatus: ReturnType<typeof vi.fn>;
fetchCliStatus: ReturnType<typeof vi.fn>; fetchCliStatus: ReturnType<typeof vi.fn>;
fetchApiKeys: ReturnType<typeof vi.fn>; fetchApiKeys: ReturnType<typeof vi.fn>;
fetchSkillsCatalog: ReturnType<typeof vi.fn>; fetchSkillsCatalog: ReturnType<typeof vi.fn>;
@ -18,13 +20,30 @@ interface StoreState {
cliStatus: CliInstallationStatus | null; cliStatus: CliInstallationStatus | null;
cliStatusLoading: boolean; cliStatusLoading: boolean;
cliProviderStatusLoading: Record<string, boolean>; cliProviderStatusLoading: Record<string, boolean>;
appConfig: {
general: {
multimodelEnabled: boolean;
};
};
openDashboard: ReturnType<typeof vi.fn>; openDashboard: ReturnType<typeof vi.fn>;
sessions: Array<{ isOngoing: boolean }>; sessions: { isOngoing: boolean }[];
projects: unknown[]; projects: unknown[];
repositoryGroups: unknown[]; repositoryGroups: unknown[];
} }
const storeState = {} as StoreState; const storeState = {} as StoreState;
const codexAccountHookState = {
snapshot: null as CodexAccountSnapshotDto | null,
loading: false,
error: null as string | null,
refresh: vi.fn(() => Promise.resolve(undefined)),
startChatgptLogin: vi.fn(() => Promise.resolve(true)),
cancelChatgptLogin: vi.fn(() => Promise.resolve(true)),
logout: vi.fn(() => Promise.resolve(true)),
};
const pluginsPanelSpy = vi.fn();
const mcpServersPanelSpy = vi.fn();
const customMcpDialogSpy = vi.fn();
vi.mock('@renderer/store', () => ({ vi.mock('@renderer/store', () => ({
useStore: (selector: (state: StoreState) => unknown) => selector(storeState), useStore: (selector: (state: StoreState) => unknown) => selector(storeState),
@ -40,8 +59,17 @@ vi.mock('@renderer/api', () => ({
mcpRegistry: {}, mcpRegistry: {},
skills: {}, skills: {},
}, },
isElectronMode: () => true,
})); }));
vi.mock('@features/codex-account/renderer', async (importOriginal) => {
const actual = await importOriginal<typeof import('@features/codex-account/renderer')>();
return {
...actual,
useCodexAccountSnapshot: () => codexAccountHookState,
};
});
vi.mock('@renderer/contexts/useTabUIContext', () => ({ vi.mock('@renderer/contexts/useTabUIContext', () => ({
useTabIdOptional: () => undefined, useTabIdOptional: () => undefined,
})); }));
@ -134,11 +162,17 @@ vi.mock('@renderer/components/extensions/ExtensionsSubTabTrigger', () => ({
})); }));
vi.mock('@renderer/components/extensions/plugins/PluginsPanel', () => ({ vi.mock('@renderer/components/extensions/plugins/PluginsPanel', () => ({
PluginsPanel: () => React.createElement('div', null, 'plugins-panel'), PluginsPanel: (props: unknown) => {
pluginsPanelSpy(props);
return React.createElement('div', null, 'plugins-panel');
},
})); }));
vi.mock('@renderer/components/extensions/mcp/McpServersPanel', () => ({ vi.mock('@renderer/components/extensions/mcp/McpServersPanel', () => ({
McpServersPanel: () => React.createElement('div', null, 'mcp-panel'), McpServersPanel: (props: unknown) => {
mcpServersPanelSpy(props);
return React.createElement('div', null, 'mcp-panel');
},
})); }));
vi.mock('@renderer/components/extensions/skills/SkillsPanel', () => ({ vi.mock('@renderer/components/extensions/skills/SkillsPanel', () => ({
@ -150,7 +184,10 @@ vi.mock('@renderer/components/extensions/apikeys/ApiKeysPanel', () => ({
})); }));
vi.mock('@renderer/components/extensions/mcp/CustomMcpServerDialog', () => ({ vi.mock('@renderer/components/extensions/mcp/CustomMcpServerDialog', () => ({
CustomMcpServerDialog: () => null, CustomMcpServerDialog: (props: unknown) => {
customMcpDialogSpy(props);
return null;
},
})); }));
vi.mock('lucide-react', () => { vi.mock('lucide-react', () => {
@ -252,7 +289,18 @@ function createLoadingMultimodelStatus(): CliInstallationStatus {
describe('ExtensionStoreView provider loading placeholders', () => { describe('ExtensionStoreView provider loading placeholders', () => {
beforeEach(() => { beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
pluginsPanelSpy.mockReset();
mcpServersPanelSpy.mockReset();
customMcpDialogSpy.mockReset();
codexAccountHookState.snapshot = null;
codexAccountHookState.loading = false;
codexAccountHookState.error = null;
codexAccountHookState.refresh.mockReset().mockResolvedValue(undefined);
codexAccountHookState.startChatgptLogin.mockReset().mockResolvedValue(true);
codexAccountHookState.cancelChatgptLogin.mockReset().mockResolvedValue(true);
codexAccountHookState.logout.mockReset().mockResolvedValue(true);
storeState.fetchPluginCatalog = vi.fn().mockResolvedValue(undefined); storeState.fetchPluginCatalog = vi.fn().mockResolvedValue(undefined);
storeState.bootstrapCliStatus = vi.fn().mockResolvedValue(undefined);
storeState.fetchCliStatus = vi.fn().mockResolvedValue(undefined); storeState.fetchCliStatus = vi.fn().mockResolvedValue(undefined);
storeState.fetchApiKeys = vi.fn().mockResolvedValue(undefined); storeState.fetchApiKeys = vi.fn().mockResolvedValue(undefined);
storeState.fetchSkillsCatalog = vi.fn().mockResolvedValue(undefined); storeState.fetchSkillsCatalog = vi.fn().mockResolvedValue(undefined);
@ -268,6 +316,11 @@ describe('ExtensionStoreView provider loading placeholders', () => {
anthropic: true, anthropic: true,
codex: true, codex: true,
}; };
storeState.appConfig = {
general: {
multimodelEnabled: true,
},
};
storeState.openDashboard = vi.fn(); storeState.openDashboard = vi.fn();
storeState.sessions = []; storeState.sessions = [];
storeState.projects = []; storeState.projects = [];
@ -290,6 +343,9 @@ describe('ExtensionStoreView provider loading placeholders', () => {
await Promise.resolve(); await Promise.resolve();
}); });
expect(storeState.bootstrapCliStatus).toHaveBeenCalledWith({ multimodelEnabled: true });
expect(storeState.fetchCliStatus).not.toHaveBeenCalled();
expect(host.textContent).toContain('Multimodel runtime capabilities'); expect(host.textContent).toContain('Multimodel runtime capabilities');
expect(host.textContent).toContain('Anthropic'); expect(host.textContent).toContain('Anthropic');
expect(host.textContent).toContain('Codex'); expect(host.textContent).toContain('Codex');
@ -303,6 +359,32 @@ describe('ExtensionStoreView provider loading placeholders', () => {
}); });
}); });
it('falls back to legacy refresh when multimodel is disabled', async () => {
storeState.appConfig = {
general: {
multimodelEnabled: false,
},
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(ExtensionStoreView));
await Promise.resolve();
await Promise.resolve();
});
expect(storeState.fetchCliStatus).toHaveBeenCalledTimes(1);
expect(storeState.bootstrapCliStatus).not.toHaveBeenCalled();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('keeps provider placeholders visible when bootstrap data still says Checking...', async () => { it('keeps provider placeholders visible when bootstrap data still says Checking...', async () => {
storeState.cliStatusLoading = false; storeState.cliStatusLoading = false;
storeState.cliProviderStatusLoading = {}; storeState.cliProviderStatusLoading = {};
@ -326,4 +408,264 @@ describe('ExtensionStoreView provider loading placeholders', () => {
await Promise.resolve(); await Promise.resolve();
}); });
}); });
it('uses the live Codex account snapshot to replace stale extension-card status', async () => {
storeState.cliStatusLoading = false;
storeState.cliProviderStatusLoading = {};
codexAccountHookState.snapshot = {
preferredAuthMode: 'chatgpt',
effectiveAuthMode: 'chatgpt',
launchAllowed: true,
launchIssueMessage: null,
launchReadinessState: 'ready_chatgpt',
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount: {
type: 'chatgpt',
email: 'user@example.com',
planType: 'pro',
},
apiKey: {
available: true,
source: 'environment',
sourceLabel: 'Detected from OPENAI_API_KEY',
},
requiresOpenaiAuth: false,
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: null,
updatedAt: new Date().toISOString(),
};
storeState.cliStatus = {
...createLoadingMultimodelStatus(),
authLoggedIn: true,
authStatusChecking: false,
providers: [
createLoadingMultimodelStatus().providers[1],
],
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(ExtensionStoreView));
await Promise.resolve();
await Promise.resolve();
});
expect(host.textContent).toContain('Codex');
expect(host.textContent).toContain('ChatGPT account ready');
expect(host.textContent).not.toContain('Checking provider status...');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('uses the live Codex snapshot even while multimodel root status is still loading', async () => {
storeState.cliStatus = null;
storeState.cliStatusLoading = true;
storeState.cliProviderStatusLoading = {};
codexAccountHookState.snapshot = {
preferredAuthMode: 'chatgpt',
effectiveAuthMode: 'chatgpt',
launchAllowed: true,
launchIssueMessage: null,
launchReadinessState: 'ready_chatgpt',
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount: {
type: 'chatgpt',
email: 'user@example.com',
planType: 'pro',
},
apiKey: {
available: true,
source: 'environment',
sourceLabel: 'Detected from OPENAI_API_KEY',
},
requiresOpenaiAuth: false,
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: {
limitId: 'plan-pro',
limitName: 'Pro',
primary: {
usedPercent: 5,
windowDurationMins: 300,
resetsAt: 1_762_547_200,
},
secondary: {
usedPercent: 41,
windowDurationMins: 10_080,
resetsAt: 1_762_891_200,
},
credits: {
hasCredits: false,
unlimited: false,
balance: null,
},
planType: 'pro',
},
updatedAt: new Date().toISOString(),
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(ExtensionStoreView));
await Promise.resolve();
await Promise.resolve();
});
expect(host.textContent).toContain('Codex');
expect(host.textContent).toContain('ChatGPT account ready');
expect(host.textContent).not.toContain('Checking extensions runtime availability');
expect(host.querySelector('button[disabled]')).toBeNull();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('does not leave the stale Codex placeholder stuck as unsupported once live snapshot truth arrives', async () => {
storeState.cliStatusLoading = false;
storeState.cliProviderStatusLoading = {};
codexAccountHookState.snapshot = {
preferredAuthMode: 'chatgpt',
effectiveAuthMode: null,
launchAllowed: false,
launchIssueMessage: 'Connect a ChatGPT account to use your Codex subscription.',
launchReadinessState: 'missing_auth',
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount: null,
apiKey: {
available: true,
source: 'environment',
sourceLabel: 'Detected from OPENAI_API_KEY',
},
requiresOpenaiAuth: true,
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: null,
updatedAt: new Date().toISOString(),
};
storeState.cliStatus = {
...createLoadingMultimodelStatus(),
authLoggedIn: true,
authStatusChecking: false,
providers: [createLoadingMultimodelStatus().providers[1]],
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(ExtensionStoreView));
await Promise.resolve();
await Promise.resolve();
});
expect(host.textContent).toContain('Codex');
expect(host.textContent).toContain('Needs setup');
expect(host.textContent).not.toContain('Unsupported');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('passes merged effective Codex status to nested extension panels and dialogs', async () => {
storeState.cliStatusLoading = true;
storeState.cliProviderStatusLoading = {};
codexAccountHookState.snapshot = {
preferredAuthMode: 'chatgpt',
effectiveAuthMode: 'chatgpt',
launchAllowed: true,
launchIssueMessage: null,
launchReadinessState: 'ready_chatgpt',
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount: {
type: 'chatgpt',
email: 'user@example.com',
planType: 'pro',
},
apiKey: {
available: false,
source: null,
sourceLabel: null,
},
requiresOpenaiAuth: false,
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: null,
updatedAt: new Date().toISOString(),
};
storeState.cliStatus = {
...createLoadingMultimodelStatus(),
authLoggedIn: true,
authStatusChecking: false,
providers: [createLoadingMultimodelStatus().providers[1]],
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(ExtensionStoreView));
await Promise.resolve();
await Promise.resolve();
});
const pluginsPanelProps = pluginsPanelSpy.mock.calls.at(-1)?.[0] as {
cliStatus?: CliInstallationStatus | null;
cliStatusLoading?: boolean;
};
const mcpPanelProps = mcpServersPanelSpy.mock.calls.at(-1)?.[0] as {
cliStatus?: CliInstallationStatus | null;
cliStatusLoading?: boolean;
};
const customDialogProps = customMcpDialogSpy.mock.calls.at(-1)?.[0] as {
cliStatus?: CliInstallationStatus | null;
cliStatusLoading?: boolean;
};
expect(pluginsPanelProps.cliStatusLoading).toBe(false);
expect(mcpPanelProps.cliStatusLoading).toBe(false);
expect(customDialogProps.cliStatusLoading).toBe(false);
expect(pluginsPanelProps.cliStatus?.providers[0]?.supported).toBe(true);
expect(pluginsPanelProps.cliStatus?.providers[0]?.statusMessage).toBe('ChatGPT account ready');
expect(mcpPanelProps.cliStatus?.providers[0]?.resolvedBackendId).toBe('codex-native');
expect(customDialogProps.cliStatus?.providers[0]?.connection?.codex?.managedAccount?.email).toBe(
'user@example.com'
);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
}); });

View file

@ -0,0 +1,318 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
import type { CliInstallationStatus } from '@shared/types';
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
interface StoreState {
apiKeys: Array<{
id: string;
providerId: string;
displayName: string;
envVarName: string;
scope: 'user';
createdAt: number;
updatedAt: number;
}>;
apiKeysLoading: boolean;
apiKeysError: string | null;
apiKeyStorageStatus: {
encryptionMethod: 'os-keychain' | 'local-aes';
backend: string;
} | null;
fetchApiKeyStorageStatus: ReturnType<typeof vi.fn>;
cliStatus: CliInstallationStatus | null;
cliStatusLoading: boolean;
appConfig: {
general: {
multimodelEnabled: boolean;
};
} | null;
}
const storeState = {} as StoreState;
const codexAccountHookState = {
snapshot: null as CodexAccountSnapshotDto | null,
loading: false,
error: null as string | null,
refresh: vi.fn(() => Promise.resolve(undefined)),
startChatgptLogin: vi.fn(() => Promise.resolve(true)),
cancelChatgptLogin: vi.fn(() => Promise.resolve(true)),
logout: vi.fn(() => Promise.resolve(true)),
};
vi.mock('@renderer/store', () => ({
useStore: (selector: (state: StoreState) => unknown) => selector(storeState),
}));
vi.mock('@features/codex-account/renderer', async (importOriginal) => {
const actual = await importOriginal<typeof import('@features/codex-account/renderer')>();
return {
...actual,
useCodexAccountSnapshot: () => codexAccountHookState,
};
});
vi.mock('@renderer/api', () => ({
isElectronMode: () => true,
}));
vi.mock('@renderer/components/ui/button', () => ({
Button: ({
children,
onClick,
}: React.PropsWithChildren<{ onClick?: () => void }>) =>
React.createElement(
'button',
{
type: 'button',
onClick,
},
children
),
}));
vi.mock('@renderer/components/ui/tooltip', () => ({
Tooltip: ({ children }: React.PropsWithChildren) => React.createElement(React.Fragment, null, children),
TooltipTrigger: ({ children }: React.PropsWithChildren) =>
React.createElement(React.Fragment, null, children),
TooltipContent: () => null,
}));
vi.mock('@renderer/components/extensions/apikeys/ApiKeyCard', () => ({
ApiKeyCard: ({ apiKey }: { apiKey: { displayName: string } }) =>
React.createElement('div', null, apiKey.displayName),
}));
vi.mock('@renderer/components/extensions/apikeys/ApiKeyFormDialog', () => ({
ApiKeyFormDialog: () => null,
}));
vi.mock('lucide-react', () => {
const Icon = (props: React.SVGProps<SVGSVGElement>) => React.createElement('svg', props);
return {
AlertTriangle: Icon,
Info: Icon,
Key: Icon,
Plus: Icon,
};
});
import { ApiKeysPanel } from '@renderer/components/extensions/apikeys/ApiKeysPanel';
function createCliStatus(): CliInstallationStatus {
return {
flavor: 'agent_teams_orchestrator',
displayName: 'Multimodel runtime',
supportsSelfUpdate: false,
showVersionDetails: false,
showBinaryPath: false,
installed: true,
installedVersion: null,
binaryPath: '/usr/local/bin/agent-teams',
launchError: null,
latestVersion: null,
updateAvailable: false,
authLoggedIn: true,
authStatusChecking: false,
authMethod: null,
providers: [
{
providerId: 'anthropic',
displayName: 'Anthropic',
supported: true,
authenticated: true,
authMethod: 'oauth_token',
verificationState: 'verified',
statusMessage: 'Connected',
models: [],
modelAvailability: [],
canLoginFromUi: true,
capabilities: {
teamLaunch: true,
oneShot: true,
extensions: createDefaultCliExtensionCapabilities(),
},
selectedBackendId: null,
resolvedBackendId: null,
availableBackends: [],
externalRuntimeDiagnostics: [],
backend: null,
connection: {
supportsOAuth: true,
supportsApiKey: true,
configurableAuthModes: ['auto', 'oauth', 'api_key'],
configuredAuthMode: 'auto',
apiKeyConfigured: false,
apiKeySource: null,
apiKeySourceLabel: null,
},
},
{
providerId: 'codex',
displayName: 'Codex',
supported: true,
authenticated: false,
authMethod: null,
verificationState: 'unknown',
statusMessage: 'Connect a ChatGPT account to use your Codex subscription.',
models: [],
modelAvailability: [],
canLoginFromUi: false,
capabilities: {
teamLaunch: true,
oneShot: true,
extensions: createDefaultCliExtensionCapabilities(),
},
selectedBackendId: 'codex-native',
resolvedBackendId: null,
availableBackends: [],
externalRuntimeDiagnostics: [],
backend: null,
connection: null,
},
],
};
}
describe('ApiKeysPanel', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.apiKeys = [];
storeState.apiKeysLoading = false;
storeState.apiKeysError = null;
storeState.apiKeyStorageStatus = {
encryptionMethod: 'os-keychain',
backend: 'Keychain Access',
};
storeState.fetchApiKeyStorageStatus = vi.fn().mockResolvedValue(undefined);
storeState.cliStatus = createCliStatus();
storeState.cliStatusLoading = false;
storeState.appConfig = {
general: {
multimodelEnabled: true,
},
};
codexAccountHookState.snapshot = null;
codexAccountHookState.loading = false;
codexAccountHookState.error = null;
});
afterEach(() => {
document.body.innerHTML = '';
vi.unstubAllGlobals();
});
it('uses the live Codex account snapshot for the Codex runtime card', async () => {
codexAccountHookState.snapshot = {
preferredAuthMode: 'chatgpt',
effectiveAuthMode: 'chatgpt',
launchAllowed: true,
launchIssueMessage: null,
launchReadinessState: 'ready_chatgpt',
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount: {
type: 'chatgpt',
email: 'user@example.com',
planType: 'pro',
},
apiKey: {
available: true,
source: 'environment',
sourceLabel: 'Detected from OPENAI_API_KEY',
},
requiresOpenaiAuth: false,
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: null,
updatedAt: new Date().toISOString(),
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(ApiKeysPanel, {
projectPath: null,
projectLabel: null,
})
);
await Promise.resolve();
await Promise.resolve();
});
expect(host.textContent).toContain('Codex runtime');
expect(host.textContent).toContain('Connected');
expect(host.textContent).toContain('Current source: Detected from OPENAI_API_KEY.');
expect(host.textContent).toContain('ChatGPT account ready');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('uses the live Codex snapshot even while multimodel provider status is still loading', async () => {
storeState.cliStatus = null;
storeState.cliStatusLoading = true;
codexAccountHookState.snapshot = {
preferredAuthMode: 'chatgpt',
effectiveAuthMode: 'chatgpt',
launchAllowed: true,
launchIssueMessage: null,
launchReadinessState: 'ready_chatgpt',
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount: {
type: 'chatgpt',
email: 'user@example.com',
planType: 'pro',
},
apiKey: {
available: true,
source: 'environment',
sourceLabel: 'Detected from OPENAI_API_KEY',
},
requiresOpenaiAuth: false,
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: null,
updatedAt: new Date().toISOString(),
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(ApiKeysPanel, {
projectPath: null,
projectLabel: null,
})
);
await Promise.resolve();
await Promise.resolve();
});
expect(host.textContent).toContain('Codex runtime');
expect(host.textContent).toContain('Connected');
expect(host.textContent).toContain('ChatGPT account ready');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CLI_NOT_FOUND_MARKER } from '@shared/constants/cli'; import { CLI_NOT_FOUND_MARKER } from '@shared/constants/cli';
interface StoreState { interface StoreState {
mcpBrowseCatalog: Array<{ mcpBrowseCatalog: {
id: string; id: string;
name: string; name: string;
description: string; description: string;
@ -14,15 +14,15 @@ interface StoreState {
envVars: []; envVars: [];
tools: []; tools: [];
requiresAuth: boolean; requiresAuth: boolean;
}>; }[];
mcpBrowseNextCursor?: string; mcpBrowseNextCursor?: string;
mcpBrowseLoading: boolean; mcpBrowseLoading: boolean;
mcpBrowseError: string | null; mcpBrowseError: string | null;
mcpBrowse: ReturnType<typeof vi.fn>; mcpBrowse: ReturnType<typeof vi.fn>;
mcpInstalledServers: Array<{ name: string; scope: 'local' | 'user' | 'project' }>; mcpInstalledServers: { name: string; scope: 'local' | 'user' | 'project' }[];
mcpInstalledServersByProjectPath?: Record< mcpInstalledServersByProjectPath?: Record<
string, string,
Array<{ name: string; scope: 'local' | 'user' | 'project' }> { name: string; scope: 'local' | 'user' | 'project' }[]
>; >;
fetchMcpGitHubStars: ReturnType<typeof vi.fn>; fetchMcpGitHubStars: ReturnType<typeof vi.fn>;
mcpDiagnostics: Record<string, never>; mcpDiagnostics: Record<string, never>;
@ -310,8 +310,8 @@ describe('McpServersPanel initial browse loading', () => {
await Promise.resolve(); await Promise.resolve();
}); });
expect(host.textContent).toContain('Configured runtime not available'); expect(host.textContent).toContain('Multimodel runtime not available');
expect(host.textContent).toContain('MCP health checks require the configured runtime'); expect(host.textContent).toContain('MCP health checks require Multimodel runtime');
expect(host.textContent).not.toContain('Claude CLI not installed'); expect(host.textContent).not.toContain('Claude CLI not installed');
await act(async () => { await act(async () => {
@ -356,7 +356,7 @@ describe('McpServersPanel initial browse loading', () => {
button.textContent?.includes('Check Status') button.textContent?.includes('Check Status')
); );
expect(checkStatusButton).toBeDefined(); expect(checkStatusButton).toBeDefined();
expect((checkStatusButton as HTMLButtonElement).disabled).toBe(true); expect((checkStatusButton!).disabled).toBe(true);
await act(async () => { await act(async () => {
root.unmount(); root.unmount();
@ -426,4 +426,86 @@ describe('McpServersPanel initial browse loading', () => {
await Promise.resolve(); await Promise.resolve();
}); });
}); });
it('uses the effective runtime status override for diagnostics gating during background refresh', async () => {
storeState.cliStatus = null;
storeState.cliStatusLoading = true;
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(McpServersPanel, {
projectPath: null,
mcpSearchQuery: '',
mcpSearch: vi.fn(),
mcpSearchResults: [],
mcpSearchLoading: false,
mcpSearchWarnings: [],
selectedMcpServerId: null,
setSelectedMcpServerId: vi.fn(),
cliStatus: {
flavor: 'agent_teams_orchestrator',
displayName: 'Multimodel runtime',
installed: true,
authLoggedIn: false,
binaryPath: '/usr/local/bin/agent-teams',
launchError: null,
providers: [],
},
cliStatusLoading: false,
})
);
await Promise.resolve();
});
expect(storeState.runMcpDiagnostics).toHaveBeenCalledTimes(1);
expect(host.textContent).not.toContain('Checking runtime availability...');
expect(host.textContent).not.toContain('The configured runtime is required.');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('does not block diagnostics when a usable runtime status already exists during background refresh', async () => {
storeState.cliStatus = {
flavor: 'agent_teams_orchestrator',
installed: true,
binaryPath: '/usr/local/bin/agent-teams',
launchError: null,
};
storeState.cliStatusLoading = true;
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(McpServersPanel, {
projectPath: null,
mcpSearchQuery: '',
mcpSearch: vi.fn(),
mcpSearchResults: [],
mcpSearchLoading: false,
mcpSearchWarnings: [],
selectedMcpServerId: null,
setSelectedMcpServerId: vi.fn(),
})
);
await Promise.resolve();
});
expect(storeState.runMcpDiagnostics).toHaveBeenCalledTimes(1);
expect(host.textContent).not.toContain('Checking runtime status...');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
}); });

Some files were not shown because too many files have changed in this diff Show more