feat(codex): rewrite to app-server rich variant Merge pull request #80 from 777genius/spike/codex-native-runtime-plan
Merge pull request #80 from 777genius/spike/codex-native-runtime-plan
This commit is contained in:
commit
4931115b7a
158 changed files with 26442 additions and 1785 deletions
5195
docs/research/codex-app-server-account-feature-plan.md
Normal file
5195
docs/research/codex-app-server-account-feature-plan.md
Normal file
File diff suppressed because it is too large
Load diff
303
docs/research/codex-app-server-account-signoff.md
Normal file
303
docs/research/codex-app-server-account-signoff.md
Normal 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
|
||||
File diff suppressed because it is too large
Load diff
1216
docs/research/codex-native-runtime-phase-0-implementation-spec.md
Normal file
1216
docs/research/codex-native-runtime-phase-0-implementation-spec.md
Normal file
File diff suppressed because it is too large
Load diff
226
docs/research/codex-native-runtime-phase-0-signoff-evidence.md
Normal file
226
docs/research/codex-native-runtime-phase-0-signoff-evidence.md
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
# Codex Native Runtime - Phase 0 Sign-off Evidence
|
||||
|
||||
Captured on 2026-04-19.
|
||||
|
||||
This file is the repo-visible evidence package referenced by:
|
||||
|
||||
- [codex-native-runtime-phase-0-implementation-spec.md](./codex-native-runtime-phase-0-implementation-spec.md)
|
||||
|
||||
## Verdict
|
||||
|
||||
Phase 0 sign-off evidence is now captured.
|
||||
|
||||
What this proves:
|
||||
|
||||
- the `codex-native` lane executes through the raw `codex exec --json` seam
|
||||
- persisted transcript projection remains parseable by current `claude_team` readers
|
||||
- `ephemeral` and `persistent` runs keep different history-completeness truth
|
||||
- thread status, warning attribution, executable identity, and usage authority survive end-to-end
|
||||
- old Codex lane fallback truth remains covered by targeted regression tests
|
||||
|
||||
What this does **not** mean:
|
||||
|
||||
- `codex-native` should be unlocked for general runtime selection
|
||||
- `auto` should start resolving to `codex-native`
|
||||
- broader plugin or interactive capability claims are now safe
|
||||
|
||||
## Command Package
|
||||
|
||||
### `agent_teams_orchestrator`
|
||||
|
||||
Executed:
|
||||
|
||||
```bash
|
||||
bun test src/services/codexNative/signOffHarness.test.ts \
|
||||
src/services/codexNative/statusAuthority.test.ts \
|
||||
src/services/codexNative/transcriptProjector.test.ts \
|
||||
src/services/codexNative/turnExecutor.test.ts \
|
||||
src/services/codexNative/execRunner.test.ts \
|
||||
src/services/codexNative/jsonlMapper.test.ts \
|
||||
src/services/runtimeBackends/codexBackendResolver.test.ts \
|
||||
src/services/runtimeBackends/registry.agentTeams.test.ts
|
||||
```
|
||||
|
||||
Observed result:
|
||||
|
||||
- `27 pass`
|
||||
- `0 fail`
|
||||
|
||||
### `claude_team`
|
||||
|
||||
Executed:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run \
|
||||
test/main/utils/jsonl.test.ts \
|
||||
test/main/services/parsing/SessionParser.test.ts \
|
||||
test/main/services/team/BoardTaskExactLogStrictParser.test.ts \
|
||||
test/main/ipc/configValidation.test.ts \
|
||||
test/main/services/runtime/ProviderConnectionService.test.ts \
|
||||
test/main/services/runtime/providerAwareCliEnv.test.ts \
|
||||
test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts \
|
||||
test/renderer/components/runtime/providerConnectionUi.test.ts \
|
||||
test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts \
|
||||
test/renderer/components/cli/CliStatusVisibility.test.ts
|
||||
```
|
||||
|
||||
Observed result:
|
||||
|
||||
- `134 pass`
|
||||
- `0 fail`
|
||||
|
||||
### Diff cleanliness
|
||||
|
||||
Executed:
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Observed result:
|
||||
|
||||
- clean in both worktrees
|
||||
|
||||
## Live Native Run Evidence
|
||||
|
||||
### Common live-run facts
|
||||
|
||||
Observed from both runs:
|
||||
|
||||
- native binary path: `/usr/local/bin/codex`
|
||||
- native binary source: `system-path`
|
||||
- native binary version: `codex-cli 0.117.0`
|
||||
- credential input source for the sign-off harness: `OPENAI_API_KEY`
|
||||
- credential source observed by the runner: `explicit-api-key`
|
||||
- capability profile: `headless-limited`
|
||||
- final assistant text: `OK`
|
||||
|
||||
### Ephemeral run
|
||||
|
||||
Executed:
|
||||
|
||||
```bash
|
||||
bun run ./scripts/codex-native-phase0-signoff.ts \
|
||||
--cwd /tmp \
|
||||
--prompt 'Reply only with OK' \
|
||||
--ephemeral
|
||||
```
|
||||
|
||||
Observed result:
|
||||
|
||||
- thread id: `019da680-6f43-7e10-824c-4d985bcdca12`
|
||||
- completion policy: `ephemeral`
|
||||
- final history completeness: `live-only`
|
||||
- final usage authority: `live-turn-completed`
|
||||
- assistant usage:
|
||||
- input tokens: `23616`
|
||||
- cached input tokens: `0`
|
||||
- output tokens: `42`
|
||||
|
||||
History authority proof:
|
||||
|
||||
- projected warning subtype: `codex_native_warning`
|
||||
- projected warning source: `history`
|
||||
- observed warning text contained:
|
||||
- `thread/read failed while backfilling turn items for turn completion`
|
||||
- `ephemeral threads do not support includeTurns`
|
||||
|
||||
This is the explicit proof that `ephemeral` live stream does **not** equal canonical hydrated history.
|
||||
|
||||
### Persistent run
|
||||
|
||||
Executed:
|
||||
|
||||
```bash
|
||||
bun run ./scripts/codex-native-phase0-signoff.ts \
|
||||
--cwd /tmp \
|
||||
--prompt 'Reply only with OK' \
|
||||
--persistent
|
||||
```
|
||||
|
||||
Observed result:
|
||||
|
||||
- thread id: `019da680-6f42-77c0-94f1-4e450a69d1f1`
|
||||
- completion policy: `persistent`
|
||||
- final history completeness: `explicit-hydration-required`
|
||||
- final usage authority: `live-turn-completed`
|
||||
- assistant usage:
|
||||
- input tokens: `23616`
|
||||
- cached input tokens: `0`
|
||||
- output tokens: `33`
|
||||
|
||||
This is the explicit proof that persistent native runs keep a different history-completeness contract from `ephemeral` runs.
|
||||
|
||||
## Warning Attribution Proof
|
||||
|
||||
The live runs produced both:
|
||||
|
||||
- process/runtime warnings
|
||||
- history-completeness warnings
|
||||
|
||||
Observed process-attributed warnings included:
|
||||
|
||||
- plugin cache / featured plugins unauthorized warnings
|
||||
- state DB migration mismatch warnings
|
||||
- shell snapshot timeout warnings
|
||||
- MCP process-group termination warnings
|
||||
|
||||
Observed history-attributed warning included:
|
||||
|
||||
- `thread/read failed while backfilling turn items for turn completion: ... ephemeral threads do not support includeTurns`
|
||||
|
||||
This proves the lane now keeps `process` and `history` warning truth distinct in projected transcript rows.
|
||||
|
||||
## Thread-status Proof
|
||||
|
||||
Observed projected system rows included:
|
||||
|
||||
- `codex_native_thread_status`
|
||||
- `running`
|
||||
- `completed`
|
||||
|
||||
This proves the lane now writes native thread-status authority into persisted transcript-compatible rows instead of forcing UI and replay consumers to infer health from provider-global process truth.
|
||||
|
||||
## Parser And Exact-log Proof
|
||||
|
||||
Covered by green targeted tests:
|
||||
|
||||
- `test/main/utils/jsonl.test.ts`
|
||||
- `test/main/services/parsing/SessionParser.test.ts`
|
||||
- `test/main/services/team/BoardTaskExactLogStrictParser.test.ts`
|
||||
|
||||
These tests prove:
|
||||
|
||||
- projected assistant usage remains parseable
|
||||
- projected warning/source metadata remains parseable
|
||||
- projected execution-summary/history metadata remains parseable
|
||||
- exact-log readers do not drop the native authority rows
|
||||
|
||||
## Degraded Old-lane Fallback Proof
|
||||
|
||||
Covered by green targeted tests:
|
||||
|
||||
- `src/services/runtimeBackends/codexBackendResolver.test.ts`
|
||||
- `src/services/runtimeBackends/registry.agentTeams.test.ts`
|
||||
|
||||
Those tests prove:
|
||||
|
||||
- `auto` still does not silently resolve to `codex-native`
|
||||
- native lane remains unavailable without:
|
||||
- feature flag
|
||||
- binary
|
||||
- `CODEX_API_KEY`
|
||||
- old Codex lane remains the truthful fallback when native is absent or degraded
|
||||
|
||||
## Sign-off Conclusion
|
||||
|
||||
✅ The Phase 0 code path is implementation-complete and evidence-backed.
|
||||
|
||||
⚠️ The lane should still remain:
|
||||
|
||||
- feature-flagged
|
||||
- non-default
|
||||
- non-auto-resolved
|
||||
- non-selectable for normal runtime switching
|
||||
|
||||
That remaining lock is now a rollout-policy choice, not a missing-code problem.
|
||||
204
docs/research/codex-native-runtime-phase-1-signoff-evidence.md
Normal file
204
docs/research/codex-native-runtime-phase-1-signoff-evidence.md
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
# Codex Native Runtime - Phase 1 Sign-off Evidence
|
||||
|
||||
Captured on 2026-04-19.
|
||||
|
||||
This file records the repo-visible evidence package for the Phase 1 exit gate described in:
|
||||
|
||||
- [codex-native-runtime-integration-decision.md](./codex-native-runtime-integration-decision.md)
|
||||
|
||||
## Verdict
|
||||
|
||||
Phase 1 internal unlock preparation is now complete.
|
||||
|
||||
What this proves:
|
||||
|
||||
- `codex-native` can be enabled intentionally through the internal unlock policy
|
||||
- old Codex lanes remain the default and `auto` still resolves to the old adapter/API world
|
||||
- lane-specific rollout states are explicit and honest:
|
||||
- `locked`
|
||||
- `ready`
|
||||
- `authentication-required`
|
||||
- `runtime-missing`
|
||||
- those states now survive all the way through:
|
||||
- orchestrator runtime status
|
||||
- bridge parsing
|
||||
- dashboard/runtime copy
|
||||
- settings/runtime copy
|
||||
- provisioning summaries
|
||||
|
||||
What this does **not** mean:
|
||||
|
||||
- `codex-native` should become the default Codex lane
|
||||
- `auto` should start resolving to `codex-native`
|
||||
- broader approval, plugin, or interactive parity claims are now safe
|
||||
- limited internal unlock has already started
|
||||
|
||||
That is Phase 2 territory.
|
||||
|
||||
## Command Package
|
||||
|
||||
### `agent_teams_orchestrator`
|
||||
|
||||
Executed:
|
||||
|
||||
```bash
|
||||
bun test src/services/runtimeBackends/codexBackendResolver.test.ts \
|
||||
src/services/runtimeBackends/registry.agentTeams.test.ts \
|
||||
src/services/runtimeBackends/registry.codexNativeStates.test.ts
|
||||
```
|
||||
|
||||
Observed result:
|
||||
|
||||
- `14 pass`
|
||||
- `0 fail`
|
||||
|
||||
Executed:
|
||||
|
||||
```bash
|
||||
bun run signoff:codex-native-phase1
|
||||
```
|
||||
|
||||
Observed result:
|
||||
|
||||
- exit code `0`
|
||||
- five live CLI rollout scenarios verified:
|
||||
- `locked`
|
||||
- `internal-unlock-ready`
|
||||
- `authentication-required`
|
||||
- `runtime-missing`
|
||||
- `auto-fallback-stays-old-lane`
|
||||
|
||||
### `claude_team`
|
||||
|
||||
Executed:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run \
|
||||
test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts \
|
||||
test/main/services/runtime/providerAwareCliEnv.test.ts \
|
||||
test/main/services/runtime/ProviderConnectionService.test.ts \
|
||||
test/renderer/components/runtime/providerConnectionUi.test.ts \
|
||||
test/renderer/components/runtime/ProviderRuntimeBackendSelector.test.ts \
|
||||
test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts \
|
||||
test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts \
|
||||
test/renderer/components/cli/CliStatusVisibility.test.ts \
|
||||
test/main/services/parsing/CodexNativePhase0Smoke.test.ts
|
||||
```
|
||||
|
||||
Observed result:
|
||||
|
||||
- `9` files passed
|
||||
- `83` tests passed
|
||||
- `0` failures
|
||||
|
||||
## Live CLI Rollout Evidence
|
||||
|
||||
Runner:
|
||||
|
||||
```bash
|
||||
runtime status --provider codex --json
|
||||
```
|
||||
|
||||
Observed live scenarios:
|
||||
|
||||
### Locked
|
||||
|
||||
- selected backend: `codex-native`
|
||||
- resolved backend: `codex-native`
|
||||
- provider status: `Codex native runtime ready`
|
||||
- native option:
|
||||
- `selectable=false`
|
||||
- `available=true`
|
||||
- `state=locked`
|
||||
- `audience=internal`
|
||||
- `statusMessage=Ready but locked`
|
||||
|
||||
### Internal unlock ready
|
||||
|
||||
- selected backend: `codex-native`
|
||||
- resolved backend: `codex-native`
|
||||
- provider status: `Codex native runtime ready`
|
||||
- native option:
|
||||
- `selectable=true`
|
||||
- `available=true`
|
||||
- `state=ready`
|
||||
- `audience=internal`
|
||||
- `statusMessage=Ready for internal use`
|
||||
|
||||
### Authentication required
|
||||
|
||||
- selected backend: `codex-native`
|
||||
- resolved backend: `null`
|
||||
- provider status: `Codex native runtime not ready`
|
||||
- native option:
|
||||
- `selectable=false`
|
||||
- `available=false`
|
||||
- `state=authentication-required`
|
||||
- `audience=internal`
|
||||
- `statusMessage=Authentication required`
|
||||
|
||||
### Runtime missing
|
||||
|
||||
- selected backend: `codex-native`
|
||||
- resolved backend: `null`
|
||||
- provider status: `Codex native runtime not ready`
|
||||
- native option:
|
||||
- `selectable=false`
|
||||
- `available=false`
|
||||
- `state=runtime-missing`
|
||||
- `audience=internal`
|
||||
- `statusMessage=Codex CLI not found`
|
||||
|
||||
### Auto fallback stays on the old lane
|
||||
|
||||
- selected backend: `auto`
|
||||
- resolved backend: `api`
|
||||
- provider status: `Resolved to OpenAI API`
|
||||
- native option remains visible for internal rollout:
|
||||
- `selectable=true`
|
||||
- `available=true`
|
||||
- `state=ready`
|
||||
- `audience=internal`
|
||||
- `statusMessage=Ready for internal use`
|
||||
|
||||
This is the explicit proof that internal unlock availability does **not** mutate `auto` resolution.
|
||||
|
||||
## App-facing Truth Proof
|
||||
|
||||
Covered by green targeted tests:
|
||||
|
||||
- `test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts`
|
||||
- `test/main/services/runtime/providerAwareCliEnv.test.ts`
|
||||
- `test/main/services/runtime/ProviderConnectionService.test.ts`
|
||||
- `test/renderer/components/runtime/providerConnectionUi.test.ts`
|
||||
- `test/renderer/components/runtime/ProviderRuntimeBackendSelector.test.ts`
|
||||
- `test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts`
|
||||
- `test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts`
|
||||
- `test/renderer/components/cli/CliStatusVisibility.test.ts`
|
||||
|
||||
These tests prove:
|
||||
|
||||
- internal unlock state survives bridge parsing
|
||||
- internal unlock env survives provider-aware child env building
|
||||
- dashboard and settings do not flatten native rollout states into generic `Connected via API key`
|
||||
- locked/runtime-missing/auth-required states stay visible in user-facing copy
|
||||
- provisioning summaries keep native rollout state visible
|
||||
|
||||
## Phase 1 Exit Gate Conclusion
|
||||
|
||||
✅ The Phase 1 exit gate is satisfied.
|
||||
|
||||
The lane can now be enabled intentionally by internal users, while:
|
||||
|
||||
- old Codex lanes remain the safe default
|
||||
- `auto` still avoids `codex-native`
|
||||
- degraded or blocked native states remain explicit and honest
|
||||
|
||||
⚠️ The lane should still remain:
|
||||
|
||||
- non-default
|
||||
- explicitly internal
|
||||
- rollout-gated
|
||||
- conservative in capability claims
|
||||
|
||||
The next step is **Phase 2 - limited internal unlock**, not broad rollout.
|
||||
199
docs/research/codex-native-runtime-phase-4-signoff-evidence.md
Normal file
199
docs/research/codex-native-runtime-phase-4-signoff-evidence.md
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
# Codex Native Runtime - Phase 4 Sign-off Evidence
|
||||
|
||||
Captured on 2026-04-19.
|
||||
|
||||
This file records the repo-visible evidence package for the final native-only Codex cutover.
|
||||
|
||||
Related documents:
|
||||
|
||||
- [codex-native-runtime-integration-decision.md](./codex-native-runtime-integration-decision.md)
|
||||
- [codex-native-runtime-phase-1-signoff-evidence.md](./codex-native-runtime-phase-1-signoff-evidence.md)
|
||||
|
||||
## Verdict
|
||||
|
||||
Phase 4 legacy removal is now complete.
|
||||
|
||||
What this proves:
|
||||
|
||||
- `codex-native` is now the only Codex runtime lane
|
||||
- old `adapter` and `api` Codex lanes are no longer launchable through active runtime code paths
|
||||
- Codex runtime status now exposes a single native option instead of a mixed legacy/native selector
|
||||
- stored legacy backend values normalize forward to `codex-native`
|
||||
- UI-facing Codex status, model availability, launch identity, replay parsing, and provisioning all remain truthful after legacy removal
|
||||
|
||||
What this does **not** mean:
|
||||
|
||||
- plugin execution parity is now guaranteed for multimodel Codex sessions
|
||||
- broader app-server or interactive-request parity has been added
|
||||
- Codex runtime failures silently fall back to another hidden Codex implementation
|
||||
|
||||
## Command Package
|
||||
|
||||
### `agent_teams_orchestrator`
|
||||
|
||||
Executed:
|
||||
|
||||
```bash
|
||||
bun test src/services/runtimeBackends/codexBackendResolver.test.ts \
|
||||
src/services/runtimeBackends/registry.codexNativeStates.test.ts \
|
||||
src/services/runtimeBackends/registry.agentTeams.test.ts \
|
||||
src/utils/swarm/spawnUtils.test.ts
|
||||
```
|
||||
|
||||
Observed result:
|
||||
|
||||
- `23 pass`
|
||||
- `0 fail`
|
||||
|
||||
Executed:
|
||||
|
||||
```bash
|
||||
bun run signoff:codex-native-phase4
|
||||
```
|
||||
|
||||
Observed result:
|
||||
|
||||
- exit code `0`
|
||||
- four live CLI native-only scenarios verified:
|
||||
- `ready`
|
||||
- `authentication-required`
|
||||
- `runtime-missing`
|
||||
- `openai-api-key-also-works`
|
||||
|
||||
### `claude_team`
|
||||
|
||||
Executed:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run \
|
||||
test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts \
|
||||
test/main/services/runtime/providerAwareCliEnv.test.ts \
|
||||
test/main/services/runtime/ProviderConnectionService.test.ts \
|
||||
test/main/ipc/configValidation.test.ts \
|
||||
test/main/services/team/TeamProvisioningService.test.ts \
|
||||
test/main/services/parsing/CodexNativePhase0Smoke.test.ts \
|
||||
test/main/services/parsing/SessionParser.test.ts \
|
||||
test/main/services/team/BoardTaskExactLogStrictParser.test.ts \
|
||||
test/renderer/components/runtime/providerConnectionUi.test.ts \
|
||||
test/renderer/components/runtime/ProviderRuntimeBackendSelector.test.ts \
|
||||
test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts \
|
||||
test/renderer/components/cli/CliStatusVisibility.test.ts \
|
||||
test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts \
|
||||
test/renderer/components/team/dialogs/launchDialogPrefill.test.ts \
|
||||
test/renderer/utils/memberRuntimeSummary.test.ts \
|
||||
test/renderer/utils/teamModelAvailability.test.ts
|
||||
```
|
||||
|
||||
Observed result:
|
||||
|
||||
- `16` files passed
|
||||
- `180` tests passed
|
||||
- `0` failures
|
||||
|
||||
## Live Native-only Status Evidence
|
||||
|
||||
Runner:
|
||||
|
||||
```bash
|
||||
runtime status --provider codex --json
|
||||
```
|
||||
|
||||
Observed live scenarios:
|
||||
|
||||
### Ready
|
||||
|
||||
- selected backend: `codex-native`
|
||||
- resolved backend: `codex-native`
|
||||
- provider status: `Codex native runtime ready`
|
||||
- native option:
|
||||
- `selectable=true`
|
||||
- `available=true`
|
||||
- `state=ready`
|
||||
- `audience=general`
|
||||
- `statusMessage=Ready`
|
||||
|
||||
### Authentication required
|
||||
|
||||
- selected backend: `codex-native`
|
||||
- resolved backend: `null`
|
||||
- provider status: `Codex native runtime unavailable`
|
||||
- native option:
|
||||
- `selectable=false`
|
||||
- `available=false`
|
||||
- `state=authentication-required`
|
||||
- `audience=general`
|
||||
- `statusMessage=Authentication required`
|
||||
|
||||
### Runtime missing
|
||||
|
||||
- selected backend: `codex-native`
|
||||
- resolved backend: `null`
|
||||
- provider status: `Codex native runtime unavailable`
|
||||
- native option:
|
||||
- `selectable=false`
|
||||
- `available=false`
|
||||
- `state=runtime-missing`
|
||||
- `audience=general`
|
||||
- `statusMessage=Codex CLI not found`
|
||||
|
||||
### `OPENAI_API_KEY` also works
|
||||
|
||||
- selected backend: `codex-native`
|
||||
- resolved backend: `codex-native`
|
||||
- provider status: `Codex native runtime ready`
|
||||
- explicit proof that the native lane still accepts:
|
||||
- `CODEX_API_KEY`
|
||||
- or `OPENAI_API_KEY`
|
||||
|
||||
This is the explicit proof that the final cutover no longer depends on a legacy adapter/API runtime seam while still preserving the supported credential surface.
|
||||
|
||||
## App-facing Native-only Truth Proof
|
||||
|
||||
Covered by green targeted tests:
|
||||
|
||||
- `test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts`
|
||||
- `test/main/services/runtime/providerAwareCliEnv.test.ts`
|
||||
- `test/main/services/runtime/ProviderConnectionService.test.ts`
|
||||
- `test/main/ipc/configValidation.test.ts`
|
||||
- `test/main/services/team/TeamProvisioningService.test.ts`
|
||||
- `test/main/services/parsing/CodexNativePhase0Smoke.test.ts`
|
||||
- `test/main/services/parsing/SessionParser.test.ts`
|
||||
- `test/main/services/team/BoardTaskExactLogStrictParser.test.ts`
|
||||
- `test/renderer/components/runtime/providerConnectionUi.test.ts`
|
||||
- `test/renderer/components/runtime/ProviderRuntimeBackendSelector.test.ts`
|
||||
- `test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts`
|
||||
- `test/renderer/components/cli/CliStatusVisibility.test.ts`
|
||||
- `test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts`
|
||||
- `test/renderer/components/team/dialogs/launchDialogPrefill.test.ts`
|
||||
- `test/renderer/utils/memberRuntimeSummary.test.ts`
|
||||
- `test/renderer/utils/teamModelAvailability.test.ts`
|
||||
|
||||
These tests prove:
|
||||
|
||||
- legacy Codex backend values normalize forward to `codex-native`
|
||||
- settings and dashboard now describe Codex as native-first, not adapter/API-first
|
||||
- provider backend identity survives team launch, relaunch, and launch-prefill flows
|
||||
- parser and exact-log readers stay truthful for native transcript authority rows
|
||||
- provisioning summaries and member runtime summaries no longer flatten native truth into old Codex copy
|
||||
- team model availability is keyed to the native runtime path instead of old ChatGPT-subscription heuristics
|
||||
|
||||
## Legacy Removal Proof
|
||||
|
||||
Covered by green targeted tests and runtime sign-off:
|
||||
|
||||
- orchestrator runtime backend resolver now exposes only `codex-native`
|
||||
- runtime registry now exposes a single Codex backend option
|
||||
- no active runtime branch launches Codex through:
|
||||
- `adapter`
|
||||
- `api`
|
||||
- old transport-only smoke/signoff scripts tied to legacy Codex runtime were removed
|
||||
|
||||
This is the explicit proof that Phase 4 is a real cutover, not just a UI relabeling.
|
||||
|
||||
## Sign-off Conclusion
|
||||
|
||||
✅ The Phase 4 exit gate is satisfied.
|
||||
|
||||
Codex inside the multimodel runtime is now native-only.
|
||||
|
||||
There is no longer a product-supported legacy Codex runtime lane to roll back to inside normal UI flows.
|
||||
15
src/features/codex-account/contracts/api.ts
Normal file
15
src/features/codex-account/contracts/api.ts
Normal 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;
|
||||
}
|
||||
6
src/features/codex-account/contracts/channels.ts
Normal file
6
src/features/codex-account/contracts/channels.ts
Normal 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';
|
||||
83
src/features/codex-account/contracts/dto.ts
Normal file
83
src/features/codex-account/contracts/dto.ts
Normal 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;
|
||||
}
|
||||
3
src/features/codex-account/contracts/index.ts
Normal file
3
src/features/codex-account/contracts/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export type * from './api';
|
||||
export * from './channels';
|
||||
export type * from './dto';
|
||||
|
|
@ -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.',
|
||||
};
|
||||
}
|
||||
3
src/features/codex-account/index.ts
Normal file
3
src/features/codex-account/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export type * from './contracts';
|
||||
export type { CodexLaunchReadinessResult } from './core/domain/evaluateCodexLaunchReadiness';
|
||||
export { evaluateCodexLaunchReadiness } from './core/domain/evaluateCodexLaunchReadiness';
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
6
src/features/codex-account/main/index.ts
Normal file
6
src/features/codex-account/main/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export {
|
||||
registerCodexAccountIpc,
|
||||
removeCodexAccountIpc,
|
||||
} from './adapters/input/ipc/registerCodexAccountIpc';
|
||||
export type { CodexAccountFeatureFacade } from './composition/createCodexAccountFeature';
|
||||
export { createCodexAccountFeature } from './composition/createCodexAccountFeature';
|
||||
|
|
@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
1
src/features/codex-account/preload/index.ts
Normal file
1
src/features/codex-account/preload/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { createCodexAccountBridge } from './createCodexAccountBridge';
|
||||
|
|
@ -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]
|
||||
);
|
||||
}
|
||||
14
src/features/codex-account/renderer/index.ts
Normal file
14
src/features/codex-account/renderer/index.ts
Normal 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';
|
||||
|
|
@ -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
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
129
src/features/codex-account/renderer/rateLimitDisplay.ts
Normal file
129
src/features/codex-account/renderer/rateLimitDisplay.ts
Normal 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';
|
||||
}
|
||||
|
|
@ -9,12 +9,14 @@ import { ClaudeRecentProjectsSourceAdapter } from '../adapters/output/sources/Cl
|
|||
import { CodexRecentProjectsSourceAdapter } from '../adapters/output/sources/CodexRecentProjectsSourceAdapter';
|
||||
import { InMemoryRecentProjectsCache } from '../infrastructure/cache/InMemoryRecentProjectsCache';
|
||||
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 type { ClockPort } from '../../core/application/ports/ClockPort';
|
||||
import type { LoggerPort } from '../../core/application/ports/LoggerPort';
|
||||
import {
|
||||
CodexBinaryResolver,
|
||||
JsonRpcStdioClient,
|
||||
} from '@main/services/infrastructure/codexAppServer';
|
||||
import type { ServiceContext } from '@main/services';
|
||||
|
||||
export interface RecentProjectsFeatureFacade {
|
||||
|
|
|
|||
|
|
@ -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_TOTAL_TIMEOUT_MS = 8_000;
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest
|
|||
throw new HttpBadRequestError('providerId must be anthropic, codex, or gemini');
|
||||
})();
|
||||
const prompt = assertOptionalString(payload.prompt, 'prompt');
|
||||
const providerBackendId = assertOptionalString(payload.providerBackendId, 'providerBackendId');
|
||||
const model = assertOptionalString(payload.model, 'model');
|
||||
const effort = assertOptionalEffort(payload.effort);
|
||||
const clearContext = assertOptionalBoolean(payload.clearContext, 'clearContext');
|
||||
|
|
@ -111,6 +112,9 @@ function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest
|
|||
teamName,
|
||||
cwd: assertAbsoluteCwd(payload.cwd),
|
||||
providerId,
|
||||
...(providerBackendId && {
|
||||
providerBackendId,
|
||||
}),
|
||||
...(prompt && {
|
||||
prompt,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -19,6 +19,12 @@ process.env.UV_THREADPOOL_SIZE ??= '16';
|
|||
// Sentry must be the first import to capture early errors.
|
||||
import './sentry';
|
||||
|
||||
import {
|
||||
createCodexAccountFeature,
|
||||
type CodexAccountFeatureFacade,
|
||||
registerCodexAccountIpc,
|
||||
removeCodexAccountIpc,
|
||||
} from '@features/codex-account/main';
|
||||
import {
|
||||
createRecentProjectsFeature,
|
||||
type RecentProjectsFeatureFacade,
|
||||
|
|
@ -39,6 +45,7 @@ import { TeamConfigReader } from '@main/services/team/TeamConfigReader';
|
|||
import { TeamInboxWriter } from '@main/services/team/TeamInboxWriter';
|
||||
import { TeamMcpConfigBuilder } from '@main/services/team/TeamMcpConfigBuilder';
|
||||
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
||||
import { providerConnectionService } from '@main/services/runtime/ProviderConnectionService';
|
||||
import {
|
||||
CONTEXT_CHANGED,
|
||||
SCHEDULE_CHANGE,
|
||||
|
|
@ -414,6 +421,7 @@ let contextRegistry: ServiceContextRegistry;
|
|||
let notificationManager: NotificationManager;
|
||||
let updaterService: UpdaterService;
|
||||
let sshConnectionManager: SshConnectionManager;
|
||||
let codexAccountFeature: CodexAccountFeatureFacade | null = null;
|
||||
let recentProjectsFeature: RecentProjectsFeatureFacade;
|
||||
let teamDataService: TeamDataService;
|
||||
let teamProvisioningService: TeamProvisioningService;
|
||||
|
|
@ -975,6 +983,11 @@ async function initializeServices(): Promise<void> {
|
|||
getLocalContext: () => contextRegistry.get('local'),
|
||||
logger: createLogger('Feature:RecentProjects'),
|
||||
});
|
||||
codexAccountFeature = createCodexAccountFeature({
|
||||
logger: createLogger('Feature:CodexAccount'),
|
||||
configManager,
|
||||
});
|
||||
providerConnectionService.setCodexAccountFeature(codexAccountFeature);
|
||||
|
||||
// startProcessHealthPolling() is deferred to after window creation
|
||||
// (did-finish-load handler) to avoid thread pool contention at startup.
|
||||
|
|
@ -1029,6 +1042,7 @@ async function initializeServices(): Promise<void> {
|
|||
crossTeamService,
|
||||
teamBackupService ?? undefined
|
||||
);
|
||||
registerCodexAccountIpc(ipcMain, codexAccountFeature);
|
||||
registerRecentProjectsIpc(ipcMain, recentProjectsFeature);
|
||||
|
||||
// Forward SSH state changes to renderer and HTTP SSE clients
|
||||
|
|
@ -1171,6 +1185,9 @@ function shutdownServices(): void {
|
|||
}
|
||||
|
||||
void skillsWatcherService?.stopAll();
|
||||
providerConnectionService.setCodexAccountFeature(null);
|
||||
void codexAccountFeature?.dispose();
|
||||
codexAccountFeature = null;
|
||||
|
||||
// Kill all PTY processes
|
||||
if (ptyTerminalService) {
|
||||
|
|
@ -1179,6 +1196,7 @@ function shutdownServices(): void {
|
|||
|
||||
// Remove IPC handlers
|
||||
removeIpcHandlers();
|
||||
removeCodexAccountIpc(ipcMain);
|
||||
removeRecentProjectsIpc(ipcMain);
|
||||
|
||||
// Dispose backup service timers
|
||||
|
|
@ -1458,6 +1476,7 @@ function createWindow(): void {
|
|||
if (teamProvisioningService) {
|
||||
teamProvisioningService.setMainWindow(null);
|
||||
}
|
||||
codexAccountFeature?.setMainWindow(null);
|
||||
setEditorMainWindow(null);
|
||||
setReviewMainWindow(null);
|
||||
cleanupEditorState();
|
||||
|
|
@ -1492,6 +1511,7 @@ function createWindow(): void {
|
|||
if (teamProvisioningService) {
|
||||
teamProvisioningService.setMainWindow(mainWindow);
|
||||
}
|
||||
codexAccountFeature?.setMainWindow(mainWindow);
|
||||
setEditorMainWindow(mainWindow);
|
||||
setReviewMainWindow(mainWindow);
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
|
||||
import * as path from 'path';
|
||||
|
||||
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||
|
||||
import type {
|
||||
AppConfig,
|
||||
DisplayConfig,
|
||||
|
|
@ -442,13 +444,21 @@ function validateRuntimeSection(data: unknown): ValidationSuccess<'runtime'> | V
|
|||
}
|
||||
|
||||
if (providerId === 'codex') {
|
||||
if (backendId !== 'auto' && backendId !== 'adapter') {
|
||||
if (
|
||||
backendId !== 'auto' &&
|
||||
backendId !== 'adapter' &&
|
||||
backendId !== 'api' &&
|
||||
backendId !== 'codex-native'
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'runtime.providerBackends.codex must be one of: auto, adapter',
|
||||
error: 'runtime.providerBackends.codex must be one of: codex-native',
|
||||
};
|
||||
}
|
||||
providerBackends.codex = backendId;
|
||||
providerBackends.codex = migrateProviderBackendId(
|
||||
'codex',
|
||||
backendId
|
||||
) as RuntimeConfig['providerBackends']['codex'];
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -515,25 +525,24 @@ function validateProviderConnectionsSection(
|
|||
const codexUpdate: Partial<ProviderConnectionsConfig['codex']> = {};
|
||||
|
||||
for (const [connectionKey, connectionValue] of Object.entries(value)) {
|
||||
if (connectionKey === 'apiKeyBetaEnabled') {
|
||||
if (typeof connectionValue !== 'boolean') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'providerConnections.codex.apiKeyBetaEnabled must be a boolean',
|
||||
};
|
||||
}
|
||||
codexUpdate.apiKeyBetaEnabled = connectionValue;
|
||||
if (connectionKey === 'apiKeyBetaEnabled' || connectionKey === 'authMode') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (connectionKey === 'authMode') {
|
||||
if (connectionValue !== 'oauth' && connectionValue !== 'api_key') {
|
||||
if (connectionKey === 'preferredAuthMode') {
|
||||
if (
|
||||
connectionValue !== 'auto' &&
|
||||
connectionValue !== 'chatgpt' &&
|
||||
connectionValue !== 'api_key'
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'providerConnections.codex.authMode must be one of: oauth, api_key',
|
||||
error:
|
||||
'providerConnections.codex.preferredAuthMode must be one of: auto, chatgpt, api_key',
|
||||
};
|
||||
}
|
||||
codexUpdate.authMode = connectionValue;
|
||||
|
||||
codexUpdate.preferredAuthMode = connectionValue;
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ import {
|
|||
PROTECTED_CLI_FLAGS,
|
||||
} from '@shared/utils/cliArgsParser';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
|
||||
import {
|
||||
buildStandaloneSlashCommandMeta,
|
||||
|
|
@ -1129,6 +1130,25 @@ function parseOptionalMemberProviderId(
|
|||
return { valid: false, error: 'member providerId must be anthropic, codex, or gemini' };
|
||||
}
|
||||
|
||||
function parseOptionalProviderBackendId(
|
||||
value: unknown
|
||||
): { valid: true; value: string | undefined } | { valid: false; error: string } {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return { valid: true, value: undefined };
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return { valid: false, error: 'providerBackendId must be a string' };
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return { valid: true, value: undefined };
|
||||
}
|
||||
if (trimmed.length > 64) {
|
||||
return { valid: false, error: 'providerBackendId too long (max 64)' };
|
||||
}
|
||||
return { valid: true, value: trimmed };
|
||||
}
|
||||
|
||||
function parseOptionalMemberEffort(
|
||||
value: unknown
|
||||
): { valid: true; value: EffortLevel | undefined } | { valid: false; error: string } {
|
||||
|
|
@ -1222,6 +1242,10 @@ async function validateProvisioningRequest(
|
|||
if (payload.prompt !== undefined && typeof payload.prompt !== 'string') {
|
||||
return { valid: false, error: 'prompt must be a string' };
|
||||
}
|
||||
const providerBackendValidation = parseOptionalProviderBackendId(payload.providerBackendId);
|
||||
if (!providerBackendValidation.valid) {
|
||||
return { valid: false, error: providerBackendValidation.error };
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.mkdir(cwd, { recursive: true });
|
||||
|
|
@ -1279,6 +1303,7 @@ async function validateProvisioningRequest(
|
|||
: payload.providerId === 'gemini'
|
||||
? 'gemini'
|
||||
: 'anthropic',
|
||||
providerBackendId: providerBackendValidation.value,
|
||||
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
|
||||
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
|
||||
skipPermissions:
|
||||
|
|
@ -1388,6 +1413,10 @@ async function handleLaunchTeam(
|
|||
if (payload.model !== undefined && typeof payload.model !== 'string') {
|
||||
return { success: false, error: 'model must be a string' };
|
||||
}
|
||||
const providerBackendValidation = parseOptionalProviderBackendId(payload.providerBackendId);
|
||||
if (!providerBackendValidation.valid) {
|
||||
return { success: false, error: providerBackendValidation.error };
|
||||
}
|
||||
|
||||
// Detect draft team: team.meta.json exists but config.json doesn't.
|
||||
// This happens when user created team config without launching (launchTeam=false),
|
||||
|
|
@ -1406,7 +1435,19 @@ async function handleLaunchTeam(
|
|||
if (isDraft) {
|
||||
const meta = await teamMetaStore.getMeta(tn);
|
||||
const membersStore = new TeamMembersMetaStore();
|
||||
const members = await membersStore.getMembers(tn);
|
||||
const membersMeta = await membersStore.getMeta(tn);
|
||||
const members = membersMeta?.members ?? [];
|
||||
|
||||
const resolvedProviderId =
|
||||
payload.providerId === 'codex'
|
||||
? 'codex'
|
||||
: payload.providerId === 'gemini'
|
||||
? 'gemini'
|
||||
: meta?.providerId === 'codex'
|
||||
? 'codex'
|
||||
: meta?.providerId === 'gemini'
|
||||
? 'gemini'
|
||||
: 'anthropic';
|
||||
|
||||
const createRequest: TeamCreateRequest = {
|
||||
teamName: tn,
|
||||
|
|
@ -1415,16 +1456,11 @@ async function handleLaunchTeam(
|
|||
color: meta?.color,
|
||||
cwd,
|
||||
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
|
||||
providerId:
|
||||
payload.providerId === 'codex'
|
||||
? 'codex'
|
||||
: payload.providerId === 'gemini'
|
||||
? 'gemini'
|
||||
: meta?.providerId === 'codex'
|
||||
? 'codex'
|
||||
: meta?.providerId === 'gemini'
|
||||
? 'gemini'
|
||||
: 'anthropic',
|
||||
providerId: resolvedProviderId,
|
||||
providerBackendId: migrateProviderBackendId(
|
||||
resolvedProviderId,
|
||||
providerBackendValidation.value ?? meta?.providerBackendId ?? membersMeta?.providerBackendId
|
||||
),
|
||||
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
|
||||
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
|
||||
limitContext: typeof payload.limitContext === 'boolean' ? payload.limitContext : undefined,
|
||||
|
|
@ -1471,6 +1507,7 @@ async function handleLaunchTeam(
|
|||
: payload.providerId === 'gemini'
|
||||
? 'gemini'
|
||||
: 'anthropic',
|
||||
providerBackendId: providerBackendValidation.value,
|
||||
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
|
||||
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
|
||||
clearContext: payload.clearContext === true ? true : undefined,
|
||||
|
|
@ -2555,6 +2592,10 @@ async function handleCreateConfig(
|
|||
return { success: false, error: 'cwd must be an absolute path' };
|
||||
}
|
||||
}
|
||||
const providerBackendValidation = parseOptionalProviderBackendId(payload.providerBackendId);
|
||||
if (!providerBackendValidation.valid) {
|
||||
return { success: false, error: providerBackendValidation.error };
|
||||
}
|
||||
|
||||
const seenNames = new Set<string>();
|
||||
const members: TeamCreateConfigRequest['members'] = [];
|
||||
|
|
@ -2612,6 +2653,7 @@ async function handleCreateConfig(
|
|||
color: typeof payload.color === 'string' ? payload.color.trim() || undefined : undefined,
|
||||
members,
|
||||
cwd: typeof payload.cwd === 'string' ? payload.cwd.trim() || undefined : undefined,
|
||||
providerBackendId: providerBackendValidation.value,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
@ -3887,7 +3929,10 @@ async function handleGetSavedRequest(
|
|||
}
|
||||
|
||||
const membersStore = new TeamMembersMetaStore();
|
||||
const members = await membersStore.getMembers(tn);
|
||||
const membersMeta = await membersStore.getMeta(tn);
|
||||
const members = membersMeta?.members ?? [];
|
||||
|
||||
const resolvedProviderId = meta.providerId ?? 'anthropic';
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
@ -3898,7 +3943,11 @@ async function handleGetSavedRequest(
|
|||
color: meta.color,
|
||||
cwd: meta.cwd,
|
||||
prompt: meta.prompt,
|
||||
providerId: meta.providerId ?? 'anthropic',
|
||||
providerId: resolvedProviderId,
|
||||
providerBackendId: migrateProviderBackendId(
|
||||
resolvedProviderId,
|
||||
meta.providerBackendId ?? membersMeta?.providerBackendId
|
||||
),
|
||||
model: meta.model,
|
||||
effort: meta.effort as TeamCreateRequest['effort'],
|
||||
skipPermissions: meta.skipPermissions,
|
||||
|
|
|
|||
|
|
@ -240,7 +240,7 @@ export class ProjectScanner {
|
|||
}
|
||||
|
||||
const ms = Date.now() - startedAt;
|
||||
if (ms >= 2000) {
|
||||
if (ms >= 5000) {
|
||||
logger.warn(
|
||||
`[scan] completed slow ms=${ms} projectDirs=${projectDirs.length} projects=${validProjects.length}`
|
||||
);
|
||||
|
|
|
|||
|
|
@ -71,6 +71,9 @@ const CLI_INSTALLER_PROGRESS_CHANNEL = 'cliInstaller:progress';
|
|||
|
||||
/** Timeout for `claude --version` (ms) */
|
||||
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 */
|
||||
const INSTALL_TIMEOUT_MS = 120_000;
|
||||
|
|
@ -134,6 +137,10 @@ function clipTailForDiag(s: string, maxLen: number): string {
|
|||
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_HOME_PREVIEW = 120;
|
||||
const DIAG_AUTH_STDOUT_TAIL = 160;
|
||||
|
|
@ -355,8 +362,36 @@ export class CliInstallerService {
|
|||
}
|
||||
);
|
||||
private latestStatusSnapshot: CliInstallationStatus | null = null;
|
||||
private lastHealthyStatusSnapshot: CliInstallationStatus | null = null;
|
||||
private lastHealthyStatusObservedAt = 0;
|
||||
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> {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
|
|
@ -764,6 +799,7 @@ export class CliInstallerService {
|
|||
r.installedVersion = versionProbe.version;
|
||||
r.launchError = null;
|
||||
r.authStatusChecking = true;
|
||||
this.rememberHealthyStatus(r);
|
||||
this.publishStatusSnapshot(r);
|
||||
|
||||
// Auth and GCS version check are independent — run in parallel.
|
||||
|
|
@ -772,8 +808,21 @@ export class CliInstallerService {
|
|||
this.checkAuthStatus(binaryPath, r, diag),
|
||||
r.supportsSelfUpdate ? this.fetchLatestVersion(r) : Promise.resolve(),
|
||||
]);
|
||||
this.rememberHealthyStatus(r);
|
||||
this.publishStatusSnapshot(r);
|
||||
} 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;
|
||||
r.installed = false;
|
||||
r.installedVersion = null;
|
||||
|
|
@ -806,37 +855,50 @@ export class CliInstallerService {
|
|||
private async probeCliVersion(
|
||||
binaryPath: string
|
||||
): Promise<{ ok: true; version: string | null } | { ok: false; error: string }> {
|
||||
try {
|
||||
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.' };
|
||||
}
|
||||
let lastError: string | null = null;
|
||||
|
||||
if (isSemverVersion(version)) {
|
||||
logger.info(`Installed CLI version: "${stdout.trim()}" → normalized: "${version}"`);
|
||||
return { ok: true, version };
|
||||
}
|
||||
for (let attempt = 1; attempt <= VERSION_RETRY_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
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 (inferredVersion) {
|
||||
logger.info(
|
||||
`Installed CLI version was inferred from installer path: "${stdout.trim()}" → "${inferredVersion}"`
|
||||
if (isSemverVersion(version)) {
|
||||
logger.info(`Installed CLI version: "${stdout.trim()}" → normalized: "${version}"`);
|
||||
return { ok: true, version };
|
||||
}
|
||||
|
||||
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> {
|
||||
|
|
|
|||
|
|
@ -12,12 +12,14 @@
|
|||
import { getClaudeBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder';
|
||||
import { validateRegexPattern } from '@main/utils/regexValidation';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||
import * as fs from 'fs';
|
||||
import * as fsp from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
import { DEFAULT_TRIGGERS, TriggerManager } from './TriggerManager';
|
||||
|
||||
import type { CodexAccountAuthMode } from '@features/codex-account/contracts';
|
||||
import type { TriggerColor } from '@shared/constants/triggerColors';
|
||||
import type { SshConnectionProfile } from '@shared/types/api';
|
||||
|
||||
|
|
@ -226,20 +228,18 @@ export interface GeneralConfig {
|
|||
export interface RuntimeConfig {
|
||||
providerBackends: {
|
||||
gemini: 'auto' | 'api' | 'cli-sdk';
|
||||
codex: 'auto' | 'adapter';
|
||||
codex: 'codex-native';
|
||||
};
|
||||
}
|
||||
|
||||
export type ProviderConnectionAuthMode = 'auto' | 'oauth' | 'api_key';
|
||||
export type CodexProviderConnectionAuthMode = Exclude<ProviderConnectionAuthMode, 'auto'>;
|
||||
|
||||
export interface ProviderConnectionsConfig {
|
||||
anthropic: {
|
||||
authMode: ProviderConnectionAuthMode;
|
||||
};
|
||||
codex: {
|
||||
apiKeyBetaEnabled: boolean;
|
||||
authMode: CodexProviderConnectionAuthMode;
|
||||
preferredAuthMode: CodexAccountAuthMode;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -335,14 +335,13 @@ const DEFAULT_CONFIG: AppConfig = {
|
|||
authMode: 'auto',
|
||||
},
|
||||
codex: {
|
||||
apiKeyBetaEnabled: false,
|
||||
authMode: 'oauth',
|
||||
preferredAuthMode: 'auto',
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
providerBackends: {
|
||||
gemini: 'auto',
|
||||
codex: 'auto',
|
||||
codex: 'codex-native',
|
||||
},
|
||||
},
|
||||
display: {
|
||||
|
|
@ -398,6 +397,27 @@ function normalizeConfiguredClaudeRootPath(value: unknown): string | null {
|
|||
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
|
||||
// ===========================================================================
|
||||
|
|
@ -449,9 +469,14 @@ export class ConfigManager {
|
|||
try {
|
||||
const content = fs.readFileSync(this.configPath, 'utf8');
|
||||
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
|
||||
return this.mergeWithDefaults(parsed);
|
||||
return merged;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
logger.info('No config file found, using defaults');
|
||||
|
|
@ -567,14 +592,20 @@ export class ConfigManager {
|
|||
...(loaded.providerConnections?.anthropic ?? {}),
|
||||
},
|
||||
codex: {
|
||||
...DEFAULT_CONFIG.providerConnections.codex,
|
||||
...(loaded.providerConnections?.codex ?? {}),
|
||||
preferredAuthMode: normalizeCodexPreferredAuthMode(
|
||||
loaded.providerConnections?.codex?.preferredAuthMode,
|
||||
(loaded.providerConnections?.codex as { authMode?: unknown } | undefined)?.authMode
|
||||
),
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
providerBackends: {
|
||||
...DEFAULT_CONFIG.runtime.providerBackends,
|
||||
...(loaded.runtime?.providerBackends ?? {}),
|
||||
codex: migrateProviderBackendId(
|
||||
'codex',
|
||||
loaded.runtime?.providerBackends?.codex
|
||||
) as RuntimeConfig['providerBackends']['codex'],
|
||||
},
|
||||
},
|
||||
display: {
|
||||
|
|
@ -660,6 +691,10 @@ export class ConfigManager {
|
|||
providerBackends: {
|
||||
...this.config.runtime.providerBackends,
|
||||
...runtimeUpdate.providerBackends,
|
||||
codex: migrateProviderBackendId(
|
||||
'codex',
|
||||
runtimeUpdate.providerBackends?.codex ?? this.config.runtime.providerBackends.codex
|
||||
) as RuntimeConfig['providerBackends']['codex'],
|
||||
},
|
||||
} as unknown as Partial<AppConfig[K]>;
|
||||
}
|
||||
|
|
@ -675,6 +710,10 @@ export class ConfigManager {
|
|||
codex: {
|
||||
...this.config.providerConnections.codex,
|
||||
...(connectionUpdate.codex ?? {}),
|
||||
preferredAuthMode: normalizeCodexPreferredAuthMode(
|
||||
connectionUpdate.codex?.preferredAuthMode,
|
||||
(connectionUpdate.codex as { authMode?: unknown } | undefined)?.authMode
|
||||
),
|
||||
},
|
||||
} as unknown as Partial<AppConfig[K]>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -3,10 +3,9 @@ import readline from 'node:readline';
|
|||
|
||||
import { killProcessTree, spawnCli } from '@main/utils/childProcess';
|
||||
|
||||
import type { LoggerPort } from '../../../core/application/ports/LoggerPort';
|
||||
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 3_000;
|
||||
const DEFAULT_TOTAL_TIMEOUT_MS = 8_000;
|
||||
interface JsonRpcLogger {
|
||||
warn: (message: string, meta?: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
interface JsonRpcErrorPayload {
|
||||
code?: number;
|
||||
|
|
@ -19,9 +18,16 @@ interface JsonRpcResponse<T> {
|
|||
error?: JsonRpcErrorPayload;
|
||||
}
|
||||
|
||||
interface JsonRpcNotificationMessage {
|
||||
method?: string;
|
||||
params?: unknown;
|
||||
}
|
||||
|
||||
export interface JsonRpcSession {
|
||||
request<TResult>(method: string, params?: unknown, timeoutMs?: number): Promise<TResult>;
|
||||
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> {
|
||||
|
|
@ -40,42 +46,48 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string):
|
|||
}) as Promise<T>;
|
||||
}
|
||||
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 3_000;
|
||||
const DEFAULT_TOTAL_TIMEOUT_MS = 8_000;
|
||||
|
||||
export class JsonRpcStdioClient {
|
||||
constructor(private readonly logger: LoggerPort) {}
|
||||
constructor(private readonly logger: JsonRpcLogger) {}
|
||||
|
||||
async withSession<T>(
|
||||
options: {
|
||||
binaryPath: string;
|
||||
args: string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
requestTimeoutMs?: number;
|
||||
totalTimeoutMs?: number;
|
||||
label: string;
|
||||
},
|
||||
handler: (session: JsonRpcSession) => 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;
|
||||
|
||||
return withTimeout(
|
||||
this.#runSession(options.binaryPath, options.args, requestTimeoutMs, handler),
|
||||
totalTimeoutMs,
|
||||
options.label
|
||||
);
|
||||
try {
|
||||
return await withTimeout(handler(session), totalTimeoutMs, options.label);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
}
|
||||
|
||||
async #runSession<T>(
|
||||
binaryPath: string,
|
||||
args: string[],
|
||||
requestTimeoutMs: number,
|
||||
handler: (session: JsonRpcSession) => Promise<T>
|
||||
): Promise<T> {
|
||||
const child = spawnCli(binaryPath, args, {
|
||||
async openSession(options: {
|
||||
binaryPath: string;
|
||||
args: string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
requestTimeoutMs?: number;
|
||||
}): Promise<JsonRpcSession> {
|
||||
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||
const child = spawnCli(options.binaryPath, options.args, {
|
||||
env: options.env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
const lineReader = readline.createInterface({ input: child.stdout! });
|
||||
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<
|
||||
|
|
@ -86,8 +98,10 @@ export class JsonRpcStdioClient {
|
|||
timeoutId: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
>();
|
||||
const notificationListeners = new Set<(method: string, params: unknown) => void>();
|
||||
|
||||
let nextRequestId = 1;
|
||||
let closed = false;
|
||||
|
||||
const rejectAll = (error: Error): void => {
|
||||
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) => {
|
||||
let message: JsonRpcResponse<unknown>;
|
||||
let message: JsonRpcResponse<unknown> & JsonRpcNotificationMessage;
|
||||
try {
|
||||
message = JSON.parse(line) as JsonRpcResponse<unknown>;
|
||||
message = JSON.parse(line) as JsonRpcResponse<unknown> & JsonRpcNotificationMessage;
|
||||
} catch (error) {
|
||||
this.logger.warn('json-rpc stdio emitted non-json line', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
|
|
@ -108,24 +139,25 @@ export class JsonRpcStdioClient {
|
|||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
handleNotification(message);
|
||||
});
|
||||
|
||||
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>(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
timeoutMs = requestTimeoutMs
|
||||
): Promise<TResult> =>
|
||||
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'));
|
||||
return;
|
||||
}
|
||||
|
|
@ -176,7 +236,7 @@ export class JsonRpcStdioClient {
|
|||
}),
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
|
|
@ -190,22 +250,15 @@ export class JsonRpcStdioClient {
|
|||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
return await handler(session);
|
||||
} finally {
|
||||
rejectAll(new Error('JSON-RPC session closed'));
|
||||
lineReader.close();
|
||||
if (child.stdin && !child.stdin.destroyed) {
|
||||
child.stdin.end();
|
||||
}
|
||||
killProcessTree(child);
|
||||
try {
|
||||
await once(child, 'close');
|
||||
} catch {
|
||||
this.logger.warn('json-rpc close wait failed');
|
||||
}
|
||||
}
|
||||
onNotification: (listener) => {
|
||||
notificationListeners.add(listener);
|
||||
return (): void => {
|
||||
notificationListeners.delete(listener);
|
||||
};
|
||||
},
|
||||
|
||||
close,
|
||||
};
|
||||
}
|
||||
}
|
||||
29
src/main/services/infrastructure/codexAppServer/index.ts
Normal file
29
src/main/services/infrastructure/codexAppServer/index.ts
Normal 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';
|
||||
113
src/main/services/infrastructure/codexAppServer/protocol.ts
Normal file
113
src/main/services/infrastructure/codexAppServer/protocol.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -88,6 +88,14 @@ interface UnifiedRuntimeStatusResponse {
|
|||
selectable?: boolean;
|
||||
recommended?: boolean;
|
||||
available?: boolean;
|
||||
state?:
|
||||
| 'ready'
|
||||
| 'locked'
|
||||
| 'disabled'
|
||||
| 'authentication-required'
|
||||
| 'runtime-missing'
|
||||
| 'degraded';
|
||||
audience?: 'general' | 'internal';
|
||||
statusMessage?: string | null;
|
||||
detailMessage?: string | null;
|
||||
}[];
|
||||
|
|
@ -270,6 +278,8 @@ export class ClaudeMultimodelBridgeService {
|
|||
selectable: backend.selectable !== false,
|
||||
recommended: backend.recommended === true,
|
||||
available: backend.available === true,
|
||||
state: backend.state ?? undefined,
|
||||
audience: backend.audience ?? undefined,
|
||||
statusMessage: backend.statusMessage ?? null,
|
||||
detailMessage: backend.detailMessage ?? null,
|
||||
})) ?? [],
|
||||
|
|
|
|||
|
|
@ -1,8 +1,16 @@
|
|||
import path from 'node:path';
|
||||
|
||||
import { evaluateCodexLaunchReadiness } from '@features/codex-account';
|
||||
import { getCachedShellEnv } from '@main/utils/shellEnv';
|
||||
|
||||
import { ApiKeyService } from '../extensions/apikeys/ApiKeyService';
|
||||
import { ConfigManager } from '../infrastructure/ConfigManager';
|
||||
|
||||
import type {
|
||||
CodexAccountAuthMode,
|
||||
CodexAccountSnapshotDto,
|
||||
} from '@features/codex-account/contracts';
|
||||
import type { CodexAccountFeatureFacade } from '@features/codex-account/main';
|
||||
import type {
|
||||
CliProviderAuthMode,
|
||||
CliProviderConnectionInfo,
|
||||
|
|
@ -25,9 +33,9 @@ const PROVIDER_CAPABILITIES: Record<
|
|||
configurableAuthModes: ['auto', 'oauth', 'api_key'],
|
||||
},
|
||||
codex: {
|
||||
supportsOAuth: true,
|
||||
supportsOAuth: false,
|
||||
supportsApiKey: true,
|
||||
configurableAuthModes: [],
|
||||
configurableAuthModes: ['auto', 'chatgpt', 'api_key'],
|
||||
},
|
||||
gemini: {
|
||||
supportsOAuth: false,
|
||||
|
|
@ -42,10 +50,33 @@ const PROVIDER_API_KEY_ENV_VARS: Partial<Record<CliProviderId, string>> = {
|
|||
gemini: 'GEMINI_API_KEY',
|
||||
};
|
||||
|
||||
const CODEX_API_KEY_BETA_ENV_VAR = 'CLAUDE_CODE_CODEX_API_KEY_BETA';
|
||||
const CODEX_NATIVE_API_KEY_ENV_VAR = 'CODEX_API_KEY';
|
||||
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 {
|
||||
private static instance: ProviderConnectionService | null = null;
|
||||
private codexAccountFeature: Pick<CodexAccountFeatureFacade, 'getSnapshot'> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly apiKeyService = new ApiKeyService(),
|
||||
|
|
@ -57,14 +88,17 @@ export class ProviderConnectionService {
|
|||
return ProviderConnectionService.instance;
|
||||
}
|
||||
|
||||
setCodexAccountFeature(feature: Pick<CodexAccountFeatureFacade, 'getSnapshot'> | null): void {
|
||||
this.codexAccountFeature = feature;
|
||||
}
|
||||
|
||||
getConfiguredAuthMode(providerId: CliProviderId): CliProviderAuthMode | null {
|
||||
if (providerId === 'anthropic') {
|
||||
return this.configManager.getConfig().providerConnections.anthropic.authMode;
|
||||
}
|
||||
|
||||
if (providerId === 'codex') {
|
||||
const codexConnection = this.configManager.getConfig().providerConnections.codex;
|
||||
return codexConnection.apiKeyBetaEnabled ? codexConnection.authMode : null;
|
||||
return this.configManager.getConfig().providerConnections.codex.preferredAuthMode;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
@ -72,7 +106,8 @@ export class ProviderConnectionService {
|
|||
|
||||
async applyConfiguredConnectionEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
providerId: CliProviderId
|
||||
providerId: CliProviderId,
|
||||
runtimeBackendOverride?: string | null
|
||||
): Promise<NodeJS.ProcessEnv> {
|
||||
if (providerId === 'anthropic') {
|
||||
const authMode = this.getConfiguredAuthMode(providerId);
|
||||
|
|
@ -106,32 +141,33 @@ export class ProviderConnectionService {
|
|||
return env;
|
||||
}
|
||||
|
||||
const codexConnection = this.configManager.getConfig().providerConnections.codex;
|
||||
if (!codexConnection.apiKeyBetaEnabled) {
|
||||
delete env[CODEX_API_KEY_BETA_ENV_VAR];
|
||||
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') {
|
||||
delete env.OPENAI_API_KEY;
|
||||
delete env[CODEX_NATIVE_API_KEY_ENV_VAR];
|
||||
return env;
|
||||
}
|
||||
|
||||
env[CODEX_API_KEY_BETA_ENV_VAR] = '1';
|
||||
|
||||
if (codexConnection.authMode === 'oauth') {
|
||||
env.CLAUDE_CODE_CODEX_BACKEND = 'adapter';
|
||||
delete env.OPENAI_API_KEY;
|
||||
return env;
|
||||
}
|
||||
|
||||
env.CLAUDE_CODE_CODEX_BACKEND = 'api';
|
||||
|
||||
const storedKey = await this.apiKeyService.lookupPreferred('OPENAI_API_KEY');
|
||||
if (storedKey?.value.trim()) {
|
||||
env.OPENAI_API_KEY = storedKey.value;
|
||||
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;
|
||||
return env;
|
||||
}
|
||||
|
||||
if (typeof env.OPENAI_API_KEY !== 'string' || !env.OPENAI_API_KEY.trim()) {
|
||||
delete env.OPENAI_API_KEY;
|
||||
}
|
||||
delete env[CODEX_NATIVE_API_KEY_ENV_VAR];
|
||||
|
||||
return env;
|
||||
}
|
||||
|
|
@ -146,7 +182,8 @@ export class ProviderConnectionService {
|
|||
|
||||
async augmentConfiguredConnectionEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
providerId: CliProviderId
|
||||
providerId: CliProviderId,
|
||||
runtimeBackendOverride?: string | null
|
||||
): Promise<NodeJS.ProcessEnv> {
|
||||
if (providerId === 'anthropic') {
|
||||
if (this.getConfiguredAuthMode(providerId) !== 'api_key') {
|
||||
|
|
@ -164,21 +201,26 @@ export class ProviderConnectionService {
|
|||
return env;
|
||||
}
|
||||
|
||||
const codexConnection = this.configManager.getConfig().providerConnections.codex;
|
||||
if (!codexConnection.apiKeyBetaEnabled) {
|
||||
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') {
|
||||
delete env.OPENAI_API_KEY;
|
||||
delete env[CODEX_NATIVE_API_KEY_ENV_VAR];
|
||||
return env;
|
||||
}
|
||||
|
||||
env[CODEX_API_KEY_BETA_ENV_VAR] = '1';
|
||||
env.CLAUDE_CODE_CODEX_BACKEND = codexConnection.authMode === 'oauth' ? 'adapter' : 'api';
|
||||
|
||||
if (codexConnection.authMode !== 'api_key') {
|
||||
return env;
|
||||
}
|
||||
|
||||
const storedKey = await this.apiKeyService.lookupPreferred('OPENAI_API_KEY');
|
||||
if (storedKey?.value.trim()) {
|
||||
env.OPENAI_API_KEY = storedKey.value;
|
||||
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;
|
||||
}
|
||||
|
||||
return env;
|
||||
|
|
@ -194,7 +236,8 @@ export class ProviderConnectionService {
|
|||
|
||||
async getConfiguredConnectionIssue(
|
||||
env: NodeJS.ProcessEnv,
|
||||
providerId: CliProviderId
|
||||
providerId: CliProviderId,
|
||||
_runtimeBackendOverride?: string | null
|
||||
): Promise<string | null> {
|
||||
if (providerId === 'anthropic') {
|
||||
if (this.getConfiguredAuthMode(providerId) !== 'api_key') {
|
||||
|
|
@ -215,29 +258,57 @@ export class ProviderConnectionService {
|
|||
return null;
|
||||
}
|
||||
|
||||
const codexConnection = this.configManager.getConfig().providerConnections.codex;
|
||||
if (!codexConnection.apiKeyBetaEnabled || codexConnection.authMode !== 'api_key') {
|
||||
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.launchAllowed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof env.OPENAI_API_KEY === 'string' && env.OPENAI_API_KEY.trim()) {
|
||||
return null;
|
||||
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 (
|
||||
'Codex API key mode is enabled, but no OPENAI_API_KEY is configured. ' +
|
||||
'Add a stored/environment API key or switch Codex auth mode back to OAuth.'
|
||||
readiness.issueMessage ??
|
||||
'Codex native is not ready. Connect a ChatGPT account or add an API key before launching.'
|
||||
);
|
||||
}
|
||||
|
||||
async getConfiguredConnectionIssues(
|
||||
env: NodeJS.ProcessEnv,
|
||||
providerIds: readonly CliProviderId[] = ['anthropic', 'codex', 'gemini']
|
||||
providerIds: readonly CliProviderId[] = ['anthropic', 'codex', 'gemini'],
|
||||
runtimeBackendOverrides?: Partial<Record<CliProviderId, string>>
|
||||
): Promise<Partial<Record<CliProviderId, string>>> {
|
||||
const issues: Partial<Record<CliProviderId, string>> = {};
|
||||
|
||||
for (const providerId of providerIds) {
|
||||
const issue = await this.getConfiguredConnectionIssue(env, providerId);
|
||||
const issue = await this.getConfiguredConnectionIssue(
|
||||
env,
|
||||
providerId,
|
||||
runtimeBackendOverrides?.[providerId]
|
||||
);
|
||||
if (issue) {
|
||||
issues[providerId] = issue;
|
||||
}
|
||||
|
|
@ -246,6 +317,41 @@ export class ProviderConnectionService {
|
|||
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> {
|
||||
return {
|
||||
...provider,
|
||||
|
|
@ -261,32 +367,56 @@ export class ProviderConnectionService {
|
|||
const capabilities = PROVIDER_CAPABILITIES[providerId];
|
||||
const storedApiKey = await this.getStoredApiKey(providerId);
|
||||
const externalCredential = this.getExternalCredential(providerId);
|
||||
const codexBetaEnabled =
|
||||
providerId === 'codex'
|
||||
? this.configManager.getConfig().providerConnections.codex.apiKeyBetaEnabled
|
||||
: undefined;
|
||||
const configurableAuthModes =
|
||||
providerId === 'codex' && codexBetaEnabled
|
||||
? (['oauth', 'api_key'] as CliProviderAuthMode[])
|
||||
: capabilities.configurableAuthModes;
|
||||
const codexSnapshot = providerId === 'codex' ? await this.getCodexAccountSnapshot() : null;
|
||||
const configurableAuthModes = capabilities.configurableAuthModes;
|
||||
const configuredAuthMode =
|
||||
providerId === 'codex' && !codexBetaEnabled ? 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 {
|
||||
...capabilities,
|
||||
configurableAuthModes,
|
||||
configuredAuthMode,
|
||||
apiKeyBetaAvailable: providerId === 'codex' ? true : undefined,
|
||||
apiKeyBetaEnabled: codexBetaEnabled,
|
||||
apiKeyConfigured: Boolean(storedApiKey?.value.trim() || externalCredential?.value.trim()),
|
||||
apiKeySource: storedApiKey?.value.trim()
|
||||
? 'stored'
|
||||
: externalCredential?.value.trim()
|
||||
? 'environment'
|
||||
apiKeyConfigured,
|
||||
apiKeySource,
|
||||
apiKeySourceLabel,
|
||||
codex:
|
||||
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,
|
||||
apiKeySourceLabel: storedApiKey?.value.trim()
|
||||
? 'Stored in app'
|
||||
: (externalCredential?.label ?? null),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -301,6 +431,117 @@ export class ProviderConnectionService {
|
|||
return this.apiKeyService.lookupPreferred(envVarName);
|
||||
}
|
||||
|
||||
private getConfiguredCodexRuntimeBackend(runtimeBackendOverride?: string | null): 'codex-native' {
|
||||
if (runtimeBackendOverride === CODEX_NATIVE_BACKEND_ID) {
|
||||
return runtimeBackendOverride;
|
||||
}
|
||||
return CODEX_NATIVE_BACKEND_ID;
|
||||
}
|
||||
|
||||
private async getCodexAccountSnapshot(): Promise<CodexAccountSnapshotDto> {
|
||||
if (this.codexAccountFeature) {
|
||||
return this.codexAccountFeature.getSnapshot();
|
||||
}
|
||||
|
||||
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 sources = [shellEnv, process.env];
|
||||
|
|
@ -336,6 +577,14 @@ export class ProviderConnectionService {
|
|||
}
|
||||
|
||||
if (providerId === 'codex') {
|
||||
const nativeApiKey = findEnvValue(CODEX_NATIVE_API_KEY_ENV_VAR);
|
||||
if (nativeApiKey) {
|
||||
return {
|
||||
label: `Detected from ${CODEX_NATIVE_API_KEY_ENV_VAR}`,
|
||||
value: nativeApiKey,
|
||||
};
|
||||
}
|
||||
|
||||
const apiKey = findEnvValue('OPENAI_API_KEY');
|
||||
if (apiKey) {
|
||||
return {
|
||||
|
|
|
|||
86
src/main/services/runtime/buildRuntimeBaseEnv.ts
Normal file
86
src/main/services/runtime/buildRuntimeBaseEnv.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,14 +1,7 @@
|
|||
import { buildEnrichedEnv } from '@main/utils/cliEnv';
|
||||
import { getCachedShellEnv, getShellPreferredHome } from '@main/utils/shellEnv';
|
||||
|
||||
import { configManager } from '../infrastructure/ConfigManager';
|
||||
import { getCachedShellEnv } from '@main/utils/shellEnv';
|
||||
|
||||
import { buildRuntimeBaseEnv } from './buildRuntimeBaseEnv';
|
||||
import { providerConnectionService } from './ProviderConnectionService';
|
||||
import {
|
||||
applyConfiguredRuntimeBackendsEnv,
|
||||
applyProviderRuntimeEnv,
|
||||
resolveTeamProviderId,
|
||||
} from './providerRuntimeEnv';
|
||||
|
||||
import type { CliProviderId, TeamProviderId } from '@shared/types';
|
||||
|
||||
|
|
@ -17,6 +10,7 @@ type ProviderEnvTargetId = CliProviderId | TeamProviderId | undefined;
|
|||
export interface ProviderAwareCliEnvOptions {
|
||||
binaryPath?: string | null;
|
||||
providerId?: ProviderEnvTargetId;
|
||||
providerBackendId?: string | null;
|
||||
shellEnv?: NodeJS.ProcessEnv | null;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
connectionMode?: 'strict' | 'augment';
|
||||
|
|
@ -25,15 +19,7 @@ export interface ProviderAwareCliEnvOptions {
|
|||
export interface ProviderAwareCliEnvResult {
|
||||
env: NodeJS.ProcessEnv;
|
||||
connectionIssues: Partial<Record<CliProviderId, 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;
|
||||
providerArgs: string[];
|
||||
}
|
||||
|
||||
export async function buildProviderAwareCliEnv(
|
||||
|
|
@ -41,51 +27,52 @@ export async function buildProviderAwareCliEnv(
|
|||
): Promise<ProviderAwareCliEnvResult> {
|
||||
const connectionMode = options.connectionMode ?? 'strict';
|
||||
const shellEnv = options.shellEnv ?? getCachedShellEnv() ?? {};
|
||||
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);
|
||||
}
|
||||
const { env, resolvedProviderId } = buildRuntimeBaseEnv({
|
||||
binaryPath: options.binaryPath,
|
||||
providerId: options.providerId,
|
||||
providerBackendId: options.providerBackendId,
|
||||
shellEnv,
|
||||
env: options.env,
|
||||
});
|
||||
|
||||
if (options.providerId) {
|
||||
const resolvedProviderId = resolveTeamProviderId(options.providerId);
|
||||
applyProviderRuntimeEnv(env, options.providerId);
|
||||
if (!resolvedProviderId) {
|
||||
throw new Error('Resolved provider id is required when providerId is set');
|
||||
}
|
||||
if (connectionMode === 'augment') {
|
||||
await providerConnectionService.augmentConfiguredConnectionEnv(env, resolvedProviderId);
|
||||
await providerConnectionService.augmentConfiguredConnectionEnv(
|
||||
env,
|
||||
resolvedProviderId,
|
||||
options.providerBackendId
|
||||
);
|
||||
return {
|
||||
env,
|
||||
connectionIssues: {},
|
||||
providerArgs: [],
|
||||
};
|
||||
}
|
||||
|
||||
await providerConnectionService.applyConfiguredConnectionEnv(env, resolvedProviderId);
|
||||
await providerConnectionService.applyConfiguredConnectionEnv(
|
||||
env,
|
||||
resolvedProviderId,
|
||||
options.providerBackendId
|
||||
);
|
||||
|
||||
return {
|
||||
env,
|
||||
connectionIssues: await providerConnectionService.getConfiguredConnectionIssues(env, [
|
||||
providerArgs: await providerConnectionService.getConfiguredConnectionLaunchArgs(
|
||||
env,
|
||||
resolvedProviderId,
|
||||
]),
|
||||
options.providerBackendId,
|
||||
options.binaryPath
|
||||
),
|
||||
connectionIssues: await providerConnectionService.getConfiguredConnectionIssues(
|
||||
env,
|
||||
[resolvedProviderId],
|
||||
resolvedProviderId === 'codex' || resolvedProviderId === 'gemini'
|
||||
? { [resolvedProviderId]: options.providerBackendId?.trim() || undefined }
|
||||
: undefined
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -94,6 +81,7 @@ export async function buildProviderAwareCliEnv(
|
|||
return {
|
||||
env,
|
||||
connectionIssues: {},
|
||||
providerArgs: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -101,5 +89,6 @@ export async function buildProviderAwareCliEnv(
|
|||
return {
|
||||
env,
|
||||
connectionIssues: await providerConnectionService.getConfiguredConnectionIssues(env),
|
||||
providerArgs: [],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ export function normalizeProviderModelProbeFailureReason(message: string): strin
|
|||
if (
|
||||
/The '[^']+' model is not supported when using Codex with a ChatGPT account\./i.test(trimmed)
|
||||
) {
|
||||
return 'Not available with Codex ChatGPT subscription';
|
||||
return 'Not available on this Codex native runtime';
|
||||
}
|
||||
if (/The requested model is not available for your account\./i.test(trimmed)) {
|
||||
return 'Not available for this account';
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ export class ScheduledTaskExecutor {
|
|||
request.config.providerId === 'codex' || request.config.providerId === 'gemini'
|
||||
? request.config.providerId
|
||||
: 'anthropic';
|
||||
const { env, connectionIssues } = await buildProviderAwareCliEnv({
|
||||
const { env, connectionIssues, providerArgs } = await buildProviderAwareCliEnv({
|
||||
binaryPath,
|
||||
providerId,
|
||||
shellEnv,
|
||||
|
|
@ -119,6 +119,8 @@ export class ScheduledTaskExecutor {
|
|||
throw new Error(connectionIssue);
|
||||
}
|
||||
|
||||
args.push(...providerArgs);
|
||||
|
||||
const child = spawnCli(binaryPath, args, {
|
||||
cwd: request.config.cwd,
|
||||
// shellEnv spread after buildEnrichedEnv ensures freshly-resolved values
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ const MAX_FILE_SIZE_BYTES = 20 * 1024 * 1024;
|
|||
|
||||
const TEAM_ROOT_FILES = [
|
||||
'config.json',
|
||||
'team.meta.json',
|
||||
'kanban-state.json',
|
||||
'sentMessages.json',
|
||||
'sent-cross-team.json',
|
||||
|
|
|
|||
|
|
@ -2388,6 +2388,7 @@ export class TeamDataService {
|
|||
description: request.description,
|
||||
color: request.color,
|
||||
cwd: request.cwd?.trim() || '',
|
||||
providerBackendId: request.providerBackendId,
|
||||
createdAt: joinedAt,
|
||||
});
|
||||
|
||||
|
|
@ -2425,7 +2426,9 @@ export class TeamDataService {
|
|||
joinedAt,
|
||||
}))
|
||||
);
|
||||
await this.membersMetaStore.writeMembers(request.teamName, membersToWrite);
|
||||
await this.membersMetaStore.writeMembers(request.teamName, membersToWrite, {
|
||||
providerBackendId: request.providerBackendId,
|
||||
});
|
||||
}
|
||||
|
||||
async reconcileTeamArtifacts(
|
||||
|
|
|
|||
|
|
@ -9,13 +9,22 @@ import { atomicWriteAsync } from './atomicWrite';
|
|||
|
||||
import type { TeamMember } from '@shared/types';
|
||||
|
||||
interface TeamMembersMetaFile {
|
||||
export interface TeamMembersMetaFile {
|
||||
version: 1;
|
||||
providerBackendId?: string;
|
||||
members: TeamMember[];
|
||||
}
|
||||
|
||||
const MAX_META_FILE_BYTES = 256 * 1024;
|
||||
|
||||
function normalizeOptionalBackendId(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function normalizeMember(member: TeamMember): TeamMember | null {
|
||||
const trimmedName = member.name?.trim();
|
||||
if (!trimmedName) {
|
||||
|
|
@ -45,15 +54,15 @@ export class TeamMembersMetaStore {
|
|||
return path.join(getTeamsBasePath(), teamName, 'members.meta.json');
|
||||
}
|
||||
|
||||
async getMembers(teamName: string): Promise<TeamMember[]> {
|
||||
async getMeta(teamName: string): Promise<TeamMembersMetaFile | null> {
|
||||
const metaPath = this.getMetaPath(teamName);
|
||||
try {
|
||||
const stat = await fs.promises.stat(metaPath);
|
||||
if (!stat.isFile()) {
|
||||
return [];
|
||||
return null;
|
||||
}
|
||||
if (stat.isFile() && stat.size > MAX_META_FILE_BYTES) {
|
||||
return [];
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
// ignore - readFile below will handle ENOENT and throw on other errors
|
||||
|
|
@ -63,10 +72,10 @@ export class TeamMembersMetaStore {
|
|||
raw = await readFileUtf8WithTimeout(metaPath, 5_000);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return [];
|
||||
return null;
|
||||
}
|
||||
if (error instanceof FileReadTimeoutError) {
|
||||
return [];
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -75,15 +84,15 @@ export class TeamMembersMetaStore {
|
|||
try {
|
||||
parsed = JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
return [];
|
||||
return null;
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return [];
|
||||
return null;
|
||||
}
|
||||
|
||||
const file = parsed as Partial<TeamMembersMetaFile>;
|
||||
if (!Array.isArray(file.members)) {
|
||||
return [];
|
||||
return null;
|
||||
}
|
||||
|
||||
const deduped = new Map<string, TeamMember>();
|
||||
|
|
@ -107,10 +116,22 @@ export class TeamMembersMetaStore {
|
|||
}
|
||||
}
|
||||
|
||||
return Array.from(deduped.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
return {
|
||||
version: 1,
|
||||
providerBackendId: normalizeOptionalBackendId(file.providerBackendId),
|
||||
members: Array.from(deduped.values()).sort((a, b) => a.name.localeCompare(b.name)),
|
||||
};
|
||||
}
|
||||
|
||||
async writeMembers(teamName: string, members: TeamMember[]): Promise<void> {
|
||||
async getMembers(teamName: string): Promise<TeamMember[]> {
|
||||
return (await this.getMeta(teamName))?.members ?? [];
|
||||
}
|
||||
|
||||
async writeMembers(
|
||||
teamName: string,
|
||||
members: TeamMember[],
|
||||
options?: { providerBackendId?: string }
|
||||
): Promise<void> {
|
||||
const deduped = new Map<string, TeamMember>();
|
||||
for (const member of members) {
|
||||
const normalized = normalizeMember(member);
|
||||
|
|
@ -131,6 +152,7 @@ export class TeamMembersMetaStore {
|
|||
|
||||
const payload: TeamMembersMetaFile = {
|
||||
version: 1,
|
||||
providerBackendId: normalizeOptionalBackendId(options?.providerBackendId),
|
||||
members: Array.from(deduped.values()).sort((a, b) => a.name.localeCompare(b.name)),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
||||
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
|
@ -19,6 +20,7 @@ export interface TeamMetaFile {
|
|||
cwd: string;
|
||||
prompt?: string;
|
||||
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||
providerBackendId?: string;
|
||||
model?: string;
|
||||
effort?: string;
|
||||
skipPermissions?: boolean;
|
||||
|
|
@ -30,6 +32,14 @@ export interface TeamMetaFile {
|
|||
|
||||
const MAX_META_FILE_BYTES = 256 * 1024;
|
||||
|
||||
function normalizeOptionalBackendId(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export class TeamMetaStore {
|
||||
private getMetaPath(teamName: string): string {
|
||||
return path.join(getTeamsBasePath(), teamName, 'team.meta.json');
|
||||
|
|
@ -74,6 +84,11 @@ export class TeamMetaStore {
|
|||
return null;
|
||||
}
|
||||
|
||||
const providerId =
|
||||
file.providerId === 'anthropic' || file.providerId === 'codex' || file.providerId === 'gemini'
|
||||
? file.providerId
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
displayName:
|
||||
|
|
@ -83,12 +98,11 @@ export class TeamMetaStore {
|
|||
color: typeof file.color === 'string' ? file.color.trim() || undefined : undefined,
|
||||
cwd: file.cwd.trim(),
|
||||
prompt: typeof file.prompt === 'string' ? file.prompt.trim() || undefined : undefined,
|
||||
providerId:
|
||||
file.providerId === 'anthropic' ||
|
||||
file.providerId === 'codex' ||
|
||||
file.providerId === 'gemini'
|
||||
? file.providerId
|
||||
: undefined,
|
||||
providerId,
|
||||
providerBackendId: migrateProviderBackendId(
|
||||
providerId,
|
||||
normalizeOptionalBackendId(file.providerBackendId)
|
||||
),
|
||||
model: typeof file.model === 'string' ? file.model.trim() || undefined : undefined,
|
||||
effort: typeof file.effort === 'string' ? file.effort.trim() || undefined : undefined,
|
||||
skipPermissions: typeof file.skipPermissions === 'boolean' ? file.skipPermissions : undefined,
|
||||
|
|
@ -109,6 +123,10 @@ export class TeamMetaStore {
|
|||
cwd: data.cwd.trim(),
|
||||
prompt: data.prompt?.trim() || undefined,
|
||||
providerId: data.providerId,
|
||||
providerBackendId: migrateProviderBackendId(
|
||||
data.providerId,
|
||||
normalizeOptionalBackendId(data.providerBackendId)
|
||||
),
|
||||
model: data.model?.trim() || undefined,
|
||||
effort: data.effort?.trim() || undefined,
|
||||
skipPermissions: data.skipPermissions,
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import {
|
|||
} from '@shared/utils/inboxNoise';
|
||||
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||
import { isDefaultProviderModelSelection } from '@shared/utils/providerModelSelection';
|
||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import {
|
||||
|
|
@ -402,7 +403,7 @@ function getConfiguredRuntimeBackend(providerId: TeamProviderId): string | null
|
|||
case 'gemini':
|
||||
return runtimeConfig.gemini;
|
||||
case 'codex':
|
||||
return runtimeConfig.codex;
|
||||
return migrateProviderBackendId('codex', runtimeConfig.codex) ?? 'codex-native';
|
||||
case 'anthropic':
|
||||
default:
|
||||
return null;
|
||||
|
|
@ -420,7 +421,7 @@ function mergeProvisioningWarnings(
|
|||
}
|
||||
|
||||
function buildRuntimeLaunchWarning(
|
||||
request: Pick<TeamCreateRequest, 'providerId' | 'model' | 'effort'>,
|
||||
request: Pick<TeamCreateRequest, 'providerId' | 'providerBackendId' | 'model' | 'effort'>,
|
||||
env: NodeJS.ProcessEnv,
|
||||
options?: {
|
||||
geminiRuntimeAuth?: GeminiRuntimeAuthState | null;
|
||||
|
|
@ -432,7 +433,9 @@ function buildRuntimeLaunchWarning(
|
|||
const providerLabel = getTeamProviderLabel(providerId);
|
||||
const modelLabel = request.model?.trim() || 'default';
|
||||
const effortLabel = request.effort ?? 'default';
|
||||
const backend = getConfiguredRuntimeBackend(providerId);
|
||||
const backend =
|
||||
migrateProviderBackendId(providerId, request.providerBackendId?.trim()) ||
|
||||
getConfiguredRuntimeBackend(providerId);
|
||||
const flags: string[] = [];
|
||||
if (env.CLAUDE_CODE_USE_GEMINI === '1') flags.push('USE_GEMINI');
|
||||
if (env.CLAUDE_CODE_USE_OPENAI === '1') flags.push('USE_OPENAI');
|
||||
|
|
@ -467,7 +470,7 @@ function logRuntimeLaunchSnapshot(
|
|||
teamName: string,
|
||||
claudePath: string,
|
||||
args: string[],
|
||||
request: Pick<TeamCreateRequest, 'providerId' | 'model' | 'effort'>,
|
||||
request: Pick<TeamCreateRequest, 'providerId' | 'providerBackendId' | 'model' | 'effort'>,
|
||||
env: NodeJS.ProcessEnv,
|
||||
options?: {
|
||||
geminiRuntimeAuth?: GeminiRuntimeAuthState | null;
|
||||
|
|
@ -478,9 +481,12 @@ function logRuntimeLaunchSnapshot(
|
|||
const providerId = resolveTeamProviderId(request.providerId);
|
||||
const snapshot = {
|
||||
providerId,
|
||||
providerBackendId: migrateProviderBackendId(providerId, request.providerBackendId) ?? null,
|
||||
model: request.model ?? null,
|
||||
effort: request.effort ?? null,
|
||||
configuredBackend: getConfiguredRuntimeBackend(providerId),
|
||||
configuredBackend:
|
||||
migrateProviderBackendId(providerId, request.providerBackendId?.trim()) ||
|
||||
getConfiguredRuntimeBackend(providerId),
|
||||
promptSize: options?.promptSize ?? null,
|
||||
expectedMembersCount: options?.expectedMembersCount ?? null,
|
||||
geminiRuntimeAuth:
|
||||
|
|
@ -767,6 +773,7 @@ interface ProvisioningEnvResolution {
|
|||
env: NodeJS.ProcessEnv;
|
||||
authSource: ProvisioningAuthSource;
|
||||
geminiRuntimeAuth: GeminiRuntimeAuthState | null;
|
||||
providerArgs?: string[];
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
|
|
@ -1276,14 +1283,26 @@ function buildEffectiveTeamMemberSpecs(
|
|||
}
|
||||
|
||||
function shouldSkipResumeForProviderRuntimeChange(
|
||||
request: Pick<TeamLaunchRequest, 'providerId' | 'model'>,
|
||||
config: Record<string, unknown>
|
||||
request: Pick<TeamLaunchRequest, 'providerId' | 'providerBackendId' | 'model'>,
|
||||
config: Record<string, unknown>,
|
||||
persistedProviderBackendId?: string | null
|
||||
): { skip: boolean; reason?: string } {
|
||||
const providerId = normalizeTeamMemberProviderId(request.providerId);
|
||||
if (providerId !== 'gemini' && providerId !== 'codex') {
|
||||
return { skip: false };
|
||||
}
|
||||
|
||||
const requestedBackendId =
|
||||
migrateProviderBackendId(providerId, request.providerBackendId?.trim()) || null;
|
||||
const previousBackendId =
|
||||
migrateProviderBackendId(providerId, persistedProviderBackendId?.trim()) || null;
|
||||
if (requestedBackendId && previousBackendId && requestedBackendId !== previousBackendId) {
|
||||
return {
|
||||
skip: true,
|
||||
reason: `runtime backend changed (${previousBackendId} -> ${requestedBackendId})`,
|
||||
};
|
||||
}
|
||||
|
||||
const members = Array.isArray(config.members)
|
||||
? (config.members as Record<string, unknown>[])
|
||||
: [];
|
||||
|
|
@ -4419,6 +4438,7 @@ export class TeamProvisioningService {
|
|||
const updatedAt = nowIso();
|
||||
const runId = this.getTrackedRunId(teamName);
|
||||
const run = runId ? (this.runs.get(runId) ?? null) : null;
|
||||
const persistedTeamMeta = await this.teamMetaStore.getMeta(teamName).catch(() => null);
|
||||
|
||||
let configuredMembers: TeamConfig['members'] = [];
|
||||
try {
|
||||
|
|
@ -4520,6 +4540,7 @@ export class TeamProvisioningService {
|
|||
teamName,
|
||||
updatedAt,
|
||||
runId: run?.runId ?? null,
|
||||
providerBackendId: run?.request.providerBackendId ?? persistedTeamMeta?.providerBackendId,
|
||||
members: snapshotMembers,
|
||||
};
|
||||
|
||||
|
|
@ -6177,8 +6198,16 @@ export class TeamProvisioningService {
|
|||
throw new Error('Claude CLI not found; install it or provide a valid path');
|
||||
}
|
||||
|
||||
const provisioningEnv = await this.buildProvisioningEnv(request.providerId);
|
||||
const { env: shellEnv, geminiRuntimeAuth, warning: envWarning } = provisioningEnv;
|
||||
const provisioningEnv = await this.buildProvisioningEnv(
|
||||
request.providerId,
|
||||
request.providerBackendId
|
||||
);
|
||||
const {
|
||||
env: shellEnv,
|
||||
geminiRuntimeAuth,
|
||||
providerArgs = [],
|
||||
warning: envWarning,
|
||||
} = provisioningEnv;
|
||||
if (envWarning) {
|
||||
throw new Error(envWarning);
|
||||
}
|
||||
|
|
@ -6357,6 +6386,7 @@ export class TeamProvisioningService {
|
|||
...(request.effort ? ['--effort', request.effort] : []),
|
||||
...(request.worktree ? ['--worktree', request.worktree] : []),
|
||||
...parseCliArgs(request.extraCliArgs),
|
||||
...providerArgs,
|
||||
];
|
||||
const runtimeWarning = buildRuntimeLaunchWarning(request, shellEnv, {
|
||||
geminiRuntimeAuth,
|
||||
|
|
@ -6382,6 +6412,7 @@ export class TeamProvisioningService {
|
|||
cwd: request.cwd,
|
||||
prompt: request.prompt,
|
||||
providerId: request.providerId,
|
||||
providerBackendId: request.providerBackendId,
|
||||
model: request.model,
|
||||
effort: request.effort,
|
||||
skipPermissions: request.skipPermissions,
|
||||
|
|
@ -6405,7 +6436,9 @@ export class TeamProvisioningService {
|
|||
joinedAt: Date.now(),
|
||||
}))
|
||||
);
|
||||
await this.membersMetaStore.writeMembers(request.teamName, membersToWrite);
|
||||
await this.membersMetaStore.writeMembers(request.teamName, membersToWrite, {
|
||||
providerBackendId: request.providerBackendId,
|
||||
});
|
||||
if (request.skipPermissions === false) {
|
||||
await this.seedTeammateOperationalPermissionRules(request.teamName, request.cwd);
|
||||
}
|
||||
|
|
@ -6649,7 +6682,14 @@ export class TeamProvisioningService {
|
|||
if (!skipResume) {
|
||||
try {
|
||||
const configParsed = JSON.parse(configRaw) as Record<string, unknown>;
|
||||
const resumeGuard = shouldSkipResumeForProviderRuntimeChange(request, configParsed);
|
||||
const persistedTeamMeta = await this.teamMetaStore
|
||||
.getMeta(request.teamName)
|
||||
.catch(() => null);
|
||||
const resumeGuard = shouldSkipResumeForProviderRuntimeChange(
|
||||
request,
|
||||
configParsed,
|
||||
persistedTeamMeta?.providerBackendId ?? null
|
||||
);
|
||||
if (resumeGuard.skip) {
|
||||
logger.info(
|
||||
`[${request.teamName}] Skipping session resume — ${resumeGuard.reason ?? 'runtime changed'}`
|
||||
|
|
@ -6734,8 +6774,16 @@ export class TeamProvisioningService {
|
|||
const runId = randomUUID();
|
||||
const startedAt = nowIso();
|
||||
|
||||
const provisioningEnv = await this.buildProvisioningEnv(request.providerId);
|
||||
const { env: shellEnv, geminiRuntimeAuth, warning: envWarning } = provisioningEnv;
|
||||
const provisioningEnv = await this.buildProvisioningEnv(
|
||||
request.providerId,
|
||||
request.providerBackendId
|
||||
);
|
||||
const {
|
||||
env: shellEnv,
|
||||
geminiRuntimeAuth,
|
||||
providerArgs = [],
|
||||
warning: envWarning,
|
||||
} = provisioningEnv;
|
||||
if (envWarning) {
|
||||
throw new Error(envWarning);
|
||||
}
|
||||
|
|
@ -6760,6 +6808,7 @@ export class TeamProvisioningService {
|
|||
members: effectiveMemberSpecs,
|
||||
cwd: request.cwd,
|
||||
providerId: request.providerId,
|
||||
providerBackendId: request.providerBackendId,
|
||||
model: request.model,
|
||||
effort: request.effort,
|
||||
skipPermissions: request.skipPermissions,
|
||||
|
|
@ -6971,6 +7020,7 @@ export class TeamProvisioningService {
|
|||
launchArgs.push('--worktree', request.worktree);
|
||||
}
|
||||
launchArgs.push(...parseCliArgs(request.extraCliArgs));
|
||||
launchArgs.push(...providerArgs);
|
||||
const runtimeWarning = buildRuntimeLaunchWarning(request, shellEnv, {
|
||||
geminiRuntimeAuth,
|
||||
promptSize,
|
||||
|
|
@ -6983,6 +7033,42 @@ export class TeamProvisioningService {
|
|||
});
|
||||
// --resume is added above when a valid previous session JSONL exists.
|
||||
// Without it, CLI creates a fresh session ID automatically.
|
||||
await this.teamMetaStore.writeMeta(request.teamName, {
|
||||
displayName: syntheticRequest.displayName,
|
||||
description: syntheticRequest.description,
|
||||
color: syntheticRequest.color,
|
||||
cwd: request.cwd,
|
||||
prompt: request.prompt,
|
||||
providerId: request.providerId,
|
||||
providerBackendId: request.providerBackendId,
|
||||
model: request.model,
|
||||
effort: request.effort,
|
||||
skipPermissions: request.skipPermissions,
|
||||
worktree: request.worktree,
|
||||
extraCliArgs: request.extraCliArgs,
|
||||
limitContext: request.limitContext,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
await this.membersMetaStore.writeMembers(
|
||||
request.teamName,
|
||||
effectiveMemberSpecs.map((member) => ({
|
||||
name: member.name.trim(),
|
||||
role: member.role?.trim() || undefined,
|
||||
workflow: member.workflow?.trim() || undefined,
|
||||
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||||
model: member.model?.trim() || undefined,
|
||||
effort:
|
||||
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
||||
? member.effort
|
||||
: undefined,
|
||||
agentType: 'general-purpose',
|
||||
color: getMemberColorByName(member.name.trim()),
|
||||
joinedAt: Date.now(),
|
||||
})),
|
||||
{
|
||||
providerBackendId: request.providerBackendId,
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
if (request.skipPermissions === false) {
|
||||
|
|
@ -11804,9 +11890,6 @@ export class TeamProvisioningService {
|
|||
}
|
||||
);
|
||||
|
||||
// Clean up team.meta.json — provisioning succeeded, config.json is now authoritative.
|
||||
await this.teamMetaStore.deleteMeta(run.teamName).catch(() => {});
|
||||
|
||||
// Audit: flag any expected member not registered in config.json after provisioning.
|
||||
await this.refreshMemberSpawnStatusesFromLeadInbox(run);
|
||||
await this.maybeAuditMemberSpawnStatuses(run, { force: true });
|
||||
|
|
@ -12733,7 +12816,8 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
private async buildProvisioningEnv(
|
||||
providerId: TeamProviderId | undefined = 'anthropic'
|
||||
providerId: TeamProviderId | undefined = 'anthropic',
|
||||
providerBackendId?: string | null
|
||||
): Promise<ProvisioningEnvResolution> {
|
||||
const shellEnv = await resolveInteractiveShellEnv();
|
||||
// getHomeDir() uses Electron's app.getPath('home') which handles Unicode
|
||||
|
|
@ -12779,6 +12863,7 @@ export class TeamProvisioningService {
|
|||
const resolvedProviderId = resolveTeamProviderId(providerId);
|
||||
const providerEnvResult = await buildProviderAwareCliEnv({
|
||||
providerId,
|
||||
providerBackendId,
|
||||
shellEnv,
|
||||
env,
|
||||
});
|
||||
|
|
@ -12815,12 +12900,18 @@ export class TeamProvisioningService {
|
|||
env: providerEnv,
|
||||
authSource: 'configured_api_key_missing',
|
||||
geminiRuntimeAuth: null,
|
||||
providerArgs: providerEnvResult.providerArgs,
|
||||
warning: providerConnectionIssue,
|
||||
};
|
||||
}
|
||||
|
||||
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') {
|
||||
|
|
@ -12828,6 +12919,7 @@ export class TeamProvisioningService {
|
|||
env: providerEnv,
|
||||
authSource: 'gemini_runtime',
|
||||
geminiRuntimeAuth: await resolveGeminiRuntimeAuth(providerEnv),
|
||||
providerArgs: providerEnvResult.providerArgs,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -12836,7 +12928,12 @@ export class TeamProvisioningService {
|
|||
typeof providerEnv.ANTHROPIC_API_KEY === 'string' &&
|
||||
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,
|
||||
|
|
@ -12846,7 +12943,12 @@ export class TeamProvisioningService {
|
|||
providerEnv.ANTHROPIC_AUTH_TOKEN.trim().length > 0
|
||||
) {
|
||||
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.
|
||||
|
|
@ -12854,7 +12956,12 @@ export class TeamProvisioningService {
|
|||
// tokens in-memory. Injecting CLAUDE_CODE_OAUTH_TOKEN from the
|
||||
// credentials file causes 401 errors because the stored token is
|
||||
// often stale (CLI refreshes in-memory but rarely writes back).
|
||||
return { env: providerEnv, authSource: 'none', geminiRuntimeAuth: null };
|
||||
return {
|
||||
env: providerEnv,
|
||||
authSource: 'none',
|
||||
geminiRuntimeAuth: null,
|
||||
providerArgs: providerEnvResult.providerArgs,
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveControlApiBaseUrl(): Promise<string | null> {
|
||||
|
|
@ -13610,7 +13717,9 @@ export class TeamProvisioningService {
|
|||
joinedAt,
|
||||
}))
|
||||
);
|
||||
await this.membersMetaStore.writeMembers(teamName, membersToWrite);
|
||||
await this.membersMetaStore.writeMembers(teamName, membersToWrite, {
|
||||
providerBackendId: request.providerBackendId,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[${teamName}] Failed to persist members.meta.json: ${
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export function getCliFlavorUiOptions(flavor: CliFlavor): CliFlavorUiOptions {
|
|||
switch (flavor) {
|
||||
case 'agent_teams_orchestrator':
|
||||
return {
|
||||
displayName: 'agent_teams_orchestrator',
|
||||
displayName: 'Multimodel runtime',
|
||||
supportsSelfUpdate: false,
|
||||
showVersionDetails: false,
|
||||
showBinaryPath: false,
|
||||
|
|
|
|||
|
|
@ -198,9 +198,22 @@ export interface AssistantEntry extends ConversationalEntry {
|
|||
|
||||
export interface SystemEntry extends ConversationalEntry {
|
||||
type: 'system';
|
||||
subtype: 'turn_duration' | 'init';
|
||||
durationMs: number;
|
||||
subtype?: 'turn_duration' | 'init' | 'informational' | 'permission_retry' | 'api_retry' | string;
|
||||
durationMs?: number;
|
||||
isMeta: boolean;
|
||||
content?: string;
|
||||
level?: 'info' | 'warning' | 'error' | 'suggestion' | string;
|
||||
toolUseID?: string;
|
||||
preventContinuation?: boolean;
|
||||
codexNativeWarningSource?: string;
|
||||
codexNativeThreadStatus?: string;
|
||||
codexNativeThreadId?: string;
|
||||
codexNativeCompletionPolicy?: 'ephemeral' | 'persistent' | string;
|
||||
codexNativeHistoryCompleteness?: string;
|
||||
codexNativeFinalUsageAuthority?: string;
|
||||
codexNativeExecutablePath?: string;
|
||||
codexNativeExecutableSource?: string;
|
||||
codexNativeExecutableVersion?: string | null;
|
||||
}
|
||||
|
||||
export interface SummaryEntry extends BaseEntry {
|
||||
|
|
|
|||
|
|
@ -109,6 +109,19 @@ export interface ParsedMessage {
|
|||
isCompactSummary?: boolean;
|
||||
/** API request ID for deduplicating streaming entries */
|
||||
requestId?: string;
|
||||
/** System-message severity when available in the raw transcript */
|
||||
level?: string;
|
||||
/** Raw system subtype when available in the transcript */
|
||||
subtype?: string;
|
||||
codexNativeWarningSource?: string;
|
||||
codexNativeThreadStatus?: string;
|
||||
codexNativeThreadId?: string;
|
||||
codexNativeCompletionPolicy?: string;
|
||||
codexNativeHistoryCompleteness?: string;
|
||||
codexNativeFinalUsageAuthority?: string;
|
||||
codexNativeExecutablePath?: string;
|
||||
codexNativeExecutableSource?: string;
|
||||
codexNativeExecutableVersion?: string | null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -244,6 +244,17 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null {
|
|||
let gitBranch: string | undefined;
|
||||
let agentId: string | undefined;
|
||||
let agentName: string | undefined;
|
||||
let level: string | undefined;
|
||||
let subtype: string | undefined;
|
||||
let codexNativeWarningSource: string | undefined;
|
||||
let codexNativeThreadStatus: string | undefined;
|
||||
let codexNativeThreadId: string | undefined;
|
||||
let codexNativeCompletionPolicy: string | undefined;
|
||||
let codexNativeHistoryCompleteness: string | undefined;
|
||||
let codexNativeFinalUsageAuthority: string | undefined;
|
||||
let codexNativeExecutablePath: string | undefined;
|
||||
let codexNativeExecutableSource: string | undefined;
|
||||
let codexNativeExecutableVersion: string | null | undefined;
|
||||
let isSidechain = false;
|
||||
let isMeta = false;
|
||||
let userType: string | undefined;
|
||||
|
|
@ -283,7 +294,19 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null {
|
|||
agentId = entry.agentId;
|
||||
requestId = entry.requestId;
|
||||
} else if (entry.type === 'system') {
|
||||
content = entry.content ?? '';
|
||||
isMeta = entry.isMeta ?? false;
|
||||
level = entry.level;
|
||||
subtype = entry.subtype;
|
||||
codexNativeWarningSource = entry.codexNativeWarningSource;
|
||||
codexNativeThreadStatus = entry.codexNativeThreadStatus;
|
||||
codexNativeThreadId = entry.codexNativeThreadId;
|
||||
codexNativeCompletionPolicy = entry.codexNativeCompletionPolicy;
|
||||
codexNativeHistoryCompleteness = entry.codexNativeHistoryCompleteness;
|
||||
codexNativeFinalUsageAuthority = entry.codexNativeFinalUsageAuthority;
|
||||
codexNativeExecutablePath = entry.codexNativeExecutablePath;
|
||||
codexNativeExecutableSource = entry.codexNativeExecutableSource;
|
||||
codexNativeExecutableVersion = entry.codexNativeExecutableVersion;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -310,6 +333,17 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null {
|
|||
isMeta,
|
||||
userType,
|
||||
isCompactSummary,
|
||||
level,
|
||||
subtype,
|
||||
codexNativeWarningSource,
|
||||
codexNativeThreadStatus,
|
||||
codexNativeThreadId,
|
||||
codexNativeCompletionPolicy,
|
||||
codexNativeHistoryCompleteness,
|
||||
codexNativeFinalUsageAuthority,
|
||||
codexNativeExecutablePath,
|
||||
codexNativeExecutableSource,
|
||||
codexNativeExecutableVersion,
|
||||
// Tool info
|
||||
toolCalls,
|
||||
toolResults: toolResultsList,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
import { isCommandOutputContent, sanitizeDisplayContent } from '@shared/utils/contentSanitizer';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as readline from 'readline';
|
||||
|
||||
import { LocalFileSystemProvider } from '../services/infrastructure/LocalFileSystemProvider';
|
||||
|
|
@ -29,7 +30,7 @@ function normalizeDriveLetter(p: string): string {
|
|||
|
||||
const defaultProvider = new LocalFileSystemProvider();
|
||||
|
||||
const JSONL_HEAD_TIMEOUT_MS = 2000;
|
||||
const JSONL_HEAD_TIMEOUT_MS = 5000;
|
||||
const JSONL_HEAD_MAX_BYTES = 256 * 1024;
|
||||
const JSONL_HEAD_MAX_LINES = 400;
|
||||
|
||||
|
|
@ -53,6 +54,41 @@ function createStreamCleanup(rl: readline.Interface, fileStream: Readable): () =
|
|||
};
|
||||
}
|
||||
|
||||
function extractCwdFromBufferedText(text: string): string | null {
|
||||
const lines = text.split(/\r?\n/, JSONL_HEAD_MAX_LINES);
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
let entry: ChatHistoryEntry;
|
||||
try {
|
||||
entry = JSON.parse(line) as ChatHistoryEntry;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ('cwd' in entry && entry.cwd) {
|
||||
return normalizeDriveLetter(translateWslMountPath(entry.cwd));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function extractCwdFromLocalFile(filePath: string): Promise<string | null> {
|
||||
const handle = await fs.open(filePath, 'r');
|
||||
try {
|
||||
const buffer = Buffer.alloc(JSONL_HEAD_MAX_BYTES);
|
||||
const { bytesRead } = await handle.read(buffer, 0, JSONL_HEAD_MAX_BYTES, 0);
|
||||
if (bytesRead <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return extractCwdFromBufferedText(buffer.toString('utf8', 0, bytesRead));
|
||||
} finally {
|
||||
await handle.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract CWD (current working directory) from the first entry.
|
||||
* Used to get the actual project path from encoded directory names.
|
||||
|
|
@ -74,6 +110,15 @@ export async function extractCwd(
|
|||
return null;
|
||||
}
|
||||
|
||||
if (fsProvider.type === 'local') {
|
||||
try {
|
||||
return await extractCwdFromLocalFile(filePath);
|
||||
} catch (error) {
|
||||
logger.debug(`Error extracting cwd from local file ${filePath}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' });
|
||||
const rl = readline.createInterface({
|
||||
input: fileStream,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { createCodexAccountBridge } from '@features/codex-account/preload';
|
||||
import { createRecentProjectsBridge } from '@features/recent-projects/preload';
|
||||
import { createTmuxInstallerBridge } from '@features/tmux-installer/preload';
|
||||
import { WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL } from '@shared/constants';
|
||||
|
|
@ -121,6 +122,7 @@ import {
|
|||
TEAM_DELETE_DRAFT,
|
||||
TEAM_DELETE_TASK_ATTACHMENT,
|
||||
TEAM_DELETE_TEAM,
|
||||
TEAM_GET_AGENT_RUNTIME,
|
||||
TEAM_GET_ALL_TASKS,
|
||||
TEAM_GET_ATTACHMENTS,
|
||||
TEAM_GET_CLAUDE_LOGS,
|
||||
|
|
@ -147,7 +149,6 @@ import {
|
|||
TEAM_LEAD_CONTEXT,
|
||||
TEAM_LIST,
|
||||
TEAM_MEMBER_SPAWN_STATUSES,
|
||||
TEAM_GET_AGENT_RUNTIME,
|
||||
TEAM_PERMANENTLY_DELETE,
|
||||
TEAM_PREPARE_PROVISIONING,
|
||||
TEAM_PROCESS_ALIVE,
|
||||
|
|
@ -165,9 +166,9 @@ import {
|
|||
TEAM_SAVE_TASK_ATTACHMENT,
|
||||
TEAM_SEND_MESSAGE,
|
||||
TEAM_SET_CHANGE_PRESENCE_TRACKING,
|
||||
TEAM_SET_TASK_LOG_STREAM_TRACKING,
|
||||
TEAM_SET_PROJECT_BRANCH_TRACKING,
|
||||
TEAM_SET_TASK_CLARIFICATION,
|
||||
TEAM_SET_TASK_LOG_STREAM_TRACKING,
|
||||
TEAM_SET_TOOL_ACTIVITY_TRACKING,
|
||||
TEAM_SHOW_MESSAGE_NOTIFICATION,
|
||||
TEAM_SOFT_DELETE_TASK,
|
||||
|
|
@ -270,7 +271,6 @@ import type {
|
|||
LeadContextUsageSnapshot,
|
||||
MemberFullStats,
|
||||
MemberLogSummary,
|
||||
TeamAgentRuntimeSnapshot,
|
||||
MemberSpawnStatusesSnapshot,
|
||||
MessagesPage,
|
||||
NotificationTrigger,
|
||||
|
|
@ -293,6 +293,7 @@ import type {
|
|||
TaskChangePresenceState,
|
||||
TaskChangeSetV2,
|
||||
TaskComment,
|
||||
TeamAgentRuntimeSnapshot,
|
||||
TeamChangeEvent,
|
||||
TeamClaudeLogsQuery,
|
||||
TeamClaudeLogsResponse,
|
||||
|
|
@ -458,6 +459,9 @@ ipcRenderer.on(
|
|||
// Expose protected methods that allow the renderer process to use
|
||||
// the ipcRenderer without exposing the entire object
|
||||
const electronAPI: ElectronAPI = {
|
||||
...createCodexAccountBridge({
|
||||
ipcRenderer,
|
||||
}),
|
||||
...createRecentProjectsBridge(),
|
||||
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||
getProjects: () => ipcRenderer.invoke('get-projects'),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
* 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 {
|
||||
AppConfig,
|
||||
|
|
@ -219,6 +220,29 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
|
||||
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> =>
|
||||
this.get<DashboardRecentProjectsPayload>('/api/dashboard/recent-projects');
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* 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).
|
||||
* Only rendered in Electron mode.
|
||||
|
|
@ -13,6 +13,7 @@ import { useShallow } from 'zustand/react/shallow';
|
|||
|
||||
export const CliInstallWarningBanner = (): React.JSX.Element | null => {
|
||||
const cliStatus = useStore(useShallow((s) => s.cliStatus));
|
||||
const cliStatusLoading = useStore((s) => s.cliStatusLoading);
|
||||
const openDashboard = useStore((s) => s.openDashboard);
|
||||
|
||||
// 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
|
||||
if (!isElectronMode() || !cliStatus || cliStatus.installed || isDashboardFocused) {
|
||||
if (
|
||||
!isElectronMode() ||
|
||||
cliStatusLoading ||
|
||||
!cliStatus ||
|
||||
cliStatus.installed ||
|
||||
isDashboardFocused
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -40,8 +47,8 @@ export const CliInstallWarningBanner = (): React.JSX.Element | null => {
|
|||
<AlertTriangle className="size-3.5 shrink-0" />
|
||||
<span className="text-xs">
|
||||
{cliStatus.binaryPath && cliStatus.launchError
|
||||
? 'Claude Code 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 was found but failed to start. Open the Dashboard to repair or reinstall it.`
|
||||
: `The configured ${cliStatus.displayName} runtime is not installed. Install it from the Dashboard to enable all features.`}
|
||||
</span>
|
||||
<button
|
||||
onClick={openDashboard}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,13 @@
|
|||
|
||||
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 { confirm } from '@renderer/components/common/ConfirmDialog';
|
||||
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
||||
|
|
@ -33,6 +40,8 @@ import { useStore } from '@renderer/store';
|
|||
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
|
||||
import { formatBytes } from '@renderer/utils/formatters';
|
||||
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 {
|
||||
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). */
|
||||
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
|
||||
// =============================================================================
|
||||
|
|
@ -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
|
||||
className={`mb-6 flex items-center gap-3 rounded-lg border-l-4 px-4 py-3 ${BANNER_MIN_H}`}
|
||||
style={{
|
||||
|
|
@ -93,7 +147,7 @@ const InstallCompletedNotice = ({ version }: { version: string | null }): React.
|
|||
>
|
||||
<CheckCircle className="size-4 shrink-0" style={{ color: '#4ade80' }} />
|
||||
<span className="text-sm" style={{ color: '#4ade80' }}>
|
||||
Successfully installed Claude CLI v{version ?? 'latest'}
|
||||
Successfully installed {runtimeDisplayName} v{version ?? 'latest'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -199,8 +253,10 @@ const CliCheckingSpinner = ({
|
|||
|
||||
interface InstalledBannerProps {
|
||||
cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']>;
|
||||
sourceProviderMap: Map<CliProviderId, CliProviderStatus>;
|
||||
cliStatusLoading: boolean;
|
||||
cliProviderStatusLoading: Partial<Record<CliProviderId, boolean>>;
|
||||
codexSnapshotPending: boolean;
|
||||
cliStatusError: string | null;
|
||||
isBusy: boolean;
|
||||
multimodelEnabled: boolean;
|
||||
|
|
@ -240,6 +296,15 @@ function getProviderTerminalCommand(provider: CliProviderStatus): {
|
|||
};
|
||||
}
|
||||
|
||||
if (provider.providerId === 'codex') {
|
||||
return {
|
||||
args: ['auth', 'login', '--provider', provider.providerId],
|
||||
env: {
|
||||
CLAUDE_CODE_CODEX_BACKEND: provider.selectedBackendId ?? 'codex-native',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
args: ['auth', 'login', '--provider', provider.providerId],
|
||||
};
|
||||
|
|
@ -259,6 +324,15 @@ function getProviderTerminalLogoutCommand(provider: CliProviderStatus): {
|
|||
};
|
||||
}
|
||||
|
||||
if (provider.providerId === 'codex') {
|
||||
return {
|
||||
args: ['auth', 'logout', '--provider', provider.providerId],
|
||||
env: {
|
||||
CLAUDE_CODE_CODEX_BACKEND: provider.selectedBackendId ?? 'codex-native',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
args: ['auth', 'logout', '--provider', provider.providerId],
|
||||
};
|
||||
|
|
@ -298,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(
|
||||
providers: readonly CliProviderStatus[]
|
||||
): CliProviderStatus[] {
|
||||
|
|
@ -313,9 +415,75 @@ function formatRuntimeLabel(
|
|||
return null;
|
||||
}
|
||||
|
||||
const runtimeLabel = getHumanRuntimeDisplayName(cliStatus);
|
||||
return cliStatus.showVersionDetails && cliStatus.installedVersion
|
||||
? `${cliStatus.displayName} v${cliStatus.installedVersion ?? 'unknown'}`
|
||||
: cliStatus.displayName;
|
||||
? `${runtimeLabel} v${cliStatus.installedVersion ?? 'unknown'}`
|
||||
: 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(
|
||||
|
|
@ -372,8 +540,10 @@ function hasVisibleAuthenticatedMultimodelProvider(
|
|||
|
||||
const InstalledBanner = ({
|
||||
cliStatus,
|
||||
sourceProviderMap,
|
||||
cliStatusLoading,
|
||||
cliProviderStatusLoading,
|
||||
codexSnapshotPending,
|
||||
cliStatusError,
|
||||
isBusy,
|
||||
multimodelEnabled,
|
||||
|
|
@ -502,16 +672,31 @@ const InstalledBanner = ({
|
|||
style={{ borderColor: 'var(--color-border-subtle)' }}
|
||||
>
|
||||
{visibleProviders.map((provider) => {
|
||||
const statusText = formatProviderStatusText(provider);
|
||||
const actionDisabled = isBusy || !cliStatus.binaryPath;
|
||||
const runtimeSummary = isConnectionManagedRuntimeProvider(provider)
|
||||
? getProviderCurrentRuntimeSummary(provider)
|
||||
: getProviderRuntimeBackendSummary(provider);
|
||||
const connectionModeSummary = getProviderConnectionModeSummary(provider);
|
||||
const credentialSummary = getProviderCredentialSummary(provider);
|
||||
const codexDashboardRateLimits = getCodexDashboardRateLimits(provider);
|
||||
const codexDashboardHint = getCodexDashboardHint(provider);
|
||||
const disconnectAction = getProviderDisconnectAction(provider);
|
||||
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(
|
||||
(provider.backend?.label && !runtimeSummary) ||
|
||||
runtimeSummary ||
|
||||
|
|
@ -544,7 +729,7 @@ const InstalledBanner = ({
|
|||
<span
|
||||
className="text-xs"
|
||||
style={{
|
||||
color: provider.authenticated ? '#4ade80' : 'var(--color-text-muted)',
|
||||
color: getProviderStatusColor(statusText, provider.authenticated),
|
||||
}}
|
||||
>
|
||||
{statusText}
|
||||
|
|
@ -574,6 +759,90 @@ const InstalledBanner = ({
|
|||
)}
|
||||
</div>
|
||||
) : 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 className="flex shrink-0 items-start gap-2">
|
||||
<button
|
||||
|
|
@ -601,7 +870,7 @@ const InstalledBanner = ({
|
|||
<LogOut className="size-3" />
|
||||
{disconnectAction.label}
|
||||
</button>
|
||||
) : shouldShowProviderConnectAction(provider) ? (
|
||||
) : !showSkeleton && shouldShowProviderConnectAction(provider) ? (
|
||||
<button
|
||||
onClick={() => onProviderLogin(provider.providerId)}
|
||||
disabled={actionDisabled}
|
||||
|
|
@ -617,7 +886,7 @@ const InstalledBanner = ({
|
|||
) : null}
|
||||
<button
|
||||
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"
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
|
|
@ -626,16 +895,12 @@ const InstalledBanner = ({
|
|||
title={`Re-check ${provider.displayName}`}
|
||||
>
|
||||
<RefreshCw
|
||||
className={
|
||||
cliStatusLoading || providerLoading
|
||||
? 'size-[11px] animate-spin'
|
||||
: 'size-[11px]'
|
||||
}
|
||||
className={providerLoading ? 'size-[11px] animate-spin' : 'size-[11px]'}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{!showSkeleton && provider.models.length > 0 && (
|
||||
{!showSkeleton && provider.models.length > 0 && !showInlineCodexAccessoryRow && (
|
||||
<div className="col-span-2">
|
||||
<ProviderModelBadges
|
||||
providerId={provider.providerId}
|
||||
|
|
@ -694,10 +959,56 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false);
|
||||
const [showTroubleshoot, setShowTroubleshoot] = useState(false);
|
||||
const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true;
|
||||
const visibleCliProviders = useMemo(
|
||||
() => filterMainScreenCliProviders(cliStatus?.providers ?? []),
|
||||
[cliStatus?.providers]
|
||||
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) => 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(() => {
|
||||
if (!isElectron) return;
|
||||
|
|
@ -711,24 +1022,28 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
|
||||
const interval = setInterval(
|
||||
() => {
|
||||
void fetchCliStatus();
|
||||
void refreshCliStatusForCurrentMode({
|
||||
multimodelEnabled,
|
||||
bootstrapCliStatus,
|
||||
fetchCliStatus,
|
||||
});
|
||||
},
|
||||
10 * 60 * 1000
|
||||
);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isElectron, cliStatus, fetchCliStatus]);
|
||||
}, [bootstrapCliStatus, cliStatus, fetchCliStatus, isElectron, multimodelEnabled]);
|
||||
|
||||
const handleInstall = useCallback(() => {
|
||||
installCli();
|
||||
}, [installCli]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
if (multimodelEnabled) {
|
||||
void bootstrapCliStatus({ multimodelEnabled: true });
|
||||
return;
|
||||
}
|
||||
void fetchCliStatus();
|
||||
void refreshCliStatusForCurrentMode({
|
||||
multimodelEnabled,
|
||||
bootstrapCliStatus,
|
||||
fetchCliStatus,
|
||||
});
|
||||
}, [bootstrapCliStatus, fetchCliStatus, multimodelEnabled]);
|
||||
|
||||
const handleMultimodelToggle = useCallback(
|
||||
|
|
@ -767,12 +1082,16 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
void (async () => {
|
||||
try {
|
||||
await invalidateCliStatus();
|
||||
await fetchCliStatus();
|
||||
await refreshCliStatusForCurrentMode({
|
||||
multimodelEnabled,
|
||||
bootstrapCliStatus,
|
||||
fetchCliStatus,
|
||||
});
|
||||
} finally {
|
||||
setIsVerifyingAuth(false);
|
||||
}
|
||||
})();
|
||||
}, [fetchCliStatus, invalidateCliStatus]);
|
||||
}, [bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, multimodelEnabled]);
|
||||
|
||||
const handleProviderLogin = useCallback((providerId: CliProviderId) => {
|
||||
setProviderTerminal({ providerId, action: 'login' });
|
||||
|
|
@ -782,7 +1101,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
(providerId: CliProviderId) => {
|
||||
void (async () => {
|
||||
const provider =
|
||||
cliStatus?.providers.find((entry) => entry.providerId === providerId) ?? null;
|
||||
effectiveCliStatus?.providers.find((entry) => entry.providerId === providerId) ?? null;
|
||||
const disconnectAction = provider ? getProviderDisconnectAction(provider) : null;
|
||||
if (!disconnectAction) {
|
||||
return;
|
||||
|
|
@ -803,7 +1122,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
setProviderTerminal({ providerId, action: 'logout' });
|
||||
})();
|
||||
},
|
||||
[cliStatus?.providers]
|
||||
[effectiveCliStatus?.providers]
|
||||
);
|
||||
|
||||
const handleProviderManage = useCallback((providerId: CliProviderId) => {
|
||||
|
|
@ -826,7 +1145,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
|
||||
const currentBackends = appConfig?.runtime?.providerBackends ?? {
|
||||
gemini: 'auto' as const,
|
||||
codex: 'auto' as const,
|
||||
codex: 'codex-native' as const,
|
||||
};
|
||||
|
||||
await updateConfig('runtime', {
|
||||
|
|
@ -852,29 +1171,29 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
if (installerState === 'error') return 'error';
|
||||
if (installerState === 'completed') return 'success';
|
||||
if (installerState !== 'idle') return 'info';
|
||||
if (!cliStatus) return 'loading';
|
||||
if (isCheckingMultimodelStatus(cliStatus, visibleCliProviders)) return 'info';
|
||||
if (cliStatus.authStatusChecking) return 'info';
|
||||
if (!cliStatus.installed) return 'error';
|
||||
if (isMultimodelRuntimeStatus(cliStatus) && visibleCliProviders.length === 0) {
|
||||
if (!renderCliStatus) return 'loading';
|
||||
if (isCheckingMultimodelStatus(renderCliStatus, visibleCliProviders)) return 'info';
|
||||
if (renderCliStatus.authStatusChecking) return 'info';
|
||||
if (!renderCliStatus.installed) return 'error';
|
||||
if (isMultimodelRuntimeStatus(renderCliStatus) && visibleCliProviders.length === 0) {
|
||||
return 'warning';
|
||||
}
|
||||
if (
|
||||
isMultimodelRuntimeStatus(cliStatus) &&
|
||||
isMultimodelRuntimeStatus(renderCliStatus) &&
|
||||
visibleCliProviders.length > 0 &&
|
||||
!hasVisibleAuthenticatedMultimodelProvider(visibleCliProviders)
|
||||
) {
|
||||
return 'warning';
|
||||
}
|
||||
if (cliStatus.installed && !cliStatus.authLoggedIn) return 'warning';
|
||||
if (cliStatus.updateAvailable) return 'info';
|
||||
if (renderCliStatus.installed && !renderCliStatus.authLoggedIn) return 'warning';
|
||||
if (renderCliStatus.updateAvailable) return 'info';
|
||||
return 'success';
|
||||
};
|
||||
|
||||
const variant = getVariant();
|
||||
const styles = VARIANT_STYLES[variant];
|
||||
const activeTerminalProvider = providerTerminal
|
||||
? (cliStatus?.providers.find(
|
||||
? (effectiveCliStatus?.providers.find(
|
||||
(provider) => provider.providerId === providerTerminal.providerId
|
||||
) ?? null)
|
||||
: null;
|
||||
|
|
@ -885,7 +1204,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
: getProviderTerminalLogoutCommand(activeTerminalProvider)
|
||||
: null;
|
||||
const installedAuxiliaryUi =
|
||||
cliStatus !== null ? (
|
||||
renderCliStatus !== null ? (
|
||||
<>
|
||||
<ProviderRuntimeSettingsDialog
|
||||
open={manageDialogOpen}
|
||||
|
|
@ -897,17 +1216,17 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
: (visibleCliProviders[0]?.providerId ?? 'anthropic')
|
||||
}
|
||||
providerStatusLoading={cliProviderStatusLoading}
|
||||
disabled={isBusy || cliStatusLoading || !cliStatus.binaryPath}
|
||||
disabled={isBusy || cliStatusLoading || !renderCliStatus.binaryPath}
|
||||
onSelectBackend={handleProviderBackendChange}
|
||||
onRefreshProvider={(providerId) => fetchCliProviderStatus(providerId)}
|
||||
onRequestLogin={(providerId) => setProviderTerminal({ providerId, action: 'login' })}
|
||||
/>
|
||||
{providerTerminal && cliStatus.binaryPath && (
|
||||
{providerTerminal && renderCliStatus.binaryPath && (
|
||||
<TerminalModal
|
||||
title={`${cliStatus.displayName} ${providerTerminal.action === 'login' ? 'Login' : 'Logout'}: ${getProviderLabel(
|
||||
providerTerminal.providerId
|
||||
)}`}
|
||||
command={cliStatus.binaryPath}
|
||||
title={`${getHumanRuntimeDisplayName(renderCliStatus, multimodelEnabled)} ${
|
||||
providerTerminal.action === 'login' ? 'Login' : 'Logout'
|
||||
}: ${getProviderLabel(providerTerminal.providerId)}`}
|
||||
command={renderCliStatus.binaryPath}
|
||||
args={providerTerminalCommand?.args}
|
||||
env={providerTerminalCommand?.env}
|
||||
onClose={() => {
|
||||
|
|
@ -930,7 +1249,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
) : null;
|
||||
|
||||
// ── Loading / fetch error state ────────────────────────────────────────
|
||||
if (!cliStatus && installerState === 'idle') {
|
||||
if (!renderCliStatus && installerState === 'idle') {
|
||||
// Fetch failed — show error with retry
|
||||
if (cliStatusError && !cliStatusLoading) {
|
||||
return (
|
||||
|
|
@ -970,7 +1289,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
|
||||
>
|
||||
<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>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
|
|
@ -988,9 +1307,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
if (multimodelEnabled) {
|
||||
return (
|
||||
<InstalledBanner
|
||||
cliStatus={createLoadingMultimodelCliStatus()}
|
||||
cliStatus={renderCliStatus ?? createLoadingMultimodelCliStatus()}
|
||||
sourceProviderMap={loadingCliProviderMap}
|
||||
cliStatusLoading={cliStatusLoading}
|
||||
cliProviderStatusLoading={cliProviderStatusLoading}
|
||||
codexSnapshotPending={codexSnapshotPending}
|
||||
cliStatusError={cliStatusError ?? null}
|
||||
isBusy={isBusy}
|
||||
multimodelEnabled={multimodelEnabled}
|
||||
|
|
@ -1027,7 +1348,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
<div className="flex items-center gap-2">
|
||||
<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)' }}>
|
||||
Downloading Claude CLI...
|
||||
Downloading {runtimeDisplayName}...
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs tabular-nums" style={{ color: 'var(--color-text-muted)' }}>
|
||||
|
|
@ -1086,7 +1407,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
<div className="flex items-center gap-3">
|
||||
<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)' }}>
|
||||
Installing Claude CLI...
|
||||
Installing {runtimeDisplayName}...
|
||||
</span>
|
||||
</div>
|
||||
<TerminalLogPanel chunks={installerRawChunks} />
|
||||
|
|
@ -1097,10 +1418,12 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
// ── Completed ──────────────────────────────────────────────────────────
|
||||
if (
|
||||
installerState === 'completed' &&
|
||||
!cliStatus?.installed &&
|
||||
!(cliStatus?.binaryPath && cliStatus?.launchError)
|
||||
!renderCliStatus?.installed &&
|
||||
!(renderCliStatus?.binaryPath && renderCliStatus?.launchError)
|
||||
) {
|
||||
return <InstallCompletedNotice version={completedVersion} />;
|
||||
return (
|
||||
<InstallCompletedNotice version={completedVersion} runtimeDisplayName={runtimeDisplayName} />
|
||||
);
|
||||
}
|
||||
|
||||
// ── Error ──────────────────────────────────────────────────────────────
|
||||
|
|
@ -1116,12 +1439,13 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
}
|
||||
|
||||
// ── Idle state with status ─────────────────────────────────────────────
|
||||
if (!cliStatus) return null;
|
||||
if (!renderCliStatus) return null;
|
||||
const cliLaunchIssue =
|
||||
!cliStatus.installed && Boolean(cliStatus.binaryPath && cliStatus.launchError);
|
||||
!renderCliStatus.installed &&
|
||||
Boolean(renderCliStatus.binaryPath && renderCliStatus.launchError);
|
||||
|
||||
// Not installed — red error banner
|
||||
if (!cliStatus.installed) {
|
||||
if (!renderCliStatus.installed) {
|
||||
return (
|
||||
<div
|
||||
className="mb-6 rounded-lg border-l-4 p-4"
|
||||
|
|
@ -1133,23 +1457,23 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
<div>
|
||||
<p className="text-sm font-medium" style={{ color: '#f87171' }}>
|
||||
{cliLaunchIssue
|
||||
? 'Claude CLI was found but failed to start'
|
||||
: 'Claude CLI is required'}
|
||||
? `${runtimeDisplayName} was found but failed to start`
|
||||
: `${runtimeDisplayName} is required`}
|
||||
</p>
|
||||
<p className="mt-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
{cliLaunchIssue
|
||||
? 'The app found a Claude CLI binary, 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.'}
|
||||
? `The app found the configured ${runtimeDisplayName}, but its startup health check failed. Repair or reinstall it, then retry.`
|
||||
: `${runtimeDisplayName} is required for team provisioning and session management. Install it to get started.`}
|
||||
</p>
|
||||
{cliStatus.showBinaryPath && cliStatus.binaryPath && (
|
||||
{renderCliStatus.showBinaryPath && renderCliStatus.binaryPath && (
|
||||
<p
|
||||
className="mt-2 break-all font-mono text-[11px]"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
{cliStatus.binaryPath}
|
||||
{renderCliStatus.binaryPath}
|
||||
</p>
|
||||
)}
|
||||
{cliLaunchIssue && cliStatus.launchError && (
|
||||
{cliLaunchIssue && renderCliStatus.launchError && (
|
||||
<div
|
||||
className="mt-2 rounded border px-2 py-1.5 font-mono text-[11px]"
|
||||
style={{
|
||||
|
|
@ -1158,7 +1482,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
color: 'var(--color-text-muted)',
|
||||
}}
|
||||
>
|
||||
{cliStatus.launchError}
|
||||
{renderCliStatus.launchError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1172,7 +1496,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
<RefreshCw className="size-4" />
|
||||
Re-check
|
||||
</button>
|
||||
{cliStatus.supportsSelfUpdate ? (
|
||||
{renderCliStatus.supportsSelfUpdate ? (
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
disabled={isBusy}
|
||||
|
|
@ -1180,13 +1504,15 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
style={{ backgroundColor: '#3b82f6' }}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
{cliLaunchIssue ? 'Reinstall Claude CLI' : 'Install Claude CLI'}
|
||||
{cliLaunchIssue
|
||||
? `Reinstall ${runtimeDisplayName}`
|
||||
: `Install ${runtimeDisplayName}`}
|
||||
</button>
|
||||
) : (
|
||||
<p className="max-w-40 text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
{cliLaunchIssue
|
||||
? `The configured ${cliStatus.displayName} runtime failed its startup health check.`
|
||||
: `The configured ${cliStatus.displayName} runtime was not found.`}
|
||||
? `The configured ${runtimeDisplayName} failed its startup health check.`
|
||||
: `The configured ${runtimeDisplayName} was not found.`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1197,17 +1523,19 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
|
||||
// Installed but not logged in — yellow warning banner
|
||||
if (
|
||||
cliStatus.installed &&
|
||||
cliStatus.flavor !== 'agent_teams_orchestrator' &&
|
||||
(cliStatus.authStatusChecking || isVerifyingAuth)
|
||||
renderCliStatus.installed &&
|
||||
renderCliStatus.flavor !== 'agent_teams_orchestrator' &&
|
||||
(renderCliStatus.authStatusChecking || isVerifyingAuth)
|
||||
) {
|
||||
if (cliStatus.authStatusChecking || isVerifyingAuth) {
|
||||
if (renderCliStatus.authStatusChecking || isVerifyingAuth) {
|
||||
return (
|
||||
<>
|
||||
<InstalledBanner
|
||||
cliStatus={cliStatus}
|
||||
cliStatus={renderCliStatus}
|
||||
sourceProviderMap={loadingCliProviderMap}
|
||||
cliStatusLoading={cliStatusLoading}
|
||||
cliProviderStatusLoading={cliProviderStatusLoading}
|
||||
codexSnapshotPending={codexSnapshotPending}
|
||||
cliStatusError={cliStatusError ?? null}
|
||||
isBusy={isBusy}
|
||||
multimodelEnabled={multimodelEnabled}
|
||||
|
|
@ -1228,12 +1556,14 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
}
|
||||
|
||||
if (
|
||||
cliStatus.installed &&
|
||||
cliStatus.flavor !== 'agent_teams_orchestrator' &&
|
||||
!cliStatus.authStatusChecking &&
|
||||
!cliStatus.authLoggedIn
|
||||
renderCliStatus.installed &&
|
||||
renderCliStatus.flavor !== 'agent_teams_orchestrator' &&
|
||||
!renderCliStatus.authStatusChecking &&
|
||||
!renderCliStatus.authLoggedIn
|
||||
) {
|
||||
const apiKeyActionRequiredProviders = getApiKeyActionRequiredProviders(cliStatus.providers);
|
||||
const apiKeyActionRequiredProviders = getApiKeyActionRequiredProviders(
|
||||
renderCliStatus.providers
|
||||
);
|
||||
const hasApiKeyModeIssue = apiKeyActionRequiredProviders.length > 0;
|
||||
const primaryApiKeyProvider = apiKeyActionRequiredProviders[0] ?? null;
|
||||
const apiKeyMissingProviders = apiKeyActionRequiredProviders.filter(
|
||||
|
|
@ -1254,14 +1584,16 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
: 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.`
|
||||
: '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 (
|
||||
<>
|
||||
<InstalledBanner
|
||||
cliStatus={cliStatus}
|
||||
cliStatus={renderCliStatus}
|
||||
sourceProviderMap={loadingCliProviderMap}
|
||||
cliStatusLoading={cliStatusLoading}
|
||||
cliProviderStatusLoading={cliProviderStatusLoading}
|
||||
codexSnapshotPending={codexSnapshotPending}
|
||||
cliStatusError={cliStatusError ?? null}
|
||||
isBusy={isBusy}
|
||||
multimodelEnabled={multimodelEnabled}
|
||||
|
|
@ -1385,8 +1717,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
<li>
|
||||
Open your terminal and run:{' '}
|
||||
<code className="rounded bg-white/5 px-1.5 py-0.5 font-mono text-[11px]">
|
||||
{cliStatus.showBinaryPath && cliStatus.binaryPath
|
||||
? `"${cliStatus.binaryPath}" auth status`
|
||||
{renderCliStatus.showBinaryPath && renderCliStatus.binaryPath
|
||||
? `"${renderCliStatus.binaryPath}" auth status`
|
||||
: 'your configured CLI auth status command'}
|
||||
</code>{' '}
|
||||
— check if it shows "Logged in"
|
||||
|
|
@ -1394,25 +1726,25 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
<li>
|
||||
If it says logged in but the app doesn't see it, try:{' '}
|
||||
<code className="rounded bg-white/5 px-1.5 py-0.5 font-mono text-[11px]">
|
||||
{cliStatus.showBinaryPath && cliStatus.binaryPath
|
||||
? `"${cliStatus.binaryPath}" auth logout`
|
||||
{renderCliStatus.showBinaryPath && renderCliStatus.binaryPath
|
||||
? `"${renderCliStatus.binaryPath}" auth logout`
|
||||
: 'the runtime logout command'}
|
||||
</code>{' '}
|
||||
then{' '}
|
||||
<code className="rounded bg-white/5 px-1.5 py-0.5 font-mono text-[11px]">
|
||||
{cliStatus.showBinaryPath && cliStatus.binaryPath
|
||||
? `"${cliStatus.binaryPath}" auth login`
|
||||
{renderCliStatus.showBinaryPath && renderCliStatus.binaryPath
|
||||
? `"${renderCliStatus.binaryPath}" auth login`
|
||||
: 'the runtime login command'}
|
||||
</code>{' '}
|
||||
again
|
||||
</li>
|
||||
<li>
|
||||
Make sure the CLI in your terminal is the same runtime the app uses
|
||||
{cliStatus.showBinaryPath && cliStatus.binaryPath && (
|
||||
{renderCliStatus.showBinaryPath && renderCliStatus.binaryPath && (
|
||||
<span>
|
||||
:{' '}
|
||||
<code className="rounded bg-white/5 px-1.5 py-0.5 font-mono text-[11px]">
|
||||
{cliStatus.binaryPath}
|
||||
{renderCliStatus.binaryPath}
|
||||
</code>
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -1426,10 +1758,10 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
)}
|
||||
</div>
|
||||
{installedAuxiliaryUi}
|
||||
{showLoginTerminal && cliStatus.binaryPath && (
|
||||
{showLoginTerminal && renderCliStatus.binaryPath && (
|
||||
<TerminalModal
|
||||
title={`${cliStatus.displayName} Login`}
|
||||
command={cliStatus.binaryPath}
|
||||
title={`${getHumanRuntimeDisplayName(renderCliStatus, multimodelEnabled)} Login`}
|
||||
command={renderCliStatus.binaryPath}
|
||||
args={['auth', 'login']}
|
||||
onClose={() => {
|
||||
setShowLoginTerminal(false);
|
||||
|
|
@ -1475,9 +1807,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
return (
|
||||
<>
|
||||
<InstalledBanner
|
||||
cliStatus={cliStatus}
|
||||
cliStatus={renderCliStatus}
|
||||
sourceProviderMap={loadingCliProviderMap}
|
||||
cliStatusLoading={cliStatusLoading}
|
||||
cliProviderStatusLoading={cliProviderStatusLoading}
|
||||
codexSnapshotPending={codexSnapshotPending}
|
||||
cliStatusError={cliStatusError ?? null}
|
||||
isBusy={isBusy}
|
||||
multimodelEnabled={multimodelEnabled}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@
|
|||
|
||||
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 { Badge } from '@renderer/components/ui/badge';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
|
|
@ -20,12 +24,15 @@ import {
|
|||
import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
|
||||
import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
|
||||
import {
|
||||
formatCliExtensionCapabilityStatus,
|
||||
getVisibleMultimodelProviders,
|
||||
isMultimodelRuntimeStatus,
|
||||
} from '@renderer/utils/multimodelProviderVisibility';
|
||||
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 { getCliProviderExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
||||
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 => {
|
||||
const isElectron = useMemo(() => isElectronMode(), []);
|
||||
const tabId = useTabIdOptional();
|
||||
const {
|
||||
fetchPluginCatalog,
|
||||
bootstrapCliStatus,
|
||||
fetchCliStatus,
|
||||
fetchApiKeys,
|
||||
fetchSkillsCatalog,
|
||||
|
|
@ -113,6 +129,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
cliStatus,
|
||||
cliStatusLoading,
|
||||
cliProviderStatusLoading,
|
||||
appConfig,
|
||||
openDashboard,
|
||||
sessions,
|
||||
projects,
|
||||
|
|
@ -120,6 +137,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
fetchPluginCatalog: s.fetchPluginCatalog,
|
||||
bootstrapCliStatus: s.bootstrapCliStatus,
|
||||
fetchCliStatus: s.fetchCliStatus,
|
||||
fetchApiKeys: s.fetchApiKeys,
|
||||
fetchSkillsCatalog: s.fetchSkillsCatalog,
|
||||
|
|
@ -132,13 +150,58 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
cliStatus: s.cliStatus,
|
||||
cliStatusLoading: s.cliStatusLoading,
|
||||
cliProviderStatusLoading: s.cliProviderStatusLoading,
|
||||
appConfig: s.appConfig,
|
||||
openDashboard: s.openDashboard,
|
||||
sessions: s.sessions,
|
||||
projects: s.projects,
|
||||
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 extensionsTabProjectId = useStore((s) =>
|
||||
tabId
|
||||
|
|
@ -195,8 +258,12 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
}, [fetchPluginCatalog, projectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchCliStatus();
|
||||
}, [fetchCliStatus]);
|
||||
void refreshCliStatusForCurrentMode({
|
||||
multimodelEnabled,
|
||||
bootstrapCliStatus,
|
||||
fetchCliStatus,
|
||||
});
|
||||
}, [bootstrapCliStatus, fetchCliStatus, multimodelEnabled]);
|
||||
|
||||
// Fetch MCP installed state on mount
|
||||
useEffect(() => {
|
||||
|
|
@ -215,42 +282,55 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
|
||||
// Refresh all data (plugins + MCP browse + installed + skills)
|
||||
const handleRefresh = useCallback(() => {
|
||||
void fetchCliStatus();
|
||||
void refreshCliStatusForCurrentMode({
|
||||
multimodelEnabled,
|
||||
bootstrapCliStatus,
|
||||
fetchCliStatus,
|
||||
});
|
||||
void fetchApiKeys();
|
||||
void fetchPluginCatalog(projectPath ?? undefined, true);
|
||||
void mcpBrowse(); // re-fetch first page
|
||||
void mcpFetchInstalled(projectPath ?? undefined);
|
||||
void fetchSkillsCatalog(projectPath ?? undefined);
|
||||
}, [
|
||||
bootstrapCliStatus,
|
||||
fetchApiKeys,
|
||||
fetchCliStatus,
|
||||
fetchPluginCatalog,
|
||||
fetchSkillsCatalog,
|
||||
multimodelEnabled,
|
||||
mcpBrowse,
|
||||
mcpFetchInstalled,
|
||||
projectPath,
|
||||
]);
|
||||
|
||||
const isRefreshing =
|
||||
cliStatusLoading || apiKeysLoading || pluginCatalogLoading || mcpBrowseLoading || skillsLoading;
|
||||
effectiveCliStatusLoading ||
|
||||
apiKeysLoading ||
|
||||
pluginCatalogLoading ||
|
||||
mcpBrowseLoading ||
|
||||
skillsLoading;
|
||||
const mcpMutationDisableReason = useMemo(
|
||||
() =>
|
||||
getExtensionActionDisableReason({
|
||||
isInstalled: false,
|
||||
cliStatus,
|
||||
cliStatusLoading,
|
||||
cliStatus: effectiveCliStatus,
|
||||
cliStatusLoading: effectiveCliStatusLoading,
|
||||
section: 'mcp',
|
||||
}),
|
||||
[cliStatus, cliStatusLoading]
|
||||
[effectiveCliStatus, effectiveCliStatusLoading]
|
||||
);
|
||||
const cliStatusBanner = useMemo(() => {
|
||||
const providers = cliStatus?.providers ?? [];
|
||||
const providers = effectiveCliStatus?.providers ?? [];
|
||||
const visibleProviders = getVisibleMultimodelProviders(providers);
|
||||
const isMultimodel = isMultimodelRuntimeStatus(cliStatus);
|
||||
const isMultimodel = isMultimodelRuntimeStatus(effectiveCliStatus);
|
||||
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 (
|
||||
<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" />
|
||||
|
|
@ -267,8 +347,10 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
);
|
||||
}
|
||||
|
||||
if (!cliStatus.installed) {
|
||||
const cliLaunchIssue = Boolean(cliStatus.binaryPath && cliStatus.launchError);
|
||||
if (!effectiveCliStatus.installed) {
|
||||
const cliLaunchIssue = Boolean(
|
||||
effectiveCliStatus.binaryPath && effectiveCliStatus.launchError
|
||||
);
|
||||
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">
|
||||
<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 is installed. Open the Dashboard to install it and retry.'}
|
||||
</p>
|
||||
{cliLaunchIssue && cliStatus.launchError && (
|
||||
{cliLaunchIssue && effectiveCliStatus.launchError && (
|
||||
<p className="mt-2 break-all font-mono text-[11px] text-text-muted">
|
||||
{cliStatus.launchError}
|
||||
{effectiveCliStatus.launchError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -296,16 +378,18 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
);
|
||||
}
|
||||
|
||||
if (!isMultimodel && !cliStatus.authLoggedIn) {
|
||||
if (!isMultimodel && !effectiveCliStatus.authLoggedIn) {
|
||||
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">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
|
||||
<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">
|
||||
Claude CLI was found
|
||||
{cliStatus.installedVersion ? ` (${cliStatus.installedVersion})` : ''}, but plugin
|
||||
installs are disabled until you sign in from the Dashboard.
|
||||
{runtimeDisplayName} was found
|
||||
{effectiveCliStatus.installedVersion
|
||||
? ` (${effectiveCliStatus.installedVersion})`
|
||||
: ''}
|
||||
, but plugin installs are disabled until you sign in from the Dashboard.
|
||||
</p>
|
||||
</div>
|
||||
<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">
|
||||
{visibleProviders.map((provider) => {
|
||||
const providerLoading = cliProviderStatusLoading[provider.providerId] === true;
|
||||
if (isProviderCapabilityCardLoading(provider, providerLoading)) {
|
||||
if (
|
||||
isProviderCapabilityCardLoading(provider, providerLoading) ||
|
||||
isCodexSnapshotPending(provider, codexSnapshotPending)
|
||||
) {
|
||||
return (
|
||||
<ProviderCapabilityCardSkeleton
|
||||
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">
|
||||
<Info className="mt-0.5 size-4 shrink-0 text-emerald-300" />
|
||||
<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">
|
||||
Plugins can be installed from this page
|
||||
{cliStatus.installedVersion ? ` using Claude CLI ${cliStatus.installedVersion}` : ''}.
|
||||
{effectiveCliStatus.installedVersion
|
||||
? ` using ${runtimeDisplayName} ${effectiveCliStatus.installedVersion}`
|
||||
: ''}
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [cliProviderStatusLoading, cliStatus, cliStatusLoading, openDashboard]);
|
||||
}, [
|
||||
cliProviderStatusLoading,
|
||||
codexSnapshotPending,
|
||||
effectiveCliStatus,
|
||||
effectiveCliStatusLoading,
|
||||
openDashboard,
|
||||
]);
|
||||
|
||||
// Browser mode guard
|
||||
if (!api.plugins && !api.mcpRegistry && !api.skills) {
|
||||
|
|
@ -526,6 +622,8 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
clearFilters={tabState.clearFilters}
|
||||
hasActiveFilters={tabState.hasActiveFilters}
|
||||
setPluginSort={tabState.setPluginSort}
|
||||
cliStatus={effectiveCliStatus}
|
||||
cliStatusLoading={effectiveCliStatusLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
|
|
@ -539,6 +637,8 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
mcpSearchWarnings={tabState.mcpSearchWarnings}
|
||||
selectedMcpServerId={tabState.selectedMcpServerId}
|
||||
setSelectedMcpServerId={tabState.setSelectedMcpServerId}
|
||||
cliStatus={effectiveCliStatus}
|
||||
cliStatusLoading={effectiveCliStatusLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
|
|
@ -565,6 +665,8 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
open={customMcpDialogOpen}
|
||||
onClose={() => setCustomMcpDialogOpen(false)}
|
||||
projectPath={projectPath}
|
||||
cliStatus={effectiveCliStatus}
|
||||
cliStatusLoading={effectiveCliStatusLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,9 +4,15 @@
|
|||
|
||||
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 { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
|
||||
import { AlertTriangle, Info, Key, Plus } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
|
|
@ -24,17 +30,56 @@ export const ApiKeysPanel = ({
|
|||
projectPath,
|
||||
projectLabel,
|
||||
}: ApiKeysPanelProps): React.JSX.Element => {
|
||||
const { apiKeys, apiKeysLoading, apiKeysError, storageStatus, fetchStorageStatus, cliStatus } =
|
||||
useStore(
|
||||
useShallow((s) => ({
|
||||
apiKeys: s.apiKeys,
|
||||
apiKeysLoading: s.apiKeysLoading,
|
||||
apiKeysError: s.apiKeysError,
|
||||
storageStatus: s.apiKeyStorageStatus,
|
||||
fetchStorageStatus: s.fetchApiKeyStorageStatus,
|
||||
cliStatus: s.cliStatus,
|
||||
}))
|
||||
);
|
||||
const isElectron = useMemo(() => isElectronMode(), []);
|
||||
const {
|
||||
apiKeys,
|
||||
apiKeysLoading,
|
||||
apiKeysError,
|
||||
storageStatus,
|
||||
fetchStorageStatus,
|
||||
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 [editingKey, setEditingKey] = useState<ApiKeyEntry | null>(null);
|
||||
|
|
@ -60,7 +105,7 @@ export const ApiKeysPanel = ({
|
|||
|
||||
const isOsKeychain = storageStatus?.encryptionMethod === 'os-keychain';
|
||||
const providerKeyCards = useMemo(() => {
|
||||
if (!cliStatus?.providers?.length) {
|
||||
if (!effectiveCliStatus?.providers?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
|
@ -78,7 +123,9 @@ export const ApiKeysPanel = ({
|
|||
},
|
||||
] as const
|
||||
).flatMap((item) => {
|
||||
const provider = cliStatus.providers.find((entry) => entry.providerId === item.providerId);
|
||||
const provider = effectiveCliStatus.providers.find(
|
||||
(entry) => entry.providerId === item.providerId
|
||||
);
|
||||
if (!provider) {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -93,7 +140,7 @@ export const ApiKeysPanel = ({
|
|||
},
|
||||
];
|
||||
});
|
||||
}, [cliStatus]);
|
||||
}, [effectiveCliStatus]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { getExtensionActionDisableReason } from '@shared/utils/extensionNormaliz
|
|||
import { Check, Loader2, Trash2 } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import type { CliInstallationStatus } from '@shared/types';
|
||||
import type { ExtensionOperationState } from '@shared/types/extensions';
|
||||
|
||||
interface InstallButtonProps {
|
||||
|
|
@ -28,6 +29,11 @@ interface InstallButtonProps {
|
|||
disabled?: boolean;
|
||||
size?: 'sm' | 'default';
|
||||
errorMessage?: string;
|
||||
cliStatus?: Pick<
|
||||
CliInstallationStatus,
|
||||
'installed' | 'authLoggedIn' | 'binaryPath' | 'launchError' | 'flavor' | 'providers'
|
||||
> | null;
|
||||
cliStatusLoading?: boolean;
|
||||
}
|
||||
|
||||
export const InstallButton = ({
|
||||
|
|
@ -39,13 +45,17 @@ export const InstallButton = ({
|
|||
disabled,
|
||||
size = 'sm',
|
||||
errorMessage,
|
||||
cliStatus: cliStatusOverride,
|
||||
cliStatusLoading: cliStatusLoadingOverride,
|
||||
}: InstallButtonProps) => {
|
||||
const { cliStatus, cliStatusLoading } = useStore(
|
||||
const { cliStatus: storedCliStatus, cliStatusLoading: storedCliStatusLoading } = useStore(
|
||||
useShallow((s) => ({
|
||||
cliStatus: s.cliStatus,
|
||||
cliStatusLoading: s.cliStatusLoading,
|
||||
}))
|
||||
);
|
||||
const cliStatus = cliStatusOverride ?? storedCliStatus;
|
||||
const cliStatusLoading = cliStatusLoadingOverride ?? storedCliStatusLoading;
|
||||
const disableReason = getExtensionActionDisableReason({
|
||||
isInstalled,
|
||||
cliStatus,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import {
|
|||
} from '@shared/utils/mcpScopes';
|
||||
import { Plus, Server, Trash2 } from 'lucide-react';
|
||||
|
||||
import type { CliInstallationStatus } from '@shared/types';
|
||||
import type {
|
||||
McpCustomInstallRequest,
|
||||
McpHeaderDef,
|
||||
|
|
@ -45,6 +46,11 @@ interface CustomMcpServerDialogProps {
|
|||
open: boolean;
|
||||
onClose: () => void;
|
||||
projectPath: string | null;
|
||||
cliStatus?: Pick<
|
||||
CliInstallationStatus,
|
||||
'installed' | 'authLoggedIn' | 'binaryPath' | 'launchError' | 'flavor' | 'providers'
|
||||
> | null;
|
||||
cliStatusLoading?: boolean;
|
||||
}
|
||||
|
||||
type TransportMode = 'stdio' | 'http';
|
||||
|
|
@ -66,10 +72,14 @@ export const CustomMcpServerDialog = ({
|
|||
open,
|
||||
onClose,
|
||||
projectPath,
|
||||
cliStatus: cliStatusOverride,
|
||||
cliStatusLoading: cliStatusLoadingOverride,
|
||||
}: CustomMcpServerDialogProps): React.JSX.Element => {
|
||||
const installCustomMcpServer = useStore((s) => s.installCustomMcpServer);
|
||||
const cliStatus = useStore((s) => s.cliStatus);
|
||||
const cliStatusLoading = useStore((s) => s.cliStatusLoading);
|
||||
const storedCliStatus = useStore((s) => s.cliStatus);
|
||||
const storedCliStatusLoading = useStore((s) => s.cliStatusLoading);
|
||||
const cliStatus = cliStatusOverride ?? storedCliStatus;
|
||||
const cliStatusLoading = cliStatusLoadingOverride ?? storedCliStatusLoading;
|
||||
const defaultSharedScope = getDefaultMcpSharedScope(cliStatus?.flavor);
|
||||
const scopeOptions: { value: Scope; label: string }[] = [
|
||||
{ value: defaultSharedScope, label: getMcpScopeLabel(defaultSharedScope, cliStatus?.flavor) },
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { Github as GithubIcon } from 'lucide-react';
|
|||
import { InstallButton } from '../common/InstallButton';
|
||||
import { SourceBadge } from '../common/SourceBadge';
|
||||
|
||||
import type { CliInstallationStatus } from '@shared/types';
|
||||
import type {
|
||||
InstalledMcpEntry,
|
||||
McpCatalogItem,
|
||||
|
|
@ -37,6 +38,11 @@ interface McpServerCardProps {
|
|||
diagnostic?: McpServerDiagnostic | null;
|
||||
diagnosticsLoading?: boolean;
|
||||
onClick: (serverId: string) => void;
|
||||
cliStatus?: Pick<
|
||||
CliInstallationStatus,
|
||||
'installed' | 'authLoggedIn' | 'binaryPath' | 'launchError' | 'flavor' | 'providers'
|
||||
> | null;
|
||||
cliStatusLoading?: boolean;
|
||||
}
|
||||
|
||||
export const McpServerCard = ({
|
||||
|
|
@ -47,8 +53,11 @@ export const McpServerCard = ({
|
|||
diagnostic,
|
||||
diagnosticsLoading,
|
||||
onClick,
|
||||
cliStatus: cliStatusOverride,
|
||||
cliStatusLoading,
|
||||
}: 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 operationKey = getMcpOperationKey(server.id, sharedScope);
|
||||
const installProgress = useStore((s) => s.mcpInstallProgress[operationKey] ?? 'idle');
|
||||
|
|
@ -262,6 +271,8 @@ export const McpServerCard = ({
|
|||
state={installProgress}
|
||||
isInstalled={isInstalled}
|
||||
section="mcp"
|
||||
cliStatus={cliStatus}
|
||||
cliStatusLoading={cliStatusLoading}
|
||||
onInstall={() =>
|
||||
installMcpServer({
|
||||
registryId: server.id,
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import { ExternalLink, Lock, Plus, Star, Trash2, Wrench } from 'lucide-react';
|
|||
import { InstallButton } from '../common/InstallButton';
|
||||
import { SourceBadge } from '../common/SourceBadge';
|
||||
|
||||
import type { CliInstallationStatus } from '@shared/types';
|
||||
import type {
|
||||
InstalledMcpEntry,
|
||||
McpCatalogItem,
|
||||
|
|
@ -59,6 +60,11 @@ interface McpServerDetailDialogProps {
|
|||
projectPath: string | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
cliStatus?: Pick<
|
||||
CliInstallationStatus,
|
||||
'installed' | 'authLoggedIn' | 'binaryPath' | 'launchError' | 'flavor' | 'providers'
|
||||
> | null;
|
||||
cliStatusLoading?: boolean;
|
||||
}
|
||||
|
||||
type Scope = 'local' | 'user' | 'project' | 'global';
|
||||
|
|
@ -73,8 +79,11 @@ export const McpServerDetailDialog = ({
|
|||
projectPath,
|
||||
open,
|
||||
onClose,
|
||||
cliStatus: cliStatusOverride,
|
||||
cliStatusLoading,
|
||||
}: 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 [scope, setScope] = useState<Scope>(defaultSharedScope);
|
||||
const operationKey = server ? getMcpOperationKey(server.id, scope, projectPath) : null;
|
||||
|
|
@ -587,6 +596,8 @@ export const McpServerDetailDialog = ({
|
|||
state={installProgress}
|
||||
isInstalled={isInstalledForScope}
|
||||
section="mcp"
|
||||
cliStatus={cliStatus}
|
||||
cliStatusLoading={cliStatusLoading}
|
||||
onInstall={handleInstall}
|
||||
onUninstall={handleUninstall}
|
||||
disabled={installDisabled}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
} from '@renderer/components/ui/select';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { formatRelativeTime } from '@renderer/utils/formatters';
|
||||
import { getRuntimeDisplayName } from '@renderer/utils/runtimeDisplayName';
|
||||
import { CLI_NOT_FOUND_MARKER } from '@shared/constants/cli';
|
||||
import {
|
||||
getMcpDiagnosticKey,
|
||||
|
|
@ -30,6 +31,7 @@ import { SearchInput } from '../common/SearchInput';
|
|||
import { McpServerCard } from './McpServerCard';
|
||||
import { McpServerDetailDialog } from './McpServerDetailDialog';
|
||||
|
||||
import type { CliInstallationStatus } from '@shared/types';
|
||||
import type {
|
||||
InstalledMcpEntry,
|
||||
McpCatalogItem,
|
||||
|
|
@ -68,6 +70,17 @@ interface McpServersPanelProps {
|
|||
mcpSearchWarnings: string[];
|
||||
selectedMcpServerId: string | null;
|
||||
setSelectedMcpServerId: (id: string | null) => void;
|
||||
cliStatus?: Pick<
|
||||
CliInstallationStatus,
|
||||
| 'installed'
|
||||
| 'authLoggedIn'
|
||||
| 'binaryPath'
|
||||
| 'launchError'
|
||||
| 'flavor'
|
||||
| 'displayName'
|
||||
| 'providers'
|
||||
> | null;
|
||||
cliStatusLoading?: boolean;
|
||||
}
|
||||
|
||||
export const McpServersPanel = ({
|
||||
|
|
@ -79,6 +92,8 @@ export const McpServersPanel = ({
|
|||
mcpSearchWarnings,
|
||||
selectedMcpServerId,
|
||||
setSelectedMcpServerId,
|
||||
cliStatus: cliStatusOverride,
|
||||
cliStatusLoading: cliStatusLoadingOverride,
|
||||
}: McpServersPanelProps): React.JSX.Element => {
|
||||
const projectStateKey = getMcpProjectStateKey(projectPath);
|
||||
const {
|
||||
|
|
@ -99,8 +114,6 @@ export const McpServersPanel = ({
|
|||
mcpDiagnosticsLastCheckedAtByProjectPath,
|
||||
mcpDiagnosticsLastCheckedAtFallback,
|
||||
runMcpDiagnostics,
|
||||
cliStatus,
|
||||
cliStatusLoading,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
browseCatalog: s.mcpBrowseCatalog,
|
||||
|
|
@ -120,10 +133,12 @@ export const McpServersPanel = ({
|
|||
mcpDiagnosticsLastCheckedAtByProjectPath: s.mcpDiagnosticsLastCheckedAtByProjectPath,
|
||||
mcpDiagnosticsLastCheckedAtFallback: s.mcpDiagnosticsLastCheckedAt,
|
||||
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 =
|
||||
installedServersByProjectPath?.[projectStateKey] ?? installedServersFallback ?? [];
|
||||
const mcpDiagnostics =
|
||||
|
|
@ -147,12 +162,8 @@ export const McpServersPanel = ({
|
|||
}, [browseCatalog.length, browseError, browseLoading, mcpBrowse]);
|
||||
|
||||
const diagnosticsDisableReason = useMemo(() => {
|
||||
if (cliStatusLoading) {
|
||||
return 'Checking runtime status...';
|
||||
}
|
||||
|
||||
if (cliStatus === null || typeof cliStatus === 'undefined') {
|
||||
return 'Checking runtime availability...';
|
||||
return cliStatusLoading ? 'Checking runtime status...' : 'Checking runtime availability...';
|
||||
}
|
||||
|
||||
if (cliStatus?.installed === false) {
|
||||
|
|
@ -241,8 +252,7 @@ export const McpServersPanel = ({
|
|||
|
||||
// Sort displayed servers
|
||||
const displayServers = useMemo(() => sortMcpServers(rawServers, mcpSort), [rawServers, mcpSort]);
|
||||
const runtimeLabel =
|
||||
cliStatus?.flavor === 'agent_teams_orchestrator' ? 'multimodel runtime' : 'Claude CLI';
|
||||
const runtimeLabel = getRuntimeDisplayName(cliStatus, true);
|
||||
|
||||
// Find selected server (search in both lists to avoid losing selection during search toggle)
|
||||
const selectedServer = useMemo(() => {
|
||||
|
|
@ -411,13 +421,12 @@ export const McpServersPanel = ({
|
|||
<div>
|
||||
<p className="text-sm font-medium text-amber-300">
|
||||
{cliStatus?.flavor === 'agent_teams_orchestrator'
|
||||
? 'Configured runtime not available'
|
||||
: 'Claude CLI not installed'}
|
||||
? `${runtimeLabel} not available`
|
||||
: `${runtimeLabel} not installed`}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-text-muted">
|
||||
{cliStatus?.flavor === 'agent_teams_orchestrator'
|
||||
? 'MCP health checks require the configured runtime. Go to the Dashboard to install or repair it.'
|
||||
: 'MCP health checks require Claude CLI. Go to the Dashboard to install or repair it.'}
|
||||
MCP health checks require {runtimeLabel}. Go to the Dashboard to install or repair
|
||||
it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -458,6 +467,8 @@ export const McpServersPanel = ({
|
|||
diagnostic={getDiagnostic(server)}
|
||||
diagnosticsLoading={mcpDiagnosticsLoading}
|
||||
onClick={setSelectedMcpServerId}
|
||||
cliStatus={cliStatus}
|
||||
cliStatusLoading={cliStatusLoading}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -488,6 +499,8 @@ export const McpServersPanel = ({
|
|||
projectPath={projectPath}
|
||||
open={selectedMcpServerId !== null}
|
||||
onClose={() => setSelectedMcpServerId(null)}
|
||||
cliStatus={cliStatus}
|
||||
cliStatusLoading={cliStatusLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -17,15 +17,27 @@ import { Tag } from 'lucide-react';
|
|||
import { InstallButton } from '../common/InstallButton';
|
||||
import { InstallCountBadge } from '../common/InstallCountBadge';
|
||||
|
||||
import type { CliInstallationStatus } from '@shared/types';
|
||||
import type { EnrichedPlugin } from '@shared/types/extensions';
|
||||
|
||||
interface PluginCardProps {
|
||||
plugin: EnrichedPlugin;
|
||||
index: number;
|
||||
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 category = normalizeCategory(plugin.category);
|
||||
const operationKey = getPluginOperationKey(plugin.pluginId, 'user');
|
||||
|
|
@ -120,6 +132,8 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J
|
|||
state={installProgress}
|
||||
isInstalled={isUserInstalled}
|
||||
section="plugins"
|
||||
cliStatus={cliStatus}
|
||||
cliStatusLoading={cliStatusLoading}
|
||||
onInstall={() => installPlugin({ pluginId: plugin.pluginId, scope: 'user' })}
|
||||
onUninstall={() => uninstallPlugin(plugin.pluginId, 'user')}
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import { InstallButton } from '../common/InstallButton';
|
|||
import { InstallCountBadge } from '../common/InstallCountBadge';
|
||||
import { SourceBadge } from '../common/SourceBadge';
|
||||
|
||||
import type { CliInstallationStatus } from '@shared/types';
|
||||
import type { EnrichedPlugin, InstallScope } from '@shared/types/extensions';
|
||||
|
||||
interface PluginDetailDialogProps {
|
||||
|
|
@ -46,6 +47,11 @@ interface PluginDetailDialogProps {
|
|||
open: boolean;
|
||||
onClose: () => void;
|
||||
projectPath: string | null;
|
||||
cliStatus?: Pick<
|
||||
CliInstallationStatus,
|
||||
'installed' | 'authLoggedIn' | 'binaryPath' | 'launchError' | 'flavor' | 'providers'
|
||||
> | null;
|
||||
cliStatusLoading?: boolean;
|
||||
}
|
||||
|
||||
const SCOPE_OPTIONS: { value: InstallScope; label: string }[] = [
|
||||
|
|
@ -59,6 +65,8 @@ export const PluginDetailDialog = ({
|
|||
open,
|
||||
onClose,
|
||||
projectPath,
|
||||
cliStatus,
|
||||
cliStatusLoading,
|
||||
}: PluginDetailDialogProps): React.JSX.Element => {
|
||||
const { fetchPluginReadme, readmes, readmeLoading, installPlugin, uninstallPlugin } = useStore(
|
||||
useShallow((s) => ({
|
||||
|
|
@ -198,6 +206,8 @@ export const PluginDetailDialog = ({
|
|||
state={installProgress}
|
||||
isInstalled={isInstalledForScope}
|
||||
section="plugins"
|
||||
cliStatus={cliStatus}
|
||||
cliStatusLoading={cliStatusLoading}
|
||||
onInstall={() =>
|
||||
installPlugin({
|
||||
pluginId: plugin.pluginId,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import { CategoryChips } from './CategoryChips';
|
|||
import { PluginCard } from './PluginCard';
|
||||
import { PluginDetailDialog } from './PluginDetailDialog';
|
||||
|
||||
import type { CliInstallationStatus } from '@shared/types';
|
||||
import type {
|
||||
EnrichedPlugin,
|
||||
PluginCapability,
|
||||
|
|
@ -48,6 +49,11 @@ interface PluginsPanelProps {
|
|||
clearFilters: () => void;
|
||||
hasActiveFilters: boolean;
|
||||
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 }[] = [
|
||||
|
|
@ -125,8 +131,15 @@ export const PluginsPanel = ({
|
|||
clearFilters,
|
||||
hasActiveFilters,
|
||||
setPluginSort,
|
||||
cliStatus: cliStatusOverride,
|
||||
cliStatusLoading,
|
||||
}: PluginsPanelProps): React.JSX.Element => {
|
||||
const { catalog, loading, error, cliStatus } = useStore(
|
||||
const {
|
||||
catalog,
|
||||
loading,
|
||||
error,
|
||||
cliStatus: storedCliStatus,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
catalog: s.pluginCatalog,
|
||||
loading: s.pluginCatalogLoading,
|
||||
|
|
@ -134,6 +147,7 @@ export const PluginsPanel = ({
|
|||
cliStatus: s.cliStatus,
|
||||
}))
|
||||
);
|
||||
const cliStatus = cliStatusOverride ?? storedCliStatus;
|
||||
|
||||
const filtered = useMemo(
|
||||
() => selectFilteredPlugins(catalog, pluginFilters, pluginSort),
|
||||
|
|
@ -192,8 +206,9 @@ export const PluginsPanel = ({
|
|||
|
||||
return (
|
||||
<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
|
||||
plugin support across providers is in development.
|
||||
In the multimodel runtime, plugins are currently guaranteed only for Anthropic
|
||||
sessions. We are actively building broader plugin support for all agents, including
|
||||
both universal plugins and agent-specific plugins.
|
||||
{capability.reason ? ` ${capability.reason}` : ''}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -407,6 +422,8 @@ export const PluginsPanel = ({
|
|||
plugin={plugin}
|
||||
index={index}
|
||||
onClick={setSelectedPluginId}
|
||||
cliStatus={cliStatus}
|
||||
cliStatusLoading={cliStatusLoading}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -418,6 +435,8 @@ export const PluginsPanel = ({
|
|||
open={selectedPluginId !== null}
|
||||
onClose={() => setSelectedPluginId(null)}
|
||||
projectPath={projectPath}
|
||||
cliStatus={cliStatus}
|
||||
cliStatusLoading={cliStatusLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
mergeCodexProviderStatusWithSnapshot,
|
||||
useCodexAccountSnapshot,
|
||||
} from '@features/codex-account/renderer';
|
||||
import { api } from '@renderer/api';
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
|
||||
import { getVisibleMultimodelProviders } from '@renderer/utils/multimodelProviderVisibility';
|
||||
import {
|
||||
getCliProviderExtensionCapability,
|
||||
|
|
@ -149,6 +154,8 @@ export const SkillsPanel = ({
|
|||
const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog);
|
||||
const fetchSkillDetail = useStore((s) => s.fetchSkillDetail);
|
||||
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 skillsError = useStore((s) => s.skillsCatalogErrorByProjectPath[catalogKey] ?? null);
|
||||
const detailById = useStore(useShallow((s) => s.skillsDetailsById));
|
||||
|
|
@ -167,28 +174,54 @@ export const SkillsPanel = ({
|
|||
const selectedSkillIdRef = useRef<string | null>(selectedSkillId);
|
||||
const selectedSkillItemRef = useRef<SkillCatalogItem | null>(null);
|
||||
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(
|
||||
() => [...projectSkills, ...userSkills],
|
||||
[projectSkills, userSkills]
|
||||
);
|
||||
const codexSkillOverlayAvailable = useMemo(
|
||||
() => isCodexSkillOverlayAvailable(cliStatus),
|
||||
[cliStatus]
|
||||
() => isCodexSkillOverlayAvailable(effectiveCliStatus),
|
||||
[effectiveCliStatus]
|
||||
);
|
||||
const skillsAudienceLabel = useMemo(() => {
|
||||
if (cliStatus?.flavor !== 'agent_teams_orchestrator') {
|
||||
if (effectiveCliStatus?.flavor !== 'agent_teams_orchestrator') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const providerNames = getVisibleMultimodelProviders(cliStatus.providers ?? [])
|
||||
const providerNames = getVisibleMultimodelProviders(effectiveCliStatus.providers ?? [])
|
||||
.filter((provider) =>
|
||||
isCliExtensionCapabilityAvailable(getCliProviderExtensionCapability(provider, 'skills'))
|
||||
)
|
||||
.map((provider) => provider.displayName);
|
||||
|
||||
return formatRuntimeAudienceLabel(providerNames);
|
||||
}, [cliStatus]);
|
||||
}, [effectiveCliStatus]);
|
||||
const codexOnlySkillsCount = useMemo(
|
||||
() => mergedSkills.filter((skill) => getSkillAudience(skill.rootKind) === 'codex').length,
|
||||
[mergedSkills]
|
||||
|
|
@ -314,7 +347,7 @@ export const SkillsPanel = ({
|
|||
|
||||
return (
|
||||
<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">
|
||||
Shared skills in `.claude`, `.cursor`, and `.agents` are available to{' '}
|
||||
{skillsAudienceLabel ?? 'the configured runtime'}. Skills stored in `.codex` stay
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@renderer/components/ui/tooltip';
|
||||
import { formatProviderBackendLabel } from '@renderer/utils/providerBackendIdentity';
|
||||
|
||||
import type { CliProviderStatus } from '@shared/types';
|
||||
|
||||
|
|
@ -20,10 +21,57 @@ interface Props {
|
|||
onSelect: (providerId: CliProviderStatus['providerId'], backendId: string) => void;
|
||||
}
|
||||
|
||||
export function getProviderRuntimeBackendStateLabel(
|
||||
option: NonNullable<CliProviderStatus['availableBackends']>[number]
|
||||
): string | null {
|
||||
switch (option.state) {
|
||||
case 'ready':
|
||||
return null;
|
||||
case 'locked':
|
||||
return 'Locked';
|
||||
case 'disabled':
|
||||
return 'Disabled';
|
||||
case 'authentication-required':
|
||||
return 'Auth required';
|
||||
case 'runtime-missing':
|
||||
return 'Runtime missing';
|
||||
case 'degraded':
|
||||
return 'Degraded';
|
||||
default:
|
||||
if (!option.available) {
|
||||
return 'Unavailable';
|
||||
}
|
||||
if (option.selectable === false) {
|
||||
return 'Locked';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getProviderRuntimeBackendAudienceLabel(
|
||||
option: NonNullable<CliProviderStatus['availableBackends']>[number]
|
||||
): string | null {
|
||||
return option.audience === 'internal' ? 'Internal' : null;
|
||||
}
|
||||
|
||||
export function getVisibleProviderRuntimeBackendOptions(
|
||||
provider: CliProviderStatus
|
||||
): NonNullable<CliProviderStatus['availableBackends']> {
|
||||
return provider.availableBackends ?? [];
|
||||
}
|
||||
|
||||
export function getOptionDisplayLabel(
|
||||
provider: CliProviderStatus,
|
||||
option: NonNullable<CliProviderStatus['availableBackends']>[number],
|
||||
resolvedOption: NonNullable<CliProviderStatus['availableBackends']>[number] | null
|
||||
): string {
|
||||
if (provider.providerId === 'codex') {
|
||||
const legacyLabel = formatProviderBackendLabel(provider.providerId, option.id);
|
||||
if (legacyLabel) {
|
||||
return legacyLabel;
|
||||
}
|
||||
}
|
||||
|
||||
if (option.id !== 'auto') {
|
||||
return option.label;
|
||||
}
|
||||
|
|
@ -44,8 +92,18 @@ export function getProviderRuntimeBackendSummary(provider: CliProviderStatus): s
|
|||
const selectedBackendId = provider.selectedBackendId ?? options[0]?.id ?? '';
|
||||
const selectedOption = options.find((option) => option.id === selectedBackendId) ?? options[0];
|
||||
const resolvedOption = options.find((option) => option.id === provider.resolvedBackendId) ?? null;
|
||||
const parts = [getOptionDisplayLabel(provider, selectedOption, resolvedOption)];
|
||||
const audienceLabel = getProviderRuntimeBackendAudienceLabel(selectedOption);
|
||||
const stateLabel = getProviderRuntimeBackendStateLabel(selectedOption);
|
||||
|
||||
return getOptionDisplayLabel(selectedOption, resolvedOption);
|
||||
if (audienceLabel) {
|
||||
parts.push(audienceLabel.toLowerCase());
|
||||
}
|
||||
if (stateLabel) {
|
||||
parts.push(stateLabel.toLowerCase());
|
||||
}
|
||||
|
||||
return parts.join(' - ');
|
||||
}
|
||||
|
||||
export const ProviderRuntimeBackendSelector = ({
|
||||
|
|
@ -53,15 +111,21 @@ export const ProviderRuntimeBackendSelector = ({
|
|||
disabled = false,
|
||||
onSelect,
|
||||
}: Props): React.JSX.Element | null => {
|
||||
const options = provider.availableBackends ?? [];
|
||||
const options = getVisibleProviderRuntimeBackendOptions(provider);
|
||||
if (options.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (provider.providerId === 'codex' && options.length === 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedBackendId = provider.selectedBackendId ?? options[0]?.id ?? '';
|
||||
const selectedOption = options.find((option) => option.id === selectedBackendId) ?? options[0];
|
||||
const resolvedOption = options.find((option) => option.id === provider.resolvedBackendId) ?? null;
|
||||
const selectedLabel = getOptionDisplayLabel(selectedOption, resolvedOption);
|
||||
const selectedLabel = getOptionDisplayLabel(provider, selectedOption, resolvedOption);
|
||||
const selectedStateLabel = getProviderRuntimeBackendStateLabel(selectedOption);
|
||||
const selectedAudienceLabel = getProviderRuntimeBackendAudienceLabel(selectedOption);
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-2.5">
|
||||
|
|
@ -100,12 +164,17 @@ export const ProviderRuntimeBackendSelector = ({
|
|||
<SelectItem
|
||||
key={option.id}
|
||||
value={option.id}
|
||||
disabled={!option.available && option.id !== selectedBackendId}
|
||||
disabled={
|
||||
(!option.available || option.selectable === false) &&
|
||||
option.id !== selectedBackendId
|
||||
}
|
||||
className="py-2.5"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate">{getOptionDisplayLabel(option, resolvedOption)}</span>
|
||||
<span className="truncate">
|
||||
{getOptionDisplayLabel(provider, option, resolvedOption)}
|
||||
</span>
|
||||
{option.recommended ? (
|
||||
<span
|
||||
className="shrink-0 rounded-full px-1.5 py-0.5 text-[10px]"
|
||||
|
|
@ -117,15 +186,40 @@ export const ProviderRuntimeBackendSelector = ({
|
|||
Recommended
|
||||
</span>
|
||||
) : null}
|
||||
{!option.available ? (
|
||||
{getProviderRuntimeBackendAudienceLabel(option) ? (
|
||||
<span
|
||||
className="shrink-0 rounded-full px-1.5 py-0.5 text-[10px]"
|
||||
style={{
|
||||
color: '#fca5a5',
|
||||
backgroundColor: 'rgba(248, 113, 113, 0.14)',
|
||||
color: '#93c5fd',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.14)',
|
||||
}}
|
||||
>
|
||||
Unavailable
|
||||
{getProviderRuntimeBackendAudienceLabel(option)}
|
||||
</span>
|
||||
) : null}
|
||||
{getProviderRuntimeBackendStateLabel(option) ? (
|
||||
<span
|
||||
className="shrink-0 rounded-full px-1.5 py-0.5 text-[10px]"
|
||||
style={{
|
||||
color:
|
||||
option.state === 'disabled' ||
|
||||
option.state === 'authentication-required' ||
|
||||
option.state === 'runtime-missing' ||
|
||||
option.state === 'degraded' ||
|
||||
(!option.available && option.state !== 'locked')
|
||||
? '#fca5a5'
|
||||
: 'var(--color-text-secondary)',
|
||||
backgroundColor:
|
||||
option.state === 'disabled' ||
|
||||
option.state === 'authentication-required' ||
|
||||
option.state === 'runtime-missing' ||
|
||||
option.state === 'degraded' ||
|
||||
(!option.available && option.state !== 'locked')
|
||||
? 'rgba(248, 113, 113, 0.14)'
|
||||
: 'rgba(255, 255, 255, 0.08)',
|
||||
}}
|
||||
>
|
||||
{getProviderRuntimeBackendStateLabel(option)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -160,7 +254,18 @@ export const ProviderRuntimeBackendSelector = ({
|
|||
Recommended
|
||||
</span>
|
||||
) : null}
|
||||
{!selectedOption.available ? (
|
||||
{selectedAudienceLabel ? (
|
||||
<span
|
||||
className="rounded-full px-1.5 py-0.5 text-[10px]"
|
||||
style={{
|
||||
color: '#93c5fd',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.14)',
|
||||
}}
|
||||
>
|
||||
{selectedAudienceLabel}
|
||||
</span>
|
||||
) : null}
|
||||
{!selectedStateLabel && !selectedOption.available ? (
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -179,6 +284,33 @@ export const ProviderRuntimeBackendSelector = ({
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : selectedStateLabel ? (
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="cursor-help rounded-full px-1.5 py-0.5 text-[10px]"
|
||||
style={{
|
||||
color:
|
||||
selectedOption.state === 'locked'
|
||||
? 'var(--color-text-secondary)'
|
||||
: '#fca5a5',
|
||||
backgroundColor:
|
||||
selectedOption.state === 'locked'
|
||||
? 'rgba(255, 255, 255, 0.08)'
|
||||
: 'rgba(248, 113, 113, 0.14)',
|
||||
}}
|
||||
>
|
||||
{selectedStateLabel}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{selectedOption.detailMessage ??
|
||||
selectedOption.statusMessage ??
|
||||
'This backend cannot be selected yet.'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 space-y-1 text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,12 +1,12 @@
|
|||
import type { CliProviderAuthMode, CliProviderStatus } from '@shared/types';
|
||||
|
||||
const CODEX_SUBSCRIPTION_LABEL = 'Codex subscription';
|
||||
const CODEX_API_KEY_LABEL = 'OpenAI API key';
|
||||
const CODEX_NATIVE_LABEL = 'Codex native';
|
||||
const ANTHROPIC_SUBSCRIPTION_LABEL = 'Anthropic subscription';
|
||||
|
||||
const AUTH_MODE_LABELS: Record<CliProviderAuthMode, string> = {
|
||||
auto: 'Auto',
|
||||
oauth: 'Subscription / OAuth',
|
||||
chatgpt: 'ChatGPT account',
|
||||
api_key: 'API key',
|
||||
};
|
||||
|
||||
|
|
@ -22,10 +22,6 @@ export function formatProviderAuthModeLabelForProvider(
|
|||
return null;
|
||||
}
|
||||
|
||||
if (providerId === 'codex' && authMode === 'oauth') {
|
||||
return CODEX_SUBSCRIPTION_LABEL;
|
||||
}
|
||||
|
||||
if (providerId === 'anthropic' && authMode === 'oauth') {
|
||||
return ANTHROPIC_SUBSCRIPTION_LABEL;
|
||||
}
|
||||
|
|
@ -58,10 +54,6 @@ export function formatProviderAuthMethodLabelForProvider(
|
|||
providerId: CliProviderStatus['providerId'],
|
||||
authMethod: string | null
|
||||
): string {
|
||||
if (providerId === 'codex' && authMethod === 'oauth_token') {
|
||||
return CODEX_SUBSCRIPTION_LABEL;
|
||||
}
|
||||
|
||||
if (providerId === 'anthropic' && (authMethod === 'oauth_token' || authMethod === 'claude.ai')) {
|
||||
return ANTHROPIC_SUBSCRIPTION_LABEL;
|
||||
}
|
||||
|
|
@ -69,24 +61,83 @@ export function formatProviderAuthMethodLabelForProvider(
|
|||
return formatProviderAuthMethodLabel(authMethod);
|
||||
}
|
||||
|
||||
function isCodexNativeLane(provider: CliProviderStatus): boolean {
|
||||
return (
|
||||
provider.providerId === 'codex' &&
|
||||
(provider.resolvedBackendId === 'codex-native' || provider.selectedBackendId === 'codex-native')
|
||||
);
|
||||
}
|
||||
|
||||
function getSelectedRuntimeBackendOption(
|
||||
provider: CliProviderStatus
|
||||
): NonNullable<CliProviderStatus['availableBackends']>[number] | null {
|
||||
const options = provider.availableBackends ?? [];
|
||||
if (options.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedBackendId = provider.selectedBackendId ?? null;
|
||||
const resolvedBackendId = provider.resolvedBackendId ?? null;
|
||||
|
||||
return (
|
||||
options.find((option) => option.id === selectedBackendId) ??
|
||||
options.find((option) => option.id === resolvedBackendId) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export function isConnectionManagedRuntimeProvider(provider: CliProviderStatus): boolean {
|
||||
return provider.providerId === 'codex';
|
||||
}
|
||||
|
||||
function getCodexCurrentRuntimeLabel(provider: CliProviderStatus): string {
|
||||
if (provider.authenticated) {
|
||||
return provider.authMethod === 'api_key' ? CODEX_API_KEY_LABEL : CODEX_SUBSCRIPTION_LABEL;
|
||||
return CODEX_NATIVE_LABEL;
|
||||
}
|
||||
|
||||
function getCodexApiKeyAvailabilitySummary(provider: CliProviderStatus): string | null {
|
||||
if (provider.providerId !== 'codex' || !provider.connection?.apiKeyConfigured) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (provider.connection?.configuredAuthMode === 'api_key') {
|
||||
return CODEX_API_KEY_LABEL;
|
||||
if (provider.connection.apiKeySource === 'stored') {
|
||||
return 'Saved API key available in Manage';
|
||||
}
|
||||
|
||||
return CODEX_SUBSCRIPTION_LABEL;
|
||||
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 {
|
||||
if (provider.providerId !== 'codex') {
|
||||
if (provider.providerId !== 'codex' || !isConnectionManagedRuntimeProvider(provider)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -95,6 +146,81 @@ export function getProviderCurrentRuntimeSummary(provider: CliProviderStatus): s
|
|||
}
|
||||
|
||||
export function formatProviderStatusText(provider: CliProviderStatus): string {
|
||||
const selectedBackendOption = getSelectedRuntimeBackendOption(provider);
|
||||
|
||||
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) {
|
||||
return selectedBackendOption.statusMessage;
|
||||
}
|
||||
return (
|
||||
provider.statusMessage ?? (provider.authenticated ? 'Codex native ready' : 'Not connected')
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
isCodexNativeLane(provider) &&
|
||||
selectedBackendOption &&
|
||||
selectedBackendOption.state &&
|
||||
selectedBackendOption.state !== 'ready'
|
||||
) {
|
||||
return (
|
||||
selectedBackendOption.statusMessage ?? provider.statusMessage ?? 'Codex native unavailable'
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
isCodexNativeLane(provider) &&
|
||||
selectedBackendOption?.audience === 'internal' &&
|
||||
selectedBackendOption.statusMessage
|
||||
) {
|
||||
return selectedBackendOption.statusMessage;
|
||||
}
|
||||
|
||||
if (!provider.supported) {
|
||||
return provider.statusMessage ?? 'Unavailable in current runtime';
|
||||
}
|
||||
|
|
@ -118,15 +244,17 @@ export function getProviderConnectionModeSummary(provider: CliProviderStatus): s
|
|||
return null;
|
||||
}
|
||||
|
||||
if (provider.providerId === 'codex') {
|
||||
return null;
|
||||
if (provider.providerId === 'anthropic') {
|
||||
if (provider.authenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (provider.connection?.configuredAuthMode === 'auto') {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (provider.providerId === 'anthropic' && provider.authenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (provider.providerId === 'anthropic' && provider.connection?.configuredAuthMode === 'auto') {
|
||||
if (provider.providerId === 'codex' && provider.connection?.configuredAuthMode === 'auto') {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -134,7 +262,13 @@ export function getProviderConnectionModeSummary(provider: CliProviderStatus): s
|
|||
provider.providerId,
|
||||
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 {
|
||||
|
|
@ -162,16 +296,32 @@ export function getProviderCredentialSummary(provider: CliProviderStatus): strin
|
|||
: (provider.connection.apiKeySourceLabel ?? 'API key is configured');
|
||||
}
|
||||
|
||||
if (provider.providerId === 'codex' && provider.connection?.apiKeyBetaEnabled !== true) {
|
||||
return provider.connection.apiKeySource === 'stored'
|
||||
? 'OpenAI API key is saved in Manage. Enable API key mode to use it.'
|
||||
: 'OpenAI API key detected. Enable API key mode in Manage to use it.';
|
||||
}
|
||||
if (provider.providerId === 'codex') {
|
||||
const apiKeyAvailabilitySummary = getCodexApiKeyAvailabilitySummary(provider);
|
||||
if (!apiKeyAvailabilitySummary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (provider.authMethod !== 'api_key' && provider.providerId === 'codex') {
|
||||
return provider.connection.apiKeySource === 'stored'
|
||||
? 'OpenAI API key is also configured in Manage'
|
||||
: (provider.connection.apiKeySourceLabel ?? 'OpenAI API key is configured');
|
||||
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;
|
||||
|
|
@ -202,17 +352,6 @@ export function getProviderDisconnectAction(provider: CliProviderStatus): {
|
|||
};
|
||||
}
|
||||
|
||||
if (provider.providerId === 'codex' && provider.authMethod === 'oauth_token') {
|
||||
return {
|
||||
label: 'Disconnect',
|
||||
confirmLabel: 'Disconnect',
|
||||
title: 'Disconnect Codex subscription?',
|
||||
message: provider.connection?.apiKeyConfigured
|
||||
? 'This removes the local Codex subscription session from the Claude CLI runtime. Saved OPENAI_API_KEY credentials in Manage stay available.'
|
||||
: 'This removes the local Codex subscription session from the Claude CLI runtime.',
|
||||
};
|
||||
}
|
||||
|
||||
if (provider.providerId === 'gemini' && provider.authMethod === 'cli_oauth_personal') {
|
||||
return {
|
||||
label: 'Disconnect',
|
||||
|
|
@ -232,7 +371,7 @@ export function getProviderConnectLabel(provider: CliProviderStatus): string {
|
|||
}
|
||||
|
||||
if (provider.providerId === 'codex') {
|
||||
return 'Connect Codex';
|
||||
return 'Connect ChatGPT';
|
||||
}
|
||||
|
||||
if (provider.providerId === 'gemini') {
|
||||
|
|
@ -243,6 +382,10 @@ export function getProviderConnectLabel(provider: CliProviderStatus): string {
|
|||
}
|
||||
|
||||
export function shouldShowProviderConnectAction(provider: CliProviderStatus): boolean {
|
||||
if (provider.providerId === 'codex') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!provider.canLoginFromUi || provider.authenticated) {
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -333,14 +333,13 @@ export function useSettingsHandlers({
|
|||
authMode: 'auto',
|
||||
},
|
||||
codex: {
|
||||
apiKeyBetaEnabled: false,
|
||||
authMode: 'oauth',
|
||||
preferredAuthMode: 'auto',
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
providerBackends: {
|
||||
gemini: 'auto',
|
||||
codex: 'auto',
|
||||
codex: 'codex-native',
|
||||
},
|
||||
},
|
||||
display: {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@
|
|||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
mergeCodexProviderStatusWithSnapshot,
|
||||
useCodexAccountSnapshot,
|
||||
} from '@features/codex-account/renderer';
|
||||
import { isElectronMode } from '@renderer/api';
|
||||
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
||||
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
||||
|
|
@ -29,6 +33,8 @@ import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
|
|||
import { useStore } from '@renderer/store';
|
||||
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
|
||||
import { formatBytes } from '@renderer/utils/formatters';
|
||||
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
|
||||
import { getRuntimeDisplayName } from '@renderer/utils/runtimeDisplayName';
|
||||
import {
|
||||
AlertTriangle,
|
||||
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 {
|
||||
switch (providerId) {
|
||||
case 'anthropic':
|
||||
|
|
@ -105,6 +139,15 @@ function getProviderTerminalCommand(provider: CliProviderStatus): {
|
|||
};
|
||||
}
|
||||
|
||||
if (provider.providerId === 'codex') {
|
||||
return {
|
||||
args: ['auth', 'login', '--provider', provider.providerId],
|
||||
env: {
|
||||
CLAUDE_CODE_CODEX_BACKEND: provider.selectedBackendId ?? 'codex-native',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
args: ['auth', 'login', '--provider', provider.providerId],
|
||||
};
|
||||
|
|
@ -124,6 +167,15 @@ function getProviderTerminalLogoutCommand(provider: CliProviderStatus): {
|
|||
};
|
||||
}
|
||||
|
||||
if (provider.providerId === 'codex') {
|
||||
return {
|
||||
args: ['auth', 'logout', '--provider', provider.providerId],
|
||||
env: {
|
||||
CLAUDE_CODE_CODEX_BACKEND: provider.selectedBackendId ?? 'codex-native',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
args: ['auth', 'logout', '--provider', provider.providerId],
|
||||
};
|
||||
|
|
@ -159,10 +211,43 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
const [manageDialogOpen, setManageDialogOpen] = useState(false);
|
||||
const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false);
|
||||
const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true;
|
||||
const effectiveCliStatus =
|
||||
const loadingCliStatus =
|
||||
!cliStatus && cliStatusLoading && multimodelEnabled
|
||||
? createLoadingMultimodelCliStatus()
|
||||
: 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 showInstalledControls =
|
||||
effectiveCliStatus !== null && (installerState === 'idle' || installerState === 'completed');
|
||||
|
|
@ -184,8 +269,12 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
}, [installCli]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
void fetchCliStatus();
|
||||
}, [fetchCliStatus]);
|
||||
void refreshCliStatusForCurrentMode({
|
||||
multimodelEnabled,
|
||||
bootstrapCliStatus,
|
||||
fetchCliStatus,
|
||||
});
|
||||
}, [bootstrapCliStatus, fetchCliStatus, multimodelEnabled]);
|
||||
|
||||
const handleProviderLogout = useCallback(
|
||||
async (providerId: CliProviderId) => {
|
||||
|
|
@ -224,9 +313,13 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
const recheckStatus = useCallback(() => {
|
||||
void (async () => {
|
||||
await invalidateCliStatus();
|
||||
await fetchCliStatus();
|
||||
await refreshCliStatusForCurrentMode({
|
||||
multimodelEnabled,
|
||||
bootstrapCliStatus,
|
||||
fetchCliStatus,
|
||||
});
|
||||
})();
|
||||
}, [fetchCliStatus, invalidateCliStatus]);
|
||||
}, [bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, multimodelEnabled]);
|
||||
|
||||
const handleMultimodelToggle = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
|
|
@ -263,7 +356,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
async (providerId: CliProviderId, backendId: string) => {
|
||||
const currentBackends = appConfig?.runtime?.providerBackends ?? {
|
||||
gemini: 'auto' as const,
|
||||
codex: 'auto' as const,
|
||||
codex: 'codex-native' as const,
|
||||
};
|
||||
|
||||
if (providerId !== 'gemini' && providerId !== 'codex') {
|
||||
|
|
@ -288,14 +381,15 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
|
||||
if (!isElectron) return null;
|
||||
|
||||
const runtimeDisplayName = getRuntimeDisplayName(effectiveCliStatus, multimodelEnabled);
|
||||
const runtimeLabel =
|
||||
effectiveCliStatus?.flavor === 'agent_teams_orchestrator'
|
||||
? null
|
||||
: effectiveCliStatus &&
|
||||
effectiveCliStatus.showVersionDetails &&
|
||||
effectiveCliStatus.installedVersion
|
||||
? `${effectiveCliStatus.displayName} v${effectiveCliStatus.installedVersion ?? 'unknown'}`
|
||||
: (effectiveCliStatus?.displayName ?? 'Claude CLI');
|
||||
? `${runtimeDisplayName} v${effectiveCliStatus.installedVersion ?? 'unknown'}`
|
||||
: runtimeDisplayName;
|
||||
|
||||
const activeTerminalProvider = providerTerminal
|
||||
? (effectiveCliStatus?.providers.find(
|
||||
|
|
@ -445,11 +539,22 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
{(() => {
|
||||
const providerLoading =
|
||||
cliProviderStatusLoading[provider.providerId] === true;
|
||||
const showSkeleton = isProviderCardLoading(provider, providerLoading);
|
||||
const showSkeleton =
|
||||
isProviderCardLoading(provider, providerLoading) ||
|
||||
isCodexSnapshotPending(provider, codexSnapshotPending);
|
||||
const runtimeSummary = isConnectionManagedRuntimeProvider(provider)
|
||||
? getProviderCurrentRuntimeSummary(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 credentialSummary = getProviderCredentialSummary(provider);
|
||||
const disconnectAction = getProviderDisconnectAction(provider);
|
||||
|
|
@ -480,15 +585,16 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
</span>
|
||||
<span
|
||||
style={{
|
||||
color: provider.authenticated
|
||||
? '#4ade80'
|
||||
: 'var(--color-text-muted)',
|
||||
color: getProviderStatusColor(
|
||||
statusText,
|
||||
provider.authenticated
|
||||
),
|
||||
}}
|
||||
>
|
||||
{statusText}
|
||||
</span>
|
||||
</div>
|
||||
{showSkeleton ? (
|
||||
{effectiveShowSkeleton ? (
|
||||
<ProviderDetailSkeleton />
|
||||
) : hasDetailContent ? (
|
||||
<div
|
||||
|
|
@ -543,7 +649,8 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
<LogOut className="size-3" />
|
||||
{disconnectAction.label}
|
||||
</button>
|
||||
) : shouldShowProviderConnectAction(provider) ? (
|
||||
) : !effectiveShowSkeleton &&
|
||||
shouldShowProviderConnectAction(provider) ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
|
|
@ -565,7 +672,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{!showSkeleton && provider.models.length > 0 && (
|
||||
{!effectiveShowSkeleton && provider.models.length > 0 && (
|
||||
<div className="col-span-2">
|
||||
<ProviderModelBadges
|
||||
providerId={provider.providerId}
|
||||
|
|
@ -601,8 +708,8 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="size-4 shrink-0" style={{ color: '#fbbf24' }} />
|
||||
{effectiveCliStatus.binaryPath && effectiveCliStatus.launchError
|
||||
? 'Claude CLI was found but failed to start'
|
||||
: 'Claude CLI not installed'}
|
||||
? `${runtimeDisplayName} was found but failed to start`
|
||||
: `${runtimeDisplayName} not installed`}
|
||||
</div>
|
||||
{effectiveCliStatus.showBinaryPath && effectiveCliStatus.binaryPath && (
|
||||
<div className="break-all font-mono text-xs text-text-muted">
|
||||
|
|
@ -646,16 +753,16 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
>
|
||||
<Download className="size-3.5" />
|
||||
{effectiveCliStatus.binaryPath && effectiveCliStatus.launchError
|
||||
? 'Reinstall Claude CLI'
|
||||
: 'Install Claude CLI'}
|
||||
? `Reinstall ${runtimeDisplayName}`
|
||||
: `Install ${runtimeDisplayName}`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!effectiveCliStatus.installed && !effectiveCliStatus.supportsSelfUpdate && (
|
||||
<p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
{effectiveCliStatus.binaryPath && effectiveCliStatus.launchError
|
||||
? `The configured ${effectiveCliStatus.displayName} runtime failed its startup health check.`
|
||||
: `The configured ${effectiveCliStatus.displayName} runtime was not found.`}
|
||||
? `The configured ${runtimeDisplayName} failed its startup health check.`
|
||||
: `The configured ${runtimeDisplayName} was not found.`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -761,9 +868,9 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
</div>
|
||||
{providerTerminal && cliStatus?.binaryPath && (
|
||||
<TerminalModal
|
||||
title={`${cliStatus.displayName} ${providerTerminal.action === 'login' ? 'Login' : 'Logout'}: ${getProviderLabel(
|
||||
providerTerminal.providerId
|
||||
)}`}
|
||||
title={`${getRuntimeDisplayName(cliStatus, multimodelEnabled)} ${
|
||||
providerTerminal.action === 'login' ? 'Login' : 'Logout'
|
||||
}: ${getProviderLabel(providerTerminal.providerId)}`}
|
||||
command={cliStatus.binaryPath}
|
||||
args={providerTerminalCommand?.args}
|
||||
env={providerTerminalCommand?.env}
|
||||
|
|
|
|||
|
|
@ -157,9 +157,7 @@ export const ProvisioningProgressBlock = ({
|
|||
className,
|
||||
}: ProvisioningProgressBlockProps): React.JSX.Element => {
|
||||
const elapsed = useElapsedTimer(startedAt, loading);
|
||||
const [logsOpen, setLogsOpen] = useState(
|
||||
() => defaultLogsOpen ?? (Boolean(cliLogsTail) && loading)
|
||||
);
|
||||
const [logsOpen, setLogsOpen] = useState(() => defaultLogsOpen ?? false);
|
||||
const [liveOutputOpen, setLiveOutputOpen] = useState(defaultLiveOutputOpen);
|
||||
const outputScrollRef = useRef<HTMLDivElement>(null);
|
||||
const isError = tone === 'error';
|
||||
|
|
@ -192,29 +190,6 @@ export const ProvisioningProgressBlock = ({
|
|||
}
|
||||
}, [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 (
|
||||
<div
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
mergeCodexCliStatusWithSnapshot,
|
||||
useCodexAccountSnapshot,
|
||||
} from '@features/codex-account/renderer';
|
||||
import { api } from '@renderer/api';
|
||||
import {
|
||||
buildMemberDraftColorMap,
|
||||
|
|
@ -36,11 +40,14 @@ import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
|
|||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
|
||||
import {
|
||||
isGeminiUiFrozen,
|
||||
normalizeCreateLaunchProviderForUi,
|
||||
} from '@renderer/utils/geminiUiFreeze';
|
||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import { resolveUiOwnedProviderBackendId } from '@renderer/utils/providerBackendIdentity';
|
||||
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
|
||||
import {
|
||||
getTeamModelSelectionError,
|
||||
normalizeExplicitTeamModelForUi,
|
||||
|
|
@ -308,7 +315,25 @@ export const CreateTeamDialog = ({
|
|||
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
|
||||
const cliStatus = useStore((s) => s.cliStatus);
|
||||
const cliStatusLoading = useStore((s) => s.cliStatusLoading);
|
||||
const bootstrapCliStatus = useStore((s) => s.bootstrapCliStatus);
|
||||
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) ──────────────────
|
||||
const {
|
||||
|
|
@ -507,7 +532,9 @@ export const CreateTeamDialog = ({
|
|||
}, [members, multimodelEnabled, selectedProviderId, soloTeam, syncModelsWithLead]);
|
||||
|
||||
const runtimeBackendSummaryByProvider = useMemo(() => {
|
||||
const entries: (readonly [TeamProviderId, string | null])[] = (cliStatus?.providers ?? []).map(
|
||||
const entries: (readonly [TeamProviderId, string | null])[] = (
|
||||
effectiveCliStatus?.providers ?? []
|
||||
).map(
|
||||
(provider) =>
|
||||
[
|
||||
provider.providerId as TeamProviderId,
|
||||
|
|
@ -515,7 +542,7 @@ export const CreateTeamDialog = ({
|
|||
] as const
|
||||
);
|
||||
return new Map<TeamProviderId, string | null>(entries);
|
||||
}, [cliStatus?.providers]);
|
||||
}, [effectiveCliStatus?.providers]);
|
||||
const runtimeBackendSummaryByProviderRef = useRef(runtimeBackendSummaryByProvider);
|
||||
const prepareChecksRef = useRef<ProvisioningProviderCheck[]>([]);
|
||||
const prepareModelResultsCacheRef = useRef(
|
||||
|
|
@ -555,8 +582,12 @@ export const CreateTeamDialog = ({
|
|||
if (!open || cliStatus || cliStatusLoading) {
|
||||
return;
|
||||
}
|
||||
void fetchCliStatus();
|
||||
}, [open, cliStatus, cliStatusLoading, fetchCliStatus]);
|
||||
void refreshCliStatusForCurrentMode({
|
||||
multimodelEnabled,
|
||||
bootstrapCliStatus,
|
||||
fetchCliStatus,
|
||||
});
|
||||
}, [bootstrapCliStatus, cliStatus, cliStatusLoading, fetchCliStatus, multimodelEnabled, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !canCreate || !launchTeam) {
|
||||
|
|
@ -960,9 +991,11 @@ export const CreateTeamDialog = ({
|
|||
const runtimeProviderStatusById = useMemo(
|
||||
() =>
|
||||
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());
|
||||
|
|
@ -980,6 +1013,11 @@ export const CreateTeamDialog = ({
|
|||
cwd: effectiveCwd,
|
||||
prompt: prompt.trim() || undefined,
|
||||
providerId: selectedProviderId,
|
||||
providerBackendId:
|
||||
resolveUiOwnedProviderBackendId(
|
||||
selectedProviderId,
|
||||
runtimeProviderStatusById.get(selectedProviderId)
|
||||
) ?? undefined,
|
||||
model: effectiveModel,
|
||||
effort: (selectedEffort as EffortLevel) || undefined,
|
||||
limitContext,
|
||||
|
|
@ -996,6 +1034,7 @@ export const CreateTeamDialog = ({
|
|||
effectiveCwd,
|
||||
prompt,
|
||||
selectedProviderId,
|
||||
runtimeProviderStatusById,
|
||||
effectiveModel,
|
||||
selectedEffort,
|
||||
limitContext,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
mergeCodexCliStatusWithSnapshot,
|
||||
useCodexAccountSnapshot,
|
||||
} from '@features/codex-account/renderer';
|
||||
import { api } from '@renderer/api';
|
||||
import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox';
|
||||
import {
|
||||
|
|
@ -36,6 +40,7 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
|
|||
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
|
||||
import {
|
||||
isTeamProvisioningActive,
|
||||
selectResolvedMembersForTeamName,
|
||||
|
|
@ -45,6 +50,8 @@ import {
|
|||
normalizeCreateLaunchProviderForUi,
|
||||
} from '@renderer/utils/geminiUiFreeze';
|
||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import { resolveUiOwnedProviderBackendId } from '@renderer/utils/providerBackendIdentity';
|
||||
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
|
||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||
import {
|
||||
getTeamModelSelectionError,
|
||||
|
|
@ -248,9 +255,27 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
|
||||
const cliStatus = useStore((s) => s.cliStatus);
|
||||
const cliStatusLoading = useStore((s) => s.cliStatusLoading);
|
||||
const bootstrapCliStatus = useStore((s) => s.bootstrapCliStatus);
|
||||
const fetchCliStatus = useStore((s) => s.fetchCliStatus);
|
||||
const isLaunchMode = props.mode === 'launch' || 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 schedule = isSchedule ? (props.schedule ?? null) : null;
|
||||
const isEditing = isSchedule && !!schedule;
|
||||
|
|
@ -336,6 +361,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
);
|
||||
const members = isLaunchMode ? props.members : storeMembers;
|
||||
const [savedLaunchProviderId, setSavedLaunchProviderId] = useState<TeamProviderId | null>(null);
|
||||
const [savedLaunchProviderBackendId, setSavedLaunchProviderBackendId] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
|
||||
// Advanced CLI section state (with localStorage persistence)
|
||||
const [worktreeEnabled, setWorktreeEnabledRaw] = useState(
|
||||
|
|
@ -381,7 +409,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
);
|
||||
|
||||
const runtimeBackendSummaryByProvider = useMemo(() => {
|
||||
const entries: (readonly [TeamProviderId, string | null])[] = (cliStatus?.providers ?? []).map(
|
||||
const entries: (readonly [TeamProviderId, string | null])[] = (
|
||||
effectiveCliStatus?.providers ?? []
|
||||
).map(
|
||||
(provider) =>
|
||||
[
|
||||
provider.providerId as TeamProviderId,
|
||||
|
|
@ -389,7 +419,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
] as const
|
||||
);
|
||||
return new Map<TeamProviderId, string | null>(entries);
|
||||
}, [cliStatus?.providers]);
|
||||
}, [effectiveCliStatus?.providers]);
|
||||
const runtimeBackendSummaryByProviderRef = useRef(runtimeBackendSummaryByProvider);
|
||||
const prepareChecksRef = useRef<ProvisioningProviderCheck[]>([]);
|
||||
const prepareModelResultsCacheRef = useRef(
|
||||
|
|
@ -410,9 +440,11 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
const runtimeProviderStatusById = useMemo(
|
||||
() =>
|
||||
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(() => {
|
||||
|
|
@ -438,8 +470,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
if (!open || cliStatus || cliStatusLoading) {
|
||||
return;
|
||||
}
|
||||
void fetchCliStatus();
|
||||
}, [open, cliStatus, cliStatusLoading, fetchCliStatus]);
|
||||
void refreshCliStatusForCurrentMode({
|
||||
multimodelEnabled,
|
||||
bootstrapCliStatus,
|
||||
fetchCliStatus,
|
||||
});
|
||||
}, [bootstrapCliStatus, cliStatus, cliStatusLoading, fetchCliStatus, multimodelEnabled, open]);
|
||||
|
||||
// Schedule store actions
|
||||
const createSchedule = useStore((s) => s.createSchedule);
|
||||
|
|
@ -640,6 +676,11 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
: savedRequest?.providerId === 'anthropic'
|
||||
? 'anthropic'
|
||||
: null;
|
||||
const savedProviderBackendId =
|
||||
typeof savedRequest?.providerBackendId === 'string' &&
|
||||
savedRequest.providerBackendId.trim().length > 0
|
||||
? savedRequest.providerBackendId.trim()
|
||||
: null;
|
||||
const storedProviderId = normalizeProviderForMode(getStoredTeamProvider(), multimodelEnabled);
|
||||
const launchPrefill = resolveLaunchDialogPrefill({
|
||||
members,
|
||||
|
|
@ -652,6 +693,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
getStoredModel: getStoredTeamModel,
|
||||
});
|
||||
setSavedLaunchProviderId(savedProviderId);
|
||||
setSavedLaunchProviderBackendId(
|
||||
launchPrefill.providerBackendId ?? savedProviderBackendId ?? null
|
||||
);
|
||||
|
||||
setMembersDrafts(
|
||||
createMemberDraftsFromInputs(editableMembersSource).map((member) =>
|
||||
|
|
@ -1405,6 +1449,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
cwd: effectiveCwd,
|
||||
prompt: promptDraft.value.trim() || undefined,
|
||||
providerId: selectedProviderId,
|
||||
providerBackendId:
|
||||
resolveUiOwnedProviderBackendId(
|
||||
selectedProviderId,
|
||||
runtimeProviderStatusById.get(selectedProviderId)
|
||||
) ??
|
||||
previousLaunchParams?.providerBackendId ??
|
||||
savedLaunchProviderBackendId ??
|
||||
undefined,
|
||||
model: computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId),
|
||||
effort: (selectedEffort as EffortLevel) || undefined,
|
||||
limitContext,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
|
||||
import { formatProviderBackendLabel } from '@renderer/utils/providerBackendIdentity';
|
||||
import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
|
||||
|
||||
import type { TeamProviderId } from '@shared/types';
|
||||
|
|
@ -34,7 +35,7 @@ export function getProvisioningProviderBackendSummary(
|
|||
provider:
|
||||
| Pick<
|
||||
CliProviderStatus,
|
||||
'selectedBackendId' | 'resolvedBackendId' | 'availableBackends' | 'backend'
|
||||
'providerId' | 'selectedBackendId' | 'resolvedBackendId' | 'availableBackends' | 'backend'
|
||||
>
|
||||
| null
|
||||
| undefined
|
||||
|
|
@ -46,12 +47,53 @@ export function getProvisioningProviderBackendSummary(
|
|||
const options = provider.availableBackends ?? [];
|
||||
const optionById = new Map(options.map((option) => [option.id, option.label]));
|
||||
const effectiveBackendId = provider.resolvedBackendId ?? provider.selectedBackendId;
|
||||
const effectiveOption = options.find((option) => option.id === effectiveBackendId) ?? null;
|
||||
const inferredProviderId =
|
||||
provider.providerId ??
|
||||
(effectiveBackendId === 'codex-native' || options.some((option) => option.id === 'codex-native')
|
||||
? 'codex'
|
||||
: undefined);
|
||||
const normalizedLabel =
|
||||
formatProviderBackendLabel(inferredProviderId, effectiveBackendId ?? undefined) ?? null;
|
||||
|
||||
if (effectiveBackendId) {
|
||||
return optionById.get(effectiveBackendId) ?? provider.backend?.label ?? effectiveBackendId;
|
||||
const baseSummary = effectiveBackendId
|
||||
? (normalizedLabel ??
|
||||
optionById.get(effectiveBackendId) ??
|
||||
provider.backend?.label ??
|
||||
effectiveBackendId)
|
||||
: (provider.backend?.label ?? null);
|
||||
|
||||
if (!baseSummary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return provider.backend?.label ?? null;
|
||||
const suffixes: string[] = [];
|
||||
if (effectiveOption?.audience === 'internal') {
|
||||
suffixes.push('internal');
|
||||
}
|
||||
if (effectiveOption?.state && effectiveOption.state !== 'ready') {
|
||||
switch (effectiveOption.state) {
|
||||
case 'locked':
|
||||
suffixes.push('locked');
|
||||
break;
|
||||
case 'disabled':
|
||||
suffixes.push('disabled');
|
||||
break;
|
||||
case 'authentication-required':
|
||||
suffixes.push('auth required');
|
||||
break;
|
||||
case 'runtime-missing':
|
||||
suffixes.push('runtime missing');
|
||||
break;
|
||||
case 'degraded':
|
||||
suffixes.push('degraded');
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return suffixes.length > 0 ? `${baseSummary} - ${suffixes.join(', ')}` : baseSummary;
|
||||
}
|
||||
|
||||
export function updateProviderCheck(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import React, { useEffect, useMemo } from 'react';
|
||||
|
||||
import {
|
||||
mergeCodexCliStatusWithSnapshot,
|
||||
useCodexAccountSnapshot,
|
||||
} from '@features/codex-account/renderer';
|
||||
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
||||
import { Label } from '@renderer/components/ui/label';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
||||
|
|
@ -10,6 +14,7 @@ import {
|
|||
TooltipTrigger,
|
||||
} from '@renderer/components/ui/tooltip';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
|
||||
import { useStore } from '@renderer/store';
|
||||
import {
|
||||
GEMINI_UI_DISABLED_BADGE_LABEL,
|
||||
|
|
@ -136,10 +141,26 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
const cliStatus = useStore((s) => s.cliStatus);
|
||||
const cliStatusLoading = useStore((s) => s.cliStatusLoading);
|
||||
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 =
|
||||
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(() => {
|
||||
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.';
|
||||
|
|
@ -190,12 +211,14 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
};
|
||||
const runtimeProviderStatus = useMemo(
|
||||
() =>
|
||||
cliStatus?.providers.find((provider) => provider.providerId === effectiveProviderId) ?? null,
|
||||
[cliStatus?.providers, effectiveProviderId]
|
||||
effectiveCliStatus?.providers.find(
|
||||
(provider) => provider.providerId === effectiveProviderId
|
||||
) ?? null,
|
||||
[effectiveCliStatus?.providers, effectiveProviderId]
|
||||
);
|
||||
const shouldAwaitRuntimeModelList =
|
||||
effectiveProviderId !== 'anthropic' &&
|
||||
(cliStatus == null || cliStatusLoading) &&
|
||||
(effectiveCliStatus == null || effectiveCliStatusLoading) &&
|
||||
runtimeProviderStatus == null;
|
||||
const normalizedValue = normalizeTeamModelForUi(
|
||||
effectiveProviderId,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze';
|
||||
import { getDefaultProviderBackendId } from '@renderer/utils/providerBackendIdentity';
|
||||
import { normalizeExplicitTeamModelForUi } from '@renderer/utils/teamModelAvailability';
|
||||
import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
|
|
@ -8,6 +9,7 @@ import type { ResolvedTeamMember, TeamCreateRequest, TeamProviderId } from '@sha
|
|||
|
||||
interface PreviousLaunchParamsLike {
|
||||
providerId?: TeamProviderId;
|
||||
providerBackendId?: string;
|
||||
model?: string;
|
||||
effort?: string;
|
||||
limitContext?: boolean;
|
||||
|
|
@ -26,6 +28,7 @@ interface LaunchDialogPrefillInput {
|
|||
|
||||
interface LaunchDialogPrefillResult {
|
||||
providerId: TeamProviderId;
|
||||
providerBackendId?: string;
|
||||
model: string;
|
||||
effort: string;
|
||||
limitContext: boolean;
|
||||
|
|
@ -101,6 +104,11 @@ export function resolveLaunchDialogPrefill({
|
|||
|
||||
return {
|
||||
providerId,
|
||||
providerBackendId:
|
||||
previousLaunchParams?.providerBackendId?.trim() ||
|
||||
savedRequest?.providerBackendId?.trim() ||
|
||||
getDefaultProviderBackendId(providerId) ||
|
||||
undefined,
|
||||
model: matchingModel
|
||||
? normalizeExplicitTeamModelForUi(providerId, matchingModel)
|
||||
: getStoredModel(providerId),
|
||||
|
|
|
|||
|
|
@ -149,7 +149,7 @@ function normalizeModelReason(rawReason: string | null | undefined): string | nu
|
|||
if (
|
||||
/The '[^']+' model is not supported when using Codex with a ChatGPT account\./i.test(trimmed)
|
||||
) {
|
||||
return 'Not available with Codex ChatGPT subscription';
|
||||
return 'Not available on this Codex native runtime';
|
||||
}
|
||||
if (/The requested model is not available for your account\./i.test(trimmed)) {
|
||||
return 'Not available for this account';
|
||||
|
|
|
|||
|
|
@ -166,6 +166,7 @@ function areLaunchParamsEquivalent(
|
|||
if (!left || !right) return left === right;
|
||||
return (
|
||||
left.providerId === right.providerId &&
|
||||
left.providerBackendId === right.providerBackendId &&
|
||||
left.model === right.model &&
|
||||
left.effort === right.effort
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { api } from '@renderer/api';
|
|||
import { syncRendererTelemetry } from '@renderer/sentry';
|
||||
import { cleanupStale as cleanupCommentReadState } from '@renderer/services/commentReadStorage';
|
||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
|
||||
import {
|
||||
buildTaskChangePresenceKey,
|
||||
buildTaskChangeRequestOptions,
|
||||
|
|
@ -1424,13 +1425,21 @@ export function initializeNotificationListeners(): () => void {
|
|||
break;
|
||||
}
|
||||
case 'completed':
|
||||
{
|
||||
const multimodelEnabled =
|
||||
useStore.getState().appConfig?.general?.multimodelEnabled ?? true;
|
||||
void refreshCliStatusForCurrentMode({
|
||||
multimodelEnabled,
|
||||
bootstrapCliStatus: useStore.getState().bootstrapCliStatus,
|
||||
fetchCliStatus: useStore.getState().fetchCliStatus,
|
||||
});
|
||||
}
|
||||
useStore.setState({
|
||||
cliInstallerState: 'completed',
|
||||
cliCompletedVersion: progress.version ?? null,
|
||||
cliInstallerDetail: null,
|
||||
});
|
||||
// Re-fetch status after install and auto-revert to idle after 3s
|
||||
void useStore.getState().fetchCliStatus();
|
||||
cliCompletedRevertTimer = setTimeout(() => {
|
||||
cliCompletedRevertTimer = null;
|
||||
// Only revert if still in 'completed' state (not overwritten by a new install)
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ export function createLoadingMultimodelCliStatus(): CliInstallationStatus {
|
|||
|
||||
return {
|
||||
flavor: 'agent_teams_orchestrator',
|
||||
displayName: 'agent_teams_orchestrator',
|
||||
displayName: 'Multimodel runtime',
|
||||
supportsSelfUpdate: false,
|
||||
showVersionDetails: 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
|
||||
// =============================================================================
|
||||
|
|
@ -164,6 +182,18 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
|||
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) => {
|
||||
if (epoch !== cliStatusEpoch || !state.cliStatus) {
|
||||
return {};
|
||||
|
|
@ -171,37 +201,37 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
|||
|
||||
return {
|
||||
cliStatus: {
|
||||
...state.cliStatus,
|
||||
flavor: metadata.flavor,
|
||||
displayName: metadata.displayName,
|
||||
supportsSelfUpdate: metadata.supportsSelfUpdate,
|
||||
showVersionDetails: metadata.showVersionDetails,
|
||||
showBinaryPath: metadata.showBinaryPath,
|
||||
installed: metadata.installed,
|
||||
installedVersion: metadata.installedVersion,
|
||||
binaryPath: metadata.binaryPath,
|
||||
...metadata,
|
||||
launchError: metadata.launchError ?? null,
|
||||
latestVersion: metadata.latestVersion,
|
||||
updateAvailable: metadata.updateAvailable,
|
||||
authStatusChecking:
|
||||
metadata.installed &&
|
||||
state.cliStatus.providers.some(
|
||||
(provider) => provider.statusMessage === 'Checking...'
|
||||
),
|
||||
providers: metadata.installed ? state.cliStatus.providers : metadata.providers,
|
||||
authStatusChecking: metadata.installed && pendingProviderIds.length > 0,
|
||||
},
|
||||
cliStatusLoading: false,
|
||||
cliProviderStatusLoading: nextProviderLoading,
|
||||
};
|
||||
});
|
||||
|
||||
if (!metadata.installed) {
|
||||
if (epoch === cliStatusEpoch) {
|
||||
set({
|
||||
cliStatusLoading: false,
|
||||
cliProviderStatusLoading: {},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingProviderIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.allSettled(
|
||||
pendingProviderIds.map((providerId) =>
|
||||
get().fetchCliProviderStatus(providerId, {
|
||||
silent: false,
|
||||
epoch,
|
||||
})
|
||||
)
|
||||
);
|
||||
return;
|
||||
} catch (error) {
|
||||
logger.warn('Failed to hydrate CLI metadata during provider-first bootstrap:', error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
*/
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
|
||||
import {
|
||||
getExtensionActionDisableReason,
|
||||
getMcpDiagnosticKey,
|
||||
|
|
@ -345,6 +346,26 @@ function upsertApiKeyEntry(entries: ApiKeyEntry[], entry: ApiKeyEntry): ApiKeyEn
|
|||
const SUCCESS_DISPLAY_MS = 2_000;
|
||||
const PROJECT_SCOPE_REQUIRED_MESSAGE =
|
||||
'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> = (
|
||||
set,
|
||||
get
|
||||
|
|
@ -886,22 +907,21 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
: { ...request, projectPath: effectiveProjectPath };
|
||||
|
||||
const preflightState = get();
|
||||
if (preflightState.cliStatus === null || preflightState.cliStatusLoading) {
|
||||
if (preflightState.cliStatus === null) {
|
||||
try {
|
||||
await preflightState.fetchCliStatus();
|
||||
await refreshConfiguredCliStatus(preflightState);
|
||||
} catch {
|
||||
// fetchCliStatus stores the error in cliStatusError; map to a user-facing install error below.
|
||||
}
|
||||
}
|
||||
|
||||
const cliStatus = get().cliStatus;
|
||||
const extensionCliStatusState = getExtensionActionCliStatusState(get());
|
||||
const preflightError =
|
||||
effectiveRequest.scope !== 'user' && !effectiveRequest.projectPath
|
||||
? PROJECT_SCOPE_REQUIRED_MESSAGE
|
||||
: getExtensionActionDisableReason({
|
||||
isInstalled: false,
|
||||
cliStatus,
|
||||
cliStatusLoading: get().cliStatusLoading,
|
||||
...extensionCliStatusState,
|
||||
section: 'plugins',
|
||||
});
|
||||
|
||||
|
|
@ -977,9 +997,9 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
}
|
||||
|
||||
const preflightState = get();
|
||||
if (preflightState.cliStatus === null || preflightState.cliStatusLoading) {
|
||||
if (preflightState.cliStatus === null) {
|
||||
try {
|
||||
await preflightState.fetchCliStatus();
|
||||
await refreshConfiguredCliStatus(preflightState);
|
||||
} catch {
|
||||
// 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({
|
||||
isInstalled: true,
|
||||
cliStatus: get().cliStatus,
|
||||
cliStatusLoading: get().cliStatusLoading,
|
||||
...getExtensionActionCliStatusState(get()),
|
||||
section: 'plugins',
|
||||
});
|
||||
if (uninstallDisableReason) {
|
||||
|
|
@ -1055,9 +1074,9 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
}
|
||||
|
||||
const preflightState = get();
|
||||
if (preflightState.cliStatus === null || preflightState.cliStatusLoading) {
|
||||
if (preflightState.cliStatus === null) {
|
||||
try {
|
||||
await preflightState.fetchCliStatus();
|
||||
await refreshConfiguredCliStatus(preflightState);
|
||||
} catch {
|
||||
// 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({
|
||||
isInstalled: false,
|
||||
cliStatus: get().cliStatus,
|
||||
cliStatusLoading: get().cliStatusLoading,
|
||||
...getExtensionActionCliStatusState(get()),
|
||||
section: 'mcp',
|
||||
});
|
||||
if (installDisableReason) {
|
||||
|
|
@ -1130,9 +1148,9 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
}
|
||||
|
||||
const preflightState = get();
|
||||
if (preflightState.cliStatus === null || preflightState.cliStatusLoading) {
|
||||
if (preflightState.cliStatus === null) {
|
||||
try {
|
||||
await preflightState.fetchCliStatus();
|
||||
await refreshConfiguredCliStatus(preflightState);
|
||||
} catch {
|
||||
// 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({
|
||||
isInstalled: false,
|
||||
cliStatus: get().cliStatus,
|
||||
cliStatusLoading: get().cliStatusLoading,
|
||||
...getExtensionActionCliStatusState(get()),
|
||||
section: 'mcp',
|
||||
});
|
||||
if (installDisableReason) {
|
||||
|
|
@ -1199,9 +1216,9 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
}
|
||||
|
||||
const preflightState = get();
|
||||
if (preflightState.cliStatus === null || preflightState.cliStatusLoading) {
|
||||
if (preflightState.cliStatus === null) {
|
||||
try {
|
||||
await preflightState.fetchCliStatus();
|
||||
await refreshConfiguredCliStatus(preflightState);
|
||||
} catch {
|
||||
// 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({
|
||||
isInstalled: true,
|
||||
cliStatus: get().cliStatus,
|
||||
cliStatusLoading: get().cliStatusLoading,
|
||||
...getExtensionActionCliStatusState(get()),
|
||||
section: 'mcp',
|
||||
});
|
||||
if (uninstallDisableReason) {
|
||||
|
|
@ -1309,7 +1325,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
}));
|
||||
}
|
||||
|
||||
await get().fetchCliStatus();
|
||||
await refreshConfiguredCliStatus(get());
|
||||
const refreshError = get().cliStatusError;
|
||||
if (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) => ({
|
||||
apiKeys: prev.apiKeys.filter((k) => k.id !== id),
|
||||
}));
|
||||
await get().fetchCliStatus();
|
||||
await refreshConfiguredCliStatus(get());
|
||||
const refreshError = get().cliStatusError;
|
||||
set({
|
||||
apiKeysError: refreshError
|
||||
|
|
|
|||
|
|
@ -1523,6 +1523,7 @@ export interface GlobalTaskDetailState {
|
|||
/** Per-team launch parameters shown in the header badge. */
|
||||
export interface TeamLaunchParams {
|
||||
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||
providerBackendId?: string;
|
||||
model?: string; // 'opus' | 'sonnet' | 'haiku'
|
||||
effort?: EffortLevel;
|
||||
limitContext?: boolean;
|
||||
|
|
@ -4422,6 +4423,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
const baseModel = extractBaseModel(request.model, request.providerId);
|
||||
const params: TeamLaunchParams = {
|
||||
providerId: request.providerId ?? 'anthropic',
|
||||
providerBackendId: request.providerBackendId,
|
||||
model: baseModel || 'default',
|
||||
effort: request.effort,
|
||||
limitContext: request.limitContext ?? false,
|
||||
|
|
@ -4593,6 +4595,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
const baseModel = extractBaseModel(request.model, request.providerId);
|
||||
const params: TeamLaunchParams = {
|
||||
providerId: request.providerId ?? 'anthropic',
|
||||
providerBackendId: request.providerBackendId,
|
||||
model: baseModel || 'default',
|
||||
effort: request.effort,
|
||||
limitContext: request.limitContext ?? false,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { formatTeamModelSummary } from '@renderer/components/team/dialogs/TeamModelSelector';
|
||||
import { formatBytes } from '@renderer/utils/formatters';
|
||||
import { formatTeamProviderBackendLabel } from '@renderer/utils/providerBackendIdentity';
|
||||
import { inferTeamProviderIdFromModel } from '@shared/utils/teamProvider';
|
||||
|
||||
import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice';
|
||||
|
|
@ -10,6 +11,21 @@ import type {
|
|||
TeamProviderId,
|
||||
} 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 {
|
||||
if (!spawnEntry) {
|
||||
return false;
|
||||
|
|
@ -34,6 +50,10 @@ export function resolveMemberRuntimeSummary(
|
|||
const configuredModel = member.model?.trim() || launchParams?.model?.trim() || '';
|
||||
const configuredEffort = member.effort ?? launchParams?.effort;
|
||||
const runtimeModel = spawnEntry?.runtimeModel?.trim() || runtimeEntry?.runtimeModel?.trim();
|
||||
const backendLabel = normalizeMemberBackendLabel(
|
||||
configuredProvider,
|
||||
formatTeamProviderBackendLabel(configuredProvider, launchParams?.providerBackendId)
|
||||
);
|
||||
const memorySuffix =
|
||||
typeof runtimeEntry?.rssBytes === 'number' && runtimeEntry.rssBytes > 0
|
||||
? ` · ${formatBytes(runtimeEntry.rssBytes)}`
|
||||
|
|
@ -41,12 +61,14 @@ export function resolveMemberRuntimeSummary(
|
|||
|
||||
if (runtimeModel && (isMemberLaunchPending(spawnEntry) || configuredModel.length === 0)) {
|
||||
const runtimeProvider = inferTeamProviderIdFromModel(runtimeModel) ?? configuredProvider;
|
||||
return `${formatTeamModelSummary(runtimeProvider, runtimeModel, configuredEffort)}${memorySuffix}`;
|
||||
const summary = formatTeamModelSummary(runtimeProvider, runtimeModel, configuredEffort);
|
||||
return `${summary}${backendLabel ? ` · ${backendLabel}` : ''}${memorySuffix}`;
|
||||
}
|
||||
|
||||
if (isMemberLaunchPending(spawnEntry)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `${formatTeamModelSummary(configuredProvider, configuredModel, configuredEffort)}${memorySuffix}`;
|
||||
const summary = formatTeamModelSummary(configuredProvider, configuredModel, configuredEffort);
|
||||
return `${summary}${backendLabel ? ` · ${backendLabel}` : ''}${memorySuffix}`;
|
||||
}
|
||||
|
|
|
|||
37
src/renderer/utils/providerBackendIdentity.ts
Normal file
37
src/renderer/utils/providerBackendIdentity.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import {
|
||||
formatProviderBackendLabel,
|
||||
getDefaultProviderBackendId,
|
||||
migrateProviderBackendId,
|
||||
} from '@shared/utils/providerBackend';
|
||||
|
||||
import type { CliProviderStatus, TeamProviderId } from '@shared/types';
|
||||
|
||||
function normalizeOptionalBackendId(value: string | null | undefined): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export { formatProviderBackendLabel, getDefaultProviderBackendId };
|
||||
|
||||
export function resolveEffectiveProviderBackendId(
|
||||
provider: Pick<CliProviderStatus, 'selectedBackendId' | 'resolvedBackendId'> | null | undefined
|
||||
): string | undefined {
|
||||
return normalizeOptionalBackendId(provider?.resolvedBackendId ?? provider?.selectedBackendId);
|
||||
}
|
||||
|
||||
export function resolveUiOwnedProviderBackendId(
|
||||
providerId: TeamProviderId | CliProviderStatus['providerId'] | undefined,
|
||||
provider: Pick<CliProviderStatus, 'selectedBackendId' | 'resolvedBackendId'> | null | undefined
|
||||
): string | undefined {
|
||||
return migrateProviderBackendId(
|
||||
providerId,
|
||||
provider?.selectedBackendId ?? provider?.resolvedBackendId
|
||||
);
|
||||
}
|
||||
|
||||
export function formatTeamProviderBackendLabel(
|
||||
providerId: TeamProviderId | undefined,
|
||||
providerBackendId: string | undefined
|
||||
): string | undefined {
|
||||
return formatProviderBackendLabel(providerId, providerBackendId);
|
||||
}
|
||||
17
src/renderer/utils/refreshCliStatus.ts
Normal file
17
src/renderer/utils/refreshCliStatus.ts
Normal 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();
|
||||
}
|
||||
26
src/renderer/utils/runtimeDisplayName.ts
Normal file
26
src/renderer/utils/runtimeDisplayName.ts
Normal 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';
|
||||
}
|
||||
|
|
@ -28,9 +28,9 @@ export const TEAM_MODEL_UI_DISABLED_BADGE_LABEL = 'Disabled';
|
|||
export const GPT_5_1_CODEX_MINI_UI_DISABLED_REASON =
|
||||
'Temporarily disabled for team agents - this model has been less reliable with task and reply tool contracts.';
|
||||
export const GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON =
|
||||
'Temporarily disabled for team agents when using Codex ChatGPT subscription - this model has been observed returning "Not available with Codex ChatGPT subscription".';
|
||||
'Temporarily disabled for team agents - this model is not currently available on the Codex native runtime.';
|
||||
export const GPT_5_2_CODEX_UI_DISABLED_REASON =
|
||||
'Temporarily disabled for team agents - this model has been observed returning "Not available with Codex ChatGPT subscription".';
|
||||
'Temporarily disabled for team agents - this model is not currently available on the Codex native runtime.';
|
||||
export const GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON =
|
||||
'Temporarily disabled for team agents - this model has been less reliable with bootstrap, task, and reply tool contracts.';
|
||||
|
||||
|
|
@ -327,11 +327,9 @@ export function isCodexChatGptSubscriptionProviderStatus(
|
|||
return false;
|
||||
}
|
||||
|
||||
const endpointLabel = providerStatus.backend?.endpointLabel?.toLowerCase() ?? '';
|
||||
return (
|
||||
providerStatus.authMethod === 'oauth_token' &&
|
||||
(providerStatus.backend?.kind === 'adapter' ||
|
||||
endpointLabel.includes('chatgpt.com/backend-api/codex/responses'))
|
||||
providerStatus.authMethod === 'chatgpt' ||
|
||||
providerStatus.backend?.authMethodDetail === 'chatgpt'
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -318,7 +318,7 @@ export function buildTeamProvisioningPresentation({
|
|||
panelMessage:
|
||||
failedSpawnCount > 0 ? (failedSpawnPanelMessage ?? progress.message) : progress.message,
|
||||
panelMessageSeverity: failedSpawnCount > 0 ? 'warning' : progress.messageSeverity,
|
||||
defaultLiveOutputOpen: true,
|
||||
defaultLiveOutputOpen: false,
|
||||
compactTitle: 'Launching team',
|
||||
compactDetail:
|
||||
failedSpawnCount > 0
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ import type {
|
|||
SessionsPaginationOptions,
|
||||
SubagentDetail,
|
||||
} from '@main/types';
|
||||
import type { CodexAccountElectronApi } from '@features/codex-account/contracts';
|
||||
|
||||
// =============================================================================
|
||||
// Cost Calculation Types
|
||||
|
|
@ -732,7 +733,7 @@ export interface ReviewAPI {
|
|||
/**
|
||||
* 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>;
|
||||
getProjects: () => Promise<Project[]>;
|
||||
getSessions: (projectId: string) => Promise<Session[]>;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,16 @@
|
|||
* 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
|
||||
// =============================================================================
|
||||
|
|
@ -24,18 +34,31 @@ export type CliPlatform =
|
|||
export type CliFlavor = 'claude' | 'agent_teams_orchestrator';
|
||||
|
||||
export type CliProviderId = 'anthropic' | 'codex' | 'gemini';
|
||||
export type CliProviderAuthMode = 'auto' | 'oauth' | 'api_key';
|
||||
export type CliProviderAuthMode = 'auto' | 'oauth' | 'chatgpt' | 'api_key';
|
||||
|
||||
export interface CliProviderConnectionInfo {
|
||||
supportsOAuth: boolean;
|
||||
supportsApiKey: boolean;
|
||||
configurableAuthModes: CliProviderAuthMode[];
|
||||
configuredAuthMode: CliProviderAuthMode | null;
|
||||
apiKeyBetaAvailable?: boolean;
|
||||
apiKeyBetaEnabled?: boolean;
|
||||
apiKeyConfigured: boolean;
|
||||
apiKeySource: 'stored' | 'environment' | 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 {
|
||||
|
|
@ -45,6 +68,14 @@ export interface CliProviderBackendOption {
|
|||
selectable: boolean;
|
||||
recommended: boolean;
|
||||
available: boolean;
|
||||
state?:
|
||||
| 'ready'
|
||||
| 'locked'
|
||||
| 'disabled'
|
||||
| 'authentication-required'
|
||||
| 'runtime-missing'
|
||||
| 'degraded';
|
||||
audience?: 'general' | 'internal';
|
||||
statusMessage?: string | null;
|
||||
detailMessage?: string | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -329,15 +329,14 @@ export interface AppConfig {
|
|||
authMode: 'auto' | 'oauth' | 'api_key';
|
||||
};
|
||||
codex: {
|
||||
apiKeyBetaEnabled: boolean;
|
||||
authMode: 'oauth' | 'api_key';
|
||||
preferredAuthMode: 'auto' | 'chatgpt' | 'api_key';
|
||||
};
|
||||
};
|
||||
/** Runtime backend preferences for app-launched agent_teams_orchestrator sessions */
|
||||
runtime: {
|
||||
providerBackends: {
|
||||
gemini: 'auto' | 'api' | 'cli-sdk';
|
||||
codex: 'auto' | 'adapter';
|
||||
codex: 'codex-native';
|
||||
};
|
||||
};
|
||||
/** Display and UI settings */
|
||||
|
|
|
|||
|
|
@ -784,12 +784,14 @@ export interface TeamViewSnapshot {
|
|||
|
||||
export type EffortLevel = 'low' | 'medium' | 'high';
|
||||
export type TeamProviderId = 'anthropic' | 'codex' | 'gemini';
|
||||
export type TeamProviderBackendId = string;
|
||||
|
||||
export interface TeamLaunchRequest {
|
||||
teamName: string;
|
||||
cwd: string;
|
||||
prompt?: string;
|
||||
providerId?: TeamProviderId;
|
||||
providerBackendId?: TeamProviderBackendId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
/** When true, context window is limited to 200K tokens instead of the default. */
|
||||
|
|
@ -928,6 +930,7 @@ export interface TeamAgentRuntimeSnapshot {
|
|||
teamName: string;
|
||||
updatedAt: string;
|
||||
runId: string | null;
|
||||
providerBackendId?: TeamProviderBackendId;
|
||||
members: Record<string, TeamAgentRuntimeEntry>;
|
||||
}
|
||||
|
||||
|
|
@ -1037,6 +1040,7 @@ export interface TeamCreateRequest {
|
|||
cwd: string;
|
||||
prompt?: string;
|
||||
providerId?: TeamProviderId;
|
||||
providerBackendId?: TeamProviderBackendId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
/** When true, context window is limited to 200K tokens instead of the default. */
|
||||
|
|
@ -1056,6 +1060,7 @@ export interface TeamCreateConfigRequest {
|
|||
color?: string;
|
||||
members: TeamProvisioningMemberInput[];
|
||||
cwd?: string;
|
||||
providerBackendId?: TeamProviderBackendId;
|
||||
}
|
||||
|
||||
export interface TeamCreateResponse {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue