merge: dev -> main Merge pull request #81 from 777genius/dev

dev -> main
This commit is contained in:
Илия 2026-04-20 22:37:01 +03:00 committed by GitHub
commit ccfa0b7c20
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
263 changed files with 38403 additions and 2591 deletions

File diff suppressed because it is too large Load diff

View file

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View 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.

View 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.

View 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.

View file

@ -301,7 +301,7 @@
}
]
},
"packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501",
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319",
"pnpm": {
"onlyBuiltDependencies": [
"electron",

View file

@ -32,6 +32,7 @@ export interface GraphConfigPort {
};
// ─── Filters (show/hide node kinds) ────────────────────────────────────
showActivity?: boolean;
showTasks?: boolean;
showProcesses?: boolean;
showCompletedTasks?: boolean;

View file

@ -6,6 +6,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import * as Tooltip from '@radix-ui/react-tooltip';
import {
Activity,
Columns3,
Expand,
Settings2,
@ -26,6 +27,7 @@ import {
} from 'lucide-react';
export interface GraphFilterState {
showActivity: boolean;
showTasks: boolean;
showProcesses: boolean;
showEdges: boolean;
@ -219,6 +221,13 @@ export function GraphControls({
border: '1px solid rgba(100, 200, 255, 0.12)',
}}
>
<ToolbarToggle
active={filters.showActivity}
onClick={() => toggle('showActivity')}
icon={<Activity size={13} />}
label="Activity"
block
/>
<ToolbarToggle
active={filters.showTasks}
onClick={() => toggle('showTasks')}

View file

@ -70,6 +70,7 @@ export interface GraphViewProps {
onSelectNode: (nodeId: string) => void;
}) => React.ReactNode;
renderHud?: (props: {
filters: GraphFilterState;
getLaunchAnchorScreenPlacement: (
leadNodeId: string,
) => { x: number; y: number; scale: number; visible: boolean } | null;
@ -112,6 +113,7 @@ export function GraphView({
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
const [interactionLocked, setInteractionLocked] = useState(false);
const [filters, setFilters] = useState<GraphFilterState>({
showActivity: config?.showActivity ?? true,
showTasks: config?.showTasks ?? true,
showProcesses: config?.showProcesses ?? true,
showEdges: true,
@ -1016,6 +1018,7 @@ export function GraphView({
{renderHud ? (
<div className="pointer-events-none absolute inset-0 z-[5] overflow-hidden">
{renderHud({
filters,
getLaunchAnchorScreenPlacement,
getActivityWorldRect,
getTransientHandoffSnapshot,

View file

@ -1,27 +1,27 @@
{
"version": "0.0.3",
"sourceRef": "v0.0.3",
"version": "0.0.4",
"sourceRef": "v0.0.4",
"sourceRepository": "777genius/agent_teams_orchestrator",
"releaseRepository": "777genius/claude_agent_teams_ui",
"releaseTag": "v1.2.0",
"assets": {
"darwin-arm64": {
"file": "agent-teams-runtime-darwin-arm64-v0.0.3.tar.gz",
"file": "agent-teams-runtime-darwin-arm64-v0.0.4.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"darwin-x64": {
"file": "agent-teams-runtime-darwin-x64-v0.0.3.tar.gz",
"file": "agent-teams-runtime-darwin-x64-v0.0.4.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"linux-x64": {
"file": "agent-teams-runtime-linux-x64-v0.0.3.tar.gz",
"file": "agent-teams-runtime-linux-x64-v0.0.4.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"win32-x64": {
"file": "agent-teams-runtime-win32-x64-v0.0.3.zip",
"file": "agent-teams-runtime-win32-x64-v0.0.4.zip",
"archiveKind": "zip",
"binaryName": "claude-multimodel.exe"
}

View file

@ -11,8 +11,10 @@
import { getUnreadCount } from '@renderer/services/commentReadStorage';
import {
agentAvatarUrl,
buildMemberAvatarMap,
buildMemberLaunchPresentation,
getMemberRuntimeAdvisoryLabel,
resolveMemberAvatarUrl,
} from '@renderer/utils/memberHelpers';
import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
import { formatTeamRuntimeSummary } from '@renderer/utils/teamRuntimeSummary';
@ -143,6 +145,7 @@ export class TeamGraphAdapter {
const leadId = `lead:${teamName}`;
const leadName = TeamGraphAdapter.#getLeadMemberName(teamData, teamName);
const memberNodeIdByAlias = TeamGraphAdapter.#buildMemberNodeIdByAlias(teamData, teamName);
const avatarMap = buildMemberAvatarMap(teamData.members);
const provisioningPresentation = buildTeamProvisioningPresentation({
progress: provisioningProgress,
members: teamData.members,
@ -158,6 +161,7 @@ export class TeamGraphAdapter {
teamData,
teamName,
leadName,
avatarMap,
pendingApprovalAgents,
leadActivity,
leadContext,
@ -173,6 +177,7 @@ export class TeamGraphAdapter {
teamData,
teamName,
memberNodeIdByAlias,
avatarMap,
spawnStatuses,
pendingApprovalAgents,
activeTools,
@ -369,6 +374,7 @@ export class TeamGraphAdapter {
data: TeamGraphData,
teamName: string,
leadName: string,
avatarMap: ReadonlyMap<string, string>,
pendingApprovalAgents?: Set<string>,
leadActivity?: LeadActivityState,
leadContext?: LeadContextUsage,
@ -428,7 +434,9 @@ export class TeamGraphAdapter {
launchVisualState: leadLaunchPresentation?.launchVisualState ?? undefined,
launchStatusLabel: leadLaunchPresentation?.launchStatusLabel ?? undefined,
contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined,
avatarUrl: agentAvatarUrl(leadName, 64),
avatarUrl: leadMember
? resolveMemberAvatarUrl(leadMember, avatarMap, 64)
: agentAvatarUrl(leadName, 64),
pendingApproval,
activeTool: activeTool
? {
@ -465,6 +473,7 @@ export class TeamGraphAdapter {
data: TeamGraphData,
teamName: string,
memberNodeIdByAlias: ReadonlyMap<string, string>,
avatarMap: ReadonlyMap<string, string>,
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
pendingApprovalAgents?: Set<string>,
activeTools?: Record<string, Record<string, ActiveToolCall>>,
@ -520,7 +529,7 @@ export class TeamGraphAdapter {
spawnStatus: spawn?.status,
launchVisualState: launchPresentation.launchVisualState ?? undefined,
launchStatusLabel: launchPresentation.launchStatusLabel ?? undefined,
avatarUrl: agentAvatarUrl(member.name, 64),
avatarUrl: resolveMemberAvatarUrl(member, avatarMap, 64),
currentTaskId: member.currentTaskId ?? undefined,
currentTaskSubject: member.currentTaskId
? data.tasks.find((t) => t.id === member.currentTaskId)?.subject

View file

@ -4,9 +4,15 @@
* composes project-specific UI, selectors, and presentation helpers.
*/
import { useMemo } from 'react';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { agentAvatarUrl, buildMemberLaunchPresentation } from '@renderer/utils/memberHelpers';
import {
agentAvatarUrl,
buildMemberAvatarMap,
buildMemberLaunchPresentation,
} from '@renderer/utils/memberHelpers';
import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
import { ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react';
@ -291,7 +297,6 @@ const MemberPopoverContent = ({
node.domainRef.kind === 'member' || node.domainRef.kind === 'lead'
? node.domainRef.teamName
: '';
const avatarSrc = node.avatarUrl ?? agentAvatarUrl(memberName, 64);
const {
teamData,
teamMembers,
@ -301,6 +306,8 @@ const MemberPopoverContent = ({
memberSpawnSnapshot,
memberSpawnStatuses,
} = useGraphMemberPopoverContext(teamName, memberName);
const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
const avatarSrc = node.avatarUrl ?? avatarMap.get(memberName) ?? agentAvatarUrl(memberName, 64);
const member = teamMembers.find((candidate) => candidate.name === memberName) ?? null;
const provisioningPresentation =
teamData && teamName

View file

@ -152,7 +152,7 @@ export const TeamGraphOverlay = ({
getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null;
focusEdgeIds?: ReadonlySet<string> | null;
};
const { getViewportSize, focusNodeIds } = extraHudProps;
const { getViewportSize, focusNodeIds, filters } = extraHudProps;
return (
<>
@ -174,6 +174,7 @@ export const TeamGraphOverlay = ({
getNodeWorldPosition={extraHudProps.getNodeWorldPosition}
getViewportSize={getViewportSize}
focusNodeIds={focusNodeIds}
enabled={filters?.showActivity ?? true}
onOpenTaskDetail={onOpenTaskDetail}
onOpenMemberProfile={onOpenMemberProfile}
/>

View file

@ -176,7 +176,7 @@ export const TeamGraphTab = ({
getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null;
focusEdgeIds?: ReadonlySet<string> | null;
};
const { getViewportSize, focusNodeIds } = extraHudProps;
const { getViewportSize, focusNodeIds, filters } = extraHudProps;
return (
<>
@ -199,7 +199,7 @@ export const TeamGraphTab = ({
getNodeWorldPosition={extraHudProps.getNodeWorldPosition}
getViewportSize={getViewportSize}
focusNodeIds={focusNodeIds}
enabled={isActive}
enabled={isActive && (filters?.showActivity ?? true)}
onOpenTaskDetail={dispatchOpenTask}
onOpenMemberProfile={dispatchOpenProfile}
/>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -9,12 +9,14 @@ import { ClaudeRecentProjectsSourceAdapter } from '../adapters/output/sources/Cl
import { CodexRecentProjectsSourceAdapter } from '../adapters/output/sources/CodexRecentProjectsSourceAdapter';
import { 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 {

View file

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

View file

@ -1,6 +1,7 @@
import { validateTeamName } from '@main/ipc/guards';
import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger';
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
import { isAbsolute } from 'path';
import type { HttpServices } from './index';
@ -100,6 +101,13 @@ function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest
throw new HttpBadRequestError('providerId must be anthropic, codex, or gemini');
})();
const prompt = assertOptionalString(payload.prompt, 'prompt');
const rawProviderBackendId = assertOptionalString(payload.providerBackendId, 'providerBackendId');
const providerBackendId = migrateProviderBackendId(providerId, rawProviderBackendId);
if (rawProviderBackendId && !providerBackendId) {
throw new HttpBadRequestError(
'providerBackendId must be one of auto, adapter, api, cli-sdk, or codex-native'
);
}
const model = assertOptionalString(payload.model, 'model');
const effort = assertOptionalEffort(payload.effort);
const clearContext = assertOptionalBoolean(payload.clearContext, 'clearContext');
@ -111,6 +119,9 @@ function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest
teamName,
cwd: assertAbsoluteCwd(payload.cwd),
providerId,
...(providerBackendId && {
providerBackendId,
}),
...(prompt && {
prompt,
}),

View file

@ -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,
@ -111,6 +118,7 @@ import {
} from './utils/safeWebContentsSend';
import { syncTelemetryFlag } from './sentry';
import {
ActiveTeamRegistry,
BoardTaskActivityDetailService,
BoardTaskActivityRecordSource,
BoardTaskActivityService,
@ -130,6 +138,11 @@ import {
TaskBoundaryParser,
TeamDataService,
TeamLogSourceTracker,
TeamTaskStallJournal,
TeamTaskStallMonitor,
TeamTaskStallNotifier,
TeamTaskStallPolicy,
TeamTaskStallSnapshotSource,
TeammateToolTracker,
TeamMemberLogsFinder,
TeamProvisioningService,
@ -408,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;
@ -415,6 +429,7 @@ let cliInstallerService: CliInstallerService;
let ptyTerminalService: PtyTerminalService;
let httpServer: HttpServer;
let schedulerService: SchedulerService;
let teamTaskStallMonitor: TeamTaskStallMonitor | null = null;
let skillsWatcherService: SkillsWatcherService | null = null;
let teamBackupService: TeamBackupService | null = null;
let branchStatusService: BranchStatusService | null = null;
@ -848,6 +863,13 @@ async function initializeServices(): Promise<void> {
const taskChangePresenceRepository = new JsonTaskChangePresenceRepository();
const teamLogSourceTracker = new TeamLogSourceTracker(teamMemberLogsFinder);
teamTaskStallMonitor = new TeamTaskStallMonitor(
new ActiveTeamRegistry(teamDataService, teamLogSourceTracker),
new TeamTaskStallSnapshotSource(),
new TeamTaskStallPolicy(),
new TeamTaskStallJournal(),
new TeamTaskStallNotifier(teamDataService)
);
let teammateToolTracker: TeammateToolTracker | null = null;
branchStatusService = new BranchStatusService((event) => {
safeSendToRenderer(mainWindow, TEAM_PROJECT_BRANCH_CHANGE, event);
@ -930,6 +952,7 @@ async function initializeServices(): Promise<void> {
// Allow TeamProvisioningService to trigger team refresh events (e.g. live lead replies).
const teamChangeEmitter = (event: TeamChangeEvent): void => {
forwardTeamChange(event);
teamTaskStallMonitor?.noteTeamChange(event);
if (event.type === 'lead-activity' && event.detail === 'offline') {
teammateToolTracker?.handleTeamOffline(event.teamName);
}
@ -939,6 +962,7 @@ async function initializeServices(): Promise<void> {
teamLogSourceTracker.onLogSourceChange((teamName) => {
teammateToolTracker?.handleLogSourceChange(teamName);
});
teamTaskStallMonitor.start();
// Allow SchedulerService to push schedule events to renderer
schedulerService.setChangeEmitter((event) => {
@ -959,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.
@ -1013,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
@ -1142,6 +1172,10 @@ function shutdownServices(): void {
if (teamDataService) {
teamDataService.stopProcessHealthPolling();
}
if (teamTaskStallMonitor) {
void teamTaskStallMonitor.stop();
teamTaskStallMonitor = null;
}
branchStatusService?.dispose();
branchStatusService = null;
@ -1151,6 +1185,9 @@ function shutdownServices(): void {
}
void skillsWatcherService?.stopAll();
providerConnectionService.setCodexAccountFeature(null);
void codexAccountFeature?.dispose();
codexAccountFeature = null;
// Kill all PTY processes
if (ptyTerminalService) {
@ -1159,6 +1196,7 @@ function shutdownServices(): void {
// Remove IPC handlers
removeIpcHandlers();
removeCodexAccountIpc(ipcMain);
removeRecentProjectsIpc(ipcMain);
// Dispose backup service timers
@ -1438,6 +1476,7 @@ function createWindow(): void {
if (teamProvisioningService) {
teamProvisioningService.setMainWindow(null);
}
codexAccountFeature?.setMainWindow(null);
setEditorMainWindow(null);
setReviewMainWindow(null);
cleanupEditorState();
@ -1472,6 +1511,7 @@ function createWindow(): void {
if (teamProvisioningService) {
teamProvisioningService.setMainWindow(mainWindow);
}
codexAccountFeature?.setMainWindow(mainWindow);
setEditorMainWindow(mainWindow);
setReviewMainWindow(mainWindow);

View file

@ -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;
}

View file

@ -91,6 +91,7 @@ import {
PROTECTED_CLI_FLAGS,
} from '@shared/utils/cliArgsParser';
import { createLogger } from '@shared/utils/logger';
import { isTeamProviderBackendId, migrateProviderBackendId } from '@shared/utils/providerBackend';
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
import {
buildStandaloneSlashCommandMeta,
@ -185,6 +186,8 @@ import type {
TeamLaunchResponse,
TeamMemberActivityMeta,
TeamMessageNotificationData,
TeamProviderBackendId,
TeamProviderId,
TeamProvisioningPrepareResult,
TeamProvisioningProgress,
TeamSummary,
@ -1074,6 +1077,9 @@ async function handleUpdateConfig(
}
return wrapTeamHandler('updateConfig', async () => {
const tn = validated.value!;
const teamDataService = getTeamDataService();
const previousDisplayName = await teamDataService.getTeamDisplayName(tn).catch(() => tn);
const requestedName = typeof name === 'string' ? name.trim() : '';
const result = await getTeamDataService().updateConfig(tn, {
name,
description,
@ -1084,10 +1090,10 @@ async function handleUpdateConfig(
}
// Notify running lead about the rename so it stays aware of current team name
if (typeof name === 'string' && name.trim()) {
if (requestedName && requestedName !== (previousDisplayName?.trim() || tn)) {
const provisioning = getTeamProvisioningService();
if (provisioning.isTeamAlive(tn)) {
const msg = `The team has been renamed to "${name.trim()}". Please use this name when referring to the team going forward.`;
const msg = `The team has been renamed to "${requestedName}". Please use this name when referring to the team going forward.`;
try {
await provisioning.sendMessageToTeam(tn, msg);
} catch {
@ -1126,6 +1132,38 @@ function parseOptionalMemberProviderId(
return { valid: false, error: 'member providerId must be anthropic, codex, or gemini' };
}
function parseOptionalProviderBackendId(
value: unknown,
providerId?: TeamProviderId
): { valid: true; value: TeamProviderBackendId | 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)' };
}
if (providerId) {
const migratedBackendId = migrateProviderBackendId(providerId, trimmed);
if (migratedBackendId) {
return { valid: true, value: migratedBackendId };
}
} else if (isTeamProviderBackendId(trimmed)) {
return { valid: true, value: trimmed };
}
return {
valid: false,
error: 'providerBackendId must be one of auto, adapter, api, cli-sdk, or codex-native',
};
}
function parseOptionalMemberEffort(
value: unknown
): { valid: true; value: EffortLevel | undefined } | { valid: false; error: string } {
@ -1219,6 +1257,19 @@ async function validateProvisioningRequest(
if (payload.prompt !== undefined && typeof payload.prompt !== 'string') {
return { valid: false, error: 'prompt must be a string' };
}
const providerId =
payload.providerId === 'codex'
? 'codex'
: payload.providerId === 'gemini'
? 'gemini'
: 'anthropic';
const providerBackendValidation = parseOptionalProviderBackendId(
payload.providerBackendId,
providerId
);
if (!providerBackendValidation.valid) {
return { valid: false, error: providerBackendValidation.error };
}
try {
await fs.promises.mkdir(cwd, { recursive: true });
@ -1270,12 +1321,8 @@ async function validateProvisioningRequest(
members,
cwd,
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
providerId:
payload.providerId === 'codex'
? 'codex'
: payload.providerId === 'gemini'
? 'gemini'
: 'anthropic',
providerId,
providerBackendId: providerBackendValidation.value,
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
skipPermissions:
@ -1385,6 +1432,19 @@ async function handleLaunchTeam(
if (payload.model !== undefined && typeof payload.model !== 'string') {
return { success: false, error: 'model must be a string' };
}
const providerId =
payload.providerId === 'codex'
? 'codex'
: payload.providerId === 'gemini'
? 'gemini'
: 'anthropic';
const providerBackendValidation = parseOptionalProviderBackendId(
payload.providerBackendId,
providerId
);
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),
@ -1403,7 +1463,17 @@ 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 =
providerId === 'codex' || providerId === 'gemini'
? providerId
: meta?.providerId === 'codex'
? 'codex'
: meta?.providerId === 'gemini'
? 'gemini'
: 'anthropic';
const createRequest: TeamCreateRequest = {
teamName: tn,
@ -1412,16 +1482,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,
@ -1462,12 +1527,8 @@ async function handleLaunchTeam(
teamName: validatedTeamName.value!,
cwd,
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
providerId:
payload.providerId === 'codex'
? 'codex'
: payload.providerId === 'gemini'
? 'gemini'
: 'anthropic',
providerId,
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,
@ -2552,6 +2613,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'] = [];
@ -2609,6 +2674,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,
})
);
}
@ -3884,7 +3950,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,
@ -3895,7 +3964,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,

View file

@ -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}`
);

View file

@ -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> {

View file

@ -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]>;
}

View file

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

View file

@ -3,10 +3,9 @@ import readline from 'node:readline';
import { killProcessTree, spawnCli } from '@main/utils/childProcess';
import 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,
};
}
}

View file

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

View file

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

View file

@ -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,
})) ?? [],

View file

@ -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 {

View file

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

View file

@ -1,14 +1,7 @@
import { buildEnrichedEnv } from '@main/utils/cliEnv';
import { getCachedShellEnv, 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: [],
};
}

View file

@ -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';

View file

@ -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

View file

@ -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',

View file

@ -12,9 +12,10 @@ import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSema
import { isLeadMember } from '@shared/utils/leadDetection';
import { createLogger } from '@shared/utils/logger';
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
import { parseNumericSuffixName, validateTeamMemberNameFormat } from '@shared/utils/teamMemberName';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary';
import * as agentTeamsControllerModule from 'agent-teams-controller';
@ -130,6 +131,16 @@ interface FileWatchReconcileDiagnostics {
lastPressureLogAt: number;
}
function applyDistinctRosterColors<T extends { name: string; color?: string; removedAt?: number }>(
members: readonly T[]
): T[] {
const colorMap = buildTeamMemberColorMap(members, { preferProvidedColors: false });
return members.map((member) => ({
...member,
color: colorMap.get(member.name) ?? member.color ?? getMemberColorByName(member.name),
}));
}
function normalizePassiveUserReplyLinkText(value: string | undefined): string {
if (typeof value !== 'string') return '';
return value
@ -500,6 +511,27 @@ export class TeamDataService {
return this.configReader.listTeams();
}
async listAliveProcessTeams(): Promise<string[]> {
const teams = await this.listTeams();
const alive: string[] = [];
for (const team of teams) {
if (team.deletedAt) {
continue;
}
try {
const processes = await this.readProcesses(team.teamName);
if (processes.some((process) => !process.stoppedAt)) {
alive.push(team.teamName);
}
} catch {
// best-effort per team
}
}
return alive.sort((left, right) => left.localeCompare(right));
}
async getAllTasks(): Promise<GlobalTask[]> {
const rawTasks = await this.taskReader.getAllTasks();
const teams = await this.configReader.listTeams();
@ -1161,7 +1193,7 @@ export class TeamDataService {
role: configMember.role,
workflow: configMember.workflow,
agentType: configMember.agentType ?? 'general-purpose',
color: configMember.color ?? getMemberColorByName(configMember.name.trim()),
color: configMember.color,
joinedAt: configMember.joinedAt ?? Date.now(),
cwd: configMember.cwd,
};
@ -1176,13 +1208,13 @@ export class TeamDataService {
member = {
name: memberName,
agentType: 'general-purpose',
color: getMemberColorByName(memberName),
joinedAt: Date.now(),
};
}
members.push(member);
await this.membersMetaStore.writeMembers(teamName, members);
const nextMembers = applyDistinctRosterColors([...members, member]);
member = nextMembers.find((m) => m.name === memberName) ?? member;
await this.membersMetaStore.writeMembers(teamName, nextMembers);
}
return { members, member };
@ -1193,6 +1225,13 @@ export class TeamDataService {
if (!name) {
throw new Error('Member name cannot be empty');
}
const formatError = validateTeamMemberNameFormat(name);
if (formatError) {
throw new Error(`Member name "${name}" is invalid: ${formatError}`);
}
if (name.toLowerCase() === 'user') {
throw new Error('Member name "user" is reserved');
}
const suffixInfo = parseNumericSuffixName(name);
if (suffixInfo && suffixInfo.suffix >= 2) {
throw new Error(
@ -1224,12 +1263,11 @@ export class TeamDataService {
? request.effort
: undefined,
agentType: 'general-purpose',
color: getMemberColorByName(name),
joinedAt: Date.now(),
};
members.push(newMember);
await this.membersMetaStore.writeMembers(teamName, members);
const nextMembers = applyDistinctRosterColors([...members, newMember]);
await this.membersMetaStore.writeMembers(teamName, nextMembers);
}
async updateMemberRole(
@ -1269,36 +1307,50 @@ export class TeamDataService {
const joinedAt = Date.now();
const nextByName = new Set<string>();
const nextActive: TeamMember[] = request.members.map((member) => {
const name = member.name.trim();
if (!name) throw new Error('Member name cannot be empty');
if (name.toLowerCase() === 'team-lead') {
throw new Error('Member name "team-lead" is reserved');
}
const suffixInfo = parseNumericSuffixName(name);
if (suffixInfo && suffixInfo.suffix >= 2) {
throw new Error(
`Member name "${name}" is not allowed (reserved for Claude CLI auto-suffix). Use "${suffixInfo.base}" instead.`
);
}
nextByName.add(name.toLowerCase());
const prev = existingByName.get(name.toLowerCase());
return {
name,
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: prev?.agentType ?? 'general-purpose',
color: prev?.color ?? getMemberColorByName(name),
joinedAt: prev?.joinedAt ?? joinedAt,
removedAt: undefined,
};
});
const nextActive = applyDistinctRosterColors(
request.members.map((member) => {
const name = member.name.trim();
if (!name) throw new Error('Member name cannot be empty');
const formatError = validateTeamMemberNameFormat(name);
if (formatError) {
throw new Error(`Member name "${name}" is invalid: ${formatError}`);
}
if (name.toLowerCase() === 'user') {
throw new Error('Member name "user" is reserved');
}
if (name.toLowerCase() === 'team-lead') {
throw new Error('Member name "team-lead" is reserved');
}
if (nextByName.has(name.toLowerCase())) {
throw new Error(`Member "${name}" already exists`);
}
const suffixInfo = parseNumericSuffixName(name);
if (suffixInfo && suffixInfo.suffix >= 2) {
throw new Error(
`Member name "${name}" is not allowed (reserved for Claude CLI auto-suffix). Use "${suffixInfo.base}" instead.`
);
}
nextByName.add(name.toLowerCase());
const prev = existingByName.get(name.toLowerCase());
const isSameActiveMember = Boolean(prev && prev.removedAt == null);
return {
name,
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: prev?.agentType ?? 'general-purpose',
agentId: isSameActiveMember ? prev?.agentId : undefined,
color: prev?.color,
joinedAt: prev?.joinedAt ?? joinedAt,
removedAt: undefined,
};
})
);
// Preserve/mark removed members so stale inbox files don't resurrect them in the UI.
const nextRemoved: TeamMember[] = [];
@ -1712,6 +1764,23 @@ export class TeamDataService {
return result;
}
async sendSystemNotificationToLead(args: {
teamName: string;
summary: string;
text: string;
taskRefs?: TaskRef[];
}): Promise<SendMessageResult> {
const leadName = await this.resolveLeadName(args.teamName);
return this.sendMessage(args.teamName, {
member: leadName,
from: 'system',
summary: args.summary,
text: args.text,
...(args.taskRefs && args.taskRefs.length > 0 ? { taskRefs: args.taskRefs } : {}),
source: TASK_COMMENT_NOTIFICATION_SOURCE,
});
}
private resolveLeadNameFromConfig(config: TeamConfig | null): string {
if (!config) return 'team-lead';
const lead = config.members?.find((m) => m.role?.toLowerCase().includes('lead'));
@ -2319,15 +2388,22 @@ export class TeamDataService {
description: request.description,
color: request.color,
cwd: request.cwd?.trim() || '',
providerBackendId: request.providerBackendId,
createdAt: joinedAt,
});
await this.membersMetaStore.writeMembers(
request.teamName,
const membersToWrite = applyDistinctRosterColors(
request.members.map((member) => ({
name: (() => {
const name = member.name.trim();
if (!name) throw new Error('Member name cannot be empty');
const formatError = validateTeamMemberNameFormat(name);
if (formatError) {
throw new Error(`Member name "${name}" is invalid: ${formatError}`);
}
if (name.toLowerCase() === 'user') {
throw new Error('Member name "user" is reserved');
}
if (name.toLowerCase() === 'team-lead')
throw new Error('Member name "team-lead" is reserved');
const suffixInfo = parseNumericSuffixName(name);
@ -2346,11 +2422,13 @@ export class TeamDataService {
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
? member.effort
: undefined,
agentType: 'general-purpose',
color: getMemberColorByName(member.name.trim()),
agentType: 'general-purpose' as const,
joinedAt,
}))
);
await this.membersMetaStore.writeMembers(request.teamName, membersToWrite, {
providerBackendId: request.providerBackendId,
});
}
async reconcileTeamArtifacts(

View file

@ -22,7 +22,11 @@ interface TeamLogSourceSnapshot {
logSourceGeneration: string | null;
}
export type TeamLogSourceTrackingConsumer = 'change_presence' | 'tool_activity' | 'task_log_stream';
export type TeamLogSourceTrackingConsumer =
| 'change_presence'
| 'tool_activity'
| 'task_log_stream'
| 'stall_monitor';
interface TrackingState {
watcher: FSWatcher | null;

View file

@ -3,6 +3,7 @@ import {
createCliAutoSuffixNameGuard,
createCliProvisionerNameGuard,
} from '@shared/utils/teamMemberName';
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
import type { TeamConfig, TeamMember, TeamMemberSnapshot, TeamTaskWithKanban } from '@shared/types';
@ -262,6 +263,11 @@ export class TeamMemberResolver {
}
return aStableId.localeCompare(bStableId);
});
return members;
const colorMap = buildTeamMemberColorMap(members, { preferProvidedColors: false });
return members.map((member) => ({
...member,
color: colorMap.get(member.name) ?? member.color ?? getMemberColorByName(member.name),
}));
}
}

View file

@ -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)),
};

View file

@ -1,4 +1,5 @@
import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics';
import { createLogger } from '@shared/utils/logger';
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
import { createHash } from 'crypto';
@ -7,6 +8,8 @@ import { getEffectiveInboxMessageId } from './inboxMessageIdentity';
import type { InboxMessage, TeamConfig } from '@shared/types';
const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000;
const MESSAGE_FEED_CACHE_MAX_AGE_MS = 5_000;
const logger = createLogger('Service:TeamMessageFeedService');
interface TeamMessageFeedDeps {
getConfig: (teamName: string) => Promise<TeamConfig | null>;
@ -18,6 +21,7 @@ interface TeamMessageFeedDeps {
interface TeamMessageFeedCacheEntry {
feedRevision: string;
messages: InboxMessage[];
cachedAt: number;
}
export interface TeamNormalizedMessageFeed {
@ -352,7 +356,10 @@ export class TeamMessageFeedService {
async getFeed(teamName: string): Promise<TeamNormalizedMessageFeed> {
const cached = this.cacheByTeam.get(teamName);
if (cached && !this.dirtyTeams.has(teamName)) {
const now = Date.now();
const cacheDirty = this.dirtyTeams.has(teamName);
const cacheExpired = !cached || now - cached.cachedAt >= MESSAGE_FEED_CACHE_MAX_AGE_MS;
if (cached && !cacheDirty && !cacheExpired) {
return {
teamName,
feedRevision: cached.feedRevision,
@ -362,7 +369,7 @@ export class TeamMessageFeedService {
const config = await this.deps.getConfig(teamName);
if (!config) {
const emptyEntry = { feedRevision: toFeedRevision([]), messages: [] };
const emptyEntry = { feedRevision: toFeedRevision([]), messages: [], cachedAt: now };
this.cacheByTeam.set(teamName, emptyEntry);
this.dirtyTeams.delete(teamName);
return { teamName, ...emptyEntry };
@ -389,12 +396,21 @@ export class TeamMessageFeedService {
});
const feedRevision = toFeedRevision(messages);
if (cached && !cacheDirty && cacheExpired && cached.feedRevision !== feedRevision) {
logger.warn(
`[${teamName}] Message feed cache expired without dirty invalidation and recovered newer durable messages`
);
}
const nextEntry =
cached?.feedRevision === feedRevision
? cached
? {
...cached,
cachedAt: now,
}
: {
feedRevision,
messages,
cachedAt: now,
};
this.cacheByTeam.set(teamName, nextEntry);

View file

@ -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,

File diff suppressed because it is too large Load diff

View file

@ -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,

View file

@ -39,3 +39,12 @@ export { TeamSentMessagesStore } from './TeamSentMessagesStore';
export { TeamTaskReader } from './TeamTaskReader';
export { TeamTaskWriter } from './TeamTaskWriter';
export { countLineChanges } from './UnifiedLineCounter';
export { ActiveTeamRegistry } from './stallMonitor/ActiveTeamRegistry';
export { BoardTaskActivityBatchIndexer } from './stallMonitor/BoardTaskActivityBatchIndexer';
export { TeamTaskLogFreshnessReader } from './stallMonitor/TeamTaskLogFreshnessReader';
export { TeamTaskStallExactRowReader } from './stallMonitor/TeamTaskStallExactRowReader';
export { TeamTaskStallJournal } from './stallMonitor/TeamTaskStallJournal';
export { TeamTaskStallMonitor } from './stallMonitor/TeamTaskStallMonitor';
export { TeamTaskStallNotifier } from './stallMonitor/TeamTaskStallNotifier';
export { TeamTaskStallPolicy } from './stallMonitor/TeamTaskStallPolicy';
export { TeamTaskStallSnapshotSource } from './stallMonitor/TeamTaskStallSnapshotSource';

View file

@ -0,0 +1,101 @@
import type { TeamLogSourceTracker } from '../TeamLogSourceTracker';
import type { TeamChangeEvent } from '@shared/types';
interface TeamAliveProcessesReader {
listAliveProcessTeams(): Promise<string[]>;
}
interface TeamLogSourceTrackingHandle {
enableTracking(
teamName: string,
consumer: 'stall_monitor'
): Promise<{ projectFingerprint: string | null; logSourceGeneration: string | null }>;
disableTracking(
teamName: string,
consumer: 'stall_monitor'
): Promise<{ projectFingerprint: string | null; logSourceGeneration: string | null }>;
}
export class ActiveTeamRegistry {
private readonly activeTeams = new Set<string>();
private reconcileTimer: ReturnType<typeof setInterval> | null = null;
constructor(
private readonly teamDataService: TeamAliveProcessesReader,
private readonly teamLogSourceTracker: Pick<
TeamLogSourceTracker,
'enableTracking' | 'disableTracking'
> &
TeamLogSourceTrackingHandle,
private readonly reconcileIntervalMs: number = 5 * 60_000
) {}
noteTeamChange(event: TeamChangeEvent): void {
if (
event.type === 'member-spawn' ||
(event.type === 'lead-activity' && event.detail !== 'offline')
) {
if (!this.activeTeams.has(event.teamName)) {
this.activeTeams.add(event.teamName);
void this.teamLogSourceTracker.enableTracking(event.teamName, 'stall_monitor');
}
return;
}
if (event.type === 'task-log-change' || event.type === 'log-source-change') {
if (!this.activeTeams.has(event.teamName)) {
return;
}
}
}
async listActiveTeams(): Promise<string[]> {
return [...this.activeTeams].sort((left, right) => left.localeCompare(right));
}
start(): void {
if (this.reconcileTimer) {
return;
}
void this.reconcile();
this.reconcileTimer = setInterval(() => {
void this.reconcile();
}, this.reconcileIntervalMs);
}
async stop(): Promise<void> {
if (this.reconcileTimer) {
clearInterval(this.reconcileTimer);
this.reconcileTimer = null;
}
const teamNames = [...this.activeTeams];
this.activeTeams.clear();
await Promise.all(
teamNames.map((teamName) =>
this.teamLogSourceTracker.disableTracking(teamName, 'stall_monitor')
)
);
}
async reconcile(): Promise<void> {
const aliveTeams = await this.teamDataService.listAliveProcessTeams();
const aliveSet = new Set(aliveTeams);
for (const teamName of aliveTeams) {
if (this.activeTeams.has(teamName)) {
continue;
}
this.activeTeams.add(teamName);
await this.teamLogSourceTracker.enableTracking(teamName, 'stall_monitor');
}
for (const teamName of [...this.activeTeams]) {
if (aliveSet.has(teamName)) {
continue;
}
this.activeTeams.delete(teamName);
await this.teamLogSourceTracker.disableTracking(teamName, 'stall_monitor');
}
}
}

View file

@ -0,0 +1,30 @@
import { BoardTaskActivityRecordBuilder } from '../taskLogs/activity/BoardTaskActivityRecordBuilder';
import type { BoardTaskActivityRecord } from '../taskLogs/activity/BoardTaskActivityRecord';
import type { RawTaskActivityMessage } from '../taskLogs/activity/BoardTaskActivityTranscriptReader';
import type { TeamTask } from '@shared/types';
export class BoardTaskActivityBatchIndexer {
constructor(
private readonly recordBuilder: Pick<
BoardTaskActivityRecordBuilder,
'buildForTasks'
> = new BoardTaskActivityRecordBuilder()
) {}
buildIndex(args: {
teamName: string;
tasks: TeamTask[];
messages: RawTaskActivityMessage[];
}): Map<string, BoardTaskActivityRecord[]> {
if (args.tasks.length === 0 || args.messages.length === 0) {
return new Map();
}
return this.recordBuilder.buildForTasks({
teamName: args.teamName,
tasks: args.tasks,
messages: args.messages,
});
}
}

View file

@ -0,0 +1,124 @@
import * as fs from 'fs/promises';
import * as path from 'path';
import { BoardTaskActivityParseCache } from '../taskLogs/activity/BoardTaskActivityParseCache';
import type { TaskLogFreshnessSignal } from './TeamTaskStallTypes';
const BOARD_TASK_LOG_FRESHNESS_DIRNAME = '.board-task-log-freshness';
const BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX = '.json';
interface ParsedFreshnessSignal {
taskId: string;
updatedAt: string;
transcriptFileBasename?: string;
}
function encodeTaskId(taskId: string): string {
return encodeURIComponent(taskId);
}
function isValidTimestamp(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0 && Number.isFinite(Date.parse(value));
}
export class TeamTaskLogFreshnessReader {
private readonly cache = new BoardTaskActivityParseCache<ParsedFreshnessSignal | false>();
async readSignals(
projectDir: string,
taskIds: string[]
): Promise<Map<string, TaskLogFreshnessSignal>> {
const uniqueTaskIds = [...new Set(taskIds)].filter((taskId) => taskId.trim().length > 0).sort();
const signalFilePaths = uniqueTaskIds.map((taskId) =>
path.join(
projectDir,
BOARD_TASK_LOG_FRESHNESS_DIRNAME,
`${encodeTaskId(taskId)}${BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX}`
)
);
this.cache.retainOnly(new Set(signalFilePaths));
const rows = await Promise.all(
uniqueTaskIds.map(async (taskId, index) => {
const filePath = signalFilePaths[index];
const parsed = await this.readSignal(filePath);
if (!parsed || parsed.taskId !== taskId) {
return null;
}
return [
taskId,
{
taskId,
updatedAt: parsed.updatedAt,
filePath,
...(parsed.transcriptFileBasename
? { transcriptFileBasename: parsed.transcriptFileBasename }
: {}),
} satisfies TaskLogFreshnessSignal,
] as const;
})
);
return new Map(rows.filter((row): row is NonNullable<typeof row> => row !== null));
}
private async readSignal(filePath: string): Promise<ParsedFreshnessSignal | false> {
try {
const stat = await fs.stat(filePath);
if (!stat.isFile()) {
this.cache.clearForPath(filePath);
return false;
}
const cached = this.cache.getIfFresh(filePath, stat.mtimeMs, stat.size);
if (cached !== null) {
return cached;
}
const inFlight = this.cache.getInFlight(filePath);
if (inFlight) {
return inFlight;
}
const promise = this.parseSignal(filePath);
this.cache.setInFlight(filePath, promise);
try {
const parsed = await promise;
this.cache.set(filePath, stat.mtimeMs, stat.size, parsed);
return parsed;
} finally {
this.cache.clearInFlight(filePath);
}
} catch {
this.cache.clearForPath(filePath);
return false;
}
}
private async parseSignal(filePath: string): Promise<ParsedFreshnessSignal | false> {
const raw = await fs.readFile(filePath, 'utf8');
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== 'object') {
return false;
}
const record = parsed as Record<string, unknown>;
const taskId =
typeof record.taskId === 'string' && record.taskId.trim().length > 0
? record.taskId.trim()
: null;
const updatedAt = isValidTimestamp(record.updatedAt) ? record.updatedAt : null;
if (!taskId || !updatedAt) {
return false;
}
return {
taskId,
updatedAt,
...(typeof record.transcriptFile === 'string' && record.transcriptFile.trim().length > 0
? { transcriptFileBasename: path.basename(record.transcriptFile.trim()) }
: {}),
};
}
}

View file

@ -0,0 +1,127 @@
import { yieldToEventLoop } from '@main/utils/asyncYield';
import { parseJsonlLine } from '@main/utils/jsonl';
import { createLogger } from '@shared/utils/logger';
import { createReadStream } from 'fs';
import * as fs from 'fs/promises';
import * as readline from 'readline';
import { BoardTaskActivityParseCache } from '../taskLogs/activity/BoardTaskActivityParseCache';
import type { TeamTaskStallExactRow } from './TeamTaskStallTypes';
const logger = createLogger('Service:TeamTaskStallExactRowReader');
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' ? (value as Record<string, unknown>) : null;
}
function hasStrictTimestamp(record: Record<string, unknown>): boolean {
return typeof record.timestamp === 'string' && Number.isFinite(Date.parse(record.timestamp));
}
function parseSystemSubtype(record: Record<string, unknown>): 'turn_duration' | 'init' | undefined {
return record.subtype === 'turn_duration' || record.subtype === 'init'
? record.subtype
: undefined;
}
export class TeamTaskStallExactRowReader {
private readonly cache = new BoardTaskActivityParseCache<TeamTaskStallExactRow[]>();
async parseFiles(filePaths: string[]): Promise<Map<string, TeamTaskStallExactRow[]>> {
const uniquePaths = [...new Set(filePaths)].sort();
this.cache.retainOnly(new Set(uniquePaths));
const rows = await Promise.all(
uniquePaths.map(async (filePath) => [filePath, await this.parseFile(filePath)] as const)
);
return new Map(rows);
}
private async parseFile(filePath: string): Promise<TeamTaskStallExactRow[]> {
try {
const stat = await fs.stat(filePath);
const cached = this.cache.getIfFresh(filePath, stat.mtimeMs, stat.size);
if (cached !== null) {
return cached;
}
const inFlight = this.cache.getInFlight(filePath);
if (inFlight) {
return inFlight;
}
const promise = this.readFile(filePath);
this.cache.setInFlight(filePath, promise);
try {
const parsed = await promise;
this.cache.set(filePath, stat.mtimeMs, stat.size, parsed);
return parsed;
} finally {
this.cache.clearInFlight(filePath);
}
} catch (error) {
logger.debug(`Skipping unreadable stall exact-log transcript ${filePath}: ${String(error)}`);
this.cache.clearForPath(filePath);
return [];
}
}
private async readFile(filePath: string): Promise<TeamTaskStallExactRow[]> {
const rows: TeamTaskStallExactRow[] = [];
const stream = createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({
input: stream,
crlfDelay: Infinity,
});
let lineCount = 0;
let sourceOrder = 0;
for await (const line of rl) {
if (!line.trim()) {
continue;
}
lineCount += 1;
try {
const raw = JSON.parse(line) as unknown;
const record = asRecord(raw);
if (!record || !hasStrictTimestamp(record)) {
continue;
}
const parsed = parseJsonlLine(line);
if (!parsed) {
continue;
}
sourceOrder += 1;
const systemSubtype = parseSystemSubtype(record);
rows.push({
filePath,
sourceOrder,
messageUuid: parsed.uuid,
timestamp: record.timestamp as string,
parsedMessage: parsed,
...(parsed.requestId ? { requestId: parsed.requestId } : {}),
...(parsed.sourceToolUseID ? { sourceToolUseId: parsed.sourceToolUseID } : {}),
...(parsed.sourceToolAssistantUUID
? { sourceToolAssistantUuid: parsed.sourceToolAssistantUUID }
: {}),
...(systemSubtype ? { systemSubtype } : {}),
toolUseIds: parsed.toolCalls.map((toolCall) => toolCall.id),
toolResultIds: parsed.toolResults.map((toolResult) => toolResult.toolUseId),
});
} catch (error) {
logger.debug(`Skipping malformed stall exact-log line in ${filePath}: ${String(error)}`);
}
if (lineCount % 250 === 0) {
await yieldToEventLoop();
}
}
return rows;
}
}

View file

@ -0,0 +1,145 @@
import { getTeamsBasePath } from '@main/utils/pathDecoder';
import * as fs from 'fs';
import * as path from 'path';
import { atomicWriteAsync } from '../atomicWrite';
import { withFileLock } from '../fileLock';
import type {
TaskStallEvaluation,
TaskStallJournalEntry,
TaskStallJournalState,
} from './TeamTaskStallTypes';
function isValidState(value: unknown): value is TaskStallJournalState {
return value === 'suspected' || value === 'alert_ready' || value === 'alerted';
}
export class TeamTaskStallJournal {
private getFilePath(teamName: string): string {
return path.join(getTeamsBasePath(), teamName, 'stall-monitor-journal.json');
}
async reconcileScan(args: {
teamName: string;
evaluations: TaskStallEvaluation[];
activeTaskIds: string[];
now: string;
}): Promise<TaskStallEvaluation[]> {
const filePath = this.getFilePath(args.teamName);
let readyEvaluations: TaskStallEvaluation[] = [];
await withFileLock(filePath, async () => {
const entries = await this.readUnlocked(filePath);
const candidateByEpoch = new Map(
args.evaluations
.filter(
(
evaluation
): evaluation is TaskStallEvaluation &
Required<Pick<TaskStallEvaluation, 'taskId' | 'branch' | 'signal' | 'epochKey'>> =>
evaluation.status === 'alert' &&
typeof evaluation.taskId === 'string' &&
typeof evaluation.branch === 'string' &&
typeof evaluation.signal === 'string' &&
typeof evaluation.epochKey === 'string'
)
.map((evaluation) => [evaluation.epochKey, evaluation] as const)
);
const activeTaskIdSet = new Set(args.activeTaskIds);
for (let i = entries.length - 1; i >= 0; i -= 1) {
const entry = entries[i];
if (!activeTaskIdSet.has(entry.taskId) || !candidateByEpoch.has(entry.epochKey)) {
entries.splice(i, 1);
}
}
for (const [epochKey, evaluation] of candidateByEpoch) {
const existing = entries.find((entry) => entry.epochKey === epochKey);
if (!existing) {
entries.push({
epochKey,
teamName: args.teamName,
taskId: evaluation.taskId,
branch: evaluation.branch,
signal: evaluation.signal,
state: 'suspected',
consecutiveScans: 1,
createdAt: args.now,
updatedAt: args.now,
});
continue;
}
existing.updatedAt = args.now;
if (existing.state === 'alerted') {
continue;
}
existing.consecutiveScans += 1;
if (existing.consecutiveScans >= 2) {
existing.state = 'alert_ready';
readyEvaluations.push(evaluation);
}
}
await atomicWriteAsync(filePath, JSON.stringify(entries, null, 2));
});
return readyEvaluations;
}
async markAlerted(teamName: string, epochKey: string, now: string): Promise<void> {
const filePath = this.getFilePath(teamName);
await withFileLock(filePath, async () => {
const entries = await this.readUnlocked(filePath);
const target = entries.find((entry) => entry.epochKey === epochKey);
if (!target) {
return;
}
target.state = 'alerted';
target.updatedAt = now;
target.alertedAt = now;
await atomicWriteAsync(filePath, JSON.stringify(entries, null, 2));
});
}
private async readUnlocked(filePath: string): Promise<TaskStallJournalEntry[]> {
try {
const raw = await fs.promises.readFile(filePath, 'utf8');
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) {
return [];
}
return parsed
.filter(
(item): item is TaskStallJournalEntry =>
item != null &&
typeof item === 'object' &&
typeof (item as TaskStallJournalEntry).epochKey === 'string' &&
typeof (item as TaskStallJournalEntry).teamName === 'string' &&
typeof (item as TaskStallJournalEntry).taskId === 'string' &&
((item as TaskStallJournalEntry).branch === 'work' ||
(item as TaskStallJournalEntry).branch === 'review') &&
((item as TaskStallJournalEntry).signal === 'turn_ended_after_touch' ||
(item as TaskStallJournalEntry).signal === 'mid_turn_after_touch' ||
(item as TaskStallJournalEntry).signal === 'touch_then_other_turns') &&
isValidState((item as TaskStallJournalEntry).state) &&
typeof (item as TaskStallJournalEntry).consecutiveScans === 'number' &&
typeof (item as TaskStallJournalEntry).createdAt === 'string' &&
typeof (item as TaskStallJournalEntry).updatedAt === 'string'
)
.map((entry) => ({
...entry,
...(entry.alertedAt ? { alertedAt: entry.alertedAt } : {}),
}));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
}
throw error;
}
}
}

View file

@ -0,0 +1,246 @@
import { createLogger } from '@shared/utils/logger';
import { getTaskDisplayId } from '@shared/utils/taskIdentity';
import { ActiveTeamRegistry } from './ActiveTeamRegistry';
import {
getTeamTaskStallActivationGraceMs,
getTeamTaskStallScanIntervalMs,
getTeamTaskStallStartupGraceMs,
isTeamTaskStallAlertsEnabled,
isTeamTaskStallMonitorEnabled,
} from './featureGates';
import type { TeamTaskStallSnapshotSource } from './TeamTaskStallSnapshotSource';
import type { TeamTaskStallPolicy } from './TeamTaskStallPolicy';
import type { TeamTaskStallJournal } from './TeamTaskStallJournal';
import type { TeamTaskStallNotifier } from './TeamTaskStallNotifier';
import type { TaskStallAlert, TaskStallEvaluation } from './TeamTaskStallTypes';
import type { TeamChangeEvent } from '@shared/types';
const logger = createLogger('Service:TeamTaskStallMonitor');
interface TeamObservationState {
firstSeenAtMs: number;
lastActivationAtMs: number;
}
export class TeamTaskStallMonitor {
private scanTimer: ReturnType<typeof setTimeout> | null = null;
private nudgeTimer: ReturnType<typeof setTimeout> | null = null;
private scanInFlight = false;
private started = false;
private readonly observationByTeam = new Map<string, TeamObservationState>();
constructor(
private readonly registry: ActiveTeamRegistry,
private readonly snapshotSource: TeamTaskStallSnapshotSource,
private readonly policy: TeamTaskStallPolicy,
private readonly journal: TeamTaskStallJournal,
private readonly notifier: TeamTaskStallNotifier
) {}
start(): void {
if (!isTeamTaskStallMonitorEnabled()) {
logger.debug('Task stall monitor disabled by feature gate');
return;
}
if (this.started) {
return;
}
this.started = true;
this.registry.start();
this.scheduleNextScan(2_000);
}
async stop(): Promise<void> {
this.started = false;
if (this.scanTimer) {
clearTimeout(this.scanTimer);
this.scanTimer = null;
}
if (this.nudgeTimer) {
clearTimeout(this.nudgeTimer);
this.nudgeTimer = null;
}
await this.registry.stop();
}
noteTeamChange(event: TeamChangeEvent): void {
this.registry.noteTeamChange(event);
if (!isTeamTaskStallMonitorEnabled()) {
return;
}
if (
event.type === 'member-spawn' ||
(event.type === 'lead-activity' && event.detail !== 'offline')
) {
const now = Date.now();
const existing = this.observationByTeam.get(event.teamName);
this.observationByTeam.set(event.teamName, {
firstSeenAtMs: existing?.firstSeenAtMs ?? now,
lastActivationAtMs: now,
});
this.scheduleNudgedScan();
return;
}
if (event.type === 'task-log-change' || event.type === 'log-source-change') {
this.scheduleNudgedScan();
}
}
private scheduleNextScan(delayMs: number): void {
if (!this.started) {
return;
}
if (this.scanTimer) {
clearTimeout(this.scanTimer);
}
this.scanTimer = setTimeout(() => {
this.scanTimer = null;
void this.runScan();
}, delayMs);
}
private scheduleNudgedScan(): void {
if (!this.started || this.nudgeTimer) {
return;
}
this.nudgeTimer = setTimeout(() => {
this.nudgeTimer = null;
void this.runScan();
}, 5_000);
}
private async runScan(): Promise<void> {
if (!this.started || this.scanInFlight) {
return;
}
this.scanInFlight = true;
try {
const activeTeams = await this.registry.listActiveTeams();
const activeSet = new Set(activeTeams);
for (const teamName of [...this.observationByTeam.keys()]) {
if (!activeSet.has(teamName)) {
this.observationByTeam.delete(teamName);
}
}
const now = new Date();
for (const teamName of activeTeams) {
const observation = this.getOrCreateObservation(teamName, now.getTime());
const startupAgeMs = now.getTime() - observation.firstSeenAtMs;
if (startupAgeMs < getTeamTaskStallStartupGraceMs()) {
continue;
}
const activationAgeMs = now.getTime() - observation.lastActivationAtMs;
if (activationAgeMs < getTeamTaskStallActivationGraceMs()) {
continue;
}
await this.scanTeam(teamName, now);
}
} catch (error) {
logger.warn(`Task stall monitor scan failed: ${String(error)}`);
} finally {
this.scanInFlight = false;
this.scheduleNextScan(getTeamTaskStallScanIntervalMs());
}
}
private getOrCreateObservation(teamName: string, nowMs: number): TeamObservationState {
const existing = this.observationByTeam.get(teamName);
if (existing) {
return existing;
}
const created = {
firstSeenAtMs: nowMs,
lastActivationAtMs: nowMs,
};
this.observationByTeam.set(teamName, created);
return created;
}
private async scanTeam(teamName: string, now: Date): Promise<void> {
const snapshot = await this.snapshotSource.getSnapshot(teamName);
if (!snapshot) {
return;
}
const evaluations: TaskStallEvaluation[] = [];
for (const task of snapshot.inProgressTasks) {
evaluations.push(this.policy.evaluateWork({ now, task, snapshot }));
}
for (const task of snapshot.reviewOpenTasks) {
evaluations.push(this.policy.evaluateReview({ now, task, snapshot }));
}
const activeTaskIds = [
...new Set([...snapshot.inProgressTasks, ...snapshot.reviewOpenTasks].map((task) => task.id)),
];
const readyEvaluations = await this.journal.reconcileScan({
teamName,
evaluations,
activeTaskIds,
now: now.toISOString(),
});
const alerts = readyEvaluations
.map((evaluation) => this.buildAlert(snapshot, evaluation))
.filter((alert): alert is TaskStallAlert => alert !== null);
if (alerts.length === 0) {
return;
}
if (!isTeamTaskStallAlertsEnabled()) {
logger.debug(`Task stall monitor shadow-ready alerts for ${teamName}: ${alerts.length}`);
return;
}
await this.notifier.notifyLead(teamName, alerts);
await Promise.all(
alerts.map((alert) => this.journal.markAlerted(teamName, alert.epochKey, now.toISOString()))
);
}
private buildAlert(
snapshot: Awaited<ReturnType<TeamTaskStallSnapshotSource['getSnapshot']>>,
evaluation: TaskStallEvaluation
): TaskStallAlert | null {
if (
!snapshot ||
evaluation.status !== 'alert' ||
!evaluation.taskId ||
!evaluation.branch ||
!evaluation.signal ||
!evaluation.epochKey
) {
return null;
}
const task = snapshot.allTasksById.get(evaluation.taskId);
if (!task) {
return null;
}
const displayId = getTaskDisplayId(task);
return {
teamName: snapshot.teamName,
taskId: task.id,
displayId,
subject: task.subject,
branch: evaluation.branch,
signal: evaluation.signal,
reason: evaluation.reason,
epochKey: evaluation.epochKey,
taskRef: {
taskId: task.id,
displayId,
teamName: snapshot.teamName,
},
};
}
}

View file

@ -0,0 +1,32 @@
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import type { TaskStallAlert } from './TeamTaskStallTypes';
import type { TeamDataService } from '../TeamDataService';
function buildLeadAlertText(alerts: TaskStallAlert[]): string {
return alerts
.map(
(alert) =>
`- ${formatTaskDisplayLabel({ id: alert.taskId, displayId: alert.displayId })} [${alert.branch}] ${alert.subject} - ${alert.reason}`
)
.join('\n');
}
export class TeamTaskStallNotifier {
constructor(
private readonly teamDataService: Pick<TeamDataService, 'sendSystemNotificationToLead'>
) {}
async notifyLead(teamName: string, alerts: TaskStallAlert[]): Promise<void> {
if (alerts.length === 0) {
return;
}
await this.teamDataService.sendSystemNotificationToLead({
teamName,
summary: 'Potential stalled tasks detected',
text: buildLeadAlertText(alerts),
taskRefs: alerts.map((alert) => alert.taskRef),
});
}
}

View file

@ -0,0 +1,508 @@
import type {
ReviewTaskContext,
TaskStallBranch,
TaskStallEvaluation,
TaskStallSignal,
TeamTaskStallExactRow,
TeamTaskStallSnapshot,
WorkTaskContext,
} from './TeamTaskStallTypes';
import type { BoardTaskActivityRecord } from '../taskLogs/activity/BoardTaskActivityRecord';
import type { TeamTask, TaskWorkInterval, TaskHistoryEvent } from '@shared/types';
const WORK_TOUCH_TOOLS = new Set(['task_start', 'task_add_comment', 'task_set_status']);
const REVIEW_TOUCH_TOOLS = new Set(['review_start', 'task_add_comment']);
const ONE_MINUTE_MS = 60_000;
const WORK_THRESHOLDS_MS: Record<TaskStallSignal, number> = {
turn_ended_after_touch: 8 * ONE_MINUTE_MS,
touch_then_other_turns: 10 * ONE_MINUTE_MS,
mid_turn_after_touch: 20 * ONE_MINUTE_MS,
};
const REVIEW_THRESHOLDS_MS: Record<TaskStallSignal, number> = {
turn_ended_after_touch: 10 * ONE_MINUTE_MS,
touch_then_other_turns: 10 * ONE_MINUTE_MS,
mid_turn_after_touch: 25 * ONE_MINUTE_MS,
};
function skip(
taskId: string,
reason: string,
skipReason: TaskStallEvaluation['skipReason']
): TaskStallEvaluation {
return {
status: 'skip',
taskId,
reason,
skipReason,
};
}
function isAfterOrEqual(timestamp: string, lowerBound: string): boolean {
return Date.parse(timestamp) >= Date.parse(lowerBound);
}
function getOpenWorkInterval(task: TeamTask): TaskWorkInterval | null {
const intervals = task.workIntervals ?? [];
for (let i = intervals.length - 1; i >= 0; i -= 1) {
const interval = intervals[i];
if (!interval.completedAt) {
return interval;
}
}
return null;
}
function getOpenReviewWindowStart(task: TeamTask): string | null {
if (task.reviewState !== 'review' || !task.historyEvents?.length) {
return null;
}
for (let i = task.historyEvents.length - 1; i >= 0; i -= 1) {
const event = task.historyEvents[i];
if (event.type === 'review_started') {
return event.timestamp;
}
if (
event.type === 'review_approved' ||
event.type === 'review_changes_requested' ||
(event.type === 'status_changed' && event.to === 'in_progress')
) {
return null;
}
}
return null;
}
function hasReviewStartedByReviewer(
historyEvents: TaskHistoryEvent[] | undefined,
reviewer: string,
windowStartedAt: string
): boolean {
if (!historyEvents?.length) {
return false;
}
return historyEvents.some(
(event) =>
event.type === 'review_started' &&
event.actor === reviewer &&
isAfterOrEqual(event.timestamp, windowStartedAt)
);
}
function isStrongReviewTouch(
record: BoardTaskActivityRecord,
reviewer: string,
hasExplicitStartedReview: boolean,
windowStartedAt: string
): boolean {
if (
record.actor.memberName !== reviewer ||
!record.action?.canonicalToolName ||
!REVIEW_TOUCH_TOOLS.has(record.action.canonicalToolName) ||
!isAfterOrEqual(record.timestamp, windowStartedAt)
) {
return false;
}
if (record.action.canonicalToolName === 'review_start') {
return true;
}
if (
record.actorContext.relation === 'same_task' &&
record.actorContext.activePhase === 'review'
) {
return true;
}
return hasExplicitStartedReview;
}
function findLastMeaningfulWorkTouch(
records: BoardTaskActivityRecord[],
owner: string,
intervalStartedAt: string
): BoardTaskActivityRecord | null {
return (
[...records]
.filter((record) => record.actor.memberName === owner)
.filter((record) => isAfterOrEqual(record.timestamp, intervalStartedAt))
.filter((record) => WORK_TOUCH_TOOLS.has(record.action?.canonicalToolName ?? ''))
.at(-1) ?? null
);
}
function findLastMeaningfulReviewTouch(
records: BoardTaskActivityRecord[],
reviewer: string,
windowStartedAt: string,
hasExplicitStartedReview: boolean
): BoardTaskActivityRecord | null {
return (
[...records]
.filter((record) =>
isStrongReviewTouch(record, reviewer, hasExplicitStartedReview, windowStartedAt)
)
.at(-1) ?? null
);
}
function anchorEvidenceRank(row: TeamTaskStallExactRow, toolUseId: string | undefined): number {
if (!toolUseId || row.parsedMessage.type !== 'assistant') {
return 0;
}
if (row.toolUseIds.includes(toolUseId)) {
return 2;
}
if (row.sourceToolUseId === toolUseId || row.toolResultIds.includes(toolUseId)) {
return 1;
}
return 0;
}
function deduplicateAssistantRowsByRequestId(
rows: TeamTaskStallExactRow[],
toolUseId: string | undefined
): TeamTaskStallExactRow[] {
const preferredIndexByRequestId = new Map<string, number>();
for (let i = 0; i < rows.length; i += 1) {
const row = rows[i];
if (row.parsedMessage.type !== 'assistant' || !row.requestId) {
continue;
}
const existingIndex = preferredIndexByRequestId.get(row.requestId);
if (existingIndex === undefined) {
preferredIndexByRequestId.set(row.requestId, i);
continue;
}
const existingRank = anchorEvidenceRank(rows[existingIndex], toolUseId);
const nextRank = anchorEvidenceRank(row, toolUseId);
if (nextRank > existingRank || (nextRank === existingRank && i > existingIndex)) {
preferredIndexByRequestId.set(row.requestId, i);
}
}
if (preferredIndexByRequestId.size === 0) {
return rows;
}
return rows.filter((row, index) => {
if (row.parsedMessage.type !== 'assistant' || !row.requestId) {
return true;
}
return preferredIndexByRequestId.get(row.requestId) === index;
});
}
function findAnchorRowIndex(
rows: TeamTaskStallExactRow[],
messageUuid: string,
toolUseId?: string
): number {
const candidates = rows
.map((row, index) => ({ row, index }))
.filter(({ row }) => row.messageUuid === messageUuid);
if (candidates.length === 0) {
return -1;
}
if (toolUseId) {
const explicitToolUse = candidates.filter(({ row }) => row.toolUseIds.includes(toolUseId));
if (explicitToolUse.length > 0) {
return explicitToolUse.at(-1)!.index;
}
const linkedRows = candidates.filter(
({ row }) => row.sourceToolUseId === toolUseId || row.toolResultIds.includes(toolUseId)
);
if (linkedRows.length > 0) {
return linkedRows.at(-1)!.index;
}
}
return candidates.at(-1)!.index;
}
function classifyPostTouchState(args: {
rows: TeamTaskStallExactRow[];
anchorMessageUuid: string;
anchorToolUseId?: string;
}): TaskStallSignal | 'ambiguous' {
const normalizedRows = deduplicateAssistantRowsByRequestId(args.rows, args.anchorToolUseId);
const anchorIndex = findAnchorRowIndex(
normalizedRows,
args.anchorMessageUuid,
args.anchorToolUseId
);
if (anchorIndex < 0) {
return 'ambiguous';
}
let sawTurnEnd = false;
let sawLaterRows = false;
for (let i = anchorIndex + 1; i < normalizedRows.length; i += 1) {
const row = normalizedRows[i];
if (row.systemSubtype === 'turn_duration') {
sawTurnEnd = true;
continue;
}
sawLaterRows = true;
if (sawTurnEnd) {
return 'touch_then_other_turns';
}
}
if (sawTurnEnd) {
return 'turn_ended_after_touch';
}
if (sawLaterRows) {
return 'mid_turn_after_touch';
}
return 'mid_turn_after_touch';
}
function buildEpochKey(
task: TeamTask,
branch: TaskStallBranch,
signal: TaskStallSignal,
touch: BoardTaskActivityRecord
): string {
return [
task.id,
branch,
signal,
touch.timestamp,
touch.source.filePath,
touch.source.messageUuid,
touch.source.toolUseId ?? 'ambient',
].join(':');
}
function buildAlertEvaluation(args: {
task: TeamTask;
branch: TaskStallBranch;
signal: TaskStallSignal;
touch: BoardTaskActivityRecord;
reason: string;
}): TaskStallEvaluation {
return {
status: 'alert',
taskId: args.task.id,
branch: args.branch,
signal: args.signal,
epochKey: buildEpochKey(args.task, args.branch, args.signal, args.touch),
reason: args.reason,
};
}
export class TeamTaskStallPolicy {
evaluateWork(args: {
now: Date;
task: TeamTask;
snapshot: TeamTaskStallSnapshot;
}): TaskStallEvaluation {
const { task, snapshot } = args;
if (!snapshot.activityReadsEnabled) {
return skip(task.id, 'Task activity reads are disabled', 'activity_reads_disabled');
}
if (!snapshot.exactReadsEnabled) {
return skip(task.id, 'Exact log reads are disabled', 'exact_reads_disabled');
}
if (task.status !== 'in_progress') {
return skip(task.id, 'Task is not in progress', 'task_not_in_progress');
}
if (!task.owner) {
return skip(task.id, 'Task has no owner', 'owner_missing');
}
if (task.owner === snapshot.leadName) {
return skip(task.id, 'Task owner is the lead', 'owner_is_lead');
}
if (task.reviewState === 'review') {
return skip(task.id, 'Task is currently under review', 'review_active');
}
if (task.blockedBy?.length) {
return skip(task.id, 'Task is blocked', 'task_blocked');
}
if (task.needsClarification) {
return skip(task.id, 'Task is waiting for clarification', 'needs_clarification');
}
const openWorkInterval = getOpenWorkInterval(task);
if (!openWorkInterval?.startedAt) {
return skip(task.id, 'Task has no open work interval', 'no_open_work_interval');
}
const records = snapshot.recordsByTaskId.get(task.id) ?? [];
if (records.length === 0 && !snapshot.freshnessByTaskId.has(task.id)) {
return skip(
task.id,
'Task run is not instrumented enough for stall evaluation',
'non_instrumented_run'
);
}
const workContext: WorkTaskContext | null = (() => {
const touch = findLastMeaningfulWorkTouch(records, task.owner!, openWorkInterval.startedAt);
if (!touch) {
return null;
}
return {
owner: task.owner!,
intervalStartedAt: openWorkInterval.startedAt,
lastMeaningfulTouch: touch,
lastMeaningfulTouchAt: touch.timestamp,
};
})();
if (!workContext) {
return skip(
task.id,
'No positive work touch found in current work interval',
'no_positive_touch'
);
}
const exactRows = snapshot.exactRowsByFilePath.get(
workContext.lastMeaningfulTouch.source.filePath
);
if (!exactRows?.length) {
return skip(task.id, 'Post-touch exact rows are unavailable', 'ambiguous_state');
}
const signal = classifyPostTouchState({
rows: exactRows,
anchorMessageUuid: workContext.lastMeaningfulTouch.source.messageUuid,
anchorToolUseId: workContext.lastMeaningfulTouch.source.toolUseId,
});
if (signal === 'ambiguous') {
return skip(task.id, 'Post-touch state is ambiguous', 'ambiguous_state');
}
const elapsedMs = args.now.getTime() - Date.parse(workContext.lastMeaningfulTouchAt);
const thresholdMs = WORK_THRESHOLDS_MS[signal];
if (elapsedMs < thresholdMs) {
return skip(
task.id,
'Work touch is still below the configured stall threshold',
'below_threshold'
);
}
return buildAlertEvaluation({
task,
branch: 'work',
signal,
touch: workContext.lastMeaningfulTouch,
reason: `Potential work stall after ${signal.replaceAll('_', ' ')}.`,
});
}
evaluateReview(args: {
now: Date;
task: TeamTask;
snapshot: TeamTaskStallSnapshot;
}): TaskStallEvaluation {
const { task, snapshot } = args;
if (!snapshot.activityReadsEnabled) {
return skip(task.id, 'Task activity reads are disabled', 'activity_reads_disabled');
}
if (!snapshot.exactReadsEnabled) {
return skip(task.id, 'Exact log reads are disabled', 'exact_reads_disabled');
}
if (task.reviewState !== 'review') {
return skip(task.id, 'Task is not in an open review window', 'review_terminal');
}
if (task.needsClarification) {
return skip(task.id, 'Task is waiting for clarification', 'needs_clarification');
}
const reviewWindowStartedAt = getOpenReviewWindowStart(task);
if (!reviewWindowStartedAt) {
return skip(task.id, 'Task has no open review window', 'no_open_review_window');
}
const resolvedReviewer = snapshot.resolvedReviewersByTaskId.get(task.id) ?? {
reviewer: null,
source: 'none',
};
if (!resolvedReviewer.reviewer) {
return skip(task.id, 'Reviewer could not be resolved safely', 'reviewer_unresolved');
}
const records = snapshot.recordsByTaskId.get(task.id) ?? [];
if (records.length === 0 && !snapshot.freshnessByTaskId.has(task.id)) {
return skip(
task.id,
'Review run is not instrumented enough for stall evaluation',
'non_instrumented_run'
);
}
const explicitReviewStarted = hasReviewStartedByReviewer(
task.historyEvents,
resolvedReviewer.reviewer,
reviewWindowStartedAt
);
const reviewContext: ReviewTaskContext | null = (() => {
const touch = findLastMeaningfulReviewTouch(
records,
resolvedReviewer.reviewer!,
reviewWindowStartedAt,
explicitReviewStarted
);
if (!touch) {
return null;
}
return {
resolvedReviewer,
reviewWindowStartedAt,
lastMeaningfulTouch: touch,
lastMeaningfulTouchAt: touch.timestamp,
};
})();
if (!reviewContext) {
return skip(task.id, 'No explicit started-review evidence was found', 'no_positive_touch');
}
const exactRows = snapshot.exactRowsByFilePath.get(
reviewContext.lastMeaningfulTouch.source.filePath
);
if (!exactRows?.length) {
return skip(task.id, 'Post-review exact rows are unavailable', 'ambiguous_state');
}
const signal = classifyPostTouchState({
rows: exactRows,
anchorMessageUuid: reviewContext.lastMeaningfulTouch.source.messageUuid,
anchorToolUseId: reviewContext.lastMeaningfulTouch.source.toolUseId,
});
if (signal === 'ambiguous') {
return skip(task.id, 'Post-review state is ambiguous', 'ambiguous_state');
}
const elapsedMs = args.now.getTime() - Date.parse(reviewContext.lastMeaningfulTouchAt);
const thresholdMs = REVIEW_THRESHOLDS_MS[signal];
if (elapsedMs < thresholdMs) {
return skip(
task.id,
'Review touch is still below the configured stall threshold',
'below_threshold'
);
}
return buildAlertEvaluation({
task,
branch: 'review',
signal,
touch: reviewContext.lastMeaningfulTouch,
reason: `Potential started-review stall after ${signal.replaceAll('_', ' ')}.`,
});
}
}

View file

@ -0,0 +1,119 @@
import { TeamTaskReader } from '../TeamTaskReader';
import { TeamKanbanManager } from '../TeamKanbanManager';
import { TeamTranscriptSourceLocator } from '../taskLogs/discovery/TeamTranscriptSourceLocator';
import { BoardTaskActivityTranscriptReader } from '../taskLogs/activity/BoardTaskActivityTranscriptReader';
import { isBoardTaskActivityReadEnabled } from '../taskLogs/activity/featureGates';
import { isBoardTaskExactLogsReadEnabled } from '../taskLogs/exact/featureGates';
import { BoardTaskActivityBatchIndexer } from './BoardTaskActivityBatchIndexer';
import { TeamTaskLogFreshnessReader } from './TeamTaskLogFreshnessReader';
import { TeamTaskStallExactRowReader } from './TeamTaskStallExactRowReader';
import { buildResolvedReviewerIndex } from './reviewerResolution';
import type { BoardTaskActivityRecord } from '../taskLogs/activity/BoardTaskActivityRecord';
import type { TeamTaskStallSnapshot } from './TeamTaskStallTypes';
import type { TeamConfig, TeamTask } from '@shared/types';
function resolveLeadNameFromConfig(config: TeamConfig): string {
const lead = config.members?.find((member) => member.role?.toLowerCase().includes('lead'));
return lead?.name ?? config.members?.[0]?.name ?? 'team-lead';
}
export class TeamTaskStallSnapshotSource {
constructor(
private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator(),
private readonly taskReader: TeamTaskReader = new TeamTaskReader(),
private readonly kanbanManager: TeamKanbanManager = new TeamKanbanManager(),
private readonly transcriptReader: BoardTaskActivityTranscriptReader = new BoardTaskActivityTranscriptReader(),
private readonly activityBatchIndexer: BoardTaskActivityBatchIndexer = new BoardTaskActivityBatchIndexer(),
private readonly freshnessReader: TeamTaskLogFreshnessReader = new TeamTaskLogFreshnessReader(),
private readonly exactRowReader: TeamTaskStallExactRowReader = new TeamTaskStallExactRowReader()
) {}
async getSnapshot(teamName: string): Promise<TeamTaskStallSnapshot | null> {
const transcriptContext = await this.transcriptSourceLocator.getContext(teamName);
if (!transcriptContext) {
return null;
}
const [activeTasks, deletedTasks, kanbanState] = await Promise.all([
this.taskReader.getTasks(teamName),
this.taskReader.getDeletedTasks(teamName),
this.kanbanManager.getState(teamName),
]);
const allTasks = [...activeTasks, ...deletedTasks];
const allTasksById = new Map(allTasks.map((task) => [task.id, task] as const));
const inProgressTasks = activeTasks.filter(
(task) => task.status === 'in_progress' && task.reviewState !== 'review'
);
const reviewOpenTasks = activeTasks.filter((task) => task.reviewState === 'review');
const resolvedReviewersByTaskId = buildResolvedReviewerIndex(activeTasks, kanbanState);
const activityReadsEnabled = isBoardTaskActivityReadEnabled();
const exactReadsEnabled = isBoardTaskExactLogsReadEnabled();
let recordsByTaskId = new Map<string, BoardTaskActivityRecord[]>();
if (
activityReadsEnabled &&
allTasks.length > 0 &&
transcriptContext.transcriptFiles.length > 0
) {
const messages = await this.transcriptReader.readFiles(transcriptContext.transcriptFiles);
recordsByTaskId = this.activityBatchIndexer.buildIndex({
teamName,
tasks: allTasks,
messages,
});
}
const relevantMonitorTasks = [...inProgressTasks, ...reviewOpenTasks];
const relevantExactFiles = this.collectRelevantExactFiles(
relevantMonitorTasks,
recordsByTaskId
);
const [freshnessByTaskId, exactRowsByFilePath] = await Promise.all([
this.freshnessReader.readSignals(
transcriptContext.projectDir,
relevantMonitorTasks.map((task) => task.id)
),
exactReadsEnabled
? this.exactRowReader.parseFiles(relevantExactFiles)
: Promise.resolve(new Map()),
]);
return {
teamName,
scannedAt: new Date().toISOString(),
projectDir: transcriptContext.projectDir,
projectId: transcriptContext.projectId,
leadName: resolveLeadNameFromConfig(transcriptContext.config),
transcriptFiles: transcriptContext.transcriptFiles,
activityReadsEnabled,
exactReadsEnabled,
activeTasks,
deletedTasks,
allTasksById,
inProgressTasks,
reviewOpenTasks,
resolvedReviewersByTaskId,
recordsByTaskId,
freshnessByTaskId,
exactRowsByFilePath,
};
}
private collectRelevantExactFiles(
inProgressTasks: TeamTask[],
recordsByTaskId: Map<string, BoardTaskActivityRecord[]>
): string[] {
const filePaths = new Set<string>();
for (const task of inProgressTasks) {
const records = recordsByTaskId.get(task.id) ?? [];
for (const record of records) {
filePaths.add(record.source.filePath);
}
}
return [...filePaths].sort((left, right) => left.localeCompare(right));
}
}

View file

@ -0,0 +1,139 @@
import type { BoardTaskActivityRecord } from '../taskLogs/activity/BoardTaskActivityRecord';
import type { ParsedMessage } from '@main/types';
import type { TeamTask } from '@shared/types';
export type TaskStallBranch = 'work' | 'review';
export type TaskStallSignal =
| 'turn_ended_after_touch'
| 'mid_turn_after_touch'
| 'touch_then_other_turns';
export type TaskStallEvaluationStatus = 'skip' | 'suspected' | 'alert';
export type TaskStallSkipReason =
| 'task_not_in_progress'
| 'owner_missing'
| 'owner_is_lead'
| 'task_blocked'
| 'needs_clarification'
| 'review_active'
| 'review_terminal'
| 'reviewer_unresolved'
| 'non_instrumented_run'
| 'activity_reads_disabled'
| 'exact_reads_disabled'
| 'no_positive_touch'
| 'no_open_work_interval'
| 'no_open_review_window'
| 'ambiguous_state'
| 'below_threshold'
| 'first_scan_only';
export type ResolvedReviewerSource =
| 'kanban_state'
| 'history_review_approved_actor'
| 'history_review_started_actor'
| 'history_review_requested_reviewer'
| 'none';
export interface ResolvedReviewer {
reviewer: string | null;
source: ResolvedReviewerSource;
}
export interface TaskStallEvaluation {
status: TaskStallEvaluationStatus;
taskId?: string;
branch?: TaskStallBranch;
signal?: TaskStallSignal;
epochKey?: string;
reason: string;
skipReason?: TaskStallSkipReason;
}
export interface TaskLogFreshnessSignal {
taskId: string;
updatedAt: string;
filePath: string;
transcriptFileBasename?: string;
}
export interface TeamTaskStallExactRow {
filePath: string;
sourceOrder: number;
messageUuid: string;
timestamp: string;
parsedMessage: ParsedMessage;
requestId?: string;
sourceToolUseId?: string;
sourceToolAssistantUuid?: string;
systemSubtype?: 'turn_duration' | 'init';
toolUseIds: string[];
toolResultIds: string[];
}
export interface TeamTaskStallSnapshot {
teamName: string;
scannedAt: string;
projectDir: string;
projectId: string;
leadName: string;
transcriptFiles: string[];
activityReadsEnabled: boolean;
exactReadsEnabled: boolean;
activeTasks: TeamTask[];
deletedTasks: TeamTask[];
allTasksById: Map<string, TeamTask>;
inProgressTasks: TeamTask[];
reviewOpenTasks: TeamTask[];
resolvedReviewersByTaskId: Map<string, ResolvedReviewer>;
recordsByTaskId: Map<string, BoardTaskActivityRecord[]>;
freshnessByTaskId: Map<string, TaskLogFreshnessSignal>;
exactRowsByFilePath: Map<string, TeamTaskStallExactRow[]>;
}
export interface WorkTaskContext {
owner: string;
intervalStartedAt: string;
lastMeaningfulTouch: BoardTaskActivityRecord;
lastMeaningfulTouchAt: string;
}
export interface ReviewTaskContext {
resolvedReviewer: ResolvedReviewer;
reviewWindowStartedAt: string;
lastMeaningfulTouch: BoardTaskActivityRecord;
lastMeaningfulTouchAt: string;
}
export interface TaskStallAlert {
teamName: string;
taskId: string;
displayId: string;
subject: string;
branch: TaskStallBranch;
signal: TaskStallSignal;
reason: string;
epochKey: string;
taskRef: {
taskId: string;
displayId: string;
teamName: string;
};
}
export type TaskStallJournalState = 'suspected' | 'alert_ready' | 'alerted';
export interface TaskStallJournalEntry {
epochKey: string;
teamName: string;
taskId: string;
branch: TaskStallBranch;
signal: TaskStallSignal;
state: TaskStallJournalState;
consecutiveScans: number;
createdAt: string;
updatedAt: string;
alertedAt?: string;
}

View file

@ -0,0 +1,42 @@
function readEnabledFlag(value: string | undefined, defaultValue: boolean): boolean {
if (value == null) {
return defaultValue;
}
const normalized = value.trim().toLowerCase();
if (normalized === '0' || normalized === 'false' || normalized === 'off' || normalized === 'no') {
return false;
}
if (normalized === '1' || normalized === 'true' || normalized === 'on' || normalized === 'yes') {
return true;
}
return defaultValue;
}
function readInt(value: string | undefined, defaultValue: number): number {
if (value == null) {
return defaultValue;
}
const parsed = Number.parseInt(value.trim(), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultValue;
}
export function isTeamTaskStallMonitorEnabled(): boolean {
return readEnabledFlag(process.env.CLAUDE_TEAM_TASK_STALL_MONITOR_ENABLED, false);
}
export function isTeamTaskStallAlertsEnabled(): boolean {
return readEnabledFlag(process.env.CLAUDE_TEAM_TASK_STALL_ALERTS_ENABLED, false);
}
export function getTeamTaskStallScanIntervalMs(): number {
return readInt(process.env.CLAUDE_TEAM_TASK_STALL_SCAN_INTERVAL_MS, 60_000);
}
export function getTeamTaskStallStartupGraceMs(): number {
return readInt(process.env.CLAUDE_TEAM_TASK_STALL_STARTUP_GRACE_MS, 180_000);
}
export function getTeamTaskStallActivationGraceMs(): number {
return readInt(process.env.CLAUDE_TEAM_TASK_STALL_ACTIVATION_GRACE_MS, 120_000);
}

View file

@ -0,0 +1,47 @@
import { TeamKanbanManager } from '../TeamKanbanManager';
import type { ResolvedReviewer } from './TeamTaskStallTypes';
import type { TeamTask } from '@shared/types';
export function resolveReviewerFromHistory(task: TeamTask): ResolvedReviewer {
if (!task.historyEvents?.length) {
return { reviewer: null, source: 'none' };
}
for (let i = task.historyEvents.length - 1; i >= 0; i -= 1) {
const event = task.historyEvents[i];
if (event.type === 'review_approved' && event.actor) {
return { reviewer: event.actor, source: 'history_review_approved_actor' };
}
if (event.type === 'review_started' && event.actor) {
return { reviewer: event.actor, source: 'history_review_started_actor' };
}
if (event.type === 'review_requested' && event.reviewer) {
return { reviewer: event.reviewer, source: 'history_review_requested_reviewer' };
}
}
return { reviewer: null, source: 'none' };
}
export function buildResolvedReviewerIndex(
tasks: TeamTask[],
kanbanState: Awaited<ReturnType<TeamKanbanManager['getState']>>
): Map<string, ResolvedReviewer> {
const resolved = new Map<string, ResolvedReviewer>();
for (const task of tasks) {
const kanbanReviewer = kanbanState.tasks[task.id]?.reviewer;
if (typeof kanbanReviewer === 'string' && kanbanReviewer.trim().length > 0) {
resolved.set(task.id, {
reviewer: kanbanReviewer.trim(),
source: 'kanban_state',
});
continue;
}
resolved.set(task.id, resolveReviewerFromHistory(task));
}
return resolved;
}

View file

@ -312,6 +312,21 @@ function compareRecords(left: BoardTaskActivityRecord, right: BoardTaskActivityR
return left.id.localeCompare(right.id);
}
function resolveCandidateTaskIds(locator: BoardTaskLocator, lookup: TaskLookup): string[] {
const canonicalTask =
(locator.canonicalId && lookup.byId.get(locator.canonicalId)) ||
(locator.refKind === 'canonical' ? lookup.byId.get(locator.ref) : undefined) ||
(locator.refKind === 'unknown' && looksLikeCanonicalTaskId(locator.ref)
? lookup.byId.get(locator.ref)
: undefined);
if (canonicalTask) {
return [canonicalTask.id];
}
const displayCandidates = lookup.byDisplayId.get(normalizeDisplayRef(locator.ref)) ?? [];
return [...new Set(displayCandidates.map((task) => task.id))];
}
export class BoardTaskActivityRecordBuilder {
buildForTask(args: {
teamName: string;
@ -319,64 +334,98 @@ export class BoardTaskActivityRecordBuilder {
tasks: TeamTask[];
messages: RawTaskActivityMessage[];
}): BoardTaskActivityRecord[] {
return (
this.buildForTasks({
teamName: args.teamName,
tasks: args.tasks,
messages: args.messages,
}).get(args.targetTask.id) ?? []
);
}
buildForTasks(args: {
teamName: string;
tasks: TeamTask[];
messages: RawTaskActivityMessage[];
}): Map<string, BoardTaskActivityRecord[]> {
const lookup = buildTaskLookup(args.tasks);
const records: BoardTaskActivityRecord[] = [];
const seenIds = new Set<string>();
const recordsByTaskId = new Map<string, BoardTaskActivityRecord[]>();
const seenIdsByTaskId = new Map<string, Set<string>>();
for (const message of args.messages) {
const actionMap = buildActionMap(message.boardTaskToolActions);
for (const link of message.boardTaskLinks) {
const resolvedTask = resolveLocatorToTaskRef(args.teamName, link.task, lookup);
if (
resolvedTask.taskRef?.taskId !== args.targetTask.id &&
!locatorCouldMatchTask(link.task, args.targetTask, lookup)
) {
const candidateTaskIds = resolveCandidateTaskIds(link.task, lookup);
if (candidateTaskIds.length === 0) {
continue;
}
const action =
link.linkKind === 'execution' || !link.toolUseId
? undefined
: actionMap.get(link.toolUseId);
const peerTask = resolvePeerTask(
args.teamName,
link,
message.boardTaskLinks,
args.targetTask,
lookup
);
const record: BoardTaskActivityRecord = {
id: [
message.uuid,
link.toolUseId ?? 'ambient',
link.task.ref,
link.targetRole,
link.linkKind,
].join(':'),
timestamp: message.timestamp,
task: resolvedTask,
linkKind: link.linkKind,
targetRole: link.targetRole,
actor: resolveActivityActor(message),
actorContext: buildActorContext(args.teamName, link.actorContext, lookup),
...(action ? { action: buildAction({ action, link, peerTask }) } : {}),
source: {
messageUuid: message.uuid,
filePath: message.filePath,
...(link.toolUseId ? { toolUseId: link.toolUseId } : {}),
sourceOrder: message.sourceOrder,
},
};
if (seenIds.has(record.id)) {
continue;
for (const taskId of candidateTaskIds) {
const targetTask = lookup.byId.get(taskId);
if (!targetTask) {
continue;
}
if (
resolvedTask.taskRef?.taskId !== targetTask.id &&
!locatorCouldMatchTask(link.task, targetTask, lookup)
) {
continue;
}
const peerTask = resolvePeerTask(
args.teamName,
link,
message.boardTaskLinks,
targetTask,
lookup
);
const record: BoardTaskActivityRecord = {
id: [
message.uuid,
link.toolUseId ?? 'ambient',
link.task.ref,
link.targetRole,
link.linkKind,
].join(':'),
timestamp: message.timestamp,
task: resolvedTask,
linkKind: link.linkKind,
targetRole: link.targetRole,
actor: resolveActivityActor(message),
actorContext: buildActorContext(args.teamName, link.actorContext, lookup),
...(action ? { action: buildAction({ action, link, peerTask }) } : {}),
source: {
messageUuid: message.uuid,
filePath: message.filePath,
...(link.toolUseId ? { toolUseId: link.toolUseId } : {}),
sourceOrder: message.sourceOrder,
},
};
const seenIds = seenIdsByTaskId.get(taskId) ?? new Set<string>();
if (seenIds.has(record.id)) {
continue;
}
seenIds.add(record.id);
seenIdsByTaskId.set(taskId, seenIds);
const taskRecords = recordsByTaskId.get(taskId) ?? [];
taskRecords.push(record);
recordsByTaskId.set(taskId, taskRecords);
}
seenIds.add(record.id);
records.push(record);
}
}
return records.sort(compareRecords);
for (const [taskId, records] of recordsByTaskId) {
recordsByTaskId.set(taskId, records.sort(compareRecords));
}
return recordsByTaskId;
}
}

View file

@ -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 {

View file

@ -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;
}
// =============================================================================

View file

@ -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,

View file

@ -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,

View file

@ -4,6 +4,7 @@ import { parentPort } from 'node:worker_threads';
import { normalizePersistedLaunchSnapshot } from '@main/services/team/TeamLaunchStateEvaluator';
import { isLeadMember } from '@shared/utils/leadDetection';
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
interface ListTeamsPayload {
teamsDir: string;
@ -593,6 +594,11 @@ async function listTeams(
dropCliProvisionerMembers(memberMap);
const members = Array.from(memberMap.values());
const memberColors = buildTeamMemberColorMap(members, { preferProvidedColors: false });
const coloredMembers = members.map((member) => ({
...member,
color: memberColors.get(member.name) ?? member.color,
}));
const launchStateSummary =
(await readLaunchState(payload.teamsDir, teamName)) ??
(() => {
@ -623,7 +629,7 @@ async function listTeams(
memberCount: memberMap.size,
taskCount: 0,
lastActivity: null,
...(members.length > 0 ? { members } : {}),
...(coloredMembers.length > 0 ? { members: coloredMembers } : {}),
...(color ? { color } : {}),
...(projectPath ? { projectPath } : {}),
...(leadSessionId ? { leadSessionId } : {}),

View file

@ -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'),

View file

@ -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');

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

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