From 24e19887d29ee30d8ae3049699aa804b1cc6cabe Mon Sep 17 00:00:00 2001 From: zhangziming Date: Sun, 5 Apr 2026 23:00:37 +0800 Subject: [PATCH 1/2] feat: add codex backend support for ccb Let CCB run its main conversation loop against codex app-server while keeping the existing Anthropic-compatible path working. Isolate the fork branding and config so it can coexist with the official Claude Code install. --- AGENTS.md | 179 ++++ README.md | 18 + .../add-codex-backend-to-ccb/.openspec.yaml | 2 + .../add-codex-backend-to-ccb/design.md | 87 ++ .../add-codex-backend-to-ccb/proposal.md | 25 + .../specs/codex-backend/spec.md | 37 + .../changes/add-codex-backend-to-ccb/tasks.md | 23 + openspec/config.yaml | 20 + package.json | 1 + src/components/HelpV2/HelpV2.tsx | 3 +- src/components/LogoV2/CondensedLogo.tsx | 3 +- src/components/LogoV2/LogoV2.tsx | 5 +- src/components/LogoV2/WelcomeV2.tsx | 7 +- src/entrypoints/cli.tsx | 3 +- src/main.tsx | 29 +- src/query/deps.ts | 2 +- src/services/api/backend.ts | 55 ++ src/services/api/bootstrap.ts | 7 +- src/services/api/codex.ts | 845 ++++++++++++++++++ src/services/api/metricsOptOut.ts | 5 + src/utils/appIdentity.ts | 13 + src/utils/auth.ts | 12 +- src/utils/doctorDiagnostic.ts | 35 +- src/utils/env.ts | 16 +- src/utils/envUtils.ts | 13 +- src/utils/localInstaller.ts | 20 +- src/utils/model/__tests__/codexModels.test.ts | 58 ++ src/utils/model/__tests__/providers.test.ts | 25 +- src/utils/model/codexModels.ts | 112 +++ src/utils/model/model.ts | 10 +- src/utils/model/modelOptions.ts | 39 +- src/utils/model/providers.ts | 11 + src/utils/model/validateModel.ts | 12 +- src/utils/nativeInstaller/installer.ts | 22 +- src/utils/settings/settings.ts | 5 +- src/utils/shellConfig.ts | 23 +- src/utils/status.tsx | 13 +- src/utils/tmuxSocket.ts | 3 +- src/utils/userAgent.ts | 4 +- src/utils/worktree.ts | 3 +- 40 files changed, 1721 insertions(+), 84 deletions(-) create mode 100644 AGENTS.md create mode 100644 openspec/changes/add-codex-backend-to-ccb/.openspec.yaml create mode 100644 openspec/changes/add-codex-backend-to-ccb/design.md create mode 100644 openspec/changes/add-codex-backend-to-ccb/proposal.md create mode 100644 openspec/changes/add-codex-backend-to-ccb/specs/codex-backend/spec.md create mode 100644 openspec/changes/add-codex-backend-to-ccb/tasks.md create mode 100644 openspec/config.yaml create mode 100644 src/services/api/backend.ts create mode 100644 src/services/api/codex.ts create mode 100644 src/utils/appIdentity.ts create mode 100644 src/utils/model/__tests__/codexModels.test.ts create mode 100644 src/utils/model/codexModels.ts diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..c804ded04 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,179 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. + +## Project Overview + +This is a **reverse-engineered / decompiled** version of Anthropic's official Codex CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. The codebase has ~1341 tsc errors from decompilation (mostly `unknown`/`never`/`{}` types) — these do **not** block Bun runtime execution. + +## Commands + +```bash +# Install dependencies +bun install + +# Dev mode (runs cli.tsx with MACRO defines injected via -d flags) +bun run dev + +# Dev mode with debugger (set BUN_INSPECT=9229 to pick port) +bun run dev:inspect + +# Pipe mode +echo "say hello" | bun run src/entrypoints/cli.tsx -p + +# Build (code splitting, outputs dist/cli.js + ~450 chunk files) +bun run build + +# Test +bun test # run all tests +bun test src/utils/__tests__/hash.test.ts # run single file +bun test --coverage # with coverage report + +# Lint & Format (Biome) +bun run lint # check only +bun run lint:fix # auto-fix +bun run format # format all src/ + +# Health check +bun run health + +# Check unused exports +bun run check:unused + +# Docs dev server (Mintlify) +bun run docs:dev +``` + +详细的测试规范、覆盖状态和改进计划见 `docs/testing-spec.md`。 + +## Architecture + +### Runtime & Build + +- **Runtime**: Bun (not Node.js). All imports, builds, and execution use Bun APIs. +- **Build**: `build.ts` 执行 `Bun.build()` with `splitting: true`,入口 `src/entrypoints/cli.tsx`,输出 `dist/cli.js` + chunk files。默认启用 `AGENT_TRIGGERS_REMOTE` feature。构建后自动替换 `import.meta.require` 为 Node.js 兼容版本(产物 bun/node 都可运行)。 +- **Dev mode**: `scripts/dev.ts` 通过 Bun `-d` flag 注入 `MACRO.*` defines,运行 `src/entrypoints/cli.tsx`。默认启用 `BUDDY`、`TRANSCRIPT_CLASSIFIER`、`BRIDGE_MODE`、`AGENT_TRIGGERS_REMOTE` 四个 feature。 +- **Module system**: ESM (`"type": "module"`), TSX with `react-jsx` transform. +- **Monorepo**: Bun workspaces — internal packages live in `packages/` resolved via `workspace:*`. +- **Lint/Format**: Biome (`biome.json`)。`bun run lint` / `bun run lint:fix` / `bun run format`。 +- **Defines**: 集中管理在 `scripts/defines.ts`。当前版本 `2.1.888`。 + +### Entry & Bootstrap + +1. **`src/entrypoints/cli.tsx`** — True entrypoint。`main()` 函数按优先级处理多条快速路径: + - `--version` / `-v` — 零模块加载 + - `--dump-system-prompt` — feature-gated (DUMP_SYSTEM_PROMPT) + - `--Codex-in-chrome-mcp` / `--chrome-native-host` + - `--daemon-worker=` — feature-gated (DAEMON) + - `remote-control` / `rc` / `bridge` — feature-gated (BRIDGE_MODE) + - `daemon` — feature-gated (DAEMON) + - `ps` / `logs` / `attach` / `kill` / `--bg` — feature-gated (BG_SESSIONS) + - `--tmux` + `--worktree` 组合 + - 默认路径:加载 `main.tsx` 启动完整 CLI +2. **`src/main.tsx`** (~4680 行) — Commander.js CLI definition。注册大量 subcommands:`mcp` (serve/add/remove/list...)、`server`、`ssh`、`open`、`auth`、`plugin`、`agents`、`auto-mode`、`doctor`、`update` 等。主 `.action()` 处理器负责权限、MCP、会话恢复、REPL/Headless 模式分发。 +3. **`src/entrypoints/init.ts`** — One-time initialization (telemetry, config, trust dialog)。 + +### Core Loop + +- **`src/query.ts`** — The main API query function. Sends messages to Codex API, handles streaming responses, processes tool calls, and manages the conversation turn loop. +- **`src/QueryEngine.ts`** — Higher-level orchestrator wrapping `query()`. Manages conversation state, compaction, file history snapshots, attribution, and turn-level bookkeeping. Used by the REPL screen. +- **`src/screens/REPL.tsx`** — The interactive REPL screen (React/Ink component). Handles user input, message display, tool permission prompts, and keyboard shortcuts. + +### API Layer + +- **`src/services/api/Codex.ts`** — Core API client. Builds request params (system prompt, messages, tools, betas), calls the Anthropic SDK streaming endpoint, and processes `BetaRawMessageStreamEvent` events. +- Supports multiple providers: Anthropic direct, AWS Bedrock, Google Vertex, Azure. +- Provider selection in `src/utils/model/providers.ts`. + +### Tool System + +- **`src/Tool.ts`** — Tool interface definition (`Tool` type) and utilities (`findToolByName`, `toolMatchesName`). +- **`src/tools.ts`** — Tool registry. Assembles the tool list; some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`. +- **`src/tools//`** — 61 个 tool 目录(如 BashTool, FileEditTool, GrepTool, AgentTool, WebFetchTool, LSPTool, MCPTool 等)。每个 tool 包含 `name`、`description`、`inputSchema`、`call()` 及可选的 React 渲染组件。 +- **`src/tools/shared/`** — Tool 共享工具函数。 + +### UI Layer (Ink) + +- **`src/ink.ts`** — Ink render wrapper with ThemeProvider injection. +- **`src/ink/`** — Custom Ink framework (forked/internal): custom reconciler, hooks (`useInput`, `useTerminalSize`, `useSearchHighlight`), virtual list rendering. +- **`src/components/`** — 大量 React 组件(170+ 项),渲染于终端 Ink 环境中。关键组件: + - `App.tsx` — Root provider (AppState, Stats, FpsMetrics) + - `Messages.tsx` / `MessageRow.tsx` — Conversation message rendering + - `PromptInput/` — User input handling + - `permissions/` — Tool permission approval UI + - `design-system/` — 复用 UI 组件(Dialog, FuzzyPicker, ProgressBar, ThemeProvider 等) +- Components use React Compiler runtime (`react/compiler-runtime`) — decompiled output has `_c()` memoization calls throughout. + +### State Management + +- **`src/state/AppState.tsx`** — Central app state type and context provider. Contains messages, tools, permissions, MCP connections, etc. +- **`src/state/AppStateStore.ts`** — Default state and store factory. +- **`src/state/store.ts`** — Zustand-style store for AppState (`createStore`). +- **`src/state/selectors.ts`** — State selectors. +- **`src/bootstrap/state.ts`** — Module-level singletons for session-global state (session ID, CWD, project root, token counts, model overrides, client type, permission mode). + +### Bridge / Remote Control + +- **`src/bridge/`** (~35 files) — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts`。 +- CLI 快速路径: `Codex remote-control` / `Codex rc` / `Codex bridge`。 + +### Daemon Mode + +- **`src/daemon/`** — Daemon 模式(长驻 supervisor)。feature-gated by `DAEMON`。包含 `main.ts`(entry)和 `workerRegistry.ts`(worker 管理)。 + +### Context & System Prompt + +- **`src/context.ts`** — Builds system/user context for the API call (git status, date, AGENTS.md contents, memory files). +- **`src/utils/claudemd.ts`** — Discovers and loads AGENTS.md files from project hierarchy. + +### Feature Flag System + +Feature flags control which functionality is enabled at runtime: + +- **在代码中使用**: 统一通过 `import { feature } from 'bun:bundle'` 导入,调用 `feature('FLAG_NAME')` 返回 `boolean`。**不要**在 `cli.tsx` 或其他文件里自己定义 `feature` 函数或覆盖这个 import。 +- **启用方式**: 通过环境变量 `FEATURE_=1`。例如 `FEATURE_BUDDY=1 bun run dev` 启用 BUDDY 功能。 +- **Dev 默认 features**: `BUDDY`、`TRANSCRIPT_CLASSIFIER`、`BRIDGE_MODE`、`AGENT_TRIGGERS_REMOTE`(见 `scripts/dev.ts`)。 +- **Build 默认 features**: `AGENT_TRIGGERS_REMOTE`(见 `build.ts`)。 +- **常见 flag**: `BUDDY`, `DAEMON`, `BRIDGE_MODE`, `BG_SESSIONS`, `PROACTIVE`, `KAIROS`, `VOICE_MODE`, `FORK_SUBAGENT`, `SSH_REMOTE`, `DIRECT_CONNECT`, `TEMPLATES`, `CHICAGO_MCP`, `BYOC_ENVIRONMENT_RUNNER`, `SELF_HOSTED_RUNNER`, `COORDINATOR_MODE`, `UDS_INBOX`, `LODESTONE`, `ABLATION_BASELINE` 等。 +- **类型声明**: `src/types/internal-modules.d.ts` 中声明了 `bun:bundle` 模块的 `feature` 函数签名。 + +**新增功能的正确做法**: 保留 `import { feature } from 'bun:bundle'` + `feature('FLAG_NAME')` 的标准模式,在运行时通过环境变量或配置控制,不要绕过 feature flag 直接 import。 + +### Stubbed/Deleted Modules + +| Module | Status | +|--------|--------| +| Computer Use (`@ant/*`) | Stub packages in `packages/@ant/` | +| `*-napi` packages (audio, image, url, modifiers) | Stubs in `packages/` (except `color-diff-napi` which is fully implemented) | +| Analytics / GrowthBook / Sentry | Empty implementations | +| Magic Docs / Voice Mode / LSP Server | Removed | +| Plugins / Marketplace | Removed | +| MCP OAuth | Simplified | + +### Key Type Files + +- **`src/types/global.d.ts`** — Declares `MACRO`, `BUILD_TARGET`, `BUILD_ENV` and internal Anthropic-only identifiers. +- **`src/types/internal-modules.d.ts`** — Type declarations for `bun:bundle`, `bun:ffi`, `@anthropic-ai/mcpb`. +- **`src/types/message.ts`** — Message type hierarchy (UserMessage, AssistantMessage, SystemMessage, etc.). +- **`src/types/permissions.ts`** — Permission mode and result types. + +## Testing + +- **框架**: `bun:test`(内置断言 + mock) +- **单元测试**: 就近放置于 `src/**/__tests__/`,文件名 `.test.ts` +- **集成测试**: `tests/integration/` — 4 个文件(cli-arguments, context-build, message-pipeline, tool-chain) +- **共享 mock/fixture**: `tests/mocks/`(api-responses, file-system, fixtures/) +- **命名**: `describe("functionName")` + `test("behavior description")`,英文 +- **Mock 模式**: 对重依赖模块使用 `mock.module()` + `await import()` 解锁(必须内联在测试文件中,不能从共享 helper 导入) +- **当前状态**: ~1623 tests / 114 files (110 unit + 4 integration) / 0 fail(详见 `docs/testing-spec.md`) + +## Working with This Codebase + +- **Don't try to fix all tsc errors** — they're from decompilation and don't affect runtime. +- **Feature flags** — 默认全部关闭(`feature()` 返回 `false`)。Dev/build 各有自己的默认启用列表。不要在 `cli.tsx` 中重定义 `feature` 函数。 +- **React Compiler output** — Components have decompiled memoization boilerplate (`const $ = _c(N)`). This is normal. +- **`bun:bundle` import** — `import { feature } from 'bun:bundle'` 是 Bun 内置模块,由运行时/构建器解析。不要用自定义函数替代它。 +- **`src/` path alias** — tsconfig maps `src/*` to `./src/*`. Imports like `import { ... } from 'src/utils/...'` are valid. +- **MACRO defines** — 集中管理在 `scripts/defines.ts`。Dev mode 通过 `bun -d` 注入,build 通过 `Bun.build({ define })` 注入。修改版本号等常量只改这个文件。 +- **构建产物兼容 Node.js** — `build.ts` 会自动后处理 `import.meta.require`,产物可直接用 `node dist/cli.js` 运行。 +- **Biome 配置** — 大量 lint 规则被关闭(decompiled 代码不适合严格 lint)。`.tsx` 文件用 120 行宽 + 强制分号;其他文件 80 行宽 + 按需分号。 diff --git a/README.md b/README.md index 1dfb92cb5..b5cb6157d 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,24 @@ bun run build > 支持所有 Anthropic API 兼容服务(如 OpenRouter、AWS Bedrock 代理等),只要接口兼容 Messages API 即可。 +### 实验性:使用 Codex 作为主对话后端 + +如果你已经本机登录了 `codex`,可以让 CCB 的主对话回合改走 Codex app-server: + +```bash +CLAUDE_CODE_USE_CODEX=1 bun run dev +``` + +- 默认连接 `ws://127.0.0.1:7788` +- 如果本地没有正在运行的 `codex app-server`,CCB 会尝试自动拉起一个 +- 依赖现有 `codex login` 状态 + +当前首版限制: + +- 只保证主 REPL 文本流式对话可用 +- 完整 tool-call parity 还未完成 +- 部分 side-query / helper 路径仍保留在现有 Anthropic 兼容后端 + ## Feature Flags 所有功能开关通过 `FEATURE_=1` 环境变量启用,例如: diff --git a/openspec/changes/add-codex-backend-to-ccb/.openspec.yaml b/openspec/changes/add-codex-backend-to-ccb/.openspec.yaml new file mode 100644 index 000000000..c54c1379a --- /dev/null +++ b/openspec/changes/add-codex-backend-to-ccb/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-04 diff --git a/openspec/changes/add-codex-backend-to-ccb/design.md b/openspec/changes/add-codex-backend-to-ccb/design.md new file mode 100644 index 000000000..e1ee686de --- /dev/null +++ b/openspec/changes/add-codex-backend-to-ccb/design.md @@ -0,0 +1,87 @@ +## Context + +CCB's current runtime is centered around Anthropic-compatible Messages API calls. The main REPL loop, model selection, stream handling, and tool schema generation all assume Anthropic event shapes and provider semantics. At the same time, many users already have a working `codex login` session and want to use Codex from inside CCB without giving up CCB's local agent shell, commands, and transcript UI. + +This change is cross-cutting because it affects the main query path, model/provider resolution, session lifecycle, and event normalization. Codex also introduces a different runtime boundary: instead of direct Messages API calls, CCB would need to speak to `codex app-server`, which uses thread/turn JSON-RPC semantics and may request token refresh during a session. + +## Goals / Non-Goals + +**Goals:** +- Introduce a provider-aware backend abstraction for the main conversation loop. +- Add a Codex backend that reuses the local `codex login` state via `codex app-server`. +- Support text streaming, model selection, turn interruption, and graceful startup/auth failure handling for Codex-backed sessions. +- Preserve the existing Anthropic-compatible backend path so current Claude-compatible provider setups continue to work unchanged. + +**Non-Goals:** +- Full tool-call parity between Anthropic and Codex in the first version. +- Migrating all side-query helpers, title generation, or classifier paths to Codex in the first version. +- Replacing CCB's existing tool registry or local orchestration model. + +## Decisions + +### 1. Add a backend layer above provider-specific transports +CCB will add a runtime backend abstraction for the main conversation flow instead of directly calling Anthropic-specific helpers from the query loop. This keeps the current Anthropic path intact while allowing a separate Codex implementation. + +Rationale: +- The current code is too coupled to Anthropic stream semantics to make a single `if provider === codex` branch maintainable. +- A backend boundary keeps Codex-specific protocol handling isolated. + +Alternatives considered: +- Patch `services/api/claude.ts` directly to also handle Codex. Rejected because it would mix two incompatible protocols into one module. +- Treat Codex as just another model string under the Anthropic backend. Rejected because the runtime, auth, and streaming protocol are different. + +### 2. First release is text-only for the main REPL loop +The first Codex backend will support user text input, streamed assistant output, interruption, and end-of-turn handling, but will not attempt full tool-call parity. + +Rationale: +- Tool bridging is the highest-risk part because Codex app-server uses server-driven tool call requests rather than Anthropic `tool_use` blocks. +- A text-only first version proves backend selection, auth reuse, and stream normalization before expanding scope. + +Alternatives considered: +- Implement tool bridging in the initial release. Rejected because it would expand the blast radius into permissions, tool orchestration, and transcript grouping before the backend contract is stable. + +### 3. Reuse `codex app-server` instead of reading Codex auth files directly +The Codex backend will communicate with the locally installed `codex app-server` and let that runtime own the Codex session model. + +Rationale: +- `codex login` state is already maintained by the Codex CLI. +- Reading auth files directly would be brittle and would not address in-session token refresh requests. + +Alternatives considered: +- Parse `~/.codex/auth.json` and call OpenAI APIs directly. Rejected because refresh semantics and file format stability are not guaranteed. +- Require an API key instead of reusing `codex login`. Rejected for this change because the stated goal is to reuse an existing Codex subscription session. + +### 4. Keep side queries on the existing backend initially +Only the main conversation loop will become backend-aware in this change. Smaller helper queries such as titles, hooks, or classifier paths remain on the existing Anthropic-compatible path until the main Codex backend is stable. + +Rationale: +- These helpers are scattered across the codebase and currently assume Anthropic-side helpers. +- Delaying them reduces risk while still unlocking the primary user value: using Codex as the main model runtime. + +Alternatives considered: +- Migrate all model consumers in one change. Rejected because it increases complexity and makes it harder to isolate regressions. + +## Risks / Trade-offs + +- **Different stream/event protocol** -> Introduce a normalization layer that translates Codex thread/turn events into CCB's internal message events before touching UI state. +- **Token refresh may be requested mid-session** -> Treat auth refresh and missing-login states as first-class backend errors with explicit user guidance. +- **Feature mismatch with Anthropic-specific paths** -> Limit first scope to the main REPL path and clearly defer tool parity and side-query migration. +- **Two backends increase maintenance cost** -> Keep the backend contract narrow and preserve the existing Anthropic implementation behind the same interface. +- **User confusion around backend/model choice** -> Separate provider selection from model selection so Codex models are not misrepresented as Claude family aliases. + +## Migration Plan + +1. Add the backend abstraction and wrap the current Anthropic runtime without changing user-visible behavior. +2. Add the Codex backend behind explicit provider selection. +3. Release Codex support as a text-only main-conversation option while leaving helper queries on the current backend. +4. Expand into tool parity and broader backend usage in follow-up changes once the session/runtime behavior is validated. + +Rollback strategy: +- Keep Anthropic as the default path. +- If the Codex backend is unstable, disable provider selection for Codex without removing the backend abstraction. + +## Open Questions + +- Which internal event shape should become the stable backend-neutral contract for streamed text, reasoning, and completion? +- Should unsupported tool-driven turns under Codex be blocked up front or surfaced as a structured backend limitation after the turn starts? +- When side queries are later migrated, should they use the active backend or remain configurable independently? diff --git a/openspec/changes/add-codex-backend-to-ccb/proposal.md b/openspec/changes/add-codex-backend-to-ccb/proposal.md new file mode 100644 index 000000000..13e805695 --- /dev/null +++ b/openspec/changes/add-codex-backend-to-ccb/proposal.md @@ -0,0 +1,25 @@ +## Why + +CCB currently assumes an Anthropic-compatible Messages API backend, which makes it easy to point at Claude-compatible providers but impossible to reuse a local Codex subscription directly. Users who already pay for Codex want to keep CCB's CLI workflow, slash commands, and local agent behavior while selecting Codex as the model runtime. + +## What Changes + +- Add a provider-aware backend selection layer so CCB can choose a model runtime per session instead of hard-wiring Anthropic semantics into the main query loop. +- Introduce a first Codex backend that talks to `codex app-server` and reuses the user's existing `codex login` state. +- Support Codex-backed text streaming for the main conversation flow, including provider/model selection, turn lifecycle, and interruption. +- Keep CCB's existing agent shell, local orchestration, and tool registry in place for phase one, while explicitly deferring full tool-call parity and side-query migration. +- Preserve the current Anthropic-compatible backend path so existing Claude-compatible configurations continue to work. + +## Capabilities + +### New Capabilities +- `codex-backend`: Allow CCB to run its main conversation loop against a Codex app-server backend, with provider/model selection and text-streaming responses. + +### Modified Capabilities + +## Impact + +- Affects the main query/runtime path, especially backend selection, model resolution, and streaming event handling. +- Touches the code around `src/query/deps.ts`, `src/services/api/claude.ts`, `src/utils/model/*`, and the internal message/event normalization path. +- Adds a runtime dependency on the local `codex` CLI and its app-server protocol when the Codex backend is selected. +- Keeps current Claude-compatible provider setups working, but introduces a second backend path with different protocol, auth refresh, and session semantics. diff --git a/openspec/changes/add-codex-backend-to-ccb/specs/codex-backend/spec.md b/openspec/changes/add-codex-backend-to-ccb/specs/codex-backend/spec.md new file mode 100644 index 000000000..437c006d6 --- /dev/null +++ b/openspec/changes/add-codex-backend-to-ccb/specs/codex-backend/spec.md @@ -0,0 +1,37 @@ +## ADDED Requirements + +### Requirement: User can select a Codex backend and Codex model for the main session +The system SHALL allow a session to choose the Codex backend independently from Anthropic-compatible backends, and SHALL expose Codex-specific model choices when that backend is active. + +#### Scenario: Switching from Anthropic-compatible backend to Codex +- **WHEN** the user selects the Codex backend for a session +- **THEN** the system switches the main conversation runtime to Codex for subsequent turns + +#### Scenario: Provider-aware model list +- **WHEN** the user opens model selection while the Codex backend is active +- **THEN** the system presents Codex-supported model identifiers instead of Claude-family aliases + +### Requirement: Codex backend reuses local Codex login and streams main-turn output +The system SHALL run the main conversation turn against a local `codex app-server` session, SHALL reuse the user's existing Codex login state, and SHALL stream assistant output back into the CCB transcript. + +#### Scenario: Successful Codex-backed turn +- **WHEN** the user sends a prompt in a session using the Codex backend and local Codex auth is available +- **THEN** the system starts a Codex-backed turn and streams assistant text into the transcript until the turn completes + +#### Scenario: Missing or invalid Codex login +- **WHEN** the user sends a prompt in a session using the Codex backend and local Codex auth is unavailable or rejected +- **THEN** the system surfaces a clear setup or re-login action instead of silently falling back to another backend + +### Requirement: Codex-backed sessions can be interrupted safely +The system SHALL let the user interrupt an in-flight Codex-backed turn and SHALL leave the session in a usable state for the next prompt. + +#### Scenario: User interrupts a Codex-backed turn +- **WHEN** the user interrupts an active Codex-backed turn +- **THEN** the system stops the active Codex turn and keeps the session available for another prompt + +### Requirement: Existing Anthropic-compatible backend behavior is preserved +The system SHALL keep the current Anthropic-compatible backend path available and SHALL not require Codex to be installed or selected for existing Claude-compatible workflows. + +#### Scenario: Existing Claude-compatible session remains unchanged +- **WHEN** the user continues using an Anthropic-compatible backend +- **THEN** the system keeps using the existing backend path without requiring Codex runtime setup diff --git a/openspec/changes/add-codex-backend-to-ccb/tasks.md b/openspec/changes/add-codex-backend-to-ccb/tasks.md new file mode 100644 index 000000000..defe5e449 --- /dev/null +++ b/openspec/changes/add-codex-backend-to-ccb/tasks.md @@ -0,0 +1,23 @@ +## 1. Backend abstraction + +- [x] 1.1 Identify the current main-turn call path and introduce a backend interface for session start, turn streaming, interruption, and completion events. +- [x] 1.2 Wrap the existing Anthropic-compatible flow in an `AnthropicBackend` implementation without changing current user-visible behavior. +- [x] 1.3 Add provider-aware backend selection wiring so the main query loop resolves a backend before each turn. + +## 2. Codex runtime integration + +- [x] 2.1 Implement a `CodexBackend` that starts or connects to `codex app-server`, initializes a session, and manages thread/turn lifecycle. +- [x] 2.2 Translate Codex stream notifications into CCB's internal text/reasoning/completion events for the main transcript. +- [x] 2.3 Handle Codex auth and startup failures, including missing login, rejected auth, and refresh-related errors with clear user guidance. +- [x] 2.4 Implement safe interruption for in-flight Codex turns and verify the session remains reusable afterward. + +## 3. Provider and model selection + +- [x] 3.1 Extend provider selection to include Codex as a first-class backend option. +- [x] 3.2 Make model selection backend-aware so Codex sessions show Codex-supported model identifiers instead of Claude aliases. +- [x] 3.3 Preserve existing Anthropic-compatible defaults and ensure non-Codex sessions continue to use the current backend path unchanged. + +## 4. Validation and follow-up guardrails + +- [x] 4.1 Add focused tests or runtime checks for backend selection, successful Codex text streaming, auth failure handling, and interruption behavior. +- [x] 4.2 Document first-release scope limits, especially that full tool-call parity and broad side-query migration are deferred. diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 000000000..392946c67 --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,20 @@ +schema: spec-driven + +# Project context (optional) +# This is shown to AI when creating artifacts. +# Add your tech stack, conventions, style guides, domain knowledge, etc. +# Example: +# context: | +# Tech stack: TypeScript, React, Node.js +# We use conventional commits +# Domain: e-commerce platform + +# Per-artifact rules (optional) +# Add custom rules for specific artifacts. +# Example: +# rules: +# proposal: +# - Keep proposals under 500 words +# - Always include a "Non-goals" section +# tasks: +# - Break tasks into chunks of max 2 hours diff --git a/package.json b/package.json index ca25ed5eb..0668e173b 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "scripts/download-ripgrep.ts" ], "scripts": { + "ccb": "bun run scripts/dev.ts", "build": "bun run build.ts", "dev": "bun run scripts/dev.ts", "dev:inspect": "bun run scripts/dev-debug.ts", diff --git a/src/components/HelpV2/HelpV2.tsx b/src/components/HelpV2/HelpV2.tsx index e81421fb9..7244a7d8b 100644 --- a/src/components/HelpV2/HelpV2.tsx +++ b/src/components/HelpV2/HelpV2.tsx @@ -7,6 +7,7 @@ import { useIsInsideModal } from '../../context/modalContext.js'; import { useTerminalSize } from '../../hooks/useTerminalSize.js'; import { Box, Link, Text } from '../../ink.js'; import { useKeybinding } from '../../keybindings/useKeybinding.js'; +import { APP_DISPLAY_NAME } from '../../utils/appIdentity.js'; import { Pane } from '../design-system/Pane.js'; import { Tab, Tabs } from '../design-system/Tabs.js'; import { Commands } from './Commands.js'; @@ -138,7 +139,7 @@ export function HelpV2(t0) { const t5 = insideModal ? undefined : maxHeight; let t6; if ($[31] !== tabs) { - t6 = {tabs}; + t6 = {tabs}; $[31] = tabs; $[32] = t6; } else { diff --git a/src/components/LogoV2/CondensedLogo.tsx b/src/components/LogoV2/CondensedLogo.tsx index 2f2d6307b..d1a806993 100644 --- a/src/components/LogoV2/CondensedLogo.tsx +++ b/src/components/LogoV2/CondensedLogo.tsx @@ -16,6 +16,7 @@ import { AnimatedClawd } from './AnimatedClawd.js'; import { Clawd } from './Clawd.js'; import { GuestPassesUpsell, incrementGuestPassesSeenCount, useShowGuestPassesUpsell } from './GuestPassesUpsell.js'; import { incrementOverageCreditUpsellSeenCount, OverageCreditUpsell, useShowOverageCreditUpsell } from './OverageCreditUpsell.js'; +import { APP_DISPLAY_NAME } from '../../utils/appIdentity.js'; export function CondensedLogo() { const $ = _c(29); const { @@ -88,7 +89,7 @@ export function CondensedLogo() { } let t5; if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t5 = Claude Code; + t5 = {APP_DISPLAY_NAME}; $[8] = t5; } else { t5 = $[8]; diff --git a/src/components/LogoV2/LogoV2.tsx b/src/components/LogoV2/LogoV2.tsx index 3d3359838..defff479b 100644 --- a/src/components/LogoV2/LogoV2.tsx +++ b/src/components/LogoV2/LogoV2.tsx @@ -43,6 +43,7 @@ import { useAppState } from '../../state/AppState.js'; import { getEffortSuffix } from '../../utils/effort.js'; import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'; import { renderModelSetting } from '../../utils/model/model.js'; +import { APP_DISPLAY_NAME } from '../../utils/appIdentity.js'; const LEFT_PANEL_MAX_WIDTH = 50; export function LogoV2() { const $ = _c(94); @@ -248,8 +249,8 @@ export function LogoV2() { } const layoutMode = getLayoutMode(columns); const userTheme = resolveThemeSetting(getGlobalConfig().theme); - const borderTitle = ` ${color("claude", userTheme)("Claude Code")} ${color("inactive", userTheme)(`v${version}`)} `; - const compactBorderTitle = color("claude", userTheme)(" Claude Code "); + const borderTitle = ` ${color("claude", userTheme)(APP_DISPLAY_NAME)} ${color("inactive", userTheme)(`v${version}`)} `; + const compactBorderTitle = color("claude", userTheme)(` ${APP_DISPLAY_NAME} `); if (layoutMode === "compact") { let welcomeMessage = formatWelcomeMessage(username); if (stringWidth(welcomeMessage) > columns - 4) { diff --git a/src/components/LogoV2/WelcomeV2.tsx b/src/components/LogoV2/WelcomeV2.tsx index 0094ef170..a2cde5e4a 100644 --- a/src/components/LogoV2/WelcomeV2.tsx +++ b/src/components/LogoV2/WelcomeV2.tsx @@ -1,6 +1,7 @@ import { c as _c } from "react/compiler-runtime"; import React from 'react'; import { Box, Text, useTheme } from 'src/ink.js'; +import { APP_DISPLAY_NAME } from '../../utils/appIdentity.js'; import { env } from '../../utils/env.js'; const WELCOME_V2_WIDTH = 58; export function WelcomeV2() { @@ -9,7 +10,7 @@ export function WelcomeV2() { if (env.terminal === "Apple_Terminal") { let t0; if ($[0] !== theme) { - t0 = ; + t0 = ; $[0] = theme; $[1] = t0; } else { @@ -28,7 +29,7 @@ export function WelcomeV2() { let t7; let t8; if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t0 = {"Welcome to Claude Code"} v{MACRO.VERSION} ; + t0 = {`Welcome to ${APP_DISPLAY_NAME}`} v{MACRO.VERSION} ; t1 = {"\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026"}; t2 = {" "}; t3 = {" "}; @@ -113,7 +114,7 @@ export function WelcomeV2() { let t5; let t6; if ($[18] === Symbol.for("react.memo_cache_sentinel")) { - t0 = {"Welcome to Claude Code"} v{MACRO.VERSION} ; + t0 = {`Welcome to ${APP_DISPLAY_NAME}`} v{MACRO.VERSION} ; t1 = {"\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026"}; t2 = {" "}; t3 = {" * \u2588\u2588\u2588\u2588\u2588\u2593\u2593\u2591 "}; diff --git a/src/entrypoints/cli.tsx b/src/entrypoints/cli.tsx index fa377cbc3..c2c5b879e 100644 --- a/src/entrypoints/cli.tsx +++ b/src/entrypoints/cli.tsx @@ -1,5 +1,6 @@ #!/usr/bin/env bun import { feature } from 'bun:bundle'; +import { APP_FULL_NAME } from '../utils/appIdentity.js'; // Bugfix for corepack auto-pinning, which adds yarnpkg to peoples' package.jsons // eslint-disable-next-line custom-rules/no-top-level-side-effects @@ -46,7 +47,7 @@ async function main(): Promise { if (args.length === 1 && (args[0] === '--version' || args[0] === '-v' || args[0] === '-V')) { // MACRO.VERSION is inlined at build time // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(`${MACRO.VERSION} (Claude Code)`); + console.log(`${MACRO.VERSION} (${APP_FULL_NAME})`); return; } diff --git a/src/main.tsx b/src/main.tsx index ccb6097a0..9a6ea5735 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -26,6 +26,7 @@ import mapValues from 'lodash-es/mapValues.js'; import pickBy from 'lodash-es/pickBy.js'; import uniqBy from 'lodash-es/uniqBy.js'; import React from 'react'; +import { APP_COMMAND, APP_DISPLAY_NAME, APP_FULL_NAME } from './utils/appIdentity.js'; import { getOauthConfig } from './constants/oauth.js'; import { getRemoteSessionUrl } from './constants/product.js'; import { getSystemContext, getUserContext } from './context.js'; @@ -117,6 +118,7 @@ import { logError } from './utils/log.js'; import { getModelDeprecationWarning } from './utils/model/deprecation.js'; import { getDefaultMainLoopModel, getUserSpecifiedModelSetting, normalizeModelStringForAPI, parseUserSpecifiedModel } from './utils/model/model.js'; import { ensureModelStringsInitialized } from './utils/model/modelStrings.js'; +import { isCodexBackendEnabled } from './utils/model/providers.js'; import { PERMISSION_MODES } from './utils/permissions/PermissionMode.js'; import { checkAndDisableBypassPermissions, getAutoModeEnabledStateIfCached, initializeToolPermissionContext, initialPermissionModeFromCLI, isDefaultPermissionModeAuto, parseToolListFromCLI, removeDangerousPermissions, stripDangerousPermissionsForAutoMode, verifyAutoModeGateAccess } from './utils/permissions/permissionSetup.js'; import { cleanupOrphanedPluginVersionsInBackground } from './utils/plugins/cacheUtils.js'; @@ -397,6 +399,17 @@ export function startDeferredPrefetches(): void { return; } + if (isCodexBackendEnabled()) { + void getUserContext(); + prefetchSystemContextIfSafe(); + void countFilesRoundedRg(getCwd(), AbortSignal.timeout(3000), []); + void settingsChangeDetector.initialize(); + if (!isBareMode()) { + void skillChangeDetector.initialize(); + } + return; + } + // Process-spawning prefetches (consumed at first API call, user is still typing) void initUser(); void getUserContext(); @@ -917,7 +930,7 @@ async function run(): Promise { // terminal shell integration may mirror the process name to the tab. // After init() so settings.json env can also gate this (gh-4765). if (!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_TERMINAL_TITLE)) { - process.title = 'claude'; + process.title = APP_COMMAND; } // Attach logging sinks so subcommand handlers can use logEvent/logError. @@ -962,7 +975,7 @@ async function run(): Promise { } profileCheckpoint('preAction_after_settings_sync'); }); - program.name('claude').description(`Claude Code - starts an interactive session by default, use -p/--print for non-interactive output`).argument('[prompt]', 'Your prompt', String) + program.name(APP_COMMAND).description(`${APP_FULL_NAME} - starts an interactive session by default, use -p/--print for non-interactive output`).argument('[prompt]', 'Your prompt', String) // Subcommands inherit helpOption via commander's copyInheritedSettings — // setting it once here covers mcp, plugin, auth, and all other subcommands. .helpOption('-h, --help', 'Display help for command').option('-d, --debug [filter]', 'Enable debug mode with optional category filtering (e.g., "api,hooks" or "!1p,!file")', (_value: string | true) => { @@ -1016,9 +1029,9 @@ async function run(): Promise { if (prompt === 'code') { logEvent('tengu_code_prompt_ignored', {}); // biome-ignore lint/suspicious/noConsole:: intentional console output - console.warn(chalk.yellow('Tip: You can launch Claude Code with just `claude`')); - prompt = undefined; - } + console.warn(chalk.yellow(`Tip: You can launch ${APP_DISPLAY_NAME} with just \`${APP_COMMAND}\``)); + prompt = undefined; + } // Log event for any single-word prompt if (prompt && typeof prompt === 'string' && !/\s/.test(prompt) && prompt.length > 0) { @@ -1778,7 +1791,7 @@ async function run(): Promise { // two-phase loading). Kicked off here to overlap with setup(); awaited // before runHeadless so single-turn -p sees connectors. Skipped under // enterprise/strict MCP to preserve policy boundaries. - const claudeaiConfigPromise: Promise> = isNonInteractiveSession && !strictMcpConfig && !doesEnterpriseMcpConfigExist() && + const claudeaiConfigPromise: Promise> = isCodexBackendEnabled() ? Promise.resolve({}) : isNonInteractiveSession && !strictMcpConfig && !doesEnterpriseMcpConfigExist() && // --bare / SIMPLE: skip claude.ai proxy servers (datadog, Gmail, // Slack, BigQuery, PubMed — 6-14s each to connect). Scripted calls // that need MCP pass --mcp-config explicitly. @@ -2340,7 +2353,7 @@ async function run(): Promise { // mode doesn't apply to the Agent SDK anyway (see getFastModeUnavailableReason). const bgRefreshThrottleMs = getFeatureValue_CACHED_MAY_BE_STALE('tengu_cicada_nap_ms', 0); const lastPrefetched = getGlobalConfig().startupPrefetchedAt ?? 0; - const skipStartupPrefetches = isBareMode() || bgRefreshThrottleMs > 0 && Date.now() - lastPrefetched < bgRefreshThrottleMs; + const skipStartupPrefetches = isBareMode() || isCodexBackendEnabled() || bgRefreshThrottleMs > 0 && Date.now() - lastPrefetched < bgRefreshThrottleMs; if (!skipStartupPrefetches) { const lastPrefetchedInfo = lastPrefetched > 0 ? ` last ran ${Math.round((Date.now() - lastPrefetched) / 1000)}s ago` : ''; logForDebugging(`Starting background startup prefetches${lastPrefetchedInfo}`); @@ -3802,7 +3815,7 @@ async function run(): Promise { pendingHookMessages }, renderAndRun); } - }).version(`${MACRO.VERSION} (Claude Code)`, '-v, --version', 'Output the version number'); + }).version(`${MACRO.VERSION} (${APP_FULL_NAME})`, '-v, --version', 'Output the version number'); // Worktree flags program.option('-w, --worktree [name]', 'Create a new git worktree for this session (optionally specify a name)'); diff --git a/src/query/deps.ts b/src/query/deps.ts index 713688811..cd33b9a78 100644 --- a/src/query/deps.ts +++ b/src/query/deps.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'crypto' -import { queryModelWithStreaming } from '../services/api/claude.js' +import { queryModelWithStreaming } from '../services/api/backend.js' import { autoCompactIfNeeded } from '../services/compact/autoCompact.js' import { microcompactMessages } from '../services/compact/microCompact.js' diff --git a/src/services/api/backend.ts b/src/services/api/backend.ts new file mode 100644 index 000000000..a8b948143 --- /dev/null +++ b/src/services/api/backend.ts @@ -0,0 +1,55 @@ +import type { + AssistantMessage, + Message, + StreamEvent, + SystemAPIErrorMessage, +} from '../../types/message.js' +import type { Tools } from '../../Tool.js' +import type { SystemPrompt } from '../../utils/systemPromptType.js' +import type { ThinkingConfig } from '../../utils/thinking.js' +import { getMainLoopBackend } from '../../utils/model/providers.js' +import { + type Options, + queryModelWithStreaming as queryAnthropicModelWithStreaming, +} from './claude.js' +import { queryCodexWithStreaming } from './codex.js' + +export type MainLoopStreamArgs = { + messages: Message[] + systemPrompt: SystemPrompt + thinkingConfig: ThinkingConfig + tools: Tools + signal: AbortSignal + options: Options +} + +export type MainLoopBackendName = 'anthropic' | 'codex' + +export interface MainLoopBackendTransport { + readonly name: MainLoopBackendName + startSession?(args: MainLoopStreamArgs): Promise + streamTurn( + args: MainLoopStreamArgs, + ): AsyncGenerator + interruptTurn?(sessionId: string): Promise +} + +const anthropicBackend: MainLoopBackendTransport = { + name: 'anthropic', + streamTurn: queryAnthropicModelWithStreaming, +} + +const codexBackend: MainLoopBackendTransport = { + name: 'codex', + streamTurn: queryCodexWithStreaming, +} + +export function getMainLoopBackendTransport(): MainLoopBackendTransport { + return getMainLoopBackend() === 'codex' ? codexBackend : anthropicBackend +} + +export async function* queryModelWithStreaming( + args: MainLoopStreamArgs, +): AsyncGenerator { + yield* getMainLoopBackendTransport().streamTurn(args) +} diff --git a/src/services/api/bootstrap.ts b/src/services/api/bootstrap.ts index 4782295e7..b0a0572e4 100644 --- a/src/services/api/bootstrap.ts +++ b/src/services/api/bootstrap.ts @@ -12,7 +12,7 @@ import { logForDebugging } from '../../utils/debug.js' import { withOAuth401Retry } from '../../utils/http.js' import { lazySchema } from '../../utils/lazySchema.js' import { logError } from '../../utils/log.js' -import { getAPIProvider } from '../../utils/model/providers.js' +import { getAPIProvider, isCodexBackendEnabled } from '../../utils/model/providers.js' import { isEssentialTrafficOnly } from '../../utils/privacyLevel.js' import { getClaudeCodeUserAgent } from '../../utils/userAgent.js' @@ -50,6 +50,11 @@ async function fetchBootstrapAPI(): Promise { return null } + if (isCodexBackendEnabled()) { + logForDebugging('[Bootstrap] Skipped: Codex backend active') + return null + } + // OAuth preferred (requires user:profile scope — service-key OAuth tokens // lack it and would 403). Fall back to API key auth for console users. const apiKey = getAnthropicApiKey() diff --git a/src/services/api/codex.ts b/src/services/api/codex.ts new file mode 100644 index 000000000..ecc1bdbd5 --- /dev/null +++ b/src/services/api/codex.ts @@ -0,0 +1,845 @@ +import { createHash } from 'crypto' +import { readFileSync } from 'fs' +import { homedir } from 'os' +import { join } from 'path' +import { spawn } from 'child_process' +import { getSessionId } from '../../bootstrap/state.js' +import { getCwd } from '../../utils/cwd.js' +import { logForDebugging } from '../../utils/debug.js' +import { createAssistantAPIErrorMessage, createAssistantMessage } from '../../utils/messages.js' +import { normalizeModelStringForAPI } from '../../utils/model/model.js' +import type { + AssistantMessage, + Message, + StreamEvent, + SystemAPIErrorMessage, +} from '../../types/message.js' +import type { MainLoopStreamArgs } from './backend.js' + +type JsonRpcResponse = { + id?: number + result?: unknown + error?: { message?: string } + method?: string + params?: Record +} + +type CodexThreadSession = { + threadId: string + promptHash: string +} + +type CodexAuthState = { + accessToken: string + accountId: string + planType: string | null +} + +const DEFAULT_CODEX_APP_SERVER_URL = 'ws://127.0.0.1:7788' +const CODEX_AUTH_PATH = join(homedir(), '.codex', 'auth.json') +const RETRY_DELAY_MS = 300 +const MAX_CONNECT_ATTEMPTS = 12 +const REQUEST_TIMEOUT_MS = 15000 + +const codexSessions = new Map() + +function getCodexAppServerUrl(): string { + return process.env.CLAUDE_CODE_CODEX_APP_SERVER_URL ?? 'stdio://' +} + +function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +function isStdioTransport(url: string): boolean { + return url === 'stdio://' +} + +function decodeJwtPayload(token: string): Record | null { + const [, payload] = token.split('.') + if (!payload) return null + + try { + const normalized = payload.replace(/-/g, '+').replace(/_/g, '/') + const padding = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4)) + const decoded = Buffer.from(normalized + padding, 'base64').toString('utf8') + return JSON.parse(decoded) as Record + } catch { + return null + } +} + +function readCodexAuthState(): CodexAuthState | null { + try { + const raw = readFileSync(CODEX_AUTH_PATH, 'utf8') + const parsed = JSON.parse(raw) as { + tokens?: { + access_token?: string + account_id?: string + } + } + + const accessToken = parsed.tokens?.access_token + const accountId = parsed.tokens?.account_id + if (!accessToken || !accountId) { + return null + } + + const payload = decodeJwtPayload(accessToken) + const authPayload = payload?.['https://api.openai.com/auth'] + const planType = + authPayload && typeof authPayload === 'object' + ? ((authPayload as Record).chatgpt_plan_type as string | null | undefined) ?? null + : null + + return { accessToken, accountId, planType } + } catch { + return null + } +} + +function extractVisibleText(message: Message): string { + if (!message.message?.content) { + return '' + } + + if (typeof message.message.content === 'string') { + return message.message.content.trim() + } + + const parts: string[] = [] + for (const block of message.message.content) { + if (typeof block !== 'string' && block.type === 'text' && typeof block.text === 'string') { + parts.push(block.text) + } + } + return parts.join('\n').trim() +} + +function buildSeedPrompt(messages: Message[]): string { + const lines: string[] = [] + + for (const message of messages) { + if (message.type !== 'user' && message.type !== 'assistant') { + continue + } + if (message.isMeta) { + continue + } + + const text = extractVisibleText(message) + if (!text) { + continue + } + + const role = message.type === 'assistant' ? 'Assistant' : 'User' + lines.push(`${role}: ${text}`) + } + + const transcript = lines.join('\n\n').trim() + return transcript || 'Continue.' +} + +function getLatestUserPrompt(messages: Message[]): string { + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i] + if (!message || message.type !== 'user' || message.isMeta) { + continue + } + + const text = extractVisibleText(message) + if (text) { + return text + } + } + + return buildSeedPrompt(messages) +} + +function hashSystemPrompt(systemPrompt: readonly string[]): string { + return createHash('sha1').update(systemPrompt.join('\n\n')).digest('hex') +} + +type PendingRequest = { + resolve: (value: unknown) => void + reject: (reason?: unknown) => void +} + +class CodexAppServerClient { + private static instance: CodexAppServerClient | null = null + + static async getInstance(): Promise { + if (!CodexAppServerClient.instance) { + const client = new CodexAppServerClient(getCodexAppServerUrl()) + await client.ensureConnected() + CodexAppServerClient.instance = client + } + + return CodexAppServerClient.instance + } + + static async createEphemeral(): Promise { + const client = new CodexAppServerClient(getCodexAppServerUrl()) + await client.ensureConnected() + return client + } + + private child: ReturnType | null = null + private initialized = false + private nextId = 1 + private pending = new Map() + private listeners = new Set<(message: JsonRpcResponse) => void>() + private connectPromise: Promise | null = null + private stdoutBuffer = '' + + private constructor(private readonly url: string) {} + + subscribe(listener: (message: JsonRpcResponse) => void): () => void { + this.listeners.add(listener) + return () => { + this.listeners.delete(listener) + } + } + + async close(): Promise { + this.initialized = false + this.pending.clear() + this.listeners.clear() + + if (this.child) { + const child = this.child + this.child = null + await new Promise(resolve => { + const onClose = () => { + child.removeListener('close', onClose) + resolve() + } + child.once('close', onClose) + child.kill('SIGTERM') + setTimeout(() => resolve(), RETRY_DELAY_MS).unref?.() + }) + } + + if (CodexAppServerClient.instance === this) { + CodexAppServerClient.instance = null + } + } + + async request(method: string, params: Record): Promise { + if (!this.child || this.child.killed) { + await this.ensureConnected() + } + + const id = this.nextId++ + logForDebugging(`[codex] rpc request -> ${method}`) + const payload = JSON.stringify({ + jsonrpc: '2.0', + id, + method, + params, + }) + + const response = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pending.delete(id) + reject(new Error(`Codex app-server request timed out: ${method}`)) + }, REQUEST_TIMEOUT_MS) + try { + this.child?.stdin?.write(`${payload}\n`) + } catch (error) { + this.pending.delete(id) + clearTimeout(timeout) + reject(error) + return + } + this.pending.set(id, { + resolve: value => { + clearTimeout(timeout) + resolve(value as T) + }, + reject: reason => { + clearTimeout(timeout) + reject(reason) + }, + }) + }) + + return response + } + + private async ensureConnected(): Promise { + if (this.child && !this.child.killed && this.initialized) { + return + } + + if (this.connectPromise) { + return this.connectPromise + } + + this.connectPromise = this.connectWithRetry() + try { + await this.connectPromise + } finally { + this.connectPromise = null + } + } + + private async connectWithRetry(): Promise { + let lastError: unknown + + for (let attempt = 0; attempt < MAX_CONNECT_ATTEMPTS; attempt++) { + try { + logForDebugging(`[codex] connect attempt ${attempt + 1}/${MAX_CONNECT_ATTEMPTS}`) + await this.openTransport() + await this.initialize() + return + } catch (error) { + lastError = error + logForDebugging(`[codex] connect attempt failed: ${String(error)}`) + await delay(RETRY_DELAY_MS) + } + } + + throw lastError instanceof Error + ? lastError + : new Error('Unable to connect to codex app-server') + } + + private async openTransport(): Promise { + if (!isStdioTransport(this.url)) { + throw new Error(`Unsupported Codex transport: ${this.url}`) + } + + await new Promise((resolve, reject) => { + const child = spawn('codex', ['app-server', '--listen', this.url], { + stdio: ['pipe', 'pipe', 'pipe'], + }) + this.child = child + this.stdoutBuffer = '' + logForDebugging(`[codex] opening stdio transport ${this.url}`) + this.bindProcess(child) + + const onSpawn = () => { + cleanup() + logForDebugging('[codex] stdio transport open') + resolve() + } + const onError = (error: Error) => { + cleanup() + reject(error) + } + const cleanup = () => { + child.removeListener('spawn', onSpawn) + child.removeListener('error', onError) + } + + child.once('spawn', onSpawn) + child.once('error', onError) + }) + } + + private bindProcess(child: ReturnType): void { + child.stdout?.setEncoding('utf8') + child.stdout?.on('data', chunk => { + this.stdoutBuffer += chunk + let newlineIndex = this.stdoutBuffer.indexOf('\n') + while (newlineIndex !== -1) { + const line = this.stdoutBuffer.slice(0, newlineIndex).trim() + this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1) + if (line) { + this.handleMessage(line) + } + newlineIndex = this.stdoutBuffer.indexOf('\n') + } + }) + child.on('close', () => { + this.initialized = false + this.child = null + }) + child.on('error', error => { + logForDebugging(`Codex transport error: ${String(error)}`, { + level: 'warn', + }) + }) + child.stderr?.setEncoding('utf8') + child.stderr?.on('data', chunk => { + const text = chunk.trim() + if (text) { + logForDebugging(`[codex] stderr: ${text}`, { level: 'warn' }) + } + }) + } + + private async initialize(): Promise { + if (this.initialized) { + return + } + + logForDebugging('[codex] initialize start') + await this.request('initialize', { + clientInfo: { + name: 'ccb', + title: 'Claude Code Best', + version: typeof MACRO !== 'undefined' ? MACRO.VERSION : '0.0.0', + }, + capabilities: { + experimentalApi: false, + }, + }) + + this.initialized = true + logForDebugging('[codex] initialize ok') + } + + private async handleServerRequest(message: JsonRpcResponse): Promise { + if (!this.child || typeof message.id !== 'number' || !message.method) { + return + } + + if (message.method === 'account/chatgptAuthTokens/refresh') { + const auth = readCodexAuthState() + if (auth) { + this.child.stdin?.write( + JSON.stringify({ + jsonrpc: '2.0', + id: message.id, + result: { + accessToken: auth.accessToken, + chatgptAccountId: auth.accountId, + chatgptPlanType: auth.planType, + }, + }) + '\n', + ) + return + } + } + + const deniedResponse = + message.method === 'item/commandExecution/requestApproval' + ? { decision: 'decline' } + : message.method === 'item/fileChange/requestApproval' + ? { decision: 'decline' } + : message.method === 'item/permissions/requestApproval' + ? { permissions: {}, scope: 'turn' } + : message.method === 'execCommandApproval' || + message.method === 'applyPatchApproval' + ? { decision: 'denied' } + : null + + if (deniedResponse) { + this.child.stdin?.write( + JSON.stringify({ + jsonrpc: '2.0', + id: message.id, + result: deniedResponse, + }) + '\n', + ) + return + } + + this.child.stdin?.write( + JSON.stringify({ + jsonrpc: '2.0', + id: message.id, + error: { + code: -32001, + message: `Unsupported Codex app-server request: ${message.method}`, + }, + }) + '\n', + ) + } + + private handleMessage(raw: string): void { + let message: JsonRpcResponse + try { + message = JSON.parse(raw) as JsonRpcResponse + } catch (error) { + logForDebugging(`[codex] failed to parse websocket payload: ${String(error)}`, { + level: 'warn', + }) + return + } + + if (typeof message.id === 'number' && !message.method) { + logForDebugging(`[codex] rpc response <- ${message.id}${message.error ? ' error' : ''}`) + const pending = this.pending.get(message.id) + if (!pending) { + return + } + + this.pending.delete(message.id) + if (message.error) { + pending.reject(new Error(message.error.message || 'Codex app-server request failed')) + } else { + pending.resolve(message.result) + } + return + } + + if (typeof message.id === 'number' && message.method) { + void this.handleServerRequest(message) + return + } + + if (message.method) { + for (const listener of this.listeners) { + listener(message) + } + } + } +} + +function toStreamEvent(event: Record, ttftMs?: number): StreamEvent { + return { + type: 'stream_event', + event, + ...(ttftMs !== undefined ? { ttftMs } : {}), + } +} + +function buildAssistantMessage(text: string, itemId: string, model: string): AssistantMessage { + const assistant = createAssistantMessage({ content: text }) + assistant.message.id = itemId + assistant.message.model = model + assistant.message.stop_reason = 'stop_sequence' + return assistant +} + +async function startCodexThread( + client: CodexAppServerClient, + currentModel: string, + systemPrompt: readonly string[], +): Promise { + logForDebugging(`[codex] starting thread with model=${currentModel}`) + const response = await client.request<{ + thread: { id: string } + }>('thread/start', { + cwd: getCwd(), + model: currentModel, + modelProvider: 'openai', + developerInstructions: systemPrompt.join('\n\n'), + experimentalRawEvents: false, + persistExtendedHistory: false, + }) + logForDebugging(`[codex] thread started: ${response.thread.id}`) + return response.thread.id +} + +export async function* queryCodexWithStreaming({ + messages, + systemPrompt, + signal, + options, +}: MainLoopStreamArgs): AsyncGenerator< + StreamEvent | AssistantMessage | SystemAPIErrorMessage, + void +> { + logForDebugging('[codex] queryCodexWithStreaming start') + const codexAuth = readCodexAuthState() + if (!codexAuth) { + yield createAssistantAPIErrorMessage({ + content: + 'Codex backend requires an active local Codex login. Run `codex login` and try again.', + apiError: 'authentication_error', + }) + return + } + + let client: CodexAppServerClient + try { + client = options.isNonInteractiveSession + ? await CodexAppServerClient.createEphemeral() + : await CodexAppServerClient.getInstance() + logForDebugging('[codex] app-server connected') + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + yield createAssistantAPIErrorMessage({ + content: `Unable to start or connect to codex app-server: ${message}`, + apiError: 'connection_error', + }) + return + } + + const sessionId = getSessionId() + const promptHash = hashSystemPrompt(systemPrompt) + const currentModel = normalizeModelStringForAPI(options.model) + const existingSession = codexSessions.get(sessionId) + const needsFreshThread = + !existingSession || existingSession.promptHash !== promptHash + + let threadId = existingSession?.threadId + if (needsFreshThread) { + try { + threadId = await startCodexThread(client, currentModel, systemPrompt) + codexSessions.set(sessionId, { + threadId, + promptHash, + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + yield createAssistantAPIErrorMessage({ + content: `Failed to create a Codex session: ${message}`, + apiError: 'authentication_error', + }) + return + } + } + + if (!threadId) { + yield createAssistantAPIErrorMessage({ + content: 'Failed to resolve a Codex thread for this session.', + apiError: 'internal_error', + }) + return + } + + const promptText = needsFreshThread + ? buildSeedPrompt(messages) + : getLatestUserPrompt(messages) + + const notifications: JsonRpcResponse[] = [] + let resolveNextNotification: (() => void) | null = null + const pushNotification = (notification: JsonRpcResponse) => { + notifications.push(notification) + resolveNextNotification?.() + } + const unsubscribe = client.subscribe(notification => { + if (!notification.params || notification.params.threadId !== threadId) { + return + } + pushNotification(notification) + }) + + let activeTurnId: string | null = null + let textItemId: string | null = null + let textBlockStarted = false + let textBuffer = '' + let emittedMessageStart = false + let streamDone = false + let turnErrorMessage: string | null = null + const blockIndexByItemId = new Map() + let nextBlockIndex = 0 + const startedAt = Date.now() + + const abortHandler = () => { + if (activeTurnId) { + void client + .request('turn/interrupt', { threadId, turnId: activeTurnId }) + .catch(error => { + logForDebugging(`Failed to interrupt Codex turn: ${String(error)}`, { + level: 'warn', + }) + }) + } + } + signal.addEventListener('abort', abortHandler, { once: true }) + + try { + logForDebugging(`[codex] starting turn on thread=${threadId}`) + let turnStart + try { + turnStart = await client.request<{ + turn: { id: string } + }>('turn/start', { + threadId, + model: currentModel, + input: [{ type: 'text', text: promptText, text_elements: [] }], + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (message.includes('thread not found')) { + threadId = await startCodexThread(client, currentModel, systemPrompt) + codexSessions.set(sessionId, { + threadId, + promptHash, + }) + turnStart = await client.request<{ + turn: { id: string } + }>('turn/start', { + threadId, + model: currentModel, + input: [{ type: 'text', text: buildSeedPrompt(messages), text_elements: [] }], + }) + } else { + throw error + } + } + activeTurnId = turnStart.turn.id + logForDebugging(`[codex] turn started: ${activeTurnId}`) + + while (!streamDone) { + if (notifications.length === 0) { + await new Promise(resolve => { + resolveNextNotification = resolve + }) + resolveNextNotification = null + } + + while (notifications.length > 0) { + const notification = notifications.shift()! + const method = notification.method + const params = notification.params ?? {} + + if (method === 'turn/started' && params.turn && typeof params.turn === 'object') { + activeTurnId = (params.turn as { id?: string }).id ?? activeTurnId + logForDebugging(`[codex] turn/started notification: ${activeTurnId}`) + continue + } + + if (method === 'item/started' && params.item && typeof params.item === 'object') { + const item = params.item as { id?: string; type?: string } + const itemId = item.id + if (!itemId) { + continue + } + + const blockIndex = nextBlockIndex++ + blockIndexByItemId.set(itemId, blockIndex) + + if (item.type === 'reasoning') { + logForDebugging(`[codex] reasoning item started: ${itemId}`) + yield toStreamEvent({ + type: 'content_block_start', + index: blockIndex, + content_block: { type: 'thinking' }, + }) + } else if (item.type === 'agentMessage') { + logForDebugging(`[codex] agent message item started: ${itemId}`) + textItemId = itemId + if (!emittedMessageStart) { + emittedMessageStart = true + yield toStreamEvent( + { + type: 'message_start', + message: { + id: itemId, + usage: { input_tokens: 0, output_tokens: 0 }, + }, + }, + Date.now() - startedAt, + ) + } + textBlockStarted = true + yield toStreamEvent({ + type: 'content_block_start', + index: blockIndex, + content_block: { type: 'text' }, + }) + } + continue + } + + if (method === 'item/agentMessage/delta') { + const itemId = typeof params.itemId === 'string' ? params.itemId : null + const delta = typeof params.delta === 'string' ? params.delta : '' + if (!itemId || !delta) { + continue + } + + textBuffer += delta + logForDebugging(`[codex] text delta length=${delta.length}`) + yield toStreamEvent({ + type: 'content_block_delta', + index: blockIndexByItemId.get(itemId) ?? 0, + delta: { type: 'text_delta', text: delta }, + }) + continue + } + + if (method === 'reasoningTextDelta') { + const itemId = typeof params.itemId === 'string' ? params.itemId : null + const delta = typeof params.delta === 'string' ? params.delta : '' + if (!itemId || !delta) { + continue + } + + logForDebugging(`[codex] reasoning delta length=${delta.length}`) + yield toStreamEvent({ + type: 'content_block_delta', + index: blockIndexByItemId.get(itemId) ?? 0, + delta: { type: 'thinking_delta', thinking: delta }, + }) + continue + } + + if (method === 'item/completed' && params.item && typeof params.item === 'object') { + const item = params.item as { id?: string; type?: string; text?: string } + const itemId = item.id + if (!itemId) { + continue + } + + if (item.type === 'reasoning') { + logForDebugging(`[codex] reasoning item completed: ${itemId}`) + yield toStreamEvent({ + type: 'content_block_stop', + index: blockIndexByItemId.get(itemId) ?? 0, + }) + } else if (item.type === 'agentMessage') { + logForDebugging(`[codex] agent message item completed: ${itemId}`) + if (textBlockStarted) { + yield toStreamEvent({ + type: 'content_block_stop', + index: blockIndexByItemId.get(itemId) ?? 0, + }) + textBlockStarted = false + } + + const assistantText = item.text ?? textBuffer + yield buildAssistantMessage(assistantText, itemId, currentModel) + } + continue + } + + if (method === 'error') { + const error = params.error as { message?: string } | undefined + turnErrorMessage = error?.message ?? 'Codex runtime error' + logForDebugging(`[codex] error notification: ${turnErrorMessage}`) + streamDone = true + break + } + + if (method === 'turn/completed') { + logForDebugging( + `[codex] turn/completed notification: ${ + (params.turn as { status?: string } | undefined)?.status ?? 'unknown' + }`, + ) + if (emittedMessageStart) { + yield toStreamEvent({ + type: 'message_delta', + delta: { stop_reason: signal.aborted ? 'end_turn' : 'stop_sequence' }, + usage: { input_tokens: 0, output_tokens: 0 }, + }) + yield toStreamEvent({ type: 'message_stop' }) + } + streamDone = true + break + } + } + } + + if (turnErrorMessage) { + yield createAssistantAPIErrorMessage({ + content: turnErrorMessage, + apiError: 'server_error', + }) + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + yield createAssistantAPIErrorMessage({ + content: + message.includes('401') || message.includes('unauthorized') + ? 'Codex backend authentication failed. Re-run `codex login` and try again.' + : `Codex backend error: ${message}`, + apiError: message.includes('auth') ? 'authentication_error' : 'server_error', + }) + } finally { + signal.removeEventListener('abort', abortHandler) + unsubscribe() + if (options.isNonInteractiveSession) { + logForDebugging('[codex] closing app-server client for turn') + await client.close() + } + } +} diff --git a/src/services/api/metricsOptOut.ts b/src/services/api/metricsOptOut.ts index 8ef884a7f..729222ccc 100644 --- a/src/services/api/metricsOptOut.ts +++ b/src/services/api/metricsOptOut.ts @@ -6,6 +6,7 @@ import { errorMessage } from '../../utils/errors.js' import { getAuthHeaders, withOAuth401Retry } from '../../utils/http.js' import { logError } from '../../utils/log.js' import { memoizeWithTTLAsync } from '../../utils/memoize.js' +import { isCodexBackendEnabled } from '../../utils/model/providers.js' import { isEssentialTrafficOnly } from '../../utils/privacyLevel.js' import { getClaudeCodeUserAgent } from '../../utils/userAgent.js' @@ -126,6 +127,10 @@ async function refreshMetricsStatus(): Promise { * an extra one during the 24h window is acceptable. */ export async function checkMetricsEnabled(): Promise { + if (isCodexBackendEnabled()) { + return { enabled: false, hasError: false } + } + // Service key OAuth sessions lack user:profile scope → would 403. // API key users (non-subscribers) fall through and use x-api-key auth. // This check runs before the disk read so we never persist auth-state-derived diff --git a/src/utils/appIdentity.ts b/src/utils/appIdentity.ts new file mode 100644 index 000000000..218dde7cd --- /dev/null +++ b/src/utils/appIdentity.ts @@ -0,0 +1,13 @@ +export const APP_COMMAND = 'ccb' +export const APP_DISPLAY_NAME = 'CCB' +export const APP_FULL_NAME = 'Claude Code Best' +export const APP_CONFIG_DIR_ENV = 'CCB_CONFIG_DIR' +export const APP_CONFIG_DIR_NAME = '.ccb' +export const APP_GLOBAL_CONFIG_BASENAME = '.config.json' +export const APP_PROJECT_CONFIG_DIR = '.ccb' +export const APP_LOCAL_INSTALL_DIRNAME = 'local' +export const APP_LOCAL_BINARY_NAME = 'ccb' +export const APP_NATIVE_BINARY_NAME = 'ccb' +export const APP_NATIVE_DATA_DIRNAME = 'ccb' +export const APP_TMUX_SOCKET_PREFIX = 'ccb' +export const APP_USER_AGENT_PREFIX = 'ccb' diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 64a618082..aab2045bd 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -10,7 +10,7 @@ import { logEvent, } from 'src/services/analytics/index.js' import { getModelStrings } from 'src/utils/model/modelStrings.js' -import { getAPIProvider } from 'src/utils/model/providers.js' +import { getAPIProvider, isCodexBackendEnabled } from 'src/utils/model/providers.js' import { getIsNonInteractiveSession, preferThirdPartyAuthentication, @@ -113,6 +113,7 @@ export function isAnthropicAuthEnabled(): boolean { } const is3P = + isCodexBackendEnabled() || isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) || isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) || isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) @@ -1253,6 +1254,10 @@ export function saveOAuthTokensIfNeeded(tokens: OAuthTokens): { } export const getClaudeAIOAuthTokens = memoize((): OAuthTokens | null => { + if (isCodexBackendEnabled()) { + return null + } + // --bare: API-key-only. No OAuth env tokens, no keychain, no credentials file. if (isBareMode()) return null @@ -1397,6 +1402,7 @@ async function handleOAuth401ErrorImpl( * (which don't hit the keychain), and only uses async for storage reads. */ export async function getClaudeAIOAuthTokensAsync(): Promise { + if (isCodexBackendEnabled()) return null if (isBareMode()) return null // Env var and FD tokens are sync and don't hit the keychain @@ -1428,6 +1434,10 @@ export function checkAndRefreshOAuthTokenIfNeeded( retryCount = 0, force = false, ): Promise { + if (isCodexBackendEnabled()) { + return Promise.resolve(false) + } + // Deduplicate concurrent non-retry, non-force calls if (retryCount === 0 && !force) { if (pendingRefreshCheck) { diff --git a/src/utils/doctorDiagnostic.ts b/src/utils/doctorDiagnostic.ts index 065b20cb0..11948ca77 100644 --- a/src/utils/doctorDiagnostic.ts +++ b/src/utils/doctorDiagnostic.ts @@ -10,6 +10,11 @@ import { getGlobalConfig, type InstallMethod, } from './config.js' +import { + APP_COMMAND, + APP_CONFIG_DIR_NAME, + APP_NATIVE_DATA_DIRNAME, +} from './appIdentity.js' import { getCwd } from './cwd.js' import { isEnvTruthy } from './envUtils.js' import { execFileNoThrow } from './execFileNoThrow.js' @@ -162,18 +167,18 @@ async function getInstallationPath(): Promise { } try { - const path = await which('claude') - if (path) { - return path - } + const path = await which(APP_COMMAND) + if (path) { + return path + } } catch { // This function doesn't expect errors } // If we can't find it, check common locations try { - await getFsImplementation().stat(join(homedir(), '.local/bin/claude')) - return join(homedir(), '.local/bin/claude') + await getFsImplementation().stat(join(homedir(), '.local/bin', APP_COMMAND)) + return join(homedir(), '.local/bin', APP_COMMAND) } catch { // Not found } @@ -209,7 +214,7 @@ async function detectMultipleInstallations(): Promise< const installations: Array<{ type: string; path: string }> = [] // Check for local installation - const localPath = join(homedir(), '.claude', 'local') + const localPath = join(homedir(), APP_CONFIG_DIR_NAME, 'local') if (await localInstallationExists()) { installations.push({ type: 'npm-local', path: localPath }) } @@ -229,12 +234,12 @@ async function detectMultipleInstallations(): Promise< const npmPrefix = npmResult.stdout.trim() const isWindows = getPlatform() === 'windows' - // First check for active installations via bin/claude - // Linux / macOS have prefix/bin/claude and prefix/lib/node_modules - // Windows has prefix/claude and prefix/node_modules + // First check for active installations via the fork binary. + // Linux / macOS have prefix/bin/ and prefix/lib/node_modules. + // Windows has prefix/ and prefix/node_modules. const globalBinPath = isWindows - ? join(npmPrefix, 'claude') - : join(npmPrefix, 'bin', 'claude') + ? join(npmPrefix, APP_COMMAND) + : join(npmPrefix, 'bin', APP_COMMAND) let globalBinExists = false try { @@ -267,7 +272,7 @@ async function detectMultipleInstallations(): Promise< installations.push({ type: 'npm-global', path: globalBinPath }) } } else { - // If no bin/claude exists, check for orphaned packages (no bin/claude symlink) + // If no bin exists, check for orphaned packages (no symlink) for (const packageName of packagesToCheck) { const globalPackagePath = isWindows ? join(npmPrefix, 'node_modules', packageName) @@ -289,7 +294,7 @@ async function detectMultipleInstallations(): Promise< // Check for native installation // Check common native installation paths - const nativeBinPath = join(homedir(), '.local', 'bin', 'claude') + const nativeBinPath = join(homedir(), '.local', 'bin', APP_COMMAND) try { await fs.stat(nativeBinPath) installations.push({ type: 'native', path: nativeBinPath }) @@ -300,7 +305,7 @@ async function detectMultipleInstallations(): Promise< // Also check if config indicates native installation const config = getGlobalConfig() if (config.installMethod === 'native') { - const nativeDataPath = join(homedir(), '.local', 'share', 'claude') + const nativeDataPath = join(homedir(), '.local', 'share', APP_NATIVE_DATA_DIRNAME) try { await fs.stat(nativeDataPath) if (!installations.some(i => i.type === 'native')) { diff --git a/src/utils/env.ts b/src/utils/env.ts index 0dfdc803a..f686c00c1 100644 --- a/src/utils/env.ts +++ b/src/utils/env.ts @@ -1,28 +1,16 @@ import memoize from 'lodash-es/memoize.js' -import { homedir } from 'os' import { join } from 'path' -import { fileSuffixForOauthConfig } from '../constants/oauth.js' +import { APP_GLOBAL_CONFIG_BASENAME } from './appIdentity.js' import { isRunningWithBun } from './bundledMode.js' import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js' import { findExecutable } from './findExecutable.js' -import { getFsImplementation } from './fsOperations.js' import { which } from './which.js' type Platform = 'win32' | 'darwin' | 'linux' // Config and data paths export const getGlobalClaudeFile = memoize((): string => { - // Legacy fallback for backwards compatibility - if ( - getFsImplementation().existsSync( - join(getClaudeConfigHomeDir(), '.config.json'), - ) - ) { - return join(getClaudeConfigHomeDir(), '.config.json') - } - - const filename = `.claude${fileSuffixForOauthConfig()}.json` - return join(process.env.CLAUDE_CONFIG_DIR || homedir(), filename) + return join(getClaudeConfigHomeDir(), APP_GLOBAL_CONFIG_BASENAME) }) const hasInternetAccess = memoize(async (): Promise => { diff --git a/src/utils/envUtils.ts b/src/utils/envUtils.ts index 5a3ca96ac..fe04c6369 100644 --- a/src/utils/envUtils.ts +++ b/src/utils/envUtils.ts @@ -1,16 +1,21 @@ import memoize from 'lodash-es/memoize.js' import { homedir } from 'os' import { join } from 'path' +import { APP_CONFIG_DIR_ENV, APP_CONFIG_DIR_NAME } from './appIdentity.js' -// Memoized: 150+ callers, many on hot paths. Keyed off CLAUDE_CONFIG_DIR so -// tests that change the env var get a fresh value without explicit cache.clear. +// Memoized: 150+ callers, many on hot paths. Keyed off both the fork-specific +// config env var and the upstream-compatible fallback so tests that change +// either get a fresh value without explicit cache.clear. export const getClaudeConfigHomeDir = memoize( (): string => { return ( - process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude') + process.env[APP_CONFIG_DIR_ENV] ?? + process.env.CLAUDE_CONFIG_DIR ?? + join(homedir(), APP_CONFIG_DIR_NAME) ).normalize('NFC') }, - () => process.env.CLAUDE_CONFIG_DIR, + () => + `${process.env[APP_CONFIG_DIR_ENV] ?? ''}:${process.env.CLAUDE_CONFIG_DIR ?? ''}`, ) export function getTeamsDir(): string { diff --git a/src/utils/localInstaller.ts b/src/utils/localInstaller.ts index 2532076f2..e7f8b38b9 100644 --- a/src/utils/localInstaller.ts +++ b/src/utils/localInstaller.ts @@ -4,6 +4,12 @@ import { access, chmod, writeFile } from 'fs/promises' import { join } from 'path' +import { + APP_COMMAND, + APP_CONFIG_DIR_NAME, + APP_LOCAL_BINARY_NAME, + APP_LOCAL_INSTALL_DIRNAME, +} from './appIdentity.js' import { type ReleaseChannel, saveGlobalConfig } from './config.js' import { getClaudeConfigHomeDir } from './envUtils.js' import { getErrnoCode } from './errors.js' @@ -17,10 +23,10 @@ import { jsonStringify } from './slowOperations.js' // hfi.tsx get a chance to set CLAUDE_CONFIG_DIR in main(), and would also // populate the memoize cache with that stale value for all 150+ other callers. function getLocalInstallDir(): string { - return join(getClaudeConfigHomeDir(), 'local') + return join(getClaudeConfigHomeDir(), APP_LOCAL_INSTALL_DIRNAME) } export function getLocalClaudePath(): string { - return join(getLocalInstallDir(), 'claude') + return join(getLocalInstallDir(), APP_LOCAL_BINARY_NAME) } /** @@ -28,7 +34,9 @@ export function getLocalClaudePath(): string { */ export function isRunningFromLocalInstallation(): boolean { const execPath = process.argv[1] || '' - return execPath.includes('/.claude/local/node_modules/') + return execPath.includes( + `/${APP_CONFIG_DIR_NAME}/${APP_LOCAL_INSTALL_DIRNAME}/node_modules/`, + ) } /** @@ -71,10 +79,10 @@ export async function ensureLocalPackageEnvironment(): Promise { ) // Create the wrapper script if it doesn't exist - const wrapperPath = join(localInstallDir, 'claude') + const wrapperPath = join(localInstallDir, APP_LOCAL_BINARY_NAME) const created = await writeIfMissing( wrapperPath, - `#!/bin/sh\nexec "${localInstallDir}/node_modules/.bin/claude" "$@"`, + `#!/bin/sh\nexec "${localInstallDir}/node_modules/.bin/${APP_COMMAND}" "$@"`, 0o755, ) if (created) { @@ -143,7 +151,7 @@ export async function installOrUpdateClaudePackage( */ export async function localInstallationExists(): Promise { try { - await access(join(getLocalInstallDir(), 'node_modules', '.bin', 'claude')) + await access(join(getLocalInstallDir(), 'node_modules', '.bin', APP_COMMAND)) return true } catch { return false diff --git a/src/utils/model/__tests__/codexModels.test.ts b/src/utils/model/__tests__/codexModels.test.ts new file mode 100644 index 000000000..fb813ce17 --- /dev/null +++ b/src/utils/model/__tests__/codexModels.test.ts @@ -0,0 +1,58 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { mkdirSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { + getCodexModels, + getDefaultCodexModel, + isKnownCodexModel, + resetCodexModelsCacheForTests, +} from '../codexModels' + +describe('codexModels', () => { + const originalPath = process.env.CLAUDE_CODE_CODEX_MODELS_CACHE_PATH + + afterEach(() => { + resetCodexModelsCacheForTests() + if (originalPath !== undefined) { + process.env.CLAUDE_CODE_CODEX_MODELS_CACHE_PATH = originalPath + } else { + delete process.env.CLAUDE_CODE_CODEX_MODELS_CACHE_PATH + } + }) + + test('reads visible models from the Codex cache file', () => { + const dir = join(tmpdir(), `ccb-codex-models-${Date.now()}`) + mkdirSync(dir, { recursive: true }) + const cachePath = join(dir, 'models_cache.json') + writeFileSync( + cachePath, + JSON.stringify({ + models: [ + { + slug: 'gpt-5.4', + display_name: 'gpt-5.4', + description: 'Latest frontier agentic coding model.', + visibility: 'list', + isDefault: true, + }, + { + slug: 'hidden-model', + display_name: 'Hidden', + description: 'Should not be shown', + visibility: 'hidden', + }, + ], + }), + ) + process.env.CLAUDE_CODE_CODEX_MODELS_CACHE_PATH = cachePath + + const models = getCodexModels() + expect(models).toHaveLength(1) + expect(models[0]?.id).toBe('gpt-5.4') + expect(getDefaultCodexModel()).toBe('gpt-5.4') + expect(isKnownCodexModel('gpt-5.4')).toBe(true) + + rmSync(dir, { recursive: true, force: true }) + }) +}) diff --git a/src/utils/model/__tests__/providers.test.ts b/src/utils/model/__tests__/providers.test.ts index b028f1084..65224cb3d 100644 --- a/src/utils/model/__tests__/providers.test.ts +++ b/src/utils/model/__tests__/providers.test.ts @@ -1,8 +1,9 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { getAPIProvider, isFirstPartyAnthropicBaseUrl } from "../providers"; +import { getAPIProvider, getMainLoopBackend, isFirstPartyAnthropicBaseUrl } from "../providers"; describe("getAPIProvider", () => { const envKeys = [ + "CLAUDE_CODE_USE_CODEX", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY", @@ -132,3 +133,25 @@ describe("isFirstPartyAnthropicBaseUrl", () => { expect(isFirstPartyAnthropicBaseUrl()).toBe(false); }); }); + +describe("getMainLoopBackend", () => { + const originalUseCodex = process.env.CLAUDE_CODE_USE_CODEX; + + afterEach(() => { + if (originalUseCodex !== undefined) { + process.env.CLAUDE_CODE_USE_CODEX = originalUseCodex; + } else { + delete process.env.CLAUDE_CODE_USE_CODEX; + } + }); + + test('returns "anthropic" by default', () => { + delete process.env.CLAUDE_CODE_USE_CODEX; + expect(getMainLoopBackend()).toBe("anthropic"); + }); + + test('returns "codex" when CLAUDE_CODE_USE_CODEX is set', () => { + process.env.CLAUDE_CODE_USE_CODEX = "1"; + expect(getMainLoopBackend()).toBe("codex"); + }); +}); diff --git a/src/utils/model/codexModels.ts b/src/utils/model/codexModels.ts new file mode 100644 index 000000000..73e420251 --- /dev/null +++ b/src/utils/model/codexModels.ts @@ -0,0 +1,112 @@ +import { readFileSync } from 'fs' +import { homedir } from 'os' +import { join } from 'path' + +export type CodexModelInfo = { + id: string + label: string + description: string + isDefault: boolean +} + +type CodexModelsCache = { + models?: Array<{ + slug?: string + display_name?: string + description?: string + visibility?: string + is_default?: boolean + isDefault?: boolean + }> +} + +const FALLBACK_CODEX_MODELS: CodexModelInfo[] = [ + { + id: 'gpt-5.4', + label: 'gpt-5.4', + description: 'Latest frontier agentic coding model.', + isDefault: true, + }, + { + id: 'gpt-5.4-mini', + label: 'GPT-5.4-Mini', + description: 'Smaller frontier agentic coding model.', + isDefault: false, + }, + { + id: 'gpt-5.3-codex', + label: 'gpt-5.3-codex', + description: 'Frontier Codex-optimized agentic coding model.', + isDefault: false, + }, +] + +let cachedPath: string | null = null +let cachedModels: CodexModelInfo[] | null = null + +function getCodexModelsCachePath(): string { + return ( + process.env.CLAUDE_CODE_CODEX_MODELS_CACHE_PATH ?? + join(homedir(), '.codex', 'models_cache.json') + ) +} + +function mapCacheToModels(cache: CodexModelsCache): CodexModelInfo[] { + const models = cache.models + ?.filter(model => model.visibility !== 'hidden' && !!model.slug) + .map(model => ({ + id: model.slug!, + label: model.display_name ?? model.slug!, + description: model.description ?? 'Codex model', + isDefault: model.isDefault === true || model.is_default === true, + })) + + if (!models || models.length === 0) { + return FALLBACK_CODEX_MODELS + } + + if (models.some(model => model.isDefault)) { + return models + } + + return models.map((model, index) => ({ + ...model, + isDefault: index === 0, + })) +} + +export function getCodexModels(): CodexModelInfo[] { + const path = getCodexModelsCachePath() + if (cachedModels && cachedPath === path) { + return cachedModels + } + + try { + const raw = readFileSync(path, 'utf8') + const parsed = JSON.parse(raw) as CodexModelsCache + cachedModels = mapCacheToModels(parsed) + } catch { + cachedModels = FALLBACK_CODEX_MODELS + } + + cachedPath = path + return cachedModels +} + +export function getDefaultCodexModel(): string { + if (process.env.ANTHROPIC_MODEL) { + return process.env.ANTHROPIC_MODEL + } + + const defaultModel = getCodexModels().find(model => model.isDefault) + return defaultModel?.id ?? FALLBACK_CODEX_MODELS[0]!.id +} + +export function isKnownCodexModel(model: string): boolean { + return getCodexModels().some(entry => entry.id === model) +} + +export function resetCodexModelsCacheForTests(): void { + cachedPath = null + cachedModels = null +} diff --git a/src/utils/model/model.ts b/src/utils/model/model.ts index 695076c86..303f33414 100644 --- a/src/utils/model/model.ts +++ b/src/utils/model/model.ts @@ -19,12 +19,13 @@ import { is1mContextDisabled, modelSupports1M, } from '../context.js' +import { getDefaultCodexModel } from './codexModels.js' import { isEnvTruthy } from '../envUtils.js' import { getModelStrings, resolveOverriddenModel } from './modelStrings.js' import { formatModelPricing, getOpus46CostTier } from '../modelCost.js' import { getSettings_DEPRECATED } from '../settings/settings.js' import type { PermissionMode } from '../permissions/PermissionMode.js' -import { getAPIProvider } from './providers.js' +import { getAPIProvider, getMainLoopBackend } from './providers.js' import { LIGHTNING_BOLT } from '../../constants/figures.js' import { isModelAllowed } from './modelAllowlist.js' import { type ModelAlias, isModelAlias } from './aliases.js' @@ -99,6 +100,9 @@ export function getMainLoopModel(): ModelName { } export function getBestModel(): ModelName { + if (getMainLoopBackend() === 'codex') { + return getDefaultCodexModel() + } return getDefaultOpusModel() } @@ -177,6 +181,10 @@ export function getRuntimeMainLoopModel(params: { * @returns The default model setting to use */ export function getDefaultMainLoopModelSetting(): ModelName | ModelAlias { + if (getMainLoopBackend() === 'codex') { + return getDefaultCodexModel() + } + // Ants default to defaultModel from flag config, or Opus 1M if not configured if (process.env.USER_TYPE === 'ant') { return ( diff --git a/src/utils/model/modelOptions.ts b/src/utils/model/modelOptions.ts index 30d8f0f0e..0f875c9da 100644 --- a/src/utils/model/modelOptions.ts +++ b/src/utils/model/modelOptions.ts @@ -13,9 +13,10 @@ import { COST_HAIKU_45, formatModelPricing, } from '../modelCost.js' +import { getCodexModels } from './codexModels.js' import { getSettings_DEPRECATED } from '../settings/settings.js' import { checkOpus1mAccess, checkSonnet1mAccess } from './check1mAccess.js' -import { getAPIProvider } from './providers.js' +import { getAPIProvider, getMainLoopBackend } from './providers.js' import { isModelAllowed } from './modelAllowlist.js' import { getCanonicalName, @@ -44,6 +45,18 @@ export type ModelOption = { } export function getDefaultOptionForUser(fastMode = false): ModelOption { + if (getMainLoopBackend() === 'codex') { + const currentModel = renderDefaultModelSetting( + getDefaultMainLoopModelSetting(), + ) + return { + value: null, + label: 'Default (recommended)', + description: `Use the default Codex model (currently ${currentModel})`, + descriptionForModel: `Default Codex model (currently ${currentModel})`, + } + } + if (process.env.USER_TYPE === 'ant') { const currentModel = renderDefaultModelSetting( getDefaultMainLoopModelSetting(), @@ -270,6 +283,18 @@ function getOpusPlanOption(): ModelOption { // @[MODEL LAUNCH]: Update the model picker lists below to include/reorder options for the new model. // Each user tier (ant, Max/Team Premium, Pro/Team Standard/Enterprise, PAYG 1P, PAYG 3P) has its own list. function getModelOptionsBase(fastMode = false): ModelOption[] { + if (getMainLoopBackend() === 'codex') { + return [ + getDefaultOptionForUser(fastMode), + ...getCodexModels().map(model => ({ + value: model.id, + label: model.label, + description: model.description, + descriptionForModel: model.description, + })), + ] + } + if (process.env.USER_TYPE === 'ant') { // Build options from antModels config const antModelOptions: ModelOption[] = getAntModels().map(m => ({ @@ -430,6 +455,18 @@ function getModelFamilyInfo( * Returns null if the model is not recognized. */ function getKnownModelOption(model: string): ModelOption | null { + if (getMainLoopBackend() === 'codex') { + const knownCodexModel = getCodexModels().find(entry => entry.id === model) + if (knownCodexModel) { + return { + value: knownCodexModel.id, + label: knownCodexModel.label, + description: knownCodexModel.description, + } + } + return null + } + const marketingName = getMarketingNameForModel(model) if (!marketingName) return null diff --git a/src/utils/model/providers.ts b/src/utils/model/providers.ts index aba9b7d7f..ef2b730b1 100644 --- a/src/utils/model/providers.ts +++ b/src/utils/model/providers.ts @@ -2,6 +2,17 @@ import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from import { isEnvTruthy } from '../envUtils.js' export type APIProvider = 'firstParty' | 'bedrock' | 'vertex' | 'foundry' +export type MainLoopBackend = 'anthropic' | 'codex' + +export function getMainLoopBackend(): MainLoopBackend { + return isEnvTruthy(process.env.CLAUDE_CODE_USE_CODEX) + ? 'codex' + : 'anthropic' +} + +export function isCodexBackendEnabled(): boolean { + return getMainLoopBackend() === 'codex' +} export function getAPIProvider(): APIProvider { return isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) diff --git a/src/utils/model/validateModel.ts b/src/utils/model/validateModel.ts index 14b816756..77154beb1 100644 --- a/src/utils/model/validateModel.ts +++ b/src/utils/model/validateModel.ts @@ -1,7 +1,8 @@ // biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered import { MODEL_ALIASES } from './aliases.js' +import { isKnownCodexModel } from './codexModels.js' import { isModelAllowed } from './modelAllowlist.js' -import { getAPIProvider } from './providers.js' +import { getAPIProvider, getMainLoopBackend } from './providers.js' import { sideQuery } from '../sideQuery.js' import { NotFoundError, @@ -46,6 +47,15 @@ export async function validateModel( return { valid: true } } + if (getMainLoopBackend() === 'codex') { + return isKnownCodexModel(normalizedModel) + ? { valid: true } + : { + valid: false, + error: `Model '${normalizedModel}' is not available in the local Codex model catalog`, + } + } + // Check cache first if (validModelCache.has(normalizedModel)) { return { valid: true } diff --git a/src/utils/nativeInstaller/installer.ts b/src/utils/nativeInstaller/installer.ts index 3a3b770b9..ac6043c5e 100644 --- a/src/utils/nativeInstaller/installer.ts +++ b/src/utils/nativeInstaller/installer.ts @@ -54,6 +54,10 @@ import { readFileLines, writeFileLines, } from '../shellConfig.js' +import { + APP_NATIVE_BINARY_NAME, + APP_NATIVE_DATA_DIRNAME, +} from '../appIdentity.js' import { sleep } from '../sleep.js' import { getUserBinDir, @@ -109,22 +113,24 @@ export function getPlatform(): string { } export function getBinaryName(platform: string): string { - return platform.startsWith('win32') ? 'claude.exe' : 'claude' + return platform.startsWith('win32') + ? `${APP_NATIVE_BINARY_NAME}.exe` + : APP_NATIVE_BINARY_NAME } function getBaseDirectories() { const platform = getPlatform() const executableName = getBinaryName(platform) - return { - // Data directories (permanent storage) - versions: join(getXDGDataHome(), 'claude', 'versions'), + return { + // Data directories (permanent storage) + versions: join(getXDGDataHome(), APP_NATIVE_DATA_DIRNAME, 'versions'), - // Cache directories (can be deleted) - staging: join(getXDGCacheHome(), 'claude', 'staging'), + // Cache directories (can be deleted) + staging: join(getXDGCacheHome(), APP_NATIVE_DATA_DIRNAME, 'staging'), - // State directories - locks: join(getXDGStateHome(), 'claude', 'locks'), + // State directories + locks: join(getXDGStateHome(), APP_NATIVE_DATA_DIRNAME, 'locks'), // User bin executable: join(getUserBinDir(), executableName), diff --git a/src/utils/settings/settings.ts b/src/utils/settings/settings.ts index 3bea04af2..3e814c8c1 100644 --- a/src/utils/settings/settings.ts +++ b/src/utils/settings/settings.ts @@ -33,6 +33,7 @@ import { getManagedFilePath, getManagedSettingsDropInDir, } from './managedPath.js' +import { APP_PROJECT_CONFIG_DIR } from '../appIdentity.js' import { getHkcuSettings, getMdmSettings } from './mdm/settings.js' import { getCachedParsedFile, @@ -300,9 +301,9 @@ export function getRelativeSettingsFilePathForSource( ): string { switch (source) { case 'projectSettings': - return join('.claude', 'settings.json') + return join(APP_PROJECT_CONFIG_DIR, 'settings.json') case 'localSettings': - return join('.claude', 'settings.local.json') + return join(APP_PROJECT_CONFIG_DIR, 'settings.local.json') } } diff --git a/src/utils/shellConfig.ts b/src/utils/shellConfig.ts index 329ad7f22..88ecf6d6a 100644 --- a/src/utils/shellConfig.ts +++ b/src/utils/shellConfig.ts @@ -6,10 +6,11 @@ import { open, readFile, stat } from 'fs/promises' import { homedir as osHomedir } from 'os' import { join } from 'path' +import { APP_COMMAND } from './appIdentity.js' import { isFsInaccessible } from './errors.js' import { getLocalClaudePath } from './localInstaller.js' -export const CLAUDE_ALIAS_REGEX = /^\s*alias\s+claude\s*=/ +export const CLAUDE_ALIAS_REGEX = new RegExp(`^\\s*alias\\s+${APP_COMMAND}\\s*=`) type EnvLike = Record @@ -37,8 +38,8 @@ export function getShellConfigPaths( } /** - * Filter out installer-created claude aliases from an array of lines - * Only removes aliases pointing to $HOME/.claude/local/claude + * Filter out installer-created fork aliases from an array of lines + * Only removes aliases pointing to the fork-local launcher path * Preserves custom user aliases that point to other locations * Returns the filtered lines and whether our default installer alias was found */ @@ -48,14 +49,18 @@ export function filterClaudeAliases(lines: string[]): { } { let hadAlias = false const filtered = lines.filter(line => { - // Check if this is a claude alias + // Check if this is a fork alias if (CLAUDE_ALIAS_REGEX.test(line)) { // Extract the alias target - handle spaces, quotes, and various formats // First try with quotes - let match = line.match(/alias\s+claude\s*=\s*["']([^"']+)["']/) + let match = line.match( + new RegExp(`alias\\s+${APP_COMMAND}\\s*=\\s*["']([^"']+)["']`), + ) if (!match) { // Try without quotes (capturing until end of line or comment) - match = line.match(/alias\s+claude\s*=\s*([^#\n]+)/) + match = line.match( + new RegExp(`alias\\s+${APP_COMMAND}\\s*=\\s*([^#\\n]+)`), + ) } if (match && match[1]) { @@ -107,7 +112,7 @@ export async function writeFileLines( } /** - * Check if a claude alias exists in any shell config file + * Check if a fork alias exists in any shell config file * Returns the alias target if found, null otherwise * @param options Optional overrides for testing (env, homedir) */ @@ -123,7 +128,9 @@ export async function findClaudeAlias( for (const line of lines) { if (CLAUDE_ALIAS_REGEX.test(line)) { // Extract the alias target - const match = line.match(/alias\s+claude=["']?([^"'\s]+)/) + const match = line.match( + new RegExp(`alias\\s+${APP_COMMAND}=["']?([^"'\\s]+)`), + ) if (match && match[1]) { return match[1] } diff --git a/src/utils/status.tsx b/src/utils/status.tsx index 39afd3ad6..a8f733070 100644 --- a/src/utils/status.tsx +++ b/src/utils/status.tsx @@ -11,7 +11,7 @@ import { getDisplayPath } from './file.js'; import { formatNumber } from './format.js'; import { getIdeClientName, type IDEExtensionInstallationStatus, isJetBrainsIde, toIDEDisplayName } from './ide.js'; import { getClaudeAiUserDefaultModelDescription, modelDisplayString } from './model/model.js'; -import { getAPIProvider } from './model/providers.js'; +import { getAPIProvider, getMainLoopBackend } from './model/providers.js'; import { getMTLSConfig } from './mtls.js'; import { checkInstall } from './nativeInstaller/index.js'; import { getProxyUrl } from './proxy.js'; @@ -239,7 +239,18 @@ export function buildAccountProperties(): Property[] { } export function buildAPIProviderProperties(): Property[] { const apiProvider = getAPIProvider(); + const mainLoopBackend = getMainLoopBackend(); const properties: Property[] = []; + if (mainLoopBackend === 'codex') { + properties.push({ + label: 'Main backend', + value: 'Codex app-server' + }); + properties.push({ + label: 'Codex app-server URL', + value: process.env.CLAUDE_CODE_CODEX_APP_SERVER_URL ?? 'ws://127.0.0.1:7788' + }); + } if (apiProvider !== 'firstParty') { const providerLabel = { bedrock: 'AWS Bedrock', diff --git a/src/utils/tmuxSocket.ts b/src/utils/tmuxSocket.ts index 510720a00..552a21ef5 100644 --- a/src/utils/tmuxSocket.ts +++ b/src/utils/tmuxSocket.ts @@ -24,6 +24,7 @@ */ import { posix } from 'path' +import { APP_TMUX_SOCKET_PREFIX } from './appIdentity.js' import { registerCleanup } from './cleanupRegistry.js' import { logForDebugging } from './debug.js' import { toError } from './errors.js' @@ -33,7 +34,7 @@ import { getPlatform } from './platform.js' // Constants for tmux socket management const TMUX_COMMAND = 'tmux' -const CLAUDE_SOCKET_PREFIX = 'claude' +const CLAUDE_SOCKET_PREFIX = APP_TMUX_SOCKET_PREFIX /** * Executes a tmux command, routing through WSL on Windows. diff --git a/src/utils/userAgent.ts b/src/utils/userAgent.ts index 5608de66c..d8ebb0fd3 100644 --- a/src/utils/userAgent.ts +++ b/src/utils/userAgent.ts @@ -5,6 +5,8 @@ * import without pulling in auth.ts and its transitive dependency tree. */ +import { APP_USER_AGENT_PREFIX } from './appIdentity.js' + export function getClaudeCodeUserAgent(): string { - return `claude-code/${MACRO.VERSION}` + return `${APP_USER_AGENT_PREFIX}/${MACRO.VERSION}` } diff --git a/src/utils/worktree.ts b/src/utils/worktree.ts index dc4b3db3a..bdace4117 100644 --- a/src/utils/worktree.ts +++ b/src/utils/worktree.ts @@ -17,6 +17,7 @@ import { getCwd } from './cwd.js' import { logForDebugging } from './debug.js' import { errorMessage, getErrnoCode } from './errors.js' import { execFileNoThrow, execFileNoThrowWithCwd } from './execFileNoThrow.js' +import { APP_PROJECT_CONFIG_DIR } from './appIdentity.js' import { parseGitConfigValue } from './git/gitConfigParser.js' import { getCommonDir, @@ -202,7 +203,7 @@ const GIT_NO_PROMPT_ENV = { } function worktreesDir(repoRoot: string): string { - return join(repoRoot, '.claude', 'worktrees') + return join(repoRoot, APP_PROJECT_CONFIG_DIR, 'worktrees') } // Flatten nested slugs (`user/feature` → `user+feature`) for both the branch From afa6ec42a051494456c2763ceab8f9dda7b9bd6d Mon Sep 17 00:00:00 2001 From: zhangziming Date: Tue, 7 Apr 2026 11:15:17 +0800 Subject: [PATCH 2/2] chore: add openspec codex and opencode skills --- .codex/skills/openspec-apply-change/SKILL.md | 156 ++++++++++ .../skills/openspec-archive-change/SKILL.md | 114 +++++++ .codex/skills/openspec-explore/SKILL.md | 288 ++++++++++++++++++ .codex/skills/openspec-propose/SKILL.md | 110 +++++++ .opencode/command/opsx-apply.md | 149 +++++++++ .opencode/command/opsx-archive.md | 154 ++++++++++ .opencode/command/opsx-explore.md | 170 +++++++++++ .opencode/command/opsx-propose.md | 103 +++++++ .../skills/openspec-apply-change/SKILL.md | 156 ++++++++++ .../skills/openspec-archive-change/SKILL.md | 114 +++++++ .opencode/skills/openspec-explore/SKILL.md | 288 ++++++++++++++++++ .opencode/skills/openspec-propose/SKILL.md | 110 +++++++ 12 files changed, 1912 insertions(+) create mode 100644 .codex/skills/openspec-apply-change/SKILL.md create mode 100644 .codex/skills/openspec-archive-change/SKILL.md create mode 100644 .codex/skills/openspec-explore/SKILL.md create mode 100644 .codex/skills/openspec-propose/SKILL.md create mode 100644 .opencode/command/opsx-apply.md create mode 100644 .opencode/command/opsx-archive.md create mode 100644 .opencode/command/opsx-explore.md create mode 100644 .opencode/command/opsx-propose.md create mode 100644 .opencode/skills/openspec-apply-change/SKILL.md create mode 100644 .opencode/skills/openspec-archive-change/SKILL.md create mode 100644 .opencode/skills/openspec-explore/SKILL.md create mode 100644 .opencode/skills/openspec-propose/SKILL.md diff --git a/.codex/skills/openspec-apply-change/SKILL.md b/.codex/skills/openspec-apply-change/SKILL.md new file mode 100644 index 000000000..d474dc135 --- /dev/null +++ b/.codex/skills/openspec-apply-change/SKILL.md @@ -0,0 +1,156 @@ +--- +name: openspec-apply-change +description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. +license: MIT +compatibility: Requires openspec CLI. +metadata: + author: openspec + version: "1.0" + generatedBy: "1.2.0" +--- + +Implement tasks from an OpenSpec change. + +**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. + +**Steps** + +1. **Select the change** + + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select + + Always announce: "Using change: " and how to override (e.g., `/opsx:apply `). + +2. **Check status to understand the schema** + ```bash + openspec status --change "" --json + ``` + Parse the JSON to understand: + - `schemaName`: The workflow being used (e.g., "spec-driven") + - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others) + +3. **Get apply instructions** + + ```bash + openspec instructions apply --change "" --json + ``` + + This returns: + - Context file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs) + - Progress (total, complete, remaining) + - Task list with status + - Dynamic instruction based on current state + + **Handle states:** + - If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change + - If `state: "all_done"`: congratulate, suggest archive + - Otherwise: proceed to implementation + +4. **Read context files** + + Read the files listed in `contextFiles` from the apply instructions output. + The files depend on the schema being used: + - **spec-driven**: proposal, specs, design, tasks + - Other schemas: follow the contextFiles from CLI output + +5. **Show current progress** + + Display: + - Schema being used + - Progress: "N/M tasks complete" + - Remaining tasks overview + - Dynamic instruction from CLI + +6. **Implement tasks (loop until done or blocked)** + + For each pending task: + - Show which task is being worked on + - Make the code changes required + - Keep changes minimal and focused + - Mark task complete in the tasks file: `- [ ]` → `- [x]` + - Continue to next task + + **Pause if:** + - Task is unclear → ask for clarification + - Implementation reveals a design issue → suggest updating artifacts + - Error or blocker encountered → report and wait for guidance + - User interrupts + +7. **On completion or pause, show status** + + Display: + - Tasks completed this session + - Overall progress: "N/M tasks complete" + - If all done: suggest archive + - If paused: explain why and wait for guidance + +**Output During Implementation** + +``` +## Implementing: (schema: ) + +Working on task 3/7: +[...implementation happening...] +✓ Task complete + +Working on task 4/7: +[...implementation happening...] +✓ Task complete +``` + +**Output On Completion** + +``` +## Implementation Complete + +**Change:** +**Schema:** +**Progress:** 7/7 tasks complete ✓ + +### Completed This Session +- [x] Task 1 +- [x] Task 2 +... + +All tasks complete! Ready to archive this change. +``` + +**Output On Pause (Issue Encountered)** + +``` +## Implementation Paused + +**Change:** +**Schema:** +**Progress:** 4/7 tasks complete + +### Issue Encountered + + +**Options:** +1.