diff --git a/README.md b/README.md
index 10f4c32d..007d0360 100644
--- a/README.md
+++ b/README.md
@@ -41,32 +41,51 @@
-Claude Code Router Desktop is a local gateway and desktop control panel for routing agent requests from Claude Code, Codex, ZCode, and compatible clients to the model provider you actually want to use.
+Claude Code Router Desktop is a local control plane for coding agents. It gives Claude Code, Codex, Grok CLI, ZCode, and compatible API clients one stable local endpoint, then lets you decide which provider, model, routing policy, tool stack, and account should handle each request.
+
+Instead of wiring every agent to every model service by hand, CCR centralizes the model layer on your own machine: provider presets, custom endpoints, credential pools, fallback chains, Fusion-enhanced models, MCP tools, request logs, account usage, and desktop launch profiles all live in one app.
+## What CCR Helps You Do
+
+| Goal | CCR gives you |
+| --- | --- |
+| Keep the same agent workflow while switching models | Local profiles for Claude Code, Codex, Grok CLI, and ZCode, with CLI/app launch entries and per-profile model selection |
+| Try many providers without rebuilding config every time | Built-in provider presets, custom OpenAI/Anthropic/Gemini-compatible endpoints, protocol probing, model discovery, and connectivity checks |
+| Make routing a runtime policy | Built-in agent routing, conditional rules, request rewrites, model-prefix routing, retries, and fallback model chains |
+| Control cost and quota pressure | Credential pools, key rotation, local usage limits, account balance snapshots, token/cost dashboards, and tray status |
+| Upgrade a model without replacing it | Fusion models that add vision, web search, or selected MCP tools to an existing base model |
+| Keep large tool sets usable | ToolHub, a compact MCP entry point that lets agents resolve and invoke the tools needed for the current task |
+| Debug what actually happened | Request logs, resolved provider/model fields, latency, token usage, estimated cost, network capture, and agent observability |
+
## Why Use CCR
-- Use one local endpoint for multiple agent tools instead of configuring every client separately.
-- Route requests with default routing, conditional rules, fallback targets, and request rewrites instead of editing client configuration by hand.
-- Mix providers without changing your workflow. CCR supports OpenAI-compatible APIs, Anthropic Messages, Gemini Generate Content, OpenRouter, DeepSeek, SiliconFlow, Moonshot, Kimi Code, Mistral, Z.AI, Bailian, and custom providers.
-- Control cost and reliability with fallback routing, API key rotation, usage statistics, and request logs.
+- **One gateway for your agent stack**: point clients at CCR once, then move routing, models, keys, and providers from scattered client configs into a single desktop UI.
+- **Provider freedom without workflow churn**: use OpenAI Chat/Responses, Anthropic Messages, Gemini Generate Content/Interactions, OpenRouter, DeepSeek, SiliconFlow, Moonshot, Kimi Code, Mistral, Z.AI, Bailian, and custom compatible providers.
+- **Reliability policies you can see and change**: define when a request should be rewritten, retried, or moved to another model, then verify the result in local logs.
+- **Operational visibility for AI work**: track requests, tokens, cost estimates, success rate, latency, model distribution, provider usage, and account balances from the dashboard or tray.
+- **Agent-native tools and extensions**: add Fusion capabilities, expose dynamic MCP tools through ToolHub, automate the built-in browser, relay agents through IM bots, or install local extensions.
-## Features
+## Feature Highlights
-- **Overview dashboard**: inspect system status, usage widgets, account balances, model distribution, and share cards.
-- **Provider management**: add provider presets or custom endpoints, probe protocol support, test model connectivity, manage credentials, and monitor supported account balances where available.
-- **Routing rules**: configure default routing, conditional and model-prefix rules, fallback handling, and request rewrites.
-- **Agent Config**: configure Claude Code, Codex, and ZCode launch entries, models, scopes, and multi-instance app profiles.
-- **Gateway compatibility**: translate supported client requests through the local CCR model gateway.
-- **Proxy mode**: capture supported API traffic through a local proxy with optional system proxy integration and network capture.
-- **Fusion models**: combine a base model with vision, web search, or MCP tools into a reusable selectable model.
+- **Agent profiles**: create profiles for Claude Code, Codex, Grok CLI, and ZCode with model overrides, scopes, CLI/app launch surfaces, environment settings, and multi-instance app workflows.
+- **Provider management**: add preset providers or custom endpoints; probe supported protocols; detect model lists; run real connectivity checks; manage single keys or credential pools; import local agent login state where supported.
+- **Model catalog**: search all configured models, edit model descriptions, and use those descriptions to guide Claude Code subagent, Task, and Workflow model selection.
+- **Routing engine**: combine built-in agent routing, request-header/body conditions, model-prefix routing, request rewrites, retry policy, and ordered fallback targets.
+- **Fusion models**: publish reusable virtual models that keep a base model's behavior while adding vision, hosted web search, or selected MCP tools.
+- **ToolHub**: merge multiple MCP servers into one dynamic MCP server so agents can resolve tools only when a task needs them; desktop builds can also expose built-in browser automation and Chrome login-state import.
+- **API keys and quotas**: create CCR client keys with expiration and local request/token/image limits, separate from upstream provider credentials.
+- **Logs and observability**: inspect request/response details, resolved provider and model, credential, status, latency, token usage, estimated cost, tool calls, and agent execution traces.
+- **Proxy and networking**: run CCR as a local HTTP/HTTPS proxy, optionally install the CA certificate, route supported API traffic through CCR, and capture network exchanges for debugging.
+- **Bot relay**: connect agent profiles to supported IM platforms including Weixin iLink, WeCom, Slack, Discord, Telegram, LINE, Feishu, and DingTalk.
+- **Extensions**: install wrapper plugins and core gateway plugins that can register local routes, proxy routes, provider account connectors, apps, and virtual models.
## Documentation
-Read the full documentation at [ccrdesk.top](https://ccrdesk.top/).
+Read the full documentation at [ccrdesk.top](https://ccrdesk.top/), including the [CLI reference](https://ccrdesk.top/en/guides/cli/) and [Docker deployment guide](https://ccrdesk.top/en/guides/docker/).
## Download And Install
@@ -79,11 +98,30 @@ Read the full documentation at [ccrdesk.top](https://ccrdesk.top/).
3. Install and launch **Claude Code Router**.
4. On first launch, CCR creates its local configuration database:
- macOS/Linux: `~/.claude-code-router/config.sqlite`
- - Windows: `%APPDATA%\Claude Code Router\config.sqlite`
+ - Windows: `%APPDATA%\claude-code-router\config.sqlite`
CCR stores runtime configuration in SQLite. A legacy `config.json` is read only once for migration when no SQLite config exists.
-After the service is started from the **Server** page, CCR listens on `http://localhost:8080` by default. The **Server** page controls the gateway `Host`, `Port`, proxy mode, system proxy, network capture, and CA certificate status.
+After the service is started from the **Server** page, CCR listens on `http://127.0.0.1:3456` by default. The **Server** page controls the gateway `Host`, `Port`, proxy mode, system proxy, network capture, and CA certificate status.
+
+## CLI And Docker
+
+The npm CLI requires Node.js 22 or newer and provides the browser management UI, gateway, and Agent Config launch commands without Electron:
+
+```sh
+npm install -g @musistudio/claude-code-router
+ccr ui
+```
+
+The CLI management UI defaults to `http://127.0.0.1:3458`, while its model gateway defaults to `http://127.0.0.1:3456`. See the [complete CLI reference](https://ccrdesk.top/en/guides/cli/) for background/foreground service commands, options, profile launching, authentication, and data locations.
+
+To run the browser UI and gateway behind one Nginx port with persistent Docker storage:
+
+```sh
+docker compose up -d --build
+```
+
+Docker exposes both management and gateway routes at `http://127.0.0.1:3458` by default. Read the [Docker deployment guide](https://ccrdesk.top/en/guides/docker/) before remote exposure; it covers the internal port topology, management and gateway authentication, `CCR_PUBLIC_BASE_URL`, volumes, backup/restore, upgrades, and health checks.
## Quick Start
@@ -91,25 +129,23 @@ CCR can be configured entirely from the desktop UI. Use this setup order for a c
### 1. Add a provider
-Open **Providers**, click **Add Provider**, then choose a built-in preset or **Other / custom API endpoint**. Fill in the provider name, base URL, protocol, API key, and model list. Run protocol probing and model connectivity checks when available, then save the provider.
+Open **Providers**, click **Add Provider**, then choose a built-in preset, import a supported local agent login state, or select **Other / custom API endpoint**. Fill in the provider name, base URL, protocol, API key, and model list. Run protocol probing and model connectivity checks when available, then save the provider.
### 2. Configure routing
-Open **Routing** to add conditional rules, configure request rewrites, and set fallback behavior.
-
-Use **Add Routing Rule** for request conditions, model-prefix routing, or rule-level fallback targets.
+Open **Routing** to enable built-in agent routes, add conditional rules, configure request rewrites, and set fallback behavior. Use **Add Routing Rule** for request conditions, model-prefix routing, or rule-level fallback targets.
### 3. Start the gateway
-Open **Server** and click **Start**. After the page shows Running, CCR listens on `http://localhost:8080`. Enable **Auto start** if you want CCR to start the local gateway whenever the desktop app opens.
+Open **Server** and click **Start**. After the page shows Running, CCR listens on `http://127.0.0.1:3456` by default. Enable **Auto start** if you want CCR to start the local gateway whenever the desktop app opens.
### 4. Connect your agent tool
-Open **Agent Config** and choose the client you want to use. Configure Claude Code, Codex, or ZCode, select the target model and effect scope, then apply the config. For app entries, use the **Open Agent** action to open the target app through CCR.
+Open **Agent Config** and choose the client you want to use. Configure Claude Code, Codex, Grok CLI, or ZCode, select the target model and effect scope, then apply the config. For app entries, use **Open Agent** to launch the target app through CCR.
### 5. Monitor and adjust
-Use **Settings → Logs & Observability** to enable request logs and agent observability. Use **Logs** to confirm `request model`, `resolved provider`, `resolved model`, status, tokens, latency, and errors; use the tray window for quick token and account status.
+Use **Settings → Logs & Observability** to enable request logs and agent observability. Use **Logs** to confirm `request model`, `resolved provider`, `resolved model`, status, tokens, latency, and errors. Use the dashboard and tray window for token, cost, model distribution, and account status.
## Acknowledgements
@@ -210,20 +246,43 @@ Codex support is powered by [musistudio/codexl](https://github.com/musistudio/co
-
+
code0.ai
-
+
claudeapi
+
+
+
+
+
+ Qiniu Cloud AI
+
+
+
+
+
+
+ Fenno.ai
+
+
+
+
+
+
+ Unity2.Ai
+
+
+
Community Sponsors
diff --git a/README_zh.md b/README_zh.md
index 183df2d9..8dfa475d 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -41,31 +41,51 @@
-Claude Code Router Desktop 是一个本地网关和桌面控制台,用来把 Claude Code、Codex、ZCode 以及兼容客户端的 Agent 请求路由到你真正想使用的模型服务。
+Claude Code Router Desktop 是给编程 Agent 用的本地控制平面。它为 Claude Code、Codex、Grok CLI、ZCode 以及兼容 API 客户端提供一个稳定的本地入口,然后由你在 CCR 中决定每个请求应该走哪个供应商、哪个模型、哪套路由策略、哪些工具能力和哪组账号凭据。
+
+相比在每个 Agent、每个模型服务里反复改配置,CCR 把模型层收束到本机桌面应用里:供应商预设、自定义端点、凭据池、Fallback、Fusion 组合模型、MCP 工具、请求日志、账号用量和 Agent 启动配置都在一个地方管理。
+## CCR 能帮你做什么
+
+| 目标 | CCR 提供的能力 |
+| --- | --- |
+| 保持 Agent 工作流不变,同时自由切换模型 | 为 Claude Code、Codex、Grok CLI、ZCode 创建本地配置档案,支持 CLI / App 启动入口和按配置选择模型 |
+| 快速接入多个模型供应商 | 内置供应商预设、自定义 OpenAI / Anthropic / Gemini 兼容端点、协议探测、模型发现和连通性检测 |
+| 把路由变成可配置策略 | 内置 Agent 路由、条件规则、请求改写、模型前缀路由、自动重试和 Fallback 模型链 |
+| 控制成本和额度压力 | 凭据池、Key 轮换、本地限额、账号余额快照、Token / 成本仪表盘和托盘状态 |
+| 给稳定模型补能力 | 通过 Fusion 给基础模型叠加视觉、联网搜索或指定 MCP 工具 |
+| 让大量工具变得可用 | ToolHub 把多个 MCP server 收束成一个紧凑入口,让 Agent 按任务动态解析和调用工具 |
+| 排查每一次请求 | 请求日志、最终供应商 / 模型、耗时、Token、成本估算、网络捕获和 Agent 观测链路 |
+
## 为什么使用 CCR
-- 用一个本地入口连接多个 Agent 工具,不需要在每个客户端里重复配置 Provider。
-- 在不改变工作流的情况下混用不同 Provider。CCR 支持 OpenAI 兼容 API、Anthropic Messages、Gemini Generate Content、OpenRouter、DeepSeek、SiliconFlow、Moonshot、Kimi Code、Mistral、Z.AI、百炼以及自定义 Provider。
-- 通过 fallback 路由、API Key 轮换、用量统计和请求日志来控制成本和可靠性。
+- **一个本地网关,接管整套 Agent 模型层**:客户端只需要指向 CCR,模型、供应商、Key、路由和工具能力都可以在桌面 UI 中调整。
+- **换供应商,不换工作流**:支持 OpenAI Chat / Responses、Anthropic Messages、Gemini Generate Content / Interactions、OpenRouter、DeepSeek、SiliconFlow、Moonshot、Kimi Code、Mistral、Z.AI、百炼以及自定义兼容供应商。
+- **可见、可改、可验证的可靠性策略**:配置请求什么时候改写、重试或切到备用模型,并在本地日志里确认真实命中结果。
+- **面向 AI 工作流的运营视角**:从仪表盘或托盘查看请求量、Token、成本估算、成功率、延迟、模型分布、供应商用量和账号余额。
+- **Agent 原生工具与扩展**:使用 Fusion 扩展模型能力,通过 ToolHub 暴露动态 MCP 工具,让内置浏览器参与任务,通过 IM Bot 接力 Agent,或安装本地扩展。
-## 功能和特性
+## 功能亮点
-- **概览仪表盘**:查看系统状态、用量组件、账号余额、模型分布和分享卡片。
-- **Provider 管理**:添加预设或自定义端点,探测协议支持,检测模型连通性,管理凭据,并在可用时查看账号余额。
-- **路由规则**:配置条件路由、模型前缀规则、失败降级和请求改写。
-- **Agent配置**:为 Claude Code、Codex 和 ZCode 配置启动入口、模型、作用范围和多开 App 配置。
-- **网关兼容层**:通过本地 CCR 模型网关转换支持的客户端请求。
-- **代理模式**:通过本地代理捕获支持的 API 流量,可选系统代理和网络捕获。
-- **Fusion 组合模型**:把基础模型与视觉、联网搜索或 MCP 工具组合成新的可选模型。
+- **Agent 配置档案**:为 Claude Code、Codex、Grok CLI 和 ZCode 创建配置档案,支持模型覆盖、作用范围、CLI / App 启动方式、环境变量和多开 App 工作流。
+- **供应商管理**:添加预设供应商或自定义端点;探测协议;发现模型列表;运行真实连通性检测;管理单 Key 或凭据池;在支持时导入本机 Agent 登录态。
+- **模型目录**:搜索全部已配置模型,编辑模型描述,并把这些描述用于 Claude Code Subagent、Task 和 Workflow 的模型选择提示。
+- **路由引擎**:组合内置 Agent 路由、请求 Header / Body 条件、模型前缀路由、请求改写、重试策略和有序 Fallback 目标。
+- **Fusion 组合模型**:发布可复用的虚拟模型,在保留基础模型手感的同时增加视觉、托管联网搜索或指定 MCP 工具。
+- **ToolHub**:把多个 MCP server 合并成一个动态 MCP server,让 Agent 只在任务需要时解析工具;桌面端还可暴露内置浏览器自动化和 Chrome 登录态导入。
+- **API Key 与限额**:创建访问 CCR 的客户端 Key,设置过期时间和本地请求 / Token / 图片限额,与上游供应商凭据分开管理。
+- **日志与观测**:查看请求 / 响应详情、最终供应商与模型、凭据、状态、耗时、Token、成本估算、工具调用和 Agent 执行链路。
+- **代理与网络捕获**:把 CCR 作为本地 HTTP / HTTPS 代理运行,可选安装 CA 证书,把支持的 API 流量接入 CCR,并保存网络请求用于排查。
+- **Bot 接力**:把 Agent 配置接入 Weixin iLink、企业微信、Slack、Discord、Telegram、LINE、飞书和钉钉等 IM 平台。
+- **扩展机制**:安装 wrapper plugin 和 core gateway plugin,注册本地路由、代理路由、供应商账号连接器、内置应用和虚拟模型。
## 文档
-完整文档见 [ccrdesk.top](https://ccrdesk.top/)。
+完整文档见 [ccrdesk.top](https://ccrdesk.top/),其中包括 [CLI 命令参考](https://ccrdesk.top/guides/cli/) 和 [Docker 部署指南](https://ccrdesk.top/guides/docker/)。
## 下载和安装
@@ -78,11 +98,30 @@ Claude Code Router Desktop 是一个本地网关和桌面控制台,用来把 C
3. 安装并启动 **Claude Code Router**。
4. 首次启动后,CCR 会创建本地配置数据库:
- macOS/Linux:`~/.claude-code-router/config.sqlite`
- - Windows:`%APPDATA%\Claude Code Router\config.sqlite`
+ - Windows:`%APPDATA%\claude-code-router\config.sqlite`
CCR 的运行配置存储在 SQLite 中。旧版 `config.json` 只会在没有 SQLite 配置时作为迁移来源读取一次。
-从 **服务** 页面启动后,CCR 默认监听 `http://localhost:8080`。**服务** 页面负责配置网关 `Host`、`Port`、代理模式、系统代理、网络捕获和 CA 证书状态。
+从 **服务** 页面启动后,CCR 默认监听 `http://127.0.0.1:3456`。**服务** 页面负责配置网关 `Host`、`Port`、代理模式、系统代理、网络捕获和 CA 证书状态。
+
+## CLI 与 Docker
+
+npm CLI 要求 Node.js 22 或更高版本,不依赖 Electron,也能提供浏览器管理界面、模型网关和 Agent 配置启动命令:
+
+```sh
+npm install -g @musistudio/claude-code-router
+ccr ui
+```
+
+CLI 管理界面默认是 `http://127.0.0.1:3458`,模型网关默认是 `http://127.0.0.1:3456`。后台 / 前台服务、全部选项、Profile 启动、鉴权和数据位置见[完整 CLI 参考](https://ccrdesk.top/guides/cli/)。
+
+如果要使用单一 Nginx 端口和持久化 Docker 数据卷运行管理 UI 与网关:
+
+```sh
+docker compose up -d --build
+```
+
+Docker 默认把管理和网关路径都发布在 `http://127.0.0.1:3458`。远程暴露前请先阅读 [Docker 部署指南](https://ccrdesk.top/guides/docker/),其中包含内部端口拓扑、管理与网关鉴权、`CCR_PUBLIC_BASE_URL`、数据卷、备份恢复、升级和健康检查。
## 快速开始
@@ -90,25 +129,23 @@ CCR 可以完全通过桌面 UI 完成配置。首次使用建议按下面顺序
### 1. 添加 Provider
-打开 **供应商**,点击 **添加供应商**,选择内置预设或 **其他 / 自定义 API 端点**。按表单填写 Provider 名称、基础 URL、协议、API Key 和模型列表。可用时先运行协议探测和模型连通性检查,然后保存 Provider。
+打开 **供应商**,点击 **添加供应商**,选择内置预设、导入支持的本机 Agent 登录态,或选择 **其他 / 自定义 API 端点**。按表单填写 Provider 名称、基础 URL、协议、API Key 和模型列表。可用时先运行协议探测和模型连通性检查,然后保存 Provider。
### 2. 设置路由
-打开 **路由**,添加条件规则,配置请求改写和失败降级。
-
-如果需要更细粒度控制,使用 **添加路由规则** 添加模型前缀、请求条件或规则级失败降级目标。
+打开 **路由**,启用内置 Agent 路由,添加条件规则,配置请求改写和失败降级。如果需要更细粒度控制,使用 **添加路由规则** 添加模型前缀、请求条件或规则级失败降级目标。
### 3. 启动网关
-打开 **服务**,点击 **启动**。页面显示运行中后,CCR 会在本机监听 `http://localhost:8080`。如果希望每次打开桌面应用时自动启动网关,可以启用自动启动。
+打开 **服务**,点击 **启动**。页面显示运行中后,CCR 默认会在本机监听 `http://127.0.0.1:3456`。如果希望每次打开桌面应用时自动启动网关,可以启用自动启动。
### 4. 连接 Agent 工具
-打开 **Agent配置**,选择要使用的客户端。配置 Claude Code、Codex 或 ZCode,选择目标模型和作用范围,然后应用配置。对于 App 入口,可以使用 **打开 Agent** 操作通过 CCR 打开目标应用。
+打开 **Agent配置**,选择要使用的客户端。配置 Claude Code、Codex、Grok CLI 或 ZCode,选择目标模型和作用范围,然后应用配置。对于 App 入口,可以使用 **打开 Agent** 通过 CCR 打开目标应用。
### 5. 日常查看和调整
-到 **设置 → 日志与观测** 打开请求日志和 Agent 观测。使用 **日志** 确认 `request model`、`resolved provider`、`resolved model`、状态码、tokens、耗时和错误;使用托盘窗口快速查看 Token 和账号状态。
+到 **设置 → 日志与观测** 打开请求日志和 Agent 观测。使用 **日志** 确认 `request model`、`resolved provider`、`resolved model`、状态码、tokens、耗时和错误;使用概览仪表盘和托盘窗口查看 Token、成本、模型分布和账号状态。
## 致谢
@@ -209,20 +246,43 @@ CCR 可以完全通过桌面 UI 完成配置。首次使用建议按下面顺序
-
+
code0.ai
-
+
claudeapi
+
+
+
+
+
+ 七牛云 AI
+
+
+
+
+
+
+ Fenno.ai
+
+
+
+
+
+
+ Unity2.Ai
+
+
+
社区赞助者
diff --git a/build/dev.mjs b/build/dev.mjs
index b91a341a..38901c2f 100644
--- a/build/dev.mjs
+++ b/build/dev.mjs
@@ -2,7 +2,7 @@ import electron from "electron";
import esbuild from "esbuild";
import { createHash } from "node:crypto";
import { spawn } from "node:child_process";
-import { existsSync, readdirSync, readFileSync, statSync, watch } from "node:fs";
+import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import {
buildStyles,
@@ -35,6 +35,8 @@ import {
let electronProcess = null;
let restartTimer = null;
+let restartInFlight = false;
+let restartQueued = false;
let pendingRestartReasons = [];
const watchSignatures = new Map();
let shuttingDown = false;
@@ -95,13 +97,6 @@ function readyState() {
.join(" ");
}
-function describeWatchEvent(label, watchedPath, eventType, filename, isDirectory = false) {
- const changedPath = filename
- ? path.join(isDirectory ? watchedPath : path.dirname(watchedPath), String(filename))
- : watchedPath;
- return `${label} ${eventType} ${relativePath(changedPath)}`;
-}
-
function contentSignature(targetPath) {
try {
return readContentSignature(targetPath);
@@ -177,31 +172,14 @@ function listDirectoryFiles(targetPath, basePath = targetPath) {
return files;
}
-function rememberWatchSignature(label, targetPath) {
- const signature = contentSignature(targetPath);
+function rememberWatchSignature(label, targetPath, options = {}) {
+ const signature = options.metadataOnly
+ ? metadataSignature(targetPath)
+ : contentSignature(targetPath);
watchSignatures.set(label, signature.key);
logDev(`watch baseline: ${label} ${relativePath(targetPath)}; ${signature.summary}`);
}
-function handleWatchedInput(label, watchedPath, eventType, filename, options, onChange) {
- const reason = describeWatchEvent(label, watchedPath, eventType, filename, options?.isDirectory);
- const signature = contentSignature(watchedPath);
- const previousSignature = watchSignatures.get(label);
- const changed = previousSignature !== signature.key;
- watchSignatures.set(label, signature.key);
- logDev(`watch event: ${reason}; ${signature.summary}; content=${changed ? "changed" : "unchanged"}`);
-
- if (!changed) {
- logDev(`restart skipped: ${reason} (content unchanged)`);
- return;
- }
-
- onChange();
- if (enabled.electron && options?.restart !== false) {
- scheduleRestart(reason);
- }
-}
-
function scheduleStyleBuild(reason) {
queuedStyleBuildReason = reason;
if (styleBuildTimer) {
@@ -256,6 +234,64 @@ function pollStyleWatchRoots() {
}
}
+function pollWatchedInput(label, targetPath, onChange, options = {}) {
+ const signature = options.metadataOnly
+ ? metadataSignature(targetPath)
+ : contentSignature(targetPath);
+ const previousSignature = watchSignatures.get(label);
+ if (previousSignature === signature.key) {
+ return;
+ }
+
+ watchSignatures.set(label, signature.key);
+ logDev(`watch event: ${label} ${relativePath(targetPath)}; ${signature.summary}; content=changed`);
+ try {
+ onChange();
+ if (enabled.electron && options.restart !== false) {
+ scheduleRestart(label);
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ logDev(`watch action failed: ${label}; ${message}`);
+ }
+}
+
+function metadataSignature(targetPath) {
+ if (!existsSync(targetPath)) {
+ return {
+ key: "missing",
+ summary: "missing"
+ };
+ }
+
+ const stats = statSync(targetPath);
+ return {
+ key: `metadata:${stats.size}:${stats.mtimeMs}:${stats.ctimeMs}`,
+ summary: `size=${stats.size} mtime=${stats.mtime.toISOString()} ctime=${stats.ctime.toISOString()}`
+ };
+}
+
+function pollSourceWatchTargets() {
+ pollWatchedInput("home html", rendererHtmlInput, () => {
+ copyRendererHtml();
+ syncUiRendererToRuntimeDists();
+ });
+ pollWatchedInput("browser html", browserRendererHtmlInput, () => {
+ copyBrowserRendererHtml();
+ syncUiRendererToRuntimeDists();
+ });
+ pollWatchedInput("tray html", trayRendererHtmlInput, () => {
+ copyTrayRendererHtml();
+ syncUiRendererToRuntimeDists();
+ });
+ if (enabled.electron) {
+ pollWatchedInput("app assets", appAssetsInput, copyAppAssets);
+ }
+ if ((enabled.cli || enabled.electron) && existsSync(modelCatalogInput)) {
+ pollWatchedInput("model catalog", modelCatalogInput, copyModelCatalog, { metadataOnly: true });
+ }
+}
+
function markReady(name, reason = `${name} esbuild completed`) {
if (name === "browser" || name === "cli" || name === "main" || name === "renderer" || name === "tray" || name === "webBridge") {
ready[name] = true;
@@ -281,34 +317,81 @@ function scheduleRestart(reason = "unknown trigger") {
restartTimer = setTimeout(restartElectron, restartDelayMs);
}
-function restartElectron() {
+async function restartElectron() {
+ if (restartInFlight) {
+ restartQueued = true;
+ return;
+ }
+ restartInFlight = true;
const reasons = Array.from(new Set(pendingRestartReasons));
pendingRestartReasons = [];
restartTimer = null;
- if (electronProcess) {
- logDev(`stopping Electron pid=${electronProcess.pid ?? "unknown"}`);
- electronProcess.kill();
- electronProcess = null;
- }
+ try {
+ if (electronProcess) {
+ await stopElectron(electronProcess);
+ }
- logDev(`starting Electron; reasons=${reasons.join(" | ") || "initial start"}`);
- const child = spawn(electron, ["."], {
- cwd: projectRoot,
- env: {
- ...process.env,
- NODE_ENV: "development"
- },
- stdio: "inherit"
- });
- electronProcess = child;
- logDev(`Electron started pid=${child.pid ?? "unknown"}`);
- child.on("exit", (code, signal) => {
- logDev(`Electron exited pid=${child.pid ?? "unknown"} code=${code ?? "null"} signal=${signal ?? "null"}`);
+ if (shuttingDown) {
+ return;
+ }
+ logDev(`starting Electron; reasons=${reasons.join(" | ") || "initial start"}`);
+ const child = spawn(electron, ["."], {
+ cwd: projectRoot,
+ env: {
+ ...process.env,
+ NODE_ENV: "development"
+ },
+ stdio: "inherit"
+ });
+ electronProcess = child;
+ logDev(`Electron started pid=${child.pid ?? "unknown"}`);
+ child.on("exit", (code, signal) => {
+ logDev(`Electron exited pid=${child.pid ?? "unknown"} code=${code ?? "null"} signal=${signal ?? "null"}`);
+ if (electronProcess === child) {
+ electronProcess = null;
+ }
+ });
+ } finally {
+ restartInFlight = false;
+ if (restartQueued || pendingRestartReasons.length > 0) {
+ restartQueued = false;
+ scheduleRestart("changes queued during Electron restart");
+ }
+ }
+}
+
+async function stopElectron(child) {
+ logDev(`stopping Electron pid=${child.pid ?? "unknown"}`);
+ if (child.exitCode !== null || child.signalCode !== null) {
if (electronProcess === child) {
electronProcess = null;
}
+ return;
+ }
+
+ await new Promise((resolve) => {
+ let forceTimer = null;
+ let giveUpTimer = null;
+ const finish = () => {
+ if (forceTimer) clearTimeout(forceTimer);
+ if (giveUpTimer) clearTimeout(giveUpTimer);
+ child.off("exit", finish);
+ resolve();
+ };
+ child.once("exit", finish);
+ child.kill();
+ forceTimer = setTimeout(() => {
+ if (child.exitCode === null && child.signalCode === null) {
+ logDev(`force stopping Electron pid=${child.pid ?? "unknown"}`);
+ child.kill("SIGKILL");
+ }
+ }, 2_000);
+ giveUpTimer = setTimeout(finish, 5_000);
});
+ if (electronProcess === child) {
+ electronProcess = null;
+ }
}
logDev(`starting dev build target=${devTarget} ui=${enabled.ui ? "on" : "off"} cli=${enabled.cli ? "on" : "off"} electron=${enabled.electron ? "on" : "off"}`);
@@ -336,43 +419,13 @@ if (enabled.electron) {
rememberWatchSignature("app assets", appAssetsInput);
}
if ((enabled.cli || enabled.electron) && existsSync(modelCatalogInput)) {
- rememberWatchSignature("model catalog", modelCatalogInput);
+ rememberWatchSignature("model catalog", modelCatalogInput, { metadataOnly: true });
}
-const htmlWatcher = watch(rendererHtmlInput, { persistent: true }, (eventType, filename) => {
- handleWatchedInput("home html", rendererHtmlInput, eventType, filename, undefined, () => {
- copyRendererHtml();
- syncUiRendererToRuntimeDists();
- });
-});
-
-const browserHtmlWatcher = watch(browserRendererHtmlInput, { persistent: true }, (eventType, filename) => {
- handleWatchedInput("browser html", browserRendererHtmlInput, eventType, filename, undefined, () => {
- copyBrowserRendererHtml();
- syncUiRendererToRuntimeDists();
- });
-});
-
-const trayHtmlWatcher = watch(trayRendererHtmlInput, { persistent: true }, (eventType, filename) => {
- handleWatchedInput("tray html", trayRendererHtmlInput, eventType, filename, undefined, () => {
- copyTrayRendererHtml();
- syncUiRendererToRuntimeDists();
- });
-});
-
-const stylePoller = setInterval(pollStyleWatchRoots, stylePollIntervalMs);
-
-const appAssetsWatcher = enabled.electron
- ? watch(appAssetsInput, { persistent: true }, (eventType, filename) => {
- handleWatchedInput("app assets", appAssetsInput, eventType, filename, { isDirectory: true }, copyAppAssets);
- })
- : { close: () => undefined };
-
-const modelCatalogWatcher = (enabled.cli || enabled.electron) && existsSync(modelCatalogInput)
- ? watch(modelCatalogInput, { persistent: true }, (eventType, filename) => {
- handleWatchedInput("model catalog", modelCatalogInput, eventType, filename, undefined, copyModelCatalog);
- })
- : { close: () => undefined };
+const sourcePoller = setInterval(() => {
+ pollStyleWatchRoots();
+ pollSourceWatchTargets();
+}, stylePollIntervalMs);
const contexts = [];
@@ -467,17 +520,12 @@ async function shutdown() {
clearTimeout(restartTimer);
}
if (electronProcess) {
- electronProcess.kill();
+ await stopElectron(electronProcess);
}
if (styleBuildTimer) {
clearTimeout(styleBuildTimer);
}
- htmlWatcher.close();
- browserHtmlWatcher.close();
- trayHtmlWatcher.close();
- clearInterval(stylePoller);
- appAssetsWatcher.close();
- modelCatalogWatcher.close();
+ clearInterval(sourcePoller);
await Promise.all(contexts.map((context) => context.dispose()));
process.exit(0);
}
diff --git a/docker/README.md b/docker/README.md
index 2b51a1f3..7a929ffe 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -1,68 +1,214 @@
-# Docker deployment
+# Docker Deployment
-This image runs the core server package with PM2 and serves the built UI package
-through Nginx. Nginx is the only published entrypoint: it serves the UI, proxies
-management API calls to the internal core server, and proxies gateway API calls
-to the internal gateway listener.
+[中文说明](#中文说明) · [Project documentation](https://ccrdesk.top/en/) · [GitHub](https://github.com/musistudio/claude-code-router)
-## Build and run
+The Docker image runs the CCR core server under PM2 and serves the built management UI through Nginx. Nginx is the only public container entrypoint: the browser UI, management RPC, gateway API, and health route all share one published port.
+
+The image is intended for a persistent gateway and browser-based administration. It does not include Electron, the npm `ccr` command, system tray features, desktop Agent/App launching, automatic desktop updates, or desktop-only browser integrations.
+
+## Architecture And Ports
+
+```text
+host:3458 -> container Nginx:8080
+ |-> static management UI
+ |-> management RPC: 127.0.0.1:3459
+ |-> gateway: 127.0.0.1:3456
+ `-> gateway core: 127.0.0.1:3457
+```
+
+Only Nginx port `8080` should be published. The three internal ports are container implementation details and should not be exposed individually.
+
+Nginx routes:
+
+| Public route | Purpose |
+| --- | --- |
+| `/` and `/pages/home/index.html` | Browser management UI. `/` redirects to a URL containing the management token. |
+| `/api/ccr/rpc` | Authenticated management RPC. |
+| `/health` | Gateway health, not container/UI health. It can return `502` until a provider and model are configured and the gateway starts. |
+| `/v1/*`, `/v1beta/*`, `/messages`, `/chat/completions`, `/responses`, `/interactions`, `/mcp/*` | Supported model and MCP gateway requests. |
+
+## Quick Start With Compose
+
+From the repository root:
```sh
-docker compose up --build
+docker compose up -d --build
+docker compose logs -f ccr
```
-Then open:
+Open . On a new volume, the management UI is immediately available. Add a provider and model, create a CCR client key under **API Keys**, and start the gateway from **Server**.
-- Web UI:
-- Gateway endpoint:
-
-`docker-compose.yml` publishes only Nginx (`3458:8080`). Behind Nginx, the image
-runs separate container-private listeners for management RPC, API gateway
-routing, and the core gateway runtime. They are implementation details and are
-not published or configured by the default Compose file.
-
-To use a different host port, change the Compose port mapping and keep the
-public router endpoint in sync:
+The repository Compose file publishes `3458:8080`, stores data in the `ccr-data` named volume, and restarts the service unless explicitly stopped. A mapping without a host IP binds on every host interface. For local-only access, change it to:
```yaml
-services:
- ccr:
- ports:
- - "8088:8080"
- environment:
- CCR_PUBLIC_BASE_URL: http://127.0.0.1:8088
+ports:
+ - "127.0.0.1:3458:8080"
```
-The container stores config and SQLite databases under `/data`, backed by the
-`ccr-data` volume in `docker-compose.yml`.
+Stop or remove the container without deleting its named volume:
-On a fresh data volume, the Web UI starts immediately. The gateway endpoint is
-available through the same Nginx entrypoint, but the gateway only starts after at
-least one provider and model are configured.
+```sh
+docker compose stop
+docker compose down
+```
-## Image scripts
+Do not add `--volumes` to `docker compose down` unless you intentionally want to delete all persisted CCR data.
+
+## `docker run`
+
+Build and run without Compose:
+
+```sh
+docker build -t claude-code-router:local .
+docker run -d \
+ --name claude-code-router \
+ --restart unless-stopped \
+ -p 127.0.0.1:3458:8080 \
+ -e CCR_PUBLIC_BASE_URL=http://127.0.0.1:3458 \
+ -v ccr-data:/data \
+ claude-code-router:local
+```
+
+Equivalent repository scripts are available:
```sh
npm run docker:build
npm run docker:run
```
-## Smoke test
+`npm run docker:run` uses port `3458` and the `ccr-data` volume, but runs an ephemeral container without a fixed name or restart policy.
-```sh
-npm run test:docker
+## Authentication And Network Security
+
+There are two independent authentication layers:
+
+1. `CCR_WEB_AUTH_TOKEN` protects management RPC. Nginx puts it into the management-page URL, and the browser sends it to RPC as `x-ccr-web-auth`.
+2. CCR client API keys created in the **API Keys** page protect model gateway requests. These are separate from upstream provider credentials.
+
+If `CCR_WEB_AUTH_TOKEN` is unset, the entrypoint generates a new random token on each container start. Opening `/` still works because Nginx redirects to a tokenized URL, but a stable token is recommended for persistent or remote deployments.
+
+Avoid putting the token directly in shell history. Create a protected environment file instead:
+
+```dotenv
+CCR_WEB_AUTH_TOKEN=replace-with-a-long-random-value
+CCR_PUBLIC_BASE_URL=http://127.0.0.1:3458
```
-The smoke test builds the image, starts an isolated temporary container with a
-special-character `CCR_WEB_AUTH_TOKEN`, verifies that only the Nginx port is
-published, checks UI and RPC authentication, confirms legacy Docker config is
-migrated to the public Nginx router endpoint, and removes its temporary
-container and volume. Set `CCR_DOCKER_TEST_SKIP_BUILD=1` to reuse an already
-built image.
+Then use it with `docker run --env-file` or map the same variables under the Compose service's `environment` section. Keep this file out of version control.
-The Dockerfile uses `node:22-bookworm` for build and native SQLite dependency
-installation, then copies the production dependencies into a smaller
-`node:22-bookworm-slim` runtime image. To use different base images:
+Security guidance:
+
+- Bind the published port to `127.0.0.1` unless LAN or remote access is intentional.
+- Never expose the management UI over untrusted networks without TLS, a firewall/private network, and a fixed strong management token.
+- Treat tokenized management URLs as secrets; URLs may be recorded in browser history, proxy logs, screenshots, and support tickets.
+- Create scoped CCR client API keys before exposing gateway routes. Do not reuse upstream provider credentials as client keys.
+- Protect `/data` and its backups because they contain configuration, provider credentials, CCR client keys, request data, and generated certificates.
+
+## Changing The Public Address
+
+The host-facing URL is separate from the container's internal ports. Whenever the host port, hostname, or scheme changes, set `CCR_PUBLIC_BASE_URL` to the exact URL clients should use:
+
+```yaml
+services:
+ ccr:
+ ports:
+ - "127.0.0.1:8088:8080"
+ environment:
+ CCR_PUBLIC_BASE_URL: http://127.0.0.1:8088
+ CCR_WEB_AUTH_TOKEN: ${CCR_WEB_AUTH_TOKEN:?set CCR_WEB_AUTH_TOKEN}
+```
+
+`CCR_PUBLIC_BASE_URL` is written to CCR's public router endpoint. It does not publish a Docker port by itself.
+
+For a reverse proxy or ingress that terminates HTTPS:
+
+```yaml
+services:
+ ccr:
+ ports:
+ - "127.0.0.1:3458:8080"
+ environment:
+ CCR_PUBLIC_BASE_URL: https://ccr.example.com
+ CCR_WEB_AUTH_TOKEN: ${CCR_WEB_AUTH_TOKEN:?set CCR_WEB_AUTH_TOKEN}
+```
+
+Proxy all paths to Nginx and preserve streaming. The external proxy should allow long-lived responses and should not buffer SSE/model streams. Keep the host port private when the reverse proxy is the public entrypoint.
+
+## Persistent Data
+
+The entrypoint sets `HOME=/data`, so CCR stores files under:
+
+```text
+/data/.claude-code-router/
+├── config.sqlite
+├── gateway.config.json
+├── app-data/
+│ ├── api-keys.sqlite
+│ ├── request-logs.sqlite
+│ ├── usage.sqlite
+│ └── certs/
+├── profiles/
+└── bin/
+```
+
+Use a named volume unless a bind mount is operationally required. Bind mounts must be writable by the container and should not be shared by two running CCR containers.
+
+The first-run bootstrap writes a minimal legacy `config.json` only when neither `config.json` nor `config.sqlite` exists. When the UI saves current settings, SQLite becomes authoritative. By default, every container start also synchronizes the stored gateway listener and `routerEndpoint` to the Docker public endpoint.
+
+## Backup And Restore
+
+The safest application-level backup is **Settings → Export data**. For a full volume backup, stop writes before copying the data directory:
+
+```sh
+docker compose stop ccr
+docker compose cp ccr:/data/. ./ccr-data-backup/
+docker compose start ccr
+```
+
+Keep the backup private. It contains secrets and may include request/response data.
+
+For a full restore, use a new empty volume or empty `/data` directory, copy the backup contents into it while the CCR container is stopped, then start the container. Do not overlay an old backup onto a populated live volume: stale SQLite WAL/SHM files and newer runtime files can produce an inconsistent result. Make a second backup before replacing existing data.
+
+## Upgrade And Rollback
+
+Back up `/data`, update the source revision, rebuild with fresh base layers, and recreate the service:
+
+```sh
+git pull
+docker compose build --pull
+docker compose up -d
+docker compose ps
+docker compose logs --tail=200 ccr
+```
+
+Configuration migrations run against the persistent data. To roll back, use the previous image/source revision together with a backup created before the upgrade; do not assume a newer database can always be read by an older build.
+
+## Environment Variables
+
+Most deployments should set only `CCR_WEB_AUTH_TOKEN`, `CCR_PUBLIC_BASE_URL`, and the Docker port mapping. Internal listener values normally should remain unchanged.
+
+| Variable | Default | Description |
+| --- | --- | --- |
+| `CCR_WEB_AUTH_TOKEN` | Random per container start | Management UI/RPC token. Set a stable strong value for persistent or remote use. |
+| `CCR_PUBLIC_BASE_URL` | `http://127.0.0.1:3458` | Exact public gateway/UI base URL written into CCR configuration. Overrides `CCR_PUBLIC_HOST` and `CCR_PUBLIC_PORT`. |
+| `CCR_PUBLIC_HOST` | `127.0.0.1` | Used only to derive `CCR_PUBLIC_BASE_URL` when the full URL is unset; it does not change Docker port publishing. |
+| `CCR_PUBLIC_PORT` | `3458` | Used only to derive `CCR_PUBLIC_BASE_URL` when the full URL is unset. |
+| `CCR_DATA_DIR` | `/data` | Container data root and process `HOME`. Mount persistent storage here. |
+| `CCR_NGINX_PORT` | `8080` | Container-private Nginx listen port. Match the container side of the published mapping if changed. |
+| `CCR_WEB_HOST` | `127.0.0.1` | Container-private management server host. |
+| `CCR_WEB_PORT` | `3459` | Container-private management server port. |
+| `CCR_GATEWAY_HOST` | `127.0.0.1` | Container-private gateway listener host. |
+| `CCR_GATEWAY_PORT` | `3456` | Container-private gateway listener port used by Nginx. |
+| `CCR_GATEWAY_CORE_PORT` | `3457` | Container-private core gateway runtime port. |
+| `CCR_NO_GATEWAY` | `0` | Set to `1`, `true`, or `yes` to run the management UI without starting the gateway at boot. |
+| `CCR_DOCKER_INIT_CONFIG` | `1` | Set to `0` to disable minimal first-run `config.json` bootstrap. |
+| `CCR_DOCKER_SYNC_PUBLIC_ENDPOINT` | `1` | Set to `0` to stop startup from syncing existing JSON/SQLite listener and public endpoint fields to Docker values. |
+
+Changing internal ports requires corresponding Nginx/PM2 variables and offers no benefit in normal deployments. Publish only `CCR_NGINX_PORT`.
+
+## Build Options And Smoke Test
+
+The Dockerfile builds native dependencies with `node:22-bookworm`, then copies production dependencies and built assets into `node:22-bookworm-slim`. Override the base images when required:
```sh
docker build \
@@ -71,21 +217,123 @@ docker build \
-t claude-code-router:local .
```
-## Environment
+Run the isolated Docker smoke test:
-Most deployments only need the published Nginx port mapping, `CCR_WEB_AUTH_TOKEN`,
-and optionally `CCR_PUBLIC_BASE_URL` when the host-facing URL is not
-`http://127.0.0.1:3458`.
+```sh
+npm run test:docker
+```
-| Variable | Default | Description |
-| --- | --- | --- |
-| `CCR_WEB_AUTH_TOKEN` | generated | Shared management UI token used by Nginx redirects and the core server. |
-| `CCR_PUBLIC_BASE_URL` | `http://127.0.0.1:3458` | Full public router endpoint override. Set this when changing the host-facing Compose port. |
-| `CCR_DATA_DIR` | `/data` | Container data root. |
-| `CCR_NO_GATEWAY` | `0` | Set to `1` to run only the Web UI management service. |
-| `CCR_DOCKER_INIT_CONFIG` | `1` | Set to `0` to disable first-run `config.json` bootstrap. |
-| `CCR_DOCKER_SYNC_PUBLIC_ENDPOINT` | `1` | Sync existing Docker config to the Nginx public router endpoint on startup. |
+The test builds the image, starts a temporary container and volume, verifies that only Nginx is published, checks UI/RPC authentication, tests public-endpoint migration, starts a configured gateway, checks `/health`, and removes its resources. Set `CCR_DOCKER_TEST_SKIP_BUILD=1` to reuse an existing image or `CCR_DOCKER_TEST_IMAGE` to test a different local tag.
-The first-run bootstrap writes a minimal legacy `config.json` only when neither
-`config.json` nor `config.sqlite` exists in the mounted data directory. Once the
-UI saves settings into SQLite, existing persisted configuration takes priority.
+## Operations And Troubleshooting
+
+Useful commands:
+
+```sh
+docker compose ps
+docker compose logs -f ccr
+docker compose restart ccr
+docker compose config
+```
+
+### `/` returns `302`
+
+This is expected. Nginx redirects the root URL to the management page and URL-encodes the management token.
+
+### `/health` returns `502`
+
+`/health` checks the model gateway, not Nginx or the management UI. On a fresh volume it returns `502` until a provider/model exists and the gateway has started. Use `docker compose ps` for container health and open the UI to configure/start the gateway.
+
+### The UI returns `401` after a token change
+
+Open the bare root URL again so Nginx creates a URL with the current token. Close stale tabs and avoid bookmarks that contain an old `ccr_web_token`.
+
+### Clients still use the old port or hostname
+
+Update `CCR_PUBLIC_BASE_URL` and recreate the container. Leave `CCR_DOCKER_SYNC_PUBLIC_ENDPOINT=1` so existing SQLite configuration is synchronized at startup.
+
+### Configuration disappears after recreation
+
+Confirm that `/data` is mounted and that the same named volume or bind-mount path is being reused. `docker compose down` keeps named volumes; `docker compose down --volumes` deletes them.
+
+### A bind mount fails with permission errors
+
+Verify that the host directory exists, is writable by the container, and is not mounted read-only. Named volumes avoid most host ownership and labeling issues.
+
+### The container is healthy but model requests fail
+
+Container health only verifies Nginx/UI reachability. Check **Server** status, provider connectivity, CCR client-key authentication, routing, and request logs. Then inspect `docker compose logs --tail=200 ccr` for startup or runtime errors.
+
+---
+
+## 中文说明
+
+Docker 镜像通过 PM2 运行 CCR Core,并由 Nginx 同时提供管理 UI、管理 RPC、模型网关和健康检查。对外只应发布 Nginx 的容器端口 `8080`;`3459`、`3456`、`3457` 都是容器内部实现端口,不应单独暴露。
+
+这个镜像面向常驻网关和浏览器管理,不包含 Electron、npm 的 `ccr` 命令、系统托盘、桌面 Agent/App 启动、桌面自动更新和桌面专属浏览器集成。
+
+### 快速启动
+
+```sh
+docker compose up -d --build
+docker compose logs -f ccr
+```
+
+打开 。首次启动时管理 UI 可以立即访问;添加供应商和模型、在 **API 密钥** 页面创建 CCR 客户端 Key,然后从 **服务** 页面启动网关。
+
+仓库默认映射是 `3458:8080`,会监听宿主机所有网卡。如果只允许本机访问,请改为:
+
+```yaml
+ports:
+ - "127.0.0.1:3458:8080"
+```
+
+### 鉴权与远程访问
+
+- `CCR_WEB_AUTH_TOKEN` 用于管理 UI / RPC;不设置时,每次容器启动都会生成新的随机 Token。
+- **API 密钥** 页面创建的 CCR 客户端 Key 用于模型网关请求。
+- 上游供应商凭据是第三类凭据,不应拿来代替 CCR 客户端 Key。
+
+根路径会重定向到包含 `ccr_web_token` 的管理 URL。请把该 URL 当作密码。远程部署至少应使用固定强 Token、TLS、主机防火墙或私网,并让反向代理把全部路径转发到 Nginx。流式响应和 SSE 不应被代理缓冲。
+
+外部端口、域名或协议变化时,必须同步设置公开地址:
+
+```yaml
+environment:
+ CCR_PUBLIC_BASE_URL: https://ccr.example.com
+ CCR_WEB_AUTH_TOKEN: ${CCR_WEB_AUTH_TOKEN:?set CCR_WEB_AUTH_TOKEN}
+```
+
+`CCR_PUBLIC_BASE_URL` 只负责写入客户端应使用的公开地址,不会自动发布 Docker 端口。
+
+### 数据、备份与升级
+
+数据实际位于 `/data/.claude-code-router/`,其中包括 `config.sqlite`、`app-data/`、Agent 配置和生成文件。优先使用命名卷,不要让两个运行中的 CCR 容器共享同一个数据目录。
+
+完整文件备份前先停止写入:
+
+```sh
+docker compose stop ccr
+docker compose cp ccr:/data/. ./ccr-data-backup/
+docker compose start ccr
+```
+
+备份包含密钥和请求数据,必须按敏感数据保存。恢复时应复制到新的空卷或空 `/data`,不要把旧备份覆盖到仍有新数据的目录。升级前先备份,然后执行:
+
+```sh
+git pull
+docker compose build --pull
+docker compose up -d
+docker compose ps
+docker compose logs --tail=200 ccr
+```
+
+### 常见排查
+
+- `/` 返回 `302`:正常,Nginx 正在跳转到带管理 Token 的页面。
+- `/health` 返回 `502`:它检查的是模型网关;首次启动尚未配置模型时属于预期行为。
+- 修改 Token 后 UI 返回 `401`:重新打开不带参数的根地址,关闭仍使用旧 Token 的标签页。
+- 重建后配置消失:检查是否仍挂载同一个 `/data` 卷;`docker compose down --volumes` 会删除数据卷。
+- 容器健康但模型请求失败:继续检查服务状态、供应商连通性、CCR 客户端 Key、路由和请求日志;容器健康只表示 Nginx / UI 可访问。
+
+完整的环境变量、端口拓扑、远程部署、构建参数和烟雾测试说明见本页英文主体,对应变量名和命令在中英文环境中完全相同。
diff --git a/docs/public/provider-icons/fenno.jpg b/docs/public/provider-icons/fenno.jpg
new file mode 100644
index 00000000..364f735c
Binary files /dev/null and b/docs/public/provider-icons/fenno.jpg differ
diff --git a/docs/public/provider-icons/qiniu-ai.png b/docs/public/provider-icons/qiniu-ai.png
new file mode 100644
index 00000000..2ff255dc
Binary files /dev/null and b/docs/public/provider-icons/qiniu-ai.png differ
diff --git a/docs/public/provider-icons/unity2.jpg b/docs/public/provider-icons/unity2.jpg
new file mode 100644
index 00000000..e83c9e02
Binary files /dev/null and b/docs/public/provider-icons/unity2.jpg differ
diff --git a/docs/src/content/docs/en/configuration/bot-setup.md b/docs/src/content/docs/en/configuration/bot-setup.md
index ef33824e..c21e9a63 100644
--- a/docs/src/content/docs/en/configuration/bot-setup.md
+++ b/docs/src/content/docs/en/configuration/bot-setup.md
@@ -11,8 +11,9 @@ lead: Add a Bot, bind it to Agent Config, and choose message forwarding or hando
2. Select a platform and fill in the required token, secret, signing secret, robot code, or OAuth fields.
3. Save the Bot.
4. Open the target **Agent Config** and enable **Bot**.
-5. Choose **Forward agent messages** or **Handoff**, then reopen the agent from CCR.
+5. Configure forwarding, handoff, language, timeout, attachments, streaming, and **Allow Agent shell tools** as needed.
+6. Reopen Claude, Codex, ZCode, or OpenCode App from CCR. The Bot is online only while the App is alive.
## Verification
-Open the agent from CCR and send a test message. Confirm that Logs include Bot records and the IM side receives the message.
+Open the Agent App from CCR, then send `/project current`, `/session list`, and one plain message from IM. The Profile card shows connection state, the last event and delivery, pending outbox count, and redacted errors; `/session doctor` provides the same runtime diagnostics. Closing the App should move the Bot to offline state.
diff --git a/docs/src/content/docs/en/configuration/bots.md b/docs/src/content/docs/en/configuration/bots.md
index 614dd766..1d4ae4b3 100644
--- a/docs/src/content/docs/en/configuration/bots.md
+++ b/docs/src/content/docs/en/configuration/bots.md
@@ -5,11 +5,61 @@ eyebrow: Detailed Configuration
lead: Forward agent messages to instant-messaging platforms or hand off active work after desktop idle.
---
+CCR App Relay shares the lifecycle of an Agent App opened by CCR. Opening Claude, Codex, ZCode, or OpenCode App starts its companion worker; closing the App stops both the worker and Bot connection.
+
## Common Modes
- **Forward agent messages**: mirror agent messages into IM.
- **Handoff**: relay the interaction into IM after desktop idle.
+- **Reply only**: with forwarding and handoff disabled, reply only to turns initiated from IM.
+
+Natural-language turns are serialized per IM conversation. `/project`, `/session status`, and `/session cancel` remain immediately responsive; queueing, timeout, cancellation, and worker-restart recovery have explicit states.
+
+## Projects And Sessions
+
+A Project is an Agent-native project or working directory, and a Session is an Agent-native conversation inside that Project.
+
+### Project Commands
+
+| Command | Purpose |
+| --- | --- |
+| `/project` | Show Project help and the App-online boundary. |
+| `/project list [page]` | List known Agent projects with pagination. |
+| `/project find ` | Search project names and paths. |
+| `/project current` | Show the current Project. |
+| `/project use ` | Change Project and clear the previous Session selection. |
+| `/project name ` | Set the Bot display label for this Project. |
+
+### Session Commands
+
+| Command | Purpose |
+| --- | --- |
+| `/session` | Show all Session commands. |
+| `/session list [page]`, `/session find ` | Browse Sessions only in the current Project. |
+| `/session current`, `new [title]`, `use `, `reset` | Inspect, create, continue, or clear a Session selection. |
+| `/session status`, `cancel` | Inspect the active turn/queue, or cancel it and clear the queue. |
+| `/session approve [session]`, `deny`, `answer ` | Answer an Agent-generated permission or input request; every platform has text commands, and card-capable platforms also show action buttons. |
+| `/session name ` | Rename the current Session. |
+| `/session archive `, `restore `, `delete confirm` | Archive, restore, or permanently delete with confirmation. |
+| `/session history [count]`, `usage` | Show recent history and token/cache/cost summaries. |
+| `/session models`, `model`, `effort`, `mode` | Inspect or change this conversation's Session runtime settings. |
+| `/session memory ...`, `skills`, `skill`, `shortcut ...` | Manage persistent context, Agent skills, and shortcuts. |
+| `/session doctor`, `deliveries` | Show connection, outbox, recent-delivery, and redacted-error diagnostics. |
+
+The public Bot command domains are `/project` and `/session`. Other slash commands return the unknown-command response, while plain natural language such as `help` or `list` enters the Agent as a prompt.
+
+## Bot Settings
+
+- **Bot language**: automatic, English, or Simplified Chinese.
+- **Maximum turn time**: interrupt timed-out turns and return a final state.
+- **Session idle reset**: prepare a new Session in the same Project after inactivity; `0` disables it.
+- **Message chunk and attachment limits**: adapt to platform limits and bound inbound files.
+- **Streaming replies and progress**: forward visible text and tool stages.
+- **Send and receive attachments**: accept inbound images/files and return artifacts from the current workspace.
+- **Allow Agent shell tools**: controls Agent shell-tool permission; the Bot command surface remains `/project` and `/session`.
+
+The local state keeps bounded deduplication records, pending turns, a durable outbox, and recent delivery results. Event idempotency gives each Agent turn one execution, and pending delivery resumes while the App is online again.
## Platform Pages
-Slack, Discord, Telegram, LINE, Weixin, WeCom, Feishu, and DingTalk each have a dedicated page.
+Slack, Discord, Telegram, LINE, Weixin, WeCom, Feishu, and DingTalk each have a dedicated page; iMessage uses a local integration. The SDK selects Markdown, cards, streaming updates, file messages, or text according to platform capabilities.
diff --git a/docs/src/content/docs/en/configuration/configuration-file.md b/docs/src/content/docs/en/configuration/configuration-file.md
index 5543fc14..7d84dd61 100644
--- a/docs/src/content/docs/en/configuration/configuration-file.md
+++ b/docs/src/content/docs/en/configuration/configuration-file.md
@@ -8,7 +8,9 @@ lead: Locate the SQLite configuration database maintained by the CCR desktop app
## Default Locations
- **macOS/Linux**: `~/.claude-code-router/config.sqlite`
-- **Windows**: `%APPDATA%\Claude Code Router\config.sqlite`
+- **Windows**: `%APPDATA%\claude-code-router\config.sqlite`
+
+Docker sets `HOME=/data`, so its configuration database is `/data/.claude-code-router/config.sqlite`. Persist the complete `/data` directory rather than mounting only one database file.
## Applying Changes
diff --git a/docs/src/content/docs/en/configuration/profiles.md b/docs/src/content/docs/en/configuration/profiles.md
index 06768113..e0c3de25 100644
--- a/docs/src/content/docs/en/configuration/profiles.md
+++ b/docs/src/content/docs/en/configuration/profiles.md
@@ -2,7 +2,7 @@
title: Agent Config
pageTitle: Agent Config
eyebrow: Detailed Configuration
-lead: Create reusable launch configurations for Claude Code, Codex, and ZCode, and open separate agent instances from different configs.
+lead: Create reusable launch configurations for Claude Code, Codex, Grok CLI, and ZCode, and open separate agent instances from different configs.
---
## Configuration Flow
@@ -14,7 +14,7 @@ lead: Create reusable launch configurations for Claude Code, Codex, and ZCode, a
5. If the entry mode includes App, optionally bind a Bot and choose whether to forward agent messages or enable handoff.
6. Save the config, then open it from the Agent Config card: the terminal button copies the CLI command, and the play button starts the App instance.
-During trial, prefer **Only opened from CCR** and always open the agent from CCR. That keeps the config limited to CCR-launched instances and avoids changing the Claude Code, Codex, or ZCode setup you open directly from the system.
+During trial, prefer **Only opened from CCR** and always open the agent from CCR. That keeps the config limited to CCR-launched instances and avoids changing the Claude Code, Codex, Grok CLI, or ZCode setup you open directly from the system.
## Multi-Instance Mechanism
@@ -23,7 +23,7 @@ Every Agent Config has its own `id` and name. When CCR opens an agent, it finds
| Mechanism | Actual behavior |
| --- | --- |
| Separate config files | With **Only opened from CCR**, Claude Code and Codex write CCR-managed config files in directories separated by config `id` |
-| Separate launchers | Claude Code uses a separate launch wrapper; Codex and ZCode use separate middleware launchers; filenames are also separated by config `id` or name |
+| Separate launchers | Claude Code and Grok CLI use separate launch wrappers; Codex and ZCode use separate middleware launchers; filenames are also separated by config `id` or name |
| Separate app data directories | When opening App mode, Claude App, ChatGPT (the renamed Codex desktop app), and ZCode App use user-data directories separated by config `id` |
| Runtime state | CCR tracks running app instances by entry mode and config `id`; reopening the same config activates the existing window, while a different config can open a separate instance |
@@ -33,11 +33,11 @@ This lets you create multiple configs for the same agent, such as "Claude Code -
| Option | Applies to | Description |
| --- | --- | --- |
-| Agent | All | Claude Code, Codex, or ZCode. ZCode supports App only. |
-| Config name | All | Identifies the config in CCR and can be used as the `ccr ` launch target. Names can contain spaces; copied commands are quoted automatically. |
+| Agent | All | Claude Code, Codex, OpenCode, Grok CLI, or ZCode. Grok CLI supports CLI only; ZCode supports App only. |
+| Config name | All | Identifies the config in CCR and can be used as the `ccr-app ` launch target. Names can contain spaces; copied commands are quoted automatically. |
| Enabled | All | Disabled configs are not exposed as active launch entries and are not applied as effective startup configs. |
| Effect scope | All | **Only opened from CCR** uses CCR-managed isolated config; **System default** writes the agent's default config. Only one enabled system-default config is allowed per agent. |
-| Entry mode | Claude Code, Codex | `CLI & APP` exposes both CLI and App entry points; `CLI only` only generates a CLI command; `App only` only exposes the App entry point. |
+| Entry mode | Claude Code, Codex, OpenCode, Grok CLI | `CLI & APP` exposes both CLI and App entry points; `CLI only` only generates a CLI command; `App only` only exposes the App entry point. Grok CLI is fixed to `CLI only`. |
| Model | All | Default model for the opened agent, either a provider model or Fusion model. For Claude Code, leaving it empty keeps the Claude Code default. |
| Bot | App entry | Bot forwarding only works for App mode opened from CCR. CLI does not forward Bot messages yet. |
| Environment variables | All | Extra environment variables injected into this config. Claude Code includes `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1` by default so gateway model discovery is enabled. |
@@ -65,6 +65,21 @@ Claude App and Claude Code CLI use different model-list adapters:
| Claude Code CLI | CCR gateway model discovery | Use `/model` in the CLI to view the list; selected requests still go through CCR providers, routing, and Fusion. |
| Claude App | CCR-generated Claude App inference models | Claude App needs Claude-compatible model names. CCR maps `Provider/model` and Fusion models into model entries Claude App can recognize, while display labels keep the real model meaning visible. |
+### OpenCode
+
+| Option | What it does |
+| --- | --- |
+| Provider ID | Writes the OpenCode provider reference, defaulting to `claude-code-router`. |
+| Provider name | Display name shown in OpenCode, defaulting to `Claude Code Router`. |
+| OpenCode model | Default model for OpenCode CLI and App. It can be a provider model or Fusion model. |
+| Config file | System-default mode uses OpenCode's default config; Only opened from CCR writes a profile-specific config under CCR's config directory. |
+| Environment variables | Injected into OpenCode CLI, OpenCode App, and its Bot worker. |
+| Bot | Applies to the OpenCode App entry opened from CCR. Incoming Bot messages run through OpenCode CLI and replies are sent back to the same Bot conversation. |
+
+CCR keeps one OpenCode Bot worker next to the OpenCode App process. The worker stores a project and optional session for each Bot conversation. Send `/project list|current|use` to select an Agent project, then use `/session list|current|new|use|reset` to manage sessions inside that project. Selecting another project clears the previous session, and sessions from another project cannot be selected. Only these slash-command domains are intercepted; removed `/task` and legacy flat commands are not supported.
+
+The OpenCode CLI must be available as `opencode` in the CCR Desktop process environment. If it is installed elsewhere, set `CCR_OPENCODE_BIN` in the Agent Config environment variables. Bot sessions default to the filesystem root used by a fresh OpenCode Desktop workspace; set `CCR_OPENCODE_BOT_CWD` to the same project directory currently opened in OpenCode App when using another workspace. CCR passes that directory explicitly through `opencode run --dir`, so the resulting session appears under the matching App project. Permissions are not auto-approved by default; `CCR_OPENCODE_BOT_AUTO_APPROVE=true` enables OpenCode's dangerous `--auto` mode and should be used only in a trusted environment.
+
### Codex
| Option | What it does |
@@ -77,10 +92,16 @@ Claude App and Claude Code CLI use different model-list adapters:
| Environment variables | Injected into Codex CLI or ChatGPT. Claude Code-specific model discovery variables are not passed to Codex. |
| Bot | Applies only to the ChatGPT app entry. |
-After saving, use the terminal button on the config card to copy the Codex CLI command, for example `ccr "Codex - Work"`. Use the play button to open ChatGPT. Following the CodexL launch model, CCR starts the Electron executable inside the ChatGPT app bundle directly, gives it an isolated user-data directory, and points `CODEX_CLI_PATH` at the CCR middleware. The middleware forwards app-server traffic to ChatGPT's bundled Codex CLI and only adapts the account display: an existing valid ChatGPT token is shown as the real ChatGPT account, while a profile without credentials uses a tokenless ChatGPT-shaped workspace identity so the desktop renderer keeps model selection available without storing a real user login. To make the native app-server select its official API marketplace, CCR creates the exact `ccr-local-profile` bootstrap only during process startup and removes it after the first native response; it is also cleaned after startup or abnormal exit and is never retained as login state. Every other authentication file is preserved. Older `Codex.app` installations remain supported.
+After saving, use the terminal button on the config card to copy the Codex CLI command, for example `ccr-app "Codex - Work"`. Use the play button to open ChatGPT. Following the CodexL launch model, CCR starts the Electron executable inside the ChatGPT app bundle directly, gives it an isolated user-data directory, and points `CODEX_CLI_PATH` at the CCR middleware. The middleware forwards app-server traffic to ChatGPT's bundled Codex CLI and only adapts the account display: an existing valid ChatGPT token is shown as the real ChatGPT account, while a profile without credentials uses a tokenless ChatGPT-shaped workspace identity so the desktop renderer keeps model selection available without storing a real user login. To make the native app-server select its official API marketplace, CCR creates the exact `ccr-local-profile` bootstrap only during process startup and removes it after the first native response; it is also cleaned after startup or abnormal exit and is never retained as login state. Every other authentication file is preserved. Older `Codex.app` installations remain supported.
Model and public plugin listings are not synthesized by the middleware. The native Codex app-server reads the generated `model_catalog_json` and handles `model/list` plus public `plugin/list` requests unchanged. This lets Codex refresh the official public [`openai/plugins`](https://github.com/openai/plugins) Git marketplace over the network. In a virtual workspace, only account-private marketplace requests are answered with an explicit empty result because the native service requires real ChatGPT authentication for those sections; they are never replaced with local plugins. Any downloaded Git checkout is owned only by Codex as its normal last-known-good data, not used by CCR as a replacement catalog.
+### Grok CLI
+
+Grok CLI profiles are fixed to **Only opened from CCR** and **CLI only**. After saving, copy and run the card command, for example `ccr-app "Grok - Work"`.
+
+The generated wrapper sets Grok's model base URL and model-list URL to CCR's `/v1` gateway, supplies the profile-specific CCR API key, and sets the selected CCR model as the default. If the CCR Desktop gateway is not running, `ccr-app` starts a shared temporary service for Grok sessions and cleans it up after the last session exits. Grok CLI does not expose a separate user-config-file option, so CCR points `GROK_HOME` at a profile-specific directory. Its `config.toml` starts as a private copy of the user's config and can change independently, while `auth.json` is excluded to prevent a local xAI OAuth token from overriding the CCR key. Plugins, skills, and sessions remain shared with the original Grok home. Inside Grok CLI, use `/model` to switch among the provider and Fusion models returned by CCR; switched requests continue through CCR.
+
### ZCode
| Option | What it does |
@@ -98,8 +119,8 @@ ZCode supports App only, so its entry mode is fixed to `App only`. The `Show all
| Mode | How to open | Best for | Key differences |
| --- | --- | --- | --- |
-| CLI | Click the terminal button to copy the command, then run `ccr ` in a terminal | Working inside a project directory, shell workflows, scripting | Uses the config-specific wrapper or middleware launcher; usually stays in the terminal without opening a desktop window; Bot forwarding support is pending. |
-| App | Click the play button in the CCR desktop app | Desktop windows, side-by-side instances, Bot forwarding, handoff | Uses a separate user-data directory per Agent Config; reopening the same config activates the existing window, while different configs can run in parallel. |
+| CLI | Click the terminal button to copy the command, then run `ccr-app ` in a terminal | Working inside a project directory, shell workflows, scripting | Uses the config-specific wrapper or middleware launcher; usually stays in the terminal without opening a desktop window; Bot forwarding support is pending. |
+| App | Click the play button in the CCR desktop app | Desktop windows, Bot forwarding, handoff | Reopening the same config activates the existing window. Multi-instance behavior depends on the Agent; OpenCode Desktop is single-instance, so CCR stops the managed instance before switching OpenCode profiles. |
| CLI & APP | One config exposes both CLI and App entry points | Reusing the same model config in both terminal and desktop App workflows | Both entries share the config name, model, effect scope, and environment variables, but launch differently. |
## Agent Differences
@@ -110,16 +131,32 @@ Claude Code CLI config writes a settings file. With **Only opened from CCR**, CC
When opening Claude App from the desktop app, CCR also prepares a separate user-data directory for that config. Different Agent Config entries use different directories, so multiple Claude App instances can run at the same time.
+With a Bot bound, Claude App's companion worker exposes Projects/Sessions, streaming replies, attachments, Session usage, and native permission/Ask User requests to IM. The worker stops with the App.
+
### Codex
Codex config writes `config.toml` and a model catalog file. With **Only opened from CCR**, CCR stores those files in a directory separated by config `id`.
Codex supports CLI and App. CLI opens through the launcher for the selected config; App launches ChatGPT, uses a separate user-data directory, and passes the selected model and provider into the app.
+With a Bot bound, the Codex App companion worker uses native Codex rollout Sessions for Project/Session browsing and continuation, queueing, cancellation, model settings, usage, attachments, and diagnostics. It exists only alongside the managed App.
+
+### OpenCode
+
+OpenCode config writes a JSON/JSONC config that routes the selected provider and model through CCR. CLI opens through a profile-specific wrapper; App launches the installed OpenCode Desktop executable with the same effective config.
+
+When a Bot is selected and the App is opened from CCR, CCR starts a companion worker using OpenCode-native Sessions and the same Project/Session, queue, media, settings, and diagnostics contract as the other Apps. The worker stops when the managed OpenCode App exits or the profile is switched.
+
+### Grok CLI
+
+Grok CLI supports CLI only. CCR opens it through a profile-specific wrapper that injects the CCR model gateway, model discovery endpoint, API key, and default model. A profile-specific Grok home excludes xAI OAuth credentials so inference reliably uses the CCR key without rewriting the user's original Grok home.
+
### ZCode
ZCode supports App only. CCR writes ZCode CLI config, v2 config, and model cache based on ZCode home or a custom config file, then starts the App with the current Agent Config's model, provider, and separate user-data directory.
+With a Bot bound, ZCode uses the Codex-compatible companion worker and native Session discovery. Closing ZCode App immediately takes the relay offline.
+
## Multi-Instance Suggestions
1. Create one Agent Config for each agent instance that should run independently.
diff --git a/docs/src/content/docs/en/configuration/provider-deeplink.md b/docs/src/content/docs/en/configuration/provider-deeplink.md
index b2dd290c..6399137d 100644
--- a/docs/src/content/docs/en/configuration/provider-deeplink.md
+++ b/docs/src/content/docs/en/configuration/provider-deeplink.md
@@ -78,14 +78,26 @@ Choose a provider below to get started. CCR shows what will be added before savi
TeamoRouter Anthropic / Chat / Responses
-
+
+
+ Unity2.Ai OpenAI compatible gateway
+
+
code0.ai Anthropic / Chat / Responses
-
+
claudeapi Anthropic Messages
+
+
+ Qiniu Cloud AI Chat / Responses / Anthropic / Gemini Generate
+
+
+
+ Fenno.ai Chat / Responses / Anthropic
+
## Embeddable Button Component
diff --git a/docs/src/content/docs/en/configuration/server.md b/docs/src/content/docs/en/configuration/server.md
index c671b31a..54a2b2ec 100644
--- a/docs/src/content/docs/en/configuration/server.md
+++ b/docs/src/content/docs/en/configuration/server.md
@@ -5,6 +5,18 @@ eyebrow: Detailed Configuration
lead: Configure the CCR gateway host, port, and Proxy mode for MITM interception and proxying into CCR.
---
+## Management And Gateway Addresses Are Separate
+
+The Host/Port fields under **Server** configure the model gateway, not the browser management page:
+
+| Distribution | Management entry | Model gateway |
+| --- | --- | --- |
+| Desktop | App window | `http://127.0.0.1:3456` by default |
+| npm CLI | `http://127.0.0.1:3458` by default | `http://127.0.0.1:3456` by default |
+| Docker | Public `http://127.0.0.1:3458` by default | Combined into the same public Nginx endpoint |
+
+CLI `--host`/`--port` options configure management; this page configures the gateway. Docker internal listeners should not be published separately. See [Docker Deployment](../../guides/docker/).
+
## Main Fields
| Field | Capability |
@@ -12,6 +24,20 @@ lead: Configure the CCR gateway host, port, and Proxy mode for MITM interception
| Host | Host address the CCR gateway listens on. Common values are `127.0.0.1` and `0.0.0.0`. |
| Port | Gateway listening port. Clients should point their API base URL to this port. |
+`127.0.0.1` allows local access only; `0.0.0.0` listens on every IPv4 interface. Use a wildcard only for intentional LAN/remote access, together with CCR client API keys, firewall/private-network controls, and TLS at a reverse proxy.
+
+Management tokens, CCR client API keys, and upstream credentials are separate. Gateway clients use keys created under **API Keys** and should never receive upstream provider credentials.
+
+## Start And Verify
+
+1. Add at least one provider and model.
+2. Create a client key under **API Keys**.
+3. Click **Start** or **Restart**.
+4. Confirm Running status and request the gateway `/health` route.
+5. Send a minimal model request and inspect the resolved provider/model under Logs.
+
+A reachable management UI does not prove the gateway is running. Docker returns `502` from `/health` until the gateway starts, and desktop/CLI can keep management available without usable models.
+
## Proxy Mode
Proxy mode is the local proxy capability. When enabled, clients can send HTTP/HTTPS traffic to CCR. CCR uses MITM interception to identify and decrypt HTTPS requests, then proxies supported model requests into the CCR gateway path.
@@ -26,3 +52,5 @@ Proxy mode is the local proxy capability. When enabled, clients can send HTTP/HT
| Check Trust | Checks again whether the proxy CA is trusted by the system. |
| Proxy status | Shows whether the proxy service is running. |
| Restart Proxy | Restarts the proxy service when proxy mode is enabled. |
+
+Proxy mode changes local networking and certificate trust and is primarily a desktop feature. Container deployments should normally point clients directly at the public CCR Nginx gateway instead of trying to change the host system proxy or install a host CA from inside the container.
diff --git a/docs/src/content/docs/en/guides.md b/docs/src/content/docs/en/guides.md
index f554a274..5a1ebcf7 100644
--- a/docs/src/content/docs/en/guides.md
+++ b/docs/src/content/docs/en/guides.md
@@ -7,17 +7,15 @@ lead: Start from installation, connect a provider, let agents send requests thro
## Install And Start CCR
-### Download And Install
+CCR is available as a desktop app, a Node.js 22+ npm CLI, and a single-entrypoint Docker deployment.
-1. Open the [GitHub Releases](https://github.com/musistudio/claude-code-router/releases) page.
-2. Download the package for your system: `.dmg` or `.zip` for macOS, `.exe` for Windows, and `.AppImage` for Linux.
-3. Install and open **Claude Code Router** like a normal desktop app.
+| Distribution | Start entry | Default management | Default model gateway |
+| --- | --- | --- | --- |
+| Desktop | App UI / `ccr-app` | In-app window | `http://127.0.0.1:3456` |
+| npm CLI | `ccr ui` / `ccr serve` | `http://127.0.0.1:3458` | `http://127.0.0.1:3456` |
+| Docker | `docker compose up -d --build` | Shared `http://127.0.0.1:3458` | Shared Nginx endpoint |
-### Start The Service
-
-Open the **Server** page and click **Start**. After the page shows Running, CCR listens on the default local address `http://localhost:8080`.
-
-If you want the service to start when the app opens, enable **Auto start** on the Server page.
+Use the [installation page](install/) to choose a distribution. See the [CLI reference](cli/) for terminal commands and [Docker Deployment](docker/) for container ports, authentication, persistence, and upgrades.
## Add A Provider
@@ -57,7 +55,7 @@ If you want the overview to show balance or remaining quota, open the provider's
## Connect Agent Config
-Agent Config lets Claude Code, Codex, ZCode, and other agents use CCR's providers, routing, and model selection.
+Agent Config lets Claude Code, Codex, Grok CLI, ZCode, and other agents use CCR's providers, routing, and model selection.
General guidance:
@@ -73,13 +71,17 @@ In **Agent Config**, choose Claude Code, set the model, small fast model, and se
In **Agent Config**, choose Codex and confirm Provider ID, Provider Name, model, and config file. Only fill Codex CLI path and Codex home when you need a specific CLI or home directory.
+### Grok CLI
+
+Choose Grok CLI and select a default model, then run the copied `ccr-app ` command. The command starts a shared temporary gateway service when CCR Desktop is not already serving one; concurrent Grok sessions keep it alive until the last session exits. CCR points Grok model discovery and inference at the local gateway; use `/model` inside Grok to switch CCR models.
+
### ZCode
ZCode mainly uses model, Provider ID, Provider Name, and whether it is launched from CCR. It uses the App surface and does not need Codex CLI path fields.
### Reuse A Locally Logged-In Agent
-If Claude Code, Codex, or ZCode is already logged in on this machine, import it as a **Local Agent Provider** from **Providers** to reuse the existing authorization without applying for another key.
+If Claude Code, Codex, Grok CLI, or ZCode is already logged in on this machine, import it as a **Local Agent Provider** from **Providers** to reuse the existing authorization without applying for another key.
## Logs & Observability
diff --git a/docs/src/content/docs/en/guides/agent-profile.md b/docs/src/content/docs/en/guides/agent-profile.md
index d38fb7ed..e5980876 100644
--- a/docs/src/content/docs/en/guides/agent-profile.md
+++ b/docs/src/content/docs/en/guides/agent-profile.md
@@ -2,7 +2,7 @@
title: Connect Agent Config
pageTitle: Connect Agent Config
eyebrow: Quick Start
-lead: Let Claude Code, Codex, ZCode, and other agents use CCR's providers, routing, and model selection.
+lead: Let Claude Code, Codex, Grok CLI, ZCode, and other agents use CCR's providers, routing, and model selection.
---
## General Guidance
@@ -23,10 +23,14 @@ In **Agent Config**, choose Codex and confirm Provider ID, Provider Name, model,
Only fill Codex CLI path and Codex home when you need a specific CLI or home directory.
+## Grok CLI
+
+Choose Grok CLI, select a model, and run the copied `ccr-app ` command. When the CCR Desktop gateway is not running, the command starts a shared temporary gateway service that remains available until the last concurrent Grok session exits. Use `/model` inside Grok to switch among models exposed by CCR.
+
## ZCode
ZCode mainly uses model, Provider ID, Provider Name, and whether it is launched from CCR. It uses the App surface and does not need Codex CLI path fields.
## Reuse A Locally Logged-In Agent
-If Claude Code, Codex, or ZCode is already logged in on this machine, import it as a **Local Agent Provider** from **Providers** to reuse the existing authorization without applying for another key.
+If Claude Code, Codex, Grok CLI, or ZCode is already logged in on this machine, import it as a **Local Agent Provider** from **Providers** to reuse the existing authorization without applying for another key.
diff --git a/docs/src/content/docs/en/guides/cli.md b/docs/src/content/docs/en/guides/cli.md
new file mode 100644
index 00000000..02a17046
--- /dev/null
+++ b/docs/src/content/docs/en/guides/cli.md
@@ -0,0 +1,161 @@
+---
+title: CLI Installation And Reference
+pageTitle: CLI Installation And Reference
+eyebrow: Quick Start
+lead: Run the browser management UI and model gateway from npm, and launch locally installed agents through CCR profiles without Electron.
+---
+
+## `ccr` And `ccr-app`
+
+CCR has two related commands:
+
+| Command | Source | Primary use |
+| --- | --- | --- |
+| `ccr` | npm package `@musistudio/claude-code-router` | Electron-free management UI, gateway service, and profile launches. |
+| `ccr-app` | CCR desktop application | Desktop-managed profile launcher used by commands copied from Agent Config cards. |
+
+Both distributions use the same local configuration directory, but their command names are not interchangeable. Use the desktop app for tray features, notifications, automatic app updates, and desktop-only browser integrations. Use the npm CLI for headless hosts or external process supervision.
+
+## Install, Upgrade, Or Remove
+
+Node.js 22 or newer is required:
+
+```sh
+node --version
+npm install -g @musistudio/claude-code-router
+ccr --help
+```
+
+Upgrade or uninstall with npm:
+
+```sh
+npm install -g @musistudio/claude-code-router@latest
+npm uninstall -g @musistudio/claude-code-router
+```
+
+Uninstalling the package does not delete local configuration or databases. If `ccr` is not found, run `npm prefix -g`, add npm's global binary directory to `PATH`, and open a new shell.
+
+## First Start
+
+Start the background service and open the UI:
+
+```sh
+ccr ui
+```
+
+For SSH or headless sessions:
+
+```sh
+ccr ui --no-open
+```
+
+Then add a provider/model, create a CCR client key under **API Keys**, configure routing if needed, and confirm the gateway is running under **Server**. The management UI defaults to `http://127.0.0.1:3458`; the model gateway defaults to `http://127.0.0.1:3456`.
+
+The management token and CCR client keys are separate credentials. The first protects UI/RPC access; the second authenticates model gateway requests.
+
+## Service Command Summary
+
+| Command | Mode | Purpose |
+| --- | --- | --- |
+| `ccr start` | Background | Starts management and the gateway, then prints the authenticated management URL. |
+| `ccr ui` | Background | Reuses or starts the background service and opens a browser. |
+| `ccr stop` | One-shot | Stops the service created by `start` or `ui`. |
+| `ccr serve` | Foreground | Runs in the current terminal for logs or process supervision. |
+| `ccr web` | Foreground | Alias of `serve`. |
+| `ccr ` | Foreground | Launches an enabled Agent Config profile. |
+
+## Service Options
+
+```text
+ccr start [--host ] [--port ] [--open|--no-open] [--gateway|--no-gateway]
+ccr ui [--host ] [--port ] [--open|--no-open] [--gateway|--no-gateway]
+ccr serve [--host ] [--port ] [--open|--no-open] [--gateway|--no-gateway]
+ccr stop
+```
+
+| Option | Description |
+| --- | --- |
+| `--host ` | Management listener, default `127.0.0.1`. `--host=value` is also accepted. |
+| `--port ` | Preferred management port, default `3458`. `--port=value` is also accepted. |
+| `--open` / `--no-open` | Enables or disables browser opening. `ui` opens by default. |
+| `--gateway` | Explicitly requests model gateway startup; this is the default. |
+| `--no-gateway` | Starts management without starting the model gateway during service startup. |
+
+When the preferred port is occupied, CCR tries following ports and prints the actual URL. `serve` handles `SIGINT` and `SIGTERM`; `ccr stop` manages only detached services.
+
+## Background Service Reuse
+
+`start` and `ui` store the process ID, URL, and a private service token in `service.json`. A later invocation verifies both the process and RPC identity before reuse.
+
+- A valid service is reused rather than duplicated.
+- New host, port, or `--no-gateway` options do not reconfigure an already running service.
+- A command that requires the gateway can ask the existing management process to start it.
+- Stale state is removed before a replacement service starts.
+
+Stop first when changing listener settings:
+
+```sh
+ccr stop
+ccr start --host 127.0.0.1 --port 3458
+```
+
+## Launch Agent Config Profiles
+
+Create and enable a profile under **Agent Config**, then use:
+
+```text
+ccr [cli|app] [-- ]
+```
+
+Examples:
+
+```sh
+ccr "Codex - Work"
+ccr "Codex - Work" app
+ccr "Claude - Review" cli -- --model sonnet
+ccr profile-id -- --help
+```
+
+- `--cli` and `--app` are alternatives to the positional surface.
+- Put agent arguments after `--` to avoid ambiguity.
+- Claude Code, Codex, and Grok default to CLI; ZCode defaults to App.
+- Grok supports CLI only; ZCode supports App only.
+- Claude App and ZCode App reject trailing agent arguments.
+- App launches require a locally installed application and graphical session.
+- Only enabled profiles are launchable. Use the profile ID when names are ambiguous.
+
+Most profiles require the CCR gateway to be running. Grok CLI can create a managed temporary shared service and stops it after the final managed Grok session exits.
+
+## Configuration And Data
+
+| Platform | Configuration directory |
+| --- | --- |
+| macOS / Linux | `~/.claude-code-router` |
+| Windows | `%APPDATA%\claude-code-router` |
+
+Important paths include `config.sqlite`, `app-data/`, `service.json`, `gateway.config.json`, `profiles/`, and generated launch wrappers under `bin/`. Do not edit or copy live SQLite files. Use **Settings → Export data**, or stop CCR before taking a filesystem backup.
+
+## Authentication And Remote Access
+
+`CCR_WEB_HOST` and `CCR_WEB_PORT` provide defaults when command-line listener options are omitted. Set `CCR_WEB_AUTH_TOKEN` to keep a stable management UI/RPC token; otherwise a random token is generated for the process. The authenticated management URL contains `ccr_web_token`; treat the full URL as a password.
+
+Keep the listener on `127.0.0.1` unless remote access is intentional. A remote deployment should use a strong fixed token, firewall/private network controls, and TLS at a trusted reverse proxy. Create separate CCR client API keys for gateway access and protect the data directory because it contains upstream credentials.
+
+## Process Supervisors
+
+Use `ccr serve --no-open` with an external supervisor. Fix the service user, `HOME`, listener, and `CCR_WEB_AUTH_TOKEN`. Do not also run a detached `ccr start` service, which can create a second management listener or make both processes compete for the same configuration.
+
+## Troubleshooting
+
+- **UI works but gateway requests fail:** add a provider/model and CCR client key, start the gateway under **Server**, and use `ccr serve` to inspect startup errors.
+- **The UI is not on port 3458:** the preferred port was occupied; use the printed URL or stop the conflict.
+- **Profile not found:** confirm it is enabled, use its ID when names are ambiguous, and re-save it if generated launchers are missing.
+- **Old background options remain active:** run `ccr stop`, then start again with the new options.
+- **A foreground service does not stop through `ccr stop`:** stop `ccr serve` from its terminal or supervisor.
+
+## Related Pages
+
+- [Install And Start CCR](../install/)
+- [Agent Config](../../configuration/profiles/)
+- [Server](../../configuration/server/)
+- [Docker Deployment](../docker/)
diff --git a/docs/src/content/docs/en/guides/docker.md b/docs/src/content/docs/en/guides/docker.md
new file mode 100644
index 00000000..f3e84138
--- /dev/null
+++ b/docs/src/content/docs/en/guides/docker.md
@@ -0,0 +1,198 @@
+---
+title: Docker Deployment
+pageTitle: Docker Deployment
+eyebrow: Quick Start
+lead: Run CCR Core and the browser UI behind a single Nginx entrypoint with documented ports, authentication, persistence, upgrades, and troubleshooting.
+---
+
+## Scope And Limitations
+
+The image contains CCR Core, the built management UI, PM2, and Nginx. It is intended for a persistent model gateway and browser administration. It does not include Electron, the npm `ccr` command, tray features, host desktop Agent/App launching, desktop automatic updates, or desktop-only browser integrations.
+
+Use the desktop distribution for local app profiles and tray workflows, or the [CLI](../cli/) for an Electron-free host command.
+
+## Process And Port Topology
+
+```text
+host 3458 -> container Nginx 8080
+ |-> static management UI
+ |-> management RPC: 127.0.0.1:3459
+ |-> model gateway: 127.0.0.1:3456
+ `-> core runtime: 127.0.0.1:3457
+```
+
+Publish only container port `8080`. The other listeners are implementation details and should remain private.
+
+| Public route | Purpose |
+| --- | --- |
+| `/`, `/pages/home/index.html` | Management UI. The root redirects to a tokenized page URL. |
+| `/api/ccr/rpc` | Authenticated management RPC. |
+| `/health` | Model gateway health, not UI/container health. |
+| `/v1/*`, `/v1beta/*`, `/messages`, `/chat/completions`, `/responses`, `/interactions`, `/mcp/*` | Model and MCP gateway routes. |
+
+## Start With Compose
+
+From the repository root:
+
+```sh
+docker compose up -d --build
+docker compose logs -f ccr
+```
+
+Open . Add a provider/model, create a CCR client key under **API Keys**, and start the gateway under **Server**. A fresh UI is available immediately, but `/health` can return `502` until the gateway has usable models.
+
+Stop or remove the container without deleting its volume:
+
+```sh
+docker compose stop
+docker compose down
+```
+
+Do not add `--volumes` unless all persisted CCR data should be deleted.
+
+The repository mapping `3458:8080` binds every host interface. For local-only access, use:
+
+```yaml
+ports:
+ - "127.0.0.1:3458:8080"
+```
+
+## Authentication
+
+CCR uses three distinct credential types:
+
+| Credential | Purpose | Location |
+| --- | --- | --- |
+| `CCR_WEB_AUTH_TOKEN` | Management UI/RPC | Container environment |
+| CCR client API key | Model gateway requests | **API Keys** page |
+| Upstream credential | Requests from CCR to a provider | **Providers** page |
+
+Without `CCR_WEB_AUTH_TOKEN`, the entrypoint generates a new random token for every container start. Opening `/` still works because Nginx redirects to a URL containing the current token. Use a fixed strong token for persistent or remote deployments.
+
+Keep secrets out of shell history by using an ignored environment file:
+
+```dotenv
+CCR_WEB_AUTH_TOKEN=replace-with-a-long-random-value
+CCR_PUBLIC_BASE_URL=http://127.0.0.1:3458
+```
+
+Pass it through `docker run --env-file` or the Compose service `environment`. Treat the complete management URL as a secret because `ccr_web_token` can be captured in browser history, proxy logs, screenshots, and tickets.
+
+## Change The Public Address
+
+Changing the host-facing port, hostname, or scheme also requires the exact client URL in `CCR_PUBLIC_BASE_URL`:
+
+```yaml
+services:
+ ccr:
+ ports:
+ - "127.0.0.1:8088:8080"
+ environment:
+ CCR_PUBLIC_BASE_URL: http://127.0.0.1:8088
+ CCR_WEB_AUTH_TOKEN: ${CCR_WEB_AUTH_TOKEN:?set CCR_WEB_AUTH_TOKEN}
+```
+
+`CCR_PUBLIC_BASE_URL` updates CCR's public router endpoint. It does not publish a Docker port.
+
+For TLS at a reverse proxy or ingress, set the HTTPS URL and keep the host port private:
+
+```yaml
+environment:
+ CCR_PUBLIC_BASE_URL: https://ccr.example.com
+ CCR_WEB_AUTH_TOKEN: ${CCR_WEB_AUTH_TOKEN:?set CCR_WEB_AUTH_TOKEN}
+```
+
+Proxy every path, allow long-lived requests and adequate body sizes, and disable buffering for SSE/model streams. Add firewall, private-network, or equivalent access controls before exposing management to an untrusted network.
+
+## Persistent Data
+
+The entrypoint sets `HOME=/data`; CCR data is under `/data/.claude-code-router/`, including `config.sqlite`, `gateway.config.json`, `app-data/`, `profiles/`, and generated files under `bin/`.
+
+Prefer a named volume. A bind mount must be writable by the container, and two running CCR containers must not share the same data directory.
+
+On a completely empty volume, the entrypoint writes minimal bootstrap `config.json`. Once the UI saves configuration, SQLite is authoritative. Startup also synchronizes persisted listener/router endpoint fields to the Docker public address unless disabled.
+
+## Backup, Restore, And Upgrade
+
+Use **Settings → Export data** for an application-level backup. For a complete copy, stop writes first:
+
+```sh
+docker compose stop ccr
+docker compose cp ccr:/data/. ./ccr-data-backup/
+docker compose start ccr
+```
+
+The backup contains secrets and may contain request/response data. Restore into a new empty volume or empty `/data` while the container is stopped. Do not overlay an old copy onto populated live data because SQLite WAL/SHM and newer runtime files can be mixed.
+
+Upgrade after backing up:
+
+```sh
+git pull
+docker compose build --pull
+docker compose up -d
+docker compose ps
+docker compose logs --tail=200 ccr
+```
+
+Rollback should pair the previous image/source revision with a pre-upgrade backup; an older build may not understand a newer database.
+
+## Environment Reference
+
+Most installations should change only `CCR_WEB_AUTH_TOKEN`, `CCR_PUBLIC_BASE_URL`, and the Docker port mapping.
+
+| Variable | Default | Description |
+| --- | --- | --- |
+| `CCR_WEB_AUTH_TOKEN` | Random per start | Management UI/RPC token. |
+| `CCR_PUBLIC_BASE_URL` | `http://127.0.0.1:3458` | Exact public URL written to CCR configuration. |
+| `CCR_PUBLIC_HOST` | `127.0.0.1` | Used only to derive the public URL when the full URL is unset. |
+| `CCR_PUBLIC_PORT` | `3458` | Used only to derive the public URL when the full URL is unset. |
+| `CCR_DATA_DIR` | `/data` | Data root and process `HOME`. |
+| `CCR_NGINX_PORT` | `8080` | Container-private Nginx port. |
+| `CCR_WEB_HOST` | `127.0.0.1` | Container-private management host. |
+| `CCR_WEB_PORT` | `3459` | Container-private management port. |
+| `CCR_GATEWAY_HOST` | `127.0.0.1` | Container-private model gateway host. |
+| `CCR_GATEWAY_PORT` | `3456` | Container-private model gateway port used by Nginx. |
+| `CCR_GATEWAY_CORE_PORT` | `3457` | Container-private core runtime port. |
+| `CCR_NO_GATEWAY` | `0` | `1`, `true`, or `yes` starts management without the gateway at boot. |
+| `CCR_DOCKER_INIT_CONFIG` | `1` | `0` disables empty-volume bootstrap `config.json`. |
+| `CCR_DOCKER_SYNC_PUBLIC_ENDPOINT` | `1` | `0` disables startup synchronization of persisted listener/public endpoint fields. |
+
+Internal ports normally should not change. Publish only `CCR_NGINX_PORT`.
+
+## Build And Smoke Test
+
+```sh
+docker build \
+ --build-arg NODE_IMAGE=node:22-bookworm \
+ --build-arg RUNTIME_NODE_IMAGE=node:22-bookworm-slim \
+ -t claude-code-router:local .
+
+npm run test:docker
+```
+
+The smoke test creates temporary resources and verifies the single Nginx entrypoint, UI/RPC auth, public endpoint migration, gateway startup, and `/health`. Set `CCR_DOCKER_TEST_SKIP_BUILD=1` to reuse an image or `CCR_DOCKER_TEST_IMAGE` to select another local tag.
+
+## Operations And Troubleshooting
+
+```sh
+docker compose ps
+docker compose logs -f ccr
+docker compose restart ccr
+docker compose config
+```
+
+- **`/` returns `302`:** expected tokenized management-page redirect.
+- **`/health` returns `502`:** the model gateway is not yet configured/running; this is separate from container health.
+- **UI returns `401` after a token change:** reopen the bare root URL and close tabs/bookmarks containing the old token.
+- **Clients use an old host/port:** update `CCR_PUBLIC_BASE_URL` and recreate the container with endpoint synchronization enabled.
+- **Data disappears after recreation:** verify the same `/data` volume is mounted; `docker compose down --volumes` deletes it.
+- **Bind mount permission errors:** ensure the host directory exists and is writable, or use a named volume.
+- **Container is healthy but requests fail:** inspect Server status, provider connectivity, CCR client-key auth, routing, request logs, and `docker compose logs --tail=200 ccr`.
+
+## Related Pages
+
+- [Install And Start CCR](../install/)
+- [CLI Installation And Reference](../cli/)
+- [Server](../../configuration/server/)
+- [API Keys](../../configuration/api-keys/)
+
diff --git a/docs/src/content/docs/en/guides/install.md b/docs/src/content/docs/en/guides/install.md
index cfafdb73..ba9fee9e 100644
--- a/docs/src/content/docs/en/guides/install.md
+++ b/docs/src/content/docs/en/guides/install.md
@@ -2,17 +2,66 @@
title: Install And Start CCR
pageTitle: Install And Start CCR
eyebrow: Quick Start
-lead: Download the desktop app, install it, and start the local CCR service.
+lead: Choose the desktop app, npm CLI, or Docker for the deployment, and distinguish the management address from the model gateway address.
---
-## Download And Install
+## Choose A Distribution
-1. Open the [GitHub Releases](https://github.com/musistudio/claude-code-router/releases) page.
-2. Download the package for your system: `.dmg` or `.zip` for macOS, `.exe` for Windows, and `.AppImage` for Linux.
-3. Install and open **Claude Code Router** like a normal desktop app.
+| Distribution | Best for | Entry | Default management address | Default gateway address |
+| --- | --- | --- | --- | --- |
+| Desktop app | Daily local use, tray, multi-instance Agent Apps, desktop integrations | App UI, `ccr-app` | In-app window | `http://127.0.0.1:3456` |
+| npm CLI | Terminal, SSH, no Electron, external process supervisors | `ccr` | `http://127.0.0.1:3458` | `http://127.0.0.1:3456` |
+| Docker | Persistent servers and container operations | Nginx | Shared public endpoint | `http://127.0.0.1:3458` with the default mapping |
-## Start The Service
+In desktop/CLI deployments, management and the model gateway do not use the same port. Do not use CLI management port `3458` as the default model gateway. Docker intentionally combines both through one Nginx endpoint.
-Open the **Server** page and click **Start**. After the page shows Running, CCR listens on the default local address `http://localhost:8080`.
+## Install The Desktop App
-If you want the service to start when the app opens, enable **Auto start** on the Server page.
+1. Open [GitHub Releases](https://github.com/musistudio/claude-code-router/releases).
+2. Download `.dmg`/`.zip` for macOS, `.exe` for Windows, or `.AppImage` for Linux.
+3. Install and open **Claude Code Router**.
+4. Add a provider/model, create a client key under **API Keys**, then click **Start** under **Server**.
+
+When Server shows Running, the model gateway defaults to `http://127.0.0.1:3456`. Enable automatic startup under Server if the gateway should start whenever the app opens.
+
+## Install The npm CLI
+
+Node.js 22 or newer is required:
+
+```sh
+npm install -g @musistudio/claude-code-router
+ccr ui
+```
+
+`ccr ui` starts a background service and opens the browser. Use `ccr ui --no-open` on a headless host or `ccr serve --no-open` under a process supervisor. See [CLI Installation And Reference](../cli/) for all commands and profile launches.
+
+## Use Docker
+
+From a source checkout:
+
+```sh
+docker compose up -d --build
+```
+
+Open . Docker publishes one Nginx endpoint shared by management and the gateway. Add a provider/model, create a CCR client key, and start the gateway under Server. See [Docker Deployment](../docker/) for ports, authentication, persistence, backups, and remote access.
+
+## Verify The Installation
+
+After configuring a provider, model, and CCR client key:
+
+1. Confirm Server shows Running.
+2. Request `/health` on the deployment's gateway address and expect a `200` running response.
+3. Send one minimal model request to a compatible endpoint using the CCR client key.
+4. Confirm requested/resolved model, provider, status, and latency under Logs.
+
+A reachable management UI does not prove that the model gateway is usable. Docker `/health` returning `502` is expected before a provider/model has been configured.
+
+## Data Locations
+
+| Distribution | Configuration location |
+| --- | --- |
+| Desktop / CLI on macOS or Linux | `~/.claude-code-router` |
+| Desktop / CLI on Windows | `%APPDATA%\claude-code-router` |
+| Docker | `/data/.claude-code-router`; persist `/data` |
+
+Current configuration is stored in `config.sqlite`. Legacy `config.json` is only a migration source when SQLite does not exist, or an initial Docker bootstrap. Do not edit live SQLite files.
diff --git a/docs/src/content/docs/en/index.md b/docs/src/content/docs/en/index.md
index a9a08ba6..2e84f8c7 100644
--- a/docs/src/content/docs/en/index.md
+++ b/docs/src/content/docs/en/index.md
@@ -12,7 +12,7 @@ The top navigation is split into four standalone pages:
| Page | Contents |
| --- | --- |
| [Documentation](./) | Product positioning, architecture overview, and reading path |
-| [Quick Start](guides/) | From installation and provider setup to connecting an agent |
+| [Quick Start](guides/) | Desktop, CLI, and Docker installation plus provider and Agent setup |
| [Detailed Configuration](configuration/overview/) | Overview dashboard, API keys, server, providers, routing, Agent Config, Fusion, Bots, tray, and config database location |
| [Q&A](troubleshooting/) | Request logs, observability panel, and common questions |
@@ -22,7 +22,8 @@ Bot platform guides are child pages under Detailed Configuration. Each platform
If this is your first time using CCR:
-1. Start with [Quick Start](guides/) to connect a provider and Agent Config.
-2. Use the app's request logs to confirm whether requests are passing through CCR.
-3. Open [Detailed Configuration](configuration/overview/) for the overview dashboard, API keys, server, providers, vision, web search, MCP tools, tray, and IM relay.
-4. Use [Q&A](troubleshooting/) for 401, 404, timeout, wrong-routing, or Bot delivery questions.
+1. Choose desktop, npm CLI, or Docker on the [installation page](guides/install/), then use the dedicated [CLI](guides/cli/) or [Docker](guides/docker/) guide.
+2. Continue through [Quick Start](guides/) to connect a provider and Agent Config.
+3. Use request logs to confirm whether requests are passing through CCR.
+4. Open [Detailed Configuration](configuration/overview/) for the overview dashboard, API keys, server, providers, vision, web search, MCP tools, tray, and IM relay.
+5. Use [Q&A](troubleshooting/) for 401, 404, timeout, wrong-routing, or Bot delivery questions.
diff --git a/docs/src/content/docs/zh/configuration/bot-relay.md b/docs/src/content/docs/zh/configuration/bot-relay.md
index b5a40db4..0ed15806 100644
--- a/docs/src/content/docs/zh/configuration/bot-relay.md
+++ b/docs/src/content/docs/zh/configuration/bot-relay.md
@@ -5,11 +5,61 @@ eyebrow: 详细配置
lead: 通过 IM Bot 转发 Agent 消息,或在桌面空闲后把任务接力到手机。
---
+CCR App Relay 的在线周期与由 CCR 打开的 Agent App 保持一致。打开 Claude、Codex、ZCode 或 OpenCode App 时,CCR 启动对应的伴生 worker;App 退出时 worker 和 Bot 连接同步停止。
+
## 常见模式
- **转发 Agent 消息**:把消息同步到 IM。
- **接力**:桌面空闲后,把交互接力到 IM。
+- **仅回复**:关闭“转发 Agent 消息”和接力后,只回复从 IM 主动发起的 turn。
+
+同一个 IM conversation 中的普通消息按顺序执行。`/project`、`/session status` 和 `/session cancel` 等管理命令保持即时响应;排队、取消、超时和 worker 重启恢复都有明确状态。
+
+## Project 与 Session
+
+Project 对应 Agent 的项目或工作目录,Session 对应该 Project 下的 Agent 原生会话。
+
+### Project 命令
+
+| 命令 | 作用 |
+| --- | --- |
+| `/project` | 查看 Project 命令帮助和 App 在线边界。 |
+| `/project list [page]` | 分页列出 Agent 已知项目。 |
+| `/project find ` | 搜索项目名称或路径。 |
+| `/project current` | 查看当前 Project。 |
+| `/project use ` | 切换 Project,并清除原 Session 选择。 |
+| `/project name ` | 设置当前 Project 的 Bot 显示名称。 |
+
+### Session 命令
+
+| 命令 | 作用 |
+| --- | --- |
+| `/session` | 查看全部 Session 命令。 |
+| `/session list [page]`、`/session find ` | 只浏览当前 Project 中的 Sessions。 |
+| `/session current`、`new [title]`、`use `、`reset` | 查看、新建、继续或清除 Session 选择。 |
+| `/session status`、`cancel` | 查看当前 turn/队列,或取消当前 turn 并清空队列。 |
+| `/session approve [session]`、`deny`、`answer ` | 响应 Agent 已经产生的权限或输入请求;所有平台提供文本命令,具备卡片能力的平台同时显示操作按钮。 |
+| `/session name ` | 重命名当前 Session。 |
+| `/session archive `、`restore `、`delete confirm` | 归档、恢复或确认永久删除。 |
+| `/session history [count]`、`usage` | 查看最近历史和 Token/缓存/成本摘要。 |
+| `/session models`、`model`、`effort`、`mode` | 查看或调整当前 conversation 的 Session 运行设置。 |
+| `/session memory ...`、`skills`、`skill`、`shortcut ...` | 管理持久上下文、Agent Skills 和快捷指令。 |
+| `/session doctor`、`deliveries` | 查看连接、outbox、最近投递和脱敏错误。 |
+
+Bot 的公开命令域为 `/project` 和 `/session`。其他 slash command 统一返回未知命令;普通自然语言(包括 `help`、`list`)作为 prompt 进入 Agent。
+
+## Bot 设置
+
+- **Bot 语言**:自动、English 或简体中文。
+- **最长 turn 时间**:超时后中断 Agent turn 并回报最终状态。
+- **Session 空闲重置**:超过指定时间后在当前 Project 中准备新 Session;设为 `0` 关闭。
+- **消息分片与附件上限**:适配平台消息长度并限制入站文件大小。
+- **流式回复与进度**:仅发送可见文本和工具阶段。
+- **收发附件**:允许入站图片/文件和当前工作区产物回传。
+- **允许 Agent 使用 Shell 工具**:控制 Agent 的 Shell 工具权限;Bot 命令域保持为 `/project` 和 `/session`。
+
+状态文件持久保存有界的去重记录、待处理 turn、outbox 和最近投递结果。事件幂等保证每个 Agent turn 执行一次;再次打开 App 后,在 App 在线期间恢复待投递消息。
## 平台页面
-Slack、Discord、Telegram、LINE、微信、企业微信、飞书和钉钉都有独立页面。
+Slack、Discord、Telegram、LINE、微信、企业微信、飞书和钉钉都有独立页面;iMessage 使用本机接入。SDK 按平台能力选择 Markdown、卡片、流式更新、文件消息或文本消息。
diff --git a/docs/src/content/docs/zh/configuration/bot-setup.md b/docs/src/content/docs/zh/configuration/bot-setup.md
index 259c1d54..a0851a32 100644
--- a/docs/src/content/docs/zh/configuration/bot-setup.md
+++ b/docs/src/content/docs/zh/configuration/bot-setup.md
@@ -11,8 +11,9 @@ lead: 添加 Bot、绑定 Agent配置,并选择消息转发或接力模式。
2. 选择平台并填写 Token、Secret、Signing Secret、Robot Code 或 OAuth 信息。
3. 保存 Bot。
4. 打开目标 Agent配置,开启 **Bot**。
-5. 选择 **转发 Agent 消息** 或 **接力**,并重新从 CCR 打开 Agent。
+5. 按需设置转发、接力、语言、超时、附件、流式回复和 **允许 Agent 使用 Shell 工具**。
+6. 从 CCR 重新打开 Claude、Codex、ZCode 或 OpenCode App。Bot 只在 App 存活期间在线。
## 验证方式
-从 CCR 打开 Agent 后发一条测试消息,确认请求日志中有 Bot 相关记录,IM 端也能收到消息。
+从 CCR 打开 Agent App 后,在 IM 发送 `/project current`、`/session list` 和一条普通消息。Profile 卡片会显示 Bot 连接、最后事件、最后投递、待投递数量和脱敏错误;也可发送 `/session doctor` 查看诊断。关闭 App 后,Bot 状态应切换为离线。
diff --git a/docs/src/content/docs/zh/configuration/config-file.md b/docs/src/content/docs/zh/configuration/config-file.md
index fe346779..d97ed814 100644
--- a/docs/src/content/docs/zh/configuration/config-file.md
+++ b/docs/src/content/docs/zh/configuration/config-file.md
@@ -8,7 +8,9 @@ lead: 找到 CCR 桌面 App 默认维护的 SQLite 配置数据库。
## 默认位置
- macOS/Linux:`~/.claude-code-router/config.sqlite`
-- Windows:`%APPDATA%\Claude Code Router\config.sqlite`
+- Windows:`%APPDATA%\claude-code-router\config.sqlite`
+
+Docker 设置 `HOME=/data`,因此配置数据库位于 `/data/.claude-code-router/config.sqlite`;需要持久化挂载整个 `/data`,而不是只挂载单个数据库文件。
## 生效方式
diff --git a/docs/src/content/docs/zh/configuration/profile.md b/docs/src/content/docs/zh/configuration/profile.md
index 6a7c91ca..b3c8b81e 100644
--- a/docs/src/content/docs/zh/configuration/profile.md
+++ b/docs/src/content/docs/zh/configuration/profile.md
@@ -2,7 +2,7 @@
title: Agent配置
pageTitle: Agent配置
eyebrow: 详细配置
-lead: 为 Claude Code、Codex、ZCode 创建可复用的启动配置,并通过不同配置打开不同的 Agent 实例。
+lead: 为 Claude Code、Codex、Grok CLI、ZCode 创建可复用的启动配置,并通过不同配置打开不同的 Agent 实例。
---
## 配置流程
@@ -14,7 +14,7 @@ lead: 为 Claude Code、Codex、ZCode 创建可复用的启动配置,并通过
5. 如果入口模式包含 App,可以绑定 Bot,并选择是否转发 Agent 消息或开启接力。
6. 保存后,从 Agent配置卡片打开:终端图标会复制 CLI 命令,播放图标会启动 App 实例。
-试用阶段建议选择 **仅从 CCR 打开时生效**,并且总是从 CCR 打开 Agent。这样配置只影响 CCR 启动的实例,不会改掉你系统里原本直接打开的 Claude Code、Codex 或 ZCode。
+试用阶段建议选择 **仅从 CCR 打开时生效**,并且总是从 CCR 打开 Agent。这样配置只影响 CCR 启动的实例,不会改掉你系统里原本直接打开的 Claude Code、Codex、Grok CLI 或 ZCode。
## 多开机制
@@ -23,7 +23,7 @@ lead: 为 Claude Code、Codex、ZCode 创建可复用的启动配置,并通过
| 机制 | 实际行为 |
| --- | --- |
| 独立配置文件 | 选择“仅从 CCR 打开时生效”时,Claude Code 和 Codex 会写入 CCR 管理的独立配置目录,路径按配置 `id` 区分 |
-| 独立启动器 | Claude Code 使用独立启动包装器,Codex 和 ZCode 使用独立中间层启动器,文件名同样按配置 `id` 或名称区分 |
+| 独立启动器 | Claude Code 和 Grok CLI 使用独立启动包装器,Codex 和 ZCode 使用独立中间层启动器,文件名同样按配置 `id` 或名称区分 |
| 独立 App 数据目录 | 从 App 打开时,Claude App、ChatGPT(Codex 桌面端的新名称)、ZCode App 都会使用按配置 `id` 区分的用户数据目录 |
| 运行状态 | CCR 按打开入口和配置 `id` 记录运行中的 App 实例;同一个配置再次打开会激活已有窗口,不同配置可以打开不同实例 |
@@ -33,11 +33,11 @@ lead: 为 Claude Code、Codex、ZCode 创建可复用的启动配置,并通过
| 选项 | 适用范围 | 说明 |
| --- | --- | --- |
-| Agent | 全部 | 选择 Claude Code、Codex 或 ZCode。ZCode 只支持 App。 |
-| 配置名称 | 全部 | 用于在 CCR 中识别配置,也会作为 `ccr <配置名称>` 的打开目标。名称可以有空格,复制命令时 CCR 会自动加引号。 |
+| Agent | 全部 | 选择 Claude Code、Codex、OpenCode、Grok CLI 或 ZCode。Grok CLI 只支持 CLI,ZCode 只支持 App。 |
+| 配置名称 | 全部 | 用于在 CCR 中识别配置,也会作为 `ccr-app <配置名称>` 的打开目标。名称可以有空格,复制命令时 CCR 会自动加引号。 |
| 启用开关 | 全部 | 关闭后该配置不会出现在打开入口中,也不会被应用为有效启动配置。 |
| 作用范围 | 全部 | **仅从 CCR 打开时生效** 会使用 CCR 管理的独立配置;**系统默认** 会写入对应 Agent 的默认配置。同一个 Agent 同时只能有一个启用的系统默认配置。 |
-| 入口模式 | Claude Code、Codex | `CLI & APP` 同时显示 CLI 和 App 打开入口;`CLI only` 只生成 CLI 命令;`App only` 只显示 App 打开入口。 |
+| 入口模式 | Claude Code、Codex、OpenCode、Grok CLI | `CLI & APP` 同时显示 CLI 和 App 打开入口;`CLI only` 只生成 CLI 命令;`App only` 只显示 App 打开入口。Grok CLI 固定为 `CLI only`。 |
| 模型 | 全部 | 该 Agent 打开后的默认模型,可以选择普通供应商模型或 Fusion 模型。Claude Code 留空表示保留 Claude Code 默认模型。 |
| Bot | App 入口 | 只有从 CCR 打开的 App 模式会转发 Bot 消息。CLI 当前不转发 Bot 消息。 |
| 环境变量 | 全部 | 为该配置注入额外环境变量。Claude Code 默认带 `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1`,用于启用网关模型发现。 |
@@ -65,6 +65,21 @@ Claude App 和 Claude Code CLI 的模型列表适配方式不同:
| Claude Code CLI | CCR 网关模型发现 | CLI 内使用 `/model` 查看列表;选择后请求仍走 CCR 的供应商、路由和 Fusion。 |
| Claude App | CCR 生成的 Claude App inference models | Claude App 需要 Claude 兼容的模型名。CCR 会把 `供应商/模型` 和 Fusion 模型映射成 Claude App 可识别的模型项,并用显示名称保留真实模型含义。 |
+### OpenCode
+
+| 配置项 | 作用 |
+| --- | --- |
+| Provider ID | 写入 OpenCode 的供应商引用,默认是 `claude-code-router`。 |
+| Provider name | OpenCode 中展示的供应商名称,默认是 `Claude Code Router`。 |
+| OpenCode model | OpenCode CLI 和 App 的默认模型,可以选择普通供应商模型或 Fusion 模型。 |
+| 配置文件 | 系统默认模式使用 OpenCode 默认配置;仅从 CCR 打开时生效会在 CCR 配置目录下写入配置专属文件。 |
+| 环境变量 | 注入 OpenCode CLI、OpenCode App 以及对应的 Bot worker。 |
+| Bot | 在从 CCR 打开的 OpenCode App 入口生效。收到 Bot 消息后会通过 OpenCode CLI 执行,并把回复发回同一个 Bot 会话。 |
+
+CCR 会为 OpenCode App 启动一个配套 Bot worker,并为每个 Bot conversation 分别保存 Project 与可选 Session。先用 `/project list|current|use` 选择 Agent Project,再用 `/session list|current|new|use|reset` 管理该 Project 下的 Agent Session。切换 Project 会清除原 Session,其他 Project 的 Session 无法被选中。Bot 只拦截这两个 slash 命令域;已移除的 `/task` 和旧平铺命令不再兼容。
+
+CCR Desktop 进程环境中必须能够执行 `opencode`。如果 CLI 安装在其他位置,可以在 Agent配置环境变量中设置 `CCR_OPENCODE_BIN`。Bot session 默认使用全新 OpenCode Desktop 工作区对应的文件系统根目录;如果 App 当前打开了其他项目,应通过 `CCR_OPENCODE_BOT_CWD` 设置同一个项目目录。CCR 会把该目录显式传给 `opencode run --dir`,使新 session 出现在 App 对应的项目下。默认不会自动批准权限;只有在可信环境中才应设置 `CCR_OPENCODE_BOT_AUTO_APPROVE=true`,因为它会启用 OpenCode 的高风险 `--auto` 模式。
+
### Codex
| 配置项 | 作用 |
@@ -77,10 +92,16 @@ Claude App 和 Claude Code CLI 的模型列表适配方式不同:
| 环境变量 | 注入 Codex CLI 或 ChatGPT。Claude Code 专用的模型发现变量不会传给 Codex。 |
| Bot | 只在 ChatGPT App 入口生效。 |
-保存后,Codex CLI 使用配置卡片里的终端图标复制命令,例如 `ccr "Codex - Work"`。ChatGPT 使用播放图标打开。CCR 按照 CodexL 的启动方式,直接运行 ChatGPT App bundle 内的 Electron 可执行文件,为它设置隔离的用户数据目录,并把 `CODEX_CLI_PATH` 指向 CCR 中间层。中间层把 app-server 流量转发给 ChatGPT 内置的 Codex CLI,只适配账号展示:隔离目录已有有效 ChatGPT token 时显示真实账号;没有凭据时使用无 token、ChatGPT 形态的虚拟工作区身份,让桌面端在不保存真实用户登录的情况下仍可使用模型选择。为让原生 app-server 选择官方 API marketplace,CCR 只在进程启动阶段创建精确的 `ccr-local-profile` 引导标记,收到第一条原生响应后立即删除;正常启动后或异常退出时也会清理,不会把它保留成登录状态。其他认证文件全部保留。旧版 `Codex.app` 仍然兼容。
+保存后,Codex CLI 使用配置卡片里的终端图标复制命令,例如 `ccr-app "Codex - Work"`。ChatGPT 使用播放图标打开。CCR 按照 CodexL 的启动方式,直接运行 ChatGPT App bundle 内的 Electron 可执行文件,为它设置隔离的用户数据目录,并把 `CODEX_CLI_PATH` 指向 CCR 中间层。中间层把 app-server 流量转发给 ChatGPT 内置的 Codex CLI,只适配账号展示:隔离目录已有有效 ChatGPT token 时显示真实账号;没有凭据时使用无 token、ChatGPT 形态的虚拟工作区身份,让桌面端在不保存真实用户登录的情况下仍可使用模型选择。为让原生 app-server 选择官方 API marketplace,CCR 只在进程启动阶段创建精确的 `ccr-local-profile` 引导标记,收到第一条原生响应后立即删除;正常启动后或异常退出时也会清理,不会把它保留成登录状态。其他认证文件全部保留。旧版 `Codex.app` 仍然兼容。
模型和公共插件列表不再由中间层合成。原生 Codex app-server 读取生成的 `model_catalog_json`,并原样处理 `model/list` 与公共 `plugin/list` 请求,因此 Codex 可以自行联网刷新官方公开 [`openai/plugins`](https://github.com/openai/plugins) Git marketplace。虚拟 workspace 中,只有必须使用真实 ChatGPT 鉴权的账号私有 marketplace 请求会得到明确空结果,绝不会用本地插件替代。下载后的 Git checkout 只作为 Codex 自己的常规 last-known-good 数据,CCR 不会拿它替代远端目录。
+### Grok CLI
+
+Grok CLI 配置固定为 **仅从 CCR 打开时生效** 和 **CLI only**。保存后复制并运行配置卡片上的命令,例如 `ccr-app "Grok - Work"`。
+
+生成的包装器会把 Grok 的模型网关和模型列表地址指向 CCR 的 `/v1`,注入该配置专属的 CCR API Key,并把选中的 CCR 模型设为默认模型。如果 CCR Desktop 网关尚未运行,`ccr-app` 会为 Grok 会话启动一个可共享的临时服务,并在最后一个会话退出后清理。Grok CLI 没有单独指定用户配置文件的选项,因此 CCR 会把 `GROK_HOME` 指向配置专属目录;其中的 `config.toml` 初始复制自用户配置,之后可以独立修改,不会回写原文件,同时隔离 `auth.json`,避免本机 xAI OAuth token 覆盖 CCR Key。插件、技能和会话目录仍与原 Grok home 共享。进入 Grok CLI 后可以使用 `/model` 切换 CCR 返回的普通供应商模型或 Fusion 模型,切换后的请求仍然经过 CCR。
+
### ZCode
| 配置项 | 作用 |
@@ -98,8 +119,8 @@ ZCode 只支持 App 打开,因此入口模式固定为 `App only`,也不会
| 模式 | 如何打开 | 适合场景 | 主要差异 |
| --- | --- | --- | --- |
-| CLI | 点击终端图标复制命令,然后在终端运行 `ccr <配置名称>` | 在项目目录中运行 Agent、需要 shell 工作流、需要把命令放进脚本 | 使用对应配置的包装器或中间层启动;通常不启动桌面窗口;当前不转发 Bot 消息。 |
-| App | 点击播放图标从 CCR 桌面 App 启动 | 需要桌面窗口、多实例并存、Bot 消息转发或接力 | 每个 Agent配置使用独立用户数据目录;同一配置重复打开会激活已有窗口,不同配置可以并行打开。 |
+| CLI | 点击终端图标复制命令,然后在终端运行 `ccr-app <配置名称>` | 在项目目录中运行 Agent、需要 shell 工作流、需要把命令放进脚本 | 使用对应配置的包装器或中间层启动;通常不启动桌面窗口;当前不转发 Bot 消息。 |
+| App | 点击播放图标从 CCR 桌面 App 启动 | 需要桌面窗口、Bot 消息转发或接力 | 同一配置重复打开会激活已有窗口。是否支持多开取决于 Agent;OpenCode Desktop 是单实例应用,切换 OpenCode 配置时 CCR 会先停止其管理的旧实例。 |
| CLI & APP | 同一个配置同时提供 CLI 和 App 入口 | 同一套模型配置既用于终端,也用于桌面 App | 两个入口共用配置名称、模型、作用范围和环境变量,但启动方式不同。 |
## 各 Agent 的差异
@@ -110,16 +131,32 @@ Claude Code CLI 配置会写入设置文件。选择“仅从 CCR 打开时生
从桌面 App 打开 Claude App 时,CCR 还会为该配置准备独立用户数据目录。不同 Agent配置使用不同目录,因此可以同时打开多个 Claude App 实例。
+绑定 Bot 后,Claude App 的伴生 worker 会把 Project/Session、流式回复、附件、会话用量和原生权限/Ask User 请求接入 IM;App 退出时 worker 同步停止。
+
### Codex
Codex 配置会写入 `config.toml`,并生成模型目录文件。选择“仅从 CCR 打开时生效”时,CCR 会把这些文件放在按配置 `id` 区分的目录中。
Codex 支持 CLI 和 App。CLI 会通过对应配置的启动器打开;App 会启动 ChatGPT、使用独立用户数据目录,并把当前配置中的模型和供应商信息带入 App。
+绑定 Bot 后,Codex App 的伴生 worker 使用 Codex 原生 rollout Session,实现 Project/Session 浏览、续接、队列、取消、模型设置、用量、附件和诊断。该 worker 只随受管 App 存活。
+
+### OpenCode
+
+OpenCode 配置会写入 JSON/JSONC 文件,把当前选择的供应商和模型路由到 CCR。CLI 通过配置专属包装器启动;App 使用相同的有效配置启动已安装的 OpenCode Desktop。
+
+选择 Bot 并从 CCR 打开 App 后,CCR 会启动配套 worker,通过 OpenCode 原生 Session 处理收到的 Bot 消息,并提供与其他 App 一致的 Project/Session、队列、媒体、设置和诊断合同。受管 OpenCode App 退出或切换配置时,该 worker 也会同步停止。
+
+### Grok CLI
+
+Grok CLI 只支持 CLI。CCR 通过配置专属包装器启动它,注入 CCR 模型网关、模型发现地址、API Key 和默认模型,并通过不含 xAI OAuth 凭据的配置专属 Grok home 保证推理使用 CCR Key;用户原有的 Grok home 不会被改写。
+
### ZCode
ZCode 只支持 App 打开。CCR 会根据 ZCode home 或自定义配置文件写入 ZCode 的 CLI 配置、v2 配置和模型缓存,并在 App 启动时使用当前 Agent配置的模型、供应商和独立用户数据目录。
+绑定 Bot 后,ZCode 使用与 Codex 同类的 App 伴生 worker 和原生 Session 扫描;ZCode App 关闭时接力立即离线。
+
## 多开建议
1. 为每个需要独立运行的 Agent 实例创建一个 Agent配置。
diff --git a/docs/src/content/docs/zh/configuration/provider-deeplink.md b/docs/src/content/docs/zh/configuration/provider-deeplink.md
index ac471c7f..6bcecfd5 100644
--- a/docs/src/content/docs/zh/configuration/provider-deeplink.md
+++ b/docs/src/content/docs/zh/configuration/provider-deeplink.md
@@ -78,14 +78,26 @@ lead: 快速添加常见模型供应商,确认无误后即可保存,减少
TeamoRouter Anthropic / Chat / Responses
-
+
+
+ Unity2.Ai OpenAI 兼容网关
+
+
code0.ai Anthropic / Chat / Responses
-
+
claudeapi Anthropic Messages
+
+
+ 七牛云 AI Chat / Responses / Anthropic / Gemini Generate
+
+
+
+ Fenno.ai Chat / Responses / Anthropic
+
## 嵌入式按钮组件
diff --git a/docs/src/content/docs/zh/configuration/routing.md b/docs/src/content/docs/zh/configuration/routing.md
index 332eda91..6b432fa9 100644
--- a/docs/src/content/docs/zh/configuration/routing.md
+++ b/docs/src/content/docs/zh/configuration/routing.md
@@ -29,7 +29,7 @@ Claude Code 的 Agent / Task / Workflow 可以派生新的模型请求。CCR 使
4. Claude Code 调用 `Agent` / `Task`,或 Workflow 内部创建 Agent 时,prompt 第一行会携带 `供应商/模型 `。
5. 派生请求进入 CCR 后,CCR 从 system 或前两条 user message 中提取并删除这个标签,然后把该请求路由到标签里的模型。
-因此,Subagent / Workflow 的自动路由不是靠 `x-claude-code-agent-id` 之类的 Header 决定模型,而是靠 prompt 标签。Header 只能作为观测线索,真正的模型选择来自标签。
+因此,Subagent / Workflow 的自动路由由 prompt 标签决定模型。`x-claude-code-agent-id` 等 Header 用于观测,模型选择以标签为准。
##### 与模型页配合
diff --git a/docs/src/content/docs/zh/configuration/server.md b/docs/src/content/docs/zh/configuration/server.md
index 39422399..e216d404 100644
--- a/docs/src/content/docs/zh/configuration/server.md
+++ b/docs/src/content/docs/zh/configuration/server.md
@@ -5,6 +5,18 @@ eyebrow: 详细配置
lead: 配置 CCR 网关监听地址、端口,以及通过代理模式进行 MITM 劫持并代理到 CCR 的能力。
---
+## 先区分管理地址和网关地址
+
+**服务配置** 中的 Host / Port 指模型网关,不是浏览器管理页面:
+
+| 运行方式 | 管理入口 | 模型网关 |
+| --- | --- | --- |
+| 桌面应用 | 应用窗口 | 默认 `http://127.0.0.1:3456` |
+| npm CLI | 默认 `http://127.0.0.1:3458` | 默认 `http://127.0.0.1:3456` |
+| Docker | 默认公开入口 `http://127.0.0.1:3458` | 由 Nginx 合并到同一公开入口 |
+
+CLI 的 `--host` / `--port` 配置管理服务;本页字段配置模型网关。Docker 的内部管理和网关端口不应单独发布,详见 [Docker 部署](../../guides/docker/)。
+
## 主字段
| 字段 | 代表的能力 |
@@ -12,6 +24,20 @@ lead: 配置 CCR 网关监听地址、端口,以及通过代理模式进行 MI
| Host | CCR 网关监听的主机地址。常见值是 `127.0.0.1` 或 `0.0.0.0`。 |
| Port | CCR 网关监听端口。客户端需要把 API Base URL 指向这个端口。 |
+Host 使用 `127.0.0.1` 时仅本机可访问;`0.0.0.0` 会监听所有 IPv4 网卡。只有在确实需要局域网或远程访问时才使用通配地址,并同时配置 CCR 客户端 API Key、防火墙 / 私网和 TLS 反向代理。
+
+管理 Token、CCR 客户端 API Key 和上游供应商凭据彼此独立。客户端访问网关时使用 **API 密钥** 页面创建的 CCR Key,不要直接暴露上游凭据。
+
+## 启动和验证
+
+1. 至少添加一个供应商和模型。
+2. 在 **API 密钥** 页面创建客户端 Key。
+3. 点击 **启动** 或 **重启**。
+4. 确认状态显示运行中,并请求网关的 `/health`。
+5. 发出最小模型请求,再到请求日志核对最终供应商 / 模型。
+
+管理 UI 可访问不代表模型网关已运行。Docker 在网关未启动时会让 `/health` 返回 `502`;桌面版 / CLI 也可能在没有可用模型时只保留管理服务。
+
## 代理模式
代理模式是本地代理能力。开启后,客户端可以把 HTTP/HTTPS 流量交给 CCR;CCR 会通过 MITM 劫持识别和解密 HTTPS 请求,并把可处理的模型请求代理到 CCR 网关链路。
@@ -26,3 +52,5 @@ lead: 配置 CCR 网关监听地址、端口,以及通过代理模式进行 MI
| 检查信任 | 重新检测代理 CA 是否已被系统信任。 |
| 代理状态 | 显示代理服务当前是否运行。 |
| 重启代理 | 代理模式开启时,重新启动代理服务。 |
+
+代理模式需要操作本机网络和证书信任,主要面向桌面环境。容器部署通常应把客户端直接指向 CCR 的 Nginx 网关入口,不建议依赖容器修改宿主机系统代理或安装宿主机 CA。
diff --git a/docs/src/content/docs/zh/guides.md b/docs/src/content/docs/zh/guides.md
index 82d5b315..dbd0aad2 100644
--- a/docs/src/content/docs/zh/guides.md
+++ b/docs/src/content/docs/zh/guides.md
@@ -7,17 +7,15 @@ lead: 从安装开始,逐步接入供应商、让 Agent 通过 CCR 发请求
## 安装并启动 CCR
-### 下载安装
+CCR 提供三种发行方式:桌面应用、Node.js 22+ 的 npm CLI,以及 Docker 单入口部署。
-1. 打开 [GitHub Releases](https://github.com/musistudio/claude-code-router/releases) 页面。
-2. 按你的系统下载安装包:macOS 使用 `.dmg` 或 `.zip`,Windows 使用 `.exe`,Linux 使用 `.AppImage`。
-3. 像普通桌面软件一样安装并打开 **Claude Code Router**。
+| 方式 | 启动入口 | 默认管理地址 | 默认模型网关 |
+| --- | --- | --- | --- |
+| 桌面应用 | 应用界面 / `ccr-app` | 应用内窗口 | `http://127.0.0.1:3456` |
+| npm CLI | `ccr ui` / `ccr serve` | `http://127.0.0.1:3458` | `http://127.0.0.1:3456` |
+| Docker | `docker compose up -d --build` | 与网关共用 `http://127.0.0.1:3458` | 与管理界面共用 Nginx 入口 |
-### 启动服务
-
-进入 **服务** 页面,点击 **启动**。页面显示运行中后,CCR 会在本机监听默认地址 `http://localhost:8080`。
-
-如果希望打开 App 后自动启动服务,可以在服务页面开启自动启动。
+先阅读[安装页](install/)选择发行方式;完整终端命令见 [CLI 参考](cli/),容器端口、鉴权、持久化和升级见 [Docker 部署](docker/)。
## 接入供应商
@@ -57,7 +55,7 @@ lead: 从安装开始,逐步接入供应商、让 Agent 通过 CCR 发请求
## 接入 Agent配置
-Agent配置让 Claude Code、Codex、ZCode 等 Agent 使用 CCR 的供应商、路由和模型选择配置。
+Agent配置让 Claude Code、Codex、Grok CLI、ZCode 等 Agent 使用 CCR 的供应商、路由和模型选择配置。
通用建议:
@@ -73,13 +71,17 @@ Agent配置让 Claude Code、Codex、ZCode 等 Agent 使用 CCR 的供应商、
在 **Agent配置** 中选择 Codex,确认供应商 ID、供应商名称、模型和配置文件。需要特定 CLI 时再填写 Codex CLI path 和 Codex home。
+### Grok CLI
+
+选择 Grok CLI 并设置默认模型,然后运行复制出的 `ccr-app <配置名称>` 命令。即使 CCR Desktop 网关尚未运行,该命令也会启动一个可共享的临时网关服务;并发 Grok 会话会共同保持服务运行,直到最后一个会话退出。CCR 会把 Grok 的模型发现和推理请求指向本地网关;进入 Grok 后可以用 `/model` 切换 CCR 模型。
+
### ZCode
ZCode 主要关注模型、供应商 ID、供应商名称,以及是否从 CCR 启动。它走 App surface,不需要 Codex CLI 的路径字段。
### 复用本机已登录的 Agent
-如果本机已经登录过 Claude Code、Codex 或 ZCode,可以在 **供应商** 中导入为 **本机 Agent 供应商**,复用已有授权,不必额外申请 Key。
+如果本机已经登录过 Claude Code、Codex、Grok CLI 或 ZCode,可以在 **供应商** 中导入为 **本机 Agent 供应商**,复用已有授权,不必额外申请 Key。
## 日志&观测
diff --git a/docs/src/content/docs/zh/guides/agent-profile.md b/docs/src/content/docs/zh/guides/agent-profile.md
index d6acfee7..134184f1 100644
--- a/docs/src/content/docs/zh/guides/agent-profile.md
+++ b/docs/src/content/docs/zh/guides/agent-profile.md
@@ -2,7 +2,7 @@
title: 接入 Agent配置
pageTitle: 接入 Agent配置
eyebrow: 快速开始
-lead: 让 Claude Code、Codex、ZCode 等 Agent 使用 CCR 的供应商、路由和模型选择配置。
+lead: 让 Claude Code、Codex、Grok CLI、ZCode 等 Agent 使用 CCR 的供应商、路由和模型选择配置。
---
## 通用建议
@@ -23,10 +23,14 @@ lead: 让 Claude Code、Codex、ZCode 等 Agent 使用 CCR 的供应商、路由
需要特定 CLI 时再填写 Codex CLI path 和 Codex home。
+## Grok CLI
+
+选择 Grok CLI、设置模型,然后运行复制出的 `ccr-app <配置名称>` 命令。CCR Desktop 网关尚未运行时,该命令会启动一个可共享的临时网关服务,并保持运行到最后一个并发 Grok 会话退出。进入 Grok 后可以使用 `/model` 切换 CCR 暴露的模型。
+
## ZCode
ZCode 主要关注模型、供应商 ID、供应商名称,以及是否从 CCR 启动。它走 App surface,不需要 Codex CLI 的路径字段。
## 复用本机已登录的 Agent
-如果本机已经登录过 Claude Code、Codex 或 ZCode,可以在 **供应商** 中导入为 **本机 Agent 供应商**,复用已有授权,不必额外申请 Key。
+如果本机已经登录过 Claude Code、Codex、Grok CLI 或 ZCode,可以在 **供应商** 中导入为 **本机 Agent 供应商**,复用已有授权,不必额外申请 Key。
diff --git a/docs/src/content/docs/zh/guides/cli.md b/docs/src/content/docs/zh/guides/cli.md
new file mode 100644
index 00000000..df411675
--- /dev/null
+++ b/docs/src/content/docs/zh/guides/cli.md
@@ -0,0 +1,220 @@
+---
+title: CLI 安装与命令参考
+pageTitle: CLI 安装与命令参考
+eyebrow: 快速开始
+lead: 使用 npm 版 CCR 在开发机或无桌面服务器上运行管理界面、模型网关,并按 Agent 配置启动本机工具。
+---
+
+## CLI 与桌面版命令的区别
+
+CCR 有两个相关命令:
+
+| 命令 | 来源 | 主要用途 |
+| --- | --- | --- |
+| `ccr` | npm 包 `@musistudio/claude-code-router` | 不依赖 Electron,启动浏览器管理界面、模型网关和 Agent 配置。 |
+| `ccr-app` | CCR 桌面应用 | 桌面版生成的配置启动器;Agent配置卡片复制的命令使用这个名称。 |
+
+两个发行版会读取同一套本机配置目录,但不要把命令名混用。需要托盘、桌面通知、自动更新和桌面专属浏览器集成时,使用桌面版;需要无桌面部署或由进程管理器托管时,使用 npm CLI。
+
+## 安装、升级与卸载
+
+CLI 要求 Node.js 22 或更高版本:
+
+```sh
+node --version
+npm install -g @musistudio/claude-code-router
+ccr --help
+```
+
+升级和卸载:
+
+```sh
+npm install -g @musistudio/claude-code-router@latest
+npm uninstall -g @musistudio/claude-code-router
+```
+
+卸载 npm 包不会删除 CCR 的本地配置和数据库。
+
+如果安装成功但找不到命令,执行 `npm prefix -g`,确认 npm 全局可执行目录已经加入 `PATH`,然后打开一个新终端。
+
+## 第一次启动
+
+在后台启动 CCR 并打开管理界面:
+
+```sh
+ccr ui
+```
+
+SSH 或无桌面环境使用:
+
+```sh
+ccr ui --no-open
+```
+
+随后按这个顺序完成配置:
+
+1. 添加供应商和至少一个模型。
+2. 在 **API 密钥** 页面创建用于访问网关的 CCR 客户端 Key。
+3. 按需要设置默认模型、路由规则和 Fallback。
+4. 在 **服务** 页面确认网关已经运行。
+5. 把客户端 Base URL 指向界面显示的网关地址。
+
+管理界面默认使用 `http://127.0.0.1:3458`,模型网关默认使用 `http://127.0.0.1:3456`。管理 Token 与 CCR 客户端 Key 是两种独立凭据:前者保护 UI / RPC,后者验证模型请求。
+
+## 服务命令总览
+
+| 命令 | 运行方式 | 用途 |
+| --- | --- | --- |
+| `ccr start` | 后台 | 启动管理服务和模型网关,打印带认证信息的管理 URL。 |
+| `ccr ui` | 后台 | 复用或启动后台服务,并打开浏览器。 |
+| `ccr stop` | 一次性 | 停止由 `start` 或 `ui` 启动的后台服务。 |
+| `ccr serve` | 前台 | 在当前终端运行,适合查看日志或交给进程管理器。 |
+| `ccr web` | 前台 | `serve` 的别名。 |
+| `ccr <配置名称或 ID>` | 前台 | 启动一个已启用的 Agent 配置。 |
+
+## `ccr start`
+
+```text
+ccr start [--host ] [--port ] [--open|--no-open] [--gateway|--no-gateway]
+```
+
+| 选项 | 说明 |
+| --- | --- |
+| `--host ` | 管理服务监听地址,默认 `127.0.0.1`。也接受 `--host=value`。 |
+| `--port ` | 管理服务首选端口,默认 `3458`。也接受 `--port=value`。 |
+| `--open` | 启动后打开浏览器。 |
+| `--no-open` | 不打开浏览器。 |
+| `--gateway` | 明确要求启动模型网关;这是默认行为。 |
+| `--no-gateway` | 只启动管理服务,不在启动阶段拉起模型网关。 |
+
+如果首选端口被占用,CCR 会继续尝试后续端口并打印实际 URL。端口必须是 `1` 到 `65535` 的整数。
+
+## `ccr ui`
+
+```text
+ccr ui [--host ] [--port ] [--open|--no-open] [--gateway|--no-gateway]
+```
+
+`ui` 与 `start` 使用同一个后台服务,但默认会打开浏览器。管理 URL 包含 `ccr_web_token` 查询参数;请把完整 URL 当作密码,不要粘贴到日志、工单或公开截图。
+
+## `ccr serve`
+
+```text
+ccr serve [--host ] [--port ] [--open|--no-open] [--gateway|--no-gateway]
+```
+
+`serve` 留在前台,收到 `SIGINT` 或 `SIGTERM` 后关闭管理服务和已配置服务。排查启动错误时优先使用它,因为错误会直接输出到当前终端。
+
+`ccr stop` 只管理后台服务。前台 `serve` 应通过当前终端或外部进程管理器停止。
+
+## 后台服务的复用规则
+
+`start` 和 `ui` 会把进程 ID、URL 和私有服务 Token 写入 `service.json`。再次执行时,CCR 会先验证对应进程和 RPC 身份:
+
+- 服务有效时直接复用,不会再启动第二个后台进程。
+- 新传入的 Host、Port 和 `--no-gateway` 不会重配已经运行的进程。
+- 如果新命令要求网关运行,CCR 会尝试在现有管理进程中启动网关。
+- 状态文件失效或进程已经退出时,CCR 会清理旧状态并启动新服务。
+
+需要修改监听参数时先执行:
+
+```sh
+ccr stop
+ccr start --host 127.0.0.1 --port 3458
+```
+
+## 按 Agent 配置启动
+
+先在 **Agent配置** 中创建并启用配置,然后使用:
+
+```text
+ccr <配置名称或 ID> [cli|app] [-- ]
+```
+
+示例:
+
+```sh
+ccr "Codex - Work"
+ccr "Codex - Work" app
+ccr "Claude - Review" cli -- --model sonnet
+ccr profile-id -- --help
+```
+
+规则如下:
+
+- `--cli` 和 `--app` 可以替代位置形式的 `cli` / `app`。
+- Agent 自己的参数放到 `--` 后,避免与 CCR 选项或入口名冲突。
+- 省略入口时,Claude Code、Codex、Grok CLI 默认使用 CLI,ZCode 默认使用 App。
+- Grok 只支持 CLI,ZCode 只支持 App。
+- Claude App 和 ZCode App 不支持额外 Agent 参数。
+- 启动 App 需要本机安装对应桌面应用,并且当前环境有图形会话。
+- 只有已启用的配置可以启动。名称产生歧义时使用配置 ID。
+
+大多数配置要求 CCR 网关已经运行。Grok CLI 是例外:如果服务不存在,它可以自动启动一个受管的临时共享服务,并在最后一个 Grok 会话退出后关闭。
+
+## 配置和数据位置
+
+| 平台 | 配置目录 |
+| --- | --- |
+| macOS / Linux | `~/.claude-code-router` |
+| Windows | `%APPDATA%\claude-code-router` |
+
+常见文件和目录:
+
+| 路径 | 用途 |
+| --- | --- |
+| `config.sqlite` | 当前应用配置。 |
+| `app-data/` | API Key、用量、请求日志、证书等运行数据。 |
+| `service.json` | 后台 CLI 服务状态和私有 Token。 |
+| `gateway.config.json` | 生成的网关运行配置。 |
+| `profiles/` | 按 Agent 配置隔离的文件。 |
+| `bin/` | CCR 生成的 Agent 启动包装器。 |
+
+不要在 CCR 运行时直接编辑或复制活跃 SQLite 文件。优先使用 **Settings → Export data**;文件级备份前先停止 CLI 和桌面应用。
+
+## 环境变量与远程访问
+
+公开的管理认证变量是:
+
+| 变量 | 说明 |
+| --- | --- |
+| `CCR_WEB_HOST` | 省略 `--host` 时使用的管理服务监听地址。 |
+| `CCR_WEB_PORT` | 省略 `--port` 时使用的管理服务端口。 |
+| `CCR_WEB_AUTH_TOKEN` | 固定管理 UI / RPC Token;不设置时进程会生成随机 Token。 |
+
+监听到 `0.0.0.0` 会让管理界面进入局域网或外部网络。只有在确实需要时才这样配置,并同时使用固定强 Token、主机防火墙或私网,以及可信反向代理提供的 TLS。
+
+模型网关还需要单独创建 CCR 客户端 Key。上游供应商凭据保存在本地数据目录,因此目录和备份都应按敏感数据保护。
+
+## 进程管理器示例
+
+生产环境应使用 `ccr serve --no-open`,让外部管理器负责重启和日志。启动命令至少应固定工作用户、`HOME`、监听地址和 `CCR_WEB_AUTH_TOKEN`。不要同时运行由 `ccr start` 创建的后台服务,否则可能得到两个管理端口或竞争同一套配置。
+
+## 常见问题
+
+### UI 能打开,但 `/health` 或模型请求失败
+
+管理服务可以在没有可用模型网关时运行。添加供应商和模型、创建 CCR 客户端 Key,然后从 **服务** 页面启动或重启网关。使用 `ccr serve` 查看启动错误。
+
+### 实际管理端口不是 3458
+
+3458 已被占用,CCR 使用了后续可用端口。以命令打印的 URL 为准;需要固定端口时,先停止冲突进程。
+
+### 找不到 Agent 配置
+
+确认配置已启用,并检查名称是否重复。CCR 会按 ID、名称、忽略大小写的名称和清理后的名称匹配;多个结果时必须使用 ID。
+
+### 提示启动器不存在
+
+先打开一次 CCR 或重新保存该 Agent 配置,让 CCR 重新生成 `bin/` 下的启动包装器。
+
+### 后台服务无法停止
+
+先运行 `ccr stop`。如果状态文件已经失效,命令会清理它并报告服务未运行。前台 `ccr serve` 不受 `ccr stop` 管理,应回到对应终端或进程管理器停止。
+
+## 相关页面
+
+- [安装并启动 CCR](../install/)
+- [Agent配置](../../configuration/profile/)
+- [服务配置](../../configuration/server/)
+- [Docker 部署](../docker/)
diff --git a/docs/src/content/docs/zh/guides/docker.md b/docs/src/content/docs/zh/guides/docker.md
new file mode 100644
index 00000000..d27c8a23
--- /dev/null
+++ b/docs/src/content/docs/zh/guides/docker.md
@@ -0,0 +1,297 @@
+---
+title: Docker 部署
+pageTitle: Docker 部署
+eyebrow: 快速开始
+lead: 使用 Nginx 单入口运行 CCR Core 和浏览器管理界面,并正确处理端口、鉴权、持久化、远程访问、备份和升级。
+---
+
+## 适用范围与限制
+
+Docker 镜像适合常驻模型网关和浏览器管理。它包含 CCR Core、构建后的管理 UI、PM2 和 Nginx,但不包含:
+
+- Electron 桌面应用、系统托盘和桌面通知;
+- npm 发行版的 `ccr` 命令;
+- 从容器中启动宿主机 Claude App、ChatGPT、ZCode 等桌面 App;
+- 桌面自动更新和桌面专属的内置浏览器集成。
+
+如果主要需求是本机 Agent 多开、托盘或桌面 App 启动,请使用桌面版;如果需要终端命令但不需要容器,请使用 [CLI](../cli/)。
+
+## 进程和端口拓扑
+
+```text
+宿主机 3458 -> 容器 Nginx 8080
+ |-> 静态管理 UI
+ |-> 管理 RPC:127.0.0.1:3459
+ |-> 模型网关:127.0.0.1:3456
+ `-> Core Runtime:127.0.0.1:3457
+```
+
+只应发布 Nginx 的容器端口 `8080`。`3459`、`3456`、`3457` 都是容器内部实现端口,不要分别映射到宿主机。
+
+Nginx 对外提供:
+
+| 路径 | 用途 |
+| --- | --- |
+| `/`、`/pages/home/index.html` | 管理 UI。根路径会跳转到带管理 Token 的页面。 |
+| `/api/ccr/rpc` | 需要管理 Token 的管理 RPC。 |
+| `/health` | 模型网关健康状态,不是容器或 UI 健康状态。 |
+| `/v1/*`、`/v1beta/*`、`/messages`、`/chat/completions`、`/responses`、`/interactions`、`/mcp/*` | 模型和 MCP 网关接口。 |
+
+## 使用 Compose 快速启动
+
+在仓库根目录执行:
+
+```sh
+docker compose up -d --build
+docker compose logs -f ccr
+```
+
+打开 。新数据卷上管理 UI 会立即可用;模型网关要在添加供应商和模型后才能正常启动。
+
+首次配置顺序:
+
+1. 添加供应商和至少一个模型。
+2. 在 **API 密钥** 页面创建 CCR 客户端 Key。
+3. 在 **服务** 页面启动网关。
+4. 请求 `/health`,确认返回 `200` 和运行状态。
+5. 把客户端 Base URL 指向 `http://127.0.0.1:3458`,并使用刚创建的 CCR 客户端 Key。
+
+停止或移除容器不会自动删除命名卷:
+
+```sh
+docker compose stop
+docker compose down
+```
+
+不要给 `docker compose down` 添加 `--volumes`,除非你明确要删除全部 CCR 数据。
+
+## 只允许本机访问
+
+仓库默认映射 `3458:8080` 会监听宿主机所有网卡。如果只从当前机器访问,修改为:
+
+```yaml
+services:
+ ccr:
+ ports:
+ - "127.0.0.1:3458:8080"
+```
+
+端口映射左侧是宿主机地址和端口,右侧是 Nginx 容器端口。不要把右侧改为内部网关的 `3456`。
+
+## 使用 `docker run`
+
+不使用 Compose 时:
+
+```sh
+docker build -t claude-code-router:local .
+docker run -d \
+ --name claude-code-router \
+ --restart unless-stopped \
+ -p 127.0.0.1:3458:8080 \
+ -e CCR_PUBLIC_BASE_URL=http://127.0.0.1:3458 \
+ -v ccr-data:/data \
+ claude-code-router:local
+```
+
+仓库也提供 `npm run docker:build` 和 `npm run docker:run`。后者使用 `3458` 和 `ccr-data`,但容器带 `--rm`,没有固定名称和自动重启策略,更适合临时验证。
+
+## 三类凭据不要混用
+
+| 凭据 | 用途 | 配置位置 |
+| --- | --- | --- |
+| `CCR_WEB_AUTH_TOKEN` | 管理 UI / RPC 鉴权 | 容器环境变量 |
+| CCR 客户端 API Key | 模型网关请求鉴权 | UI 的 **API 密钥** 页面 |
+| 上游供应商凭据 | CCR 调用模型供应商 | UI 的 **供应商** 页面 |
+
+不设置 `CCR_WEB_AUTH_TOKEN` 时,EntryPoint 每次启动容器都会生成新的随机 Token。打开根地址仍可工作,因为 Nginx 会跳转到包含当前 Token 的 URL;但持久部署和远程部署应固定一个足够长的强 Token。
+
+不要把 Token 直接写进 Shell 历史。可以创建不进入版本控制的环境文件:
+
+```dotenv
+CCR_WEB_AUTH_TOKEN=replace-with-a-long-random-value
+CCR_PUBLIC_BASE_URL=http://127.0.0.1:3458
+```
+
+通过 `docker run --env-file` 使用,或把同名变量映射到 Compose 服务的 `environment`。包含 `ccr_web_token` 的完整管理 URL 也应按密码保护,因为它可能出现在浏览器历史、反向代理日志、截图和工单中。
+
+## 修改外部端口或地址
+
+宿主机对外地址与容器内部端口是两层配置。修改宿主机端口时,还要把 `CCR_PUBLIC_BASE_URL` 设置为客户端真实使用的完整地址:
+
+```yaml
+services:
+ ccr:
+ ports:
+ - "127.0.0.1:8088:8080"
+ environment:
+ CCR_PUBLIC_BASE_URL: http://127.0.0.1:8088
+ CCR_WEB_AUTH_TOKEN: ${CCR_WEB_AUTH_TOKEN:?set CCR_WEB_AUTH_TOKEN}
+```
+
+`CCR_PUBLIC_BASE_URL` 会同步到 CCR 的公开 Router Endpoint。它本身不会发布 Docker 端口,也不会改变 Nginx 监听地址。
+
+## 域名、HTTPS 与反向代理
+
+由反向代理或 Ingress 终止 TLS 时:
+
+```yaml
+services:
+ ccr:
+ ports:
+ - "127.0.0.1:3458:8080"
+ environment:
+ CCR_PUBLIC_BASE_URL: https://ccr.example.com
+ CCR_WEB_AUTH_TOKEN: ${CCR_WEB_AUTH_TOKEN:?set CCR_WEB_AUTH_TOKEN}
+```
+
+反向代理应把全部路径交给 CCR Nginx,并满足:
+
+- 支持长时间模型请求;
+- 不缓冲 SSE 和流式模型响应;
+- 允许足够的请求体大小;
+- 只有反向代理入口对外公开,宿主机 `3458` 保持仅本机监听;
+- 配合防火墙、VPN / 私网或额外访问控制,避免管理界面直接暴露到不可信网络。
+
+## 持久化目录
+
+EntryPoint 会设置 `HOME=/data`,实际数据位于:
+
+```text
+/data/.claude-code-router/
+├── config.sqlite
+├── gateway.config.json
+├── app-data/
+│ ├── api-keys.sqlite
+│ ├── request-logs.sqlite
+│ ├── usage.sqlite
+│ └── certs/
+├── profiles/
+└── bin/
+```
+
+优先使用命名卷。Bind Mount 目录必须允许容器写入,而且不能让两个运行中的 CCR 容器共享同一份数据。
+
+全新数据目录中既没有 `config.json` 也没有 `config.sqlite` 时,EntryPoint 默认写入最小的旧格式 `config.json` 作为首次引导。UI 保存后 SQLite 成为权威配置。每次启动默认还会把 JSON / SQLite 中的网关监听字段和 `routerEndpoint` 同步到当前 Docker 公开地址。
+
+## 备份与恢复
+
+应用级备份优先使用 **Settings → Export data**。做完整文件备份时,先停止写入:
+
+```sh
+docker compose stop ccr
+docker compose cp ccr:/data/. ./ccr-data-backup/
+docker compose start ccr
+```
+
+备份包含供应商凭据、CCR 客户端 Key,并可能包含请求 / 响应数据,必须按敏感数据保存。
+
+完整恢复时,应把备份复制到新的空卷或空 `/data` 目录,并确保容器已停止。不要把旧备份直接覆盖到仍有新数据的活动目录,否则旧 SQLite WAL / SHM 和新运行文件可能混合。替换现有数据前再做一份备份。
+
+## 升级与回滚
+
+先备份 `/data`,再更新源码、刷新基础镜像并重建:
+
+```sh
+git pull
+docker compose build --pull
+docker compose up -d
+docker compose ps
+docker compose logs --tail=200 ccr
+```
+
+升级会对持久化数据执行当前版本需要的迁移。回滚时应同时使用旧镜像 / 旧源码和升级前备份,不要假设旧版本一定能读取新版本数据库。
+
+## 环境变量完整参考
+
+一般部署只需要设置 `CCR_WEB_AUTH_TOKEN`、`CCR_PUBLIC_BASE_URL` 和 Docker Port Mapping。内部监听变量通常不需要修改。
+
+| 变量 | 默认值 | 说明 |
+| --- | --- | --- |
+| `CCR_WEB_AUTH_TOKEN` | 每次启动随机生成 | 管理 UI / RPC Token。持久或远程部署应设置固定强值。 |
+| `CCR_PUBLIC_BASE_URL` | `http://127.0.0.1:3458` | 写入 CCR 配置的完整公开地址;设置后优先于 Public Host / Port。 |
+| `CCR_PUBLIC_HOST` | `127.0.0.1` | 仅在没有完整公开 URL 时用于拼接公开地址,不会改变 Docker 端口绑定。 |
+| `CCR_PUBLIC_PORT` | `3458` | 仅在没有完整公开 URL 时用于拼接公开地址。 |
+| `CCR_DATA_DIR` | `/data` | 数据根目录,同时作为进程 `HOME`。 |
+| `CCR_NGINX_PORT` | `8080` | Nginx 容器内监听端口,应与 Port Mapping 右侧一致。 |
+| `CCR_WEB_HOST` | `127.0.0.1` | 管理服务容器内监听地址。 |
+| `CCR_WEB_PORT` | `3459` | 管理服务容器内端口。 |
+| `CCR_GATEWAY_HOST` | `127.0.0.1` | 模型网关容器内监听地址。 |
+| `CCR_GATEWAY_PORT` | `3456` | Nginx 转发到的模型网关容器内端口。 |
+| `CCR_GATEWAY_CORE_PORT` | `3457` | Core Gateway Runtime 容器内端口。 |
+| `CCR_NO_GATEWAY` | `0` | 设为 `1`、`true` 或 `yes` 时,启动阶段只运行管理 UI。 |
+| `CCR_DOCKER_INIT_CONFIG` | `1` | 设为 `0` 时禁用首次最小 `config.json` 引导。 |
+| `CCR_DOCKER_SYNC_PUBLIC_ENDPOINT` | `1` | 设为 `0` 时不再在启动时同步已有 JSON / SQLite 的监听和公开地址字段。 |
+
+修改内部端口需要同时保证 PM2 和 Nginx 变量一致,正常部署没有收益。对外仍然只发布 `CCR_NGINX_PORT`。
+
+## 构建和烟雾测试
+
+默认使用 `node:22-bookworm` 构建原生依赖,再把生产依赖和构建产物复制到 `node:22-bookworm-slim`。需要替换基础镜像时:
+
+```sh
+docker build \
+ --build-arg NODE_IMAGE=node:22-bookworm \
+ --build-arg RUNTIME_NODE_IMAGE=node:22-bookworm-slim \
+ -t claude-code-router:local .
+```
+
+运行 Docker 烟雾测试:
+
+```sh
+npm run test:docker
+```
+
+测试会创建临时容器和数据卷,检查单一 Nginx 端口、UI / RPC 鉴权、公开地址迁移、网关启动和 `/health`,最后自动清理。使用 `CCR_DOCKER_TEST_SKIP_BUILD=1` 复用已有镜像,或通过 `CCR_DOCKER_TEST_IMAGE` 指定本地 Tag。
+
+## 日常运维命令
+
+```sh
+docker compose ps
+docker compose logs -f ccr
+docker compose restart ccr
+docker compose config
+```
+
+`docker compose ps` 显示的是容器健康;`/health` 显示的是模型网关健康。两者不能互相替代。
+
+## 常见问题
+
+### 根地址返回 `302`
+
+这是正常行为。Nginx 正在把根地址跳转到带 URL 编码管理 Token 的页面。
+
+### `/health` 返回 `502`
+
+它检查模型网关,不检查 Nginx 或 UI。新数据卷尚未配置供应商 / 模型时会返回 `502`。先打开 UI 完成配置并启动网关。
+
+### 修改 Token 后 UI 返回 `401`
+
+重新打开不带参数的根地址,让 Nginx 生成包含新 Token 的 URL;关闭仍使用旧 `ccr_web_token` 的标签页和书签。
+
+### 客户端仍使用旧端口或域名
+
+更新 `CCR_PUBLIC_BASE_URL` 并重新创建容器。保持 `CCR_DOCKER_SYNC_PUBLIC_ENDPOINT=1`,让已有 SQLite 配置在启动时同步。
+
+### 重建后配置消失
+
+确认 `/data` 仍挂载同一个命名卷或 Bind Mount。`docker compose down` 保留卷,`docker compose down --volumes` 删除卷。
+
+### Bind Mount 权限错误
+
+确认宿主机目录存在、容器可写且没有只读挂载。命名卷通常可以避免宿主机 UID、所有权和安全标签问题。
+
+### 容器健康,但模型请求失败
+
+容器健康只代表 Nginx / UI 可访问。继续检查 **服务** 状态、供应商连通性、CCR 客户端 Key、路由和请求日志,并查看:
+
+```sh
+docker compose logs --tail=200 ccr
+```
+
+## 相关页面
+
+- [安装并启动 CCR](../install/)
+- [CLI 安装与命令参考](../cli/)
+- [服务配置](../../configuration/server/)
+- [API 密钥](../../configuration/api-keys/)
+
diff --git a/docs/src/content/docs/zh/guides/install.md b/docs/src/content/docs/zh/guides/install.md
index 02bf0dc7..8a78da9a 100644
--- a/docs/src/content/docs/zh/guides/install.md
+++ b/docs/src/content/docs/zh/guides/install.md
@@ -2,17 +2,66 @@
title: 安装并启动 CCR
pageTitle: 安装并启动 CCR
eyebrow: 快速开始
-lead: 下载桌面应用,安装后启动本地 CCR 服务。
+lead: 根据桌面版、npm CLI 或 Docker 的运行场景选择安装方式,并确认管理界面与模型网关的不同地址。
---
-## 下载安装
+## 选择发行方式
+
+| 方式 | 适合场景 | 入口 | 默认管理地址 | 默认网关地址 |
+| --- | --- | --- | --- | --- |
+| 桌面应用 | 日常本机使用、托盘、多开 Agent App、桌面集成 | 应用界面、`ccr-app` | 应用内窗口 | `http://127.0.0.1:3456` |
+| npm CLI | 终端、SSH、无 Electron 环境、进程管理器 | `ccr` | `http://127.0.0.1:3458` | `http://127.0.0.1:3456` |
+| Docker | 常驻服务器、容器运维、统一浏览器入口 | Nginx | 与网关共用公开地址 | `http://127.0.0.1:3458`(默认端口映射) |
+
+管理 UI 地址和模型网关地址在桌面版 / CLI 中不是同一个端口。不要把 CLI 的管理端口 `3458` 当成默认模型网关端口;Docker 才通过 Nginx 把两者合并到同一公开入口。
+
+## 安装桌面应用
1. 打开 [GitHub Releases](https://github.com/musistudio/claude-code-router/releases) 页面。
-2. 按你的系统下载安装包:macOS 使用 `.dmg` 或 `.zip`,Windows 使用 `.exe`,Linux 使用 `.AppImage`。
-3. 像普通桌面软件一样安装并打开 **Claude Code Router**。
+2. 按系统下载:macOS 使用 `.dmg` 或 `.zip`,Windows 使用 `.exe`,Linux 使用 `.AppImage`。
+3. 安装并打开 **Claude Code Router**。
+4. 添加供应商和模型,在 **API 密钥** 中创建客户端 Key,然后从 **服务** 页面点击 **启动**。
-## 启动服务
+页面显示运行中后,模型网关默认监听 `http://127.0.0.1:3456`。需要打开应用时自动启动网关,可在 **服务** 页面开启自动启动。
-进入 **Server** 页面,点击 **Start**。页面显示 Running 后,CCR 会在本机监听默认地址 `http://localhost:8080`。
+## 安装 npm CLI
-如果希望打开 App 后自动启动服务,可以在 Server 页面开启 **Auto start**。
+要求 Node.js 22 或更高版本:
+
+```sh
+npm install -g @musistudio/claude-code-router
+ccr ui
+```
+
+`ccr ui` 会启动后台服务并打开浏览器。无桌面环境使用 `ccr ui --no-open`,生产前台托管使用 `ccr serve --no-open`。完整命令和 Profile 启动说明见 [CLI 安装与命令参考](../cli/)。
+
+## 使用 Docker
+
+在源码仓库根目录执行:
+
+```sh
+docker compose up -d --build
+```
+
+打开 。Docker 只发布 Nginx 单入口,管理 UI 和模型网关共用该地址。首次启动后仍需添加供应商 / 模型、创建 CCR 客户端 Key,并从 **服务** 页面启动网关。端口、鉴权、持久化、备份和远程部署见 [Docker 部署](../docker/)。
+
+## 验证安装
+
+完成供应商、模型和 CCR 客户端 Key 配置后:
+
+1. 在 **服务** 页面确认状态为运行中。
+2. 请求当前部署的 `/health`;成功时应返回 `200` 和运行状态。
+3. 用 CCR 客户端 Key 向兼容路径发送一个最小模型请求。
+4. 在 **日志** 页面确认请求模型、最终供应商 / 模型、状态码和耗时。
+
+管理界面能打开并不代表模型网关已经可用。没有供应商 / 模型时,Docker 的 `/health` 返回 `502` 属于预期行为。
+
+## 数据位置
+
+| 方式 | 配置位置 |
+| --- | --- |
+| 桌面 / CLI(macOS、Linux) | `~/.claude-code-router` |
+| 桌面 / CLI(Windows) | `%APPDATA%\claude-code-router` |
+| Docker | `/data/.claude-code-router`,应持久化挂载 `/data` |
+
+CCR 当前配置存储在 `config.sqlite` 中;`config.json` 只在没有 SQLite 配置时作为旧版迁移或 Docker 首次引导来源。不要在 CCR 运行时直接编辑 SQLite。
diff --git a/docs/src/content/docs/zh/index.md b/docs/src/content/docs/zh/index.md
index 45caad32..298da96c 100644
--- a/docs/src/content/docs/zh/index.md
+++ b/docs/src/content/docs/zh/index.md
@@ -12,7 +12,7 @@ lead: 了解 CCR 的定位、能力边界和文档结构。需要动手配置时
| 分类 | 内容 |
| --- | --- |
| [文档](./) | 产品定位、架构概览、阅读路径 |
-| [快速开始](guides/) | 从安装、接供应商,到接入 Agent 的上手流程 |
+| [快速开始](guides/) | 桌面版、CLI、Docker 安装部署,以及供应商和 Agent 接入流程 |
| [详细配置](configuration/overview/) | 概览仪表盘、API 密钥、服务、供应商、路由、Agent配置、Fusion、Bot、托盘和配置数据库位置 |
| [Q&A](troubleshooting/) | 请求日志、观测面板和常见问题 |
@@ -22,9 +22,10 @@ Bot 平台教程是「详细配置」分类下的子页面,每个平台有独
第一次使用时可以从这些页面了解 CCR 的主要流程:
-1. [快速开始](guides/) 覆盖供应商接入和 Agent配置。
-2. App 的请求日志页面展示请求是否经过 CCR。
-3. [详细配置](configuration/overview/) 覆盖概览仪表盘、API 密钥、服务、供应商、图像、联网搜索、MCP 工具、托盘和 IM 接力。
-4. [Q&A](troubleshooting/) 覆盖 401、404、超时、路由不对或 Bot 收不到消息等常见问题。
+1. 从[安装页](guides/install/)选择桌面版、npm CLI 或 Docker;对应细节见 [CLI](guides/cli/) 和 [Docker](guides/docker/) 页面。
+2. [快速开始](guides/) 继续覆盖供应商接入和 Agent配置。
+3. App 的请求日志页面展示请求是否经过 CCR。
+4. [详细配置](configuration/overview/) 覆盖概览仪表盘、API 密钥、服务、供应商、图像、联网搜索、MCP 工具、托盘和 IM 接力。
+5. [Q&A](troubleshooting/) 覆盖 401、404、超时、路由不对或 Bot 收不到消息等常见问题。
这样文档不会挤在一个长页面里,后续也能按顶部分类逐步扩展。
diff --git a/docs/src/i18n/content.ts b/docs/src/i18n/content.ts
index 58a9dc1f..aea1e0e2 100644
--- a/docs/src/i18n/content.ts
+++ b/docs/src/i18n/content.ts
@@ -40,6 +40,8 @@ export const docsContent = {
icon: "book",
items: [
"安装并启动 CCR",
+ "CLI 安装与命令参考",
+ "Docker 部署",
"接入供应商",
"接入 Agent配置",
"日志&观测",
@@ -51,6 +53,8 @@ export const docsContent = {
sidebarChildren: {},
sidebarLinks: {
"安装并启动 CCR": "/guides/install/",
+ "CLI 安装与命令参考": "/guides/cli/",
+ "Docker 部署": "/guides/docker/",
接入供应商: "/guides/provider/",
"接入 Agent配置": "/guides/agent-profile/",
"日志&观测": "/guides/observability/",
@@ -197,6 +201,8 @@ export const docsContent = {
icon: "book",
items: [
"Install And Start CCR",
+ "CLI Installation And Reference",
+ "Docker Deployment",
"Add A Provider",
"Connect Agent Config",
"Logs & Observability",
@@ -208,6 +214,8 @@ export const docsContent = {
sidebarChildren: {},
sidebarLinks: {
"Install And Start CCR": "/en/guides/install/",
+ "CLI Installation And Reference": "/en/guides/cli/",
+ "Docker Deployment": "/en/guides/docker/",
"Add A Provider": "/en/guides/provider/",
"Connect Agent Config": "/en/guides/agent-profile/",
"Logs & Observability": "/en/guides/observability/",
diff --git a/docs/src/pages/en/guides/[slug].astro b/docs/src/pages/en/guides/[slug].astro
index 8ed6b4e9..6b75b687 100644
--- a/docs/src/pages/en/guides/[slug].astro
+++ b/docs/src/pages/en/guides/[slug].astro
@@ -5,6 +5,8 @@ import { enGuideDocs, sectionSlugFromPath } from "../../../section-docs";
export function getStaticPaths() {
const activeLabels: Record = {
install: "Install And Start CCR",
+ cli: "CLI Installation And Reference",
+ docker: "Docker Deployment",
provider: "Add A Provider",
"agent-profile": "Connect Agent Config",
observability: "Logs & Observability",
diff --git a/docs/src/pages/guides/[slug].astro b/docs/src/pages/guides/[slug].astro
index c90b7810..0eeccfc3 100644
--- a/docs/src/pages/guides/[slug].astro
+++ b/docs/src/pages/guides/[slug].astro
@@ -5,6 +5,8 @@ import { sectionSlugFromPath, zhGuideDocs } from "../../section-docs";
export function getStaticPaths() {
const activeLabels: Record = {
install: "安装并启动 CCR",
+ cli: "CLI 安装与命令参考",
+ docker: "Docker 部署",
provider: "接入供应商",
"agent-profile": "接入 Agent配置",
observability: "日志&观测",
diff --git a/docs/src/styles/global.css b/docs/src/styles/global.css
index 094438bf..25640e25 100644
--- a/docs/src/styles/global.css
+++ b/docs/src/styles/global.css
@@ -1318,6 +1318,12 @@ h1 {
--provider-brand-3: #f4f4f5;
}
+.doc-markdown a.provider-import-button.provider-unity2 {
+ --provider-brand: #050505;
+ --provider-brand-2: #7a7f85;
+ --provider-brand-3: #f4f5f6;
+}
+
.doc-markdown a.provider-import-button.provider-code0 {
--provider-brand: #101214;
--provider-brand-2: #267dff;
diff --git a/package-lock.json b/package-lock.json
index 6bd7bb43..3750b983 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,18 +1,18 @@
{
"name": "claude-code-router-monorepo",
- "version": "3.0.10",
+ "version": "3.0.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-code-router-monorepo",
- "version": "3.0.10",
+ "version": "3.0.13",
"license": "MIT",
"workspaces": [
"packages/*"
],
"dependencies": {
- "@the-next-ai/ai-gateway": "^1.0.6",
+ "@the-next-ai/ai-gateway": "^1.0.9",
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
"better-sqlite3": "^12.11.1",
"electron-updater": "^6.8.9",
@@ -2310,9 +2310,9 @@
}
},
"node_modules/@the-next-ai/ai-gateway": {
- "version": "1.0.6",
- "resolved": "https://registry.npmjs.org/@the-next-ai/ai-gateway/-/ai-gateway-1.0.6.tgz",
- "integrity": "sha512-EIJjwvc//hql1jwE94yyMWNGrJLuCJQlTNgn8p3KWhDYSiOWp4vmLU2Gw3/A+J49X21V7HYdX/W9YuJUai80eQ==",
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@the-next-ai/ai-gateway/-/ai-gateway-1.0.9.tgz",
+ "integrity": "sha512-/nt/1ZciUgarfyJW6itfDIgKMa8HOgmt0RyPyvmUCDcbJGEEl0BNmd85Zz5boSwT70xcjNojds9DPsQXG7N+sQ==",
"license": "MIT",
"dependencies": {
"diff": "^8.0.3",
@@ -9542,10 +9542,10 @@
},
"packages/cli": {
"name": "@musistudio/claude-code-router",
- "version": "3.0.2",
+ "version": "3.0.5",
"license": "MIT",
"dependencies": {
- "@the-next-ai/ai-gateway": "^1.0.4",
+ "@the-next-ai/ai-gateway": "^1.0.9",
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
"better-sqlite3": "^12.11.1",
"node-forge": "^1.4.0",
@@ -9560,9 +9560,9 @@
},
"packages/core": {
"name": "@claude-code-router/core",
- "version": "3.0.2",
+ "version": "3.0.5",
"dependencies": {
- "@the-next-ai/ai-gateway": "^1.0.4",
+ "@the-next-ai/ai-gateway": "^1.0.9",
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
"better-sqlite3": "^12.11.1",
"node-forge": "^1.4.0",
@@ -9578,7 +9578,7 @@
},
"packages/electron": {
"name": "@claude-code-router/electron",
- "version": "3.0.10",
+ "version": "3.0.13",
"dependencies": {
"better-sqlite3": "^12.11.1"
},
@@ -9588,11 +9588,12 @@
},
"packages/ui": {
"name": "@claude-code-router/ui",
- "version": "3.0.10",
+ "version": "3.0.13",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
+ "@musistudio/lucide-morph-react": "^1.0.0",
"baseui": "^16.1.1",
"clsx": "^2.1.1",
"lucide-react": "^1.17.0",
@@ -9604,6 +9605,24 @@
"styletron-react": "^6.1.1",
"tailwind-merge": "^3.6.0"
}
+ },
+ "packages/ui/node_modules/@musistudio/lucide-morph": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@musistudio/lucide-morph/-/lucide-morph-1.0.0.tgz",
+ "integrity": "sha512-ZKhDPMvN85iavRksFx1vJhrcuC3JCw7NZF+HM/KEgkhUMF12C442SycDCR/rApameMZ0XuKIxgOSru6wjFI9xQ==",
+ "license": "MIT"
+ },
+ "packages/ui/node_modules/@musistudio/lucide-morph-react": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@musistudio/lucide-morph-react/-/lucide-morph-react-1.0.0.tgz",
+ "integrity": "sha512-oGW5Ihc+fSpbv1OQ+Dsa7ukH3eHiqfeFdEJfKdjb4cFU9UPx1FqGz6d1yrDWB+Dk7u3Rt+Zpmhzp5N8ia1W3Bg==",
+ "license": "MIT",
+ "dependencies": {
+ "@musistudio/lucide-morph": "1.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18"
+ }
}
}
}
diff --git a/package.json b/package.json
index 462ca1cd..73147580 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "claude-code-router-monorepo",
- "version": "3.0.10",
+ "version": "3.0.13",
"private": true,
"license": "MIT",
"description": "Local Claude Code Router gateway with CLI and web management UI.",
@@ -57,7 +57,7 @@
"rebuild:sqlite3": "electron-rebuild -f -w better-sqlite3"
},
"dependencies": {
- "@the-next-ai/ai-gateway": "^1.0.6",
+ "@the-next-ai/ai-gateway": "^1.0.9",
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
"better-sqlite3": "^12.11.1",
"electron-updater": "^6.8.9",
diff --git a/packages/cli/README.md b/packages/cli/README.md
index 1862494b..f9c47bc6 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -1,361 +1,184 @@
-Claude Code Router Desktop
+# Claude Code Router CLI
-
-
-
-
-
-
-
+[中文](README_zh.md) · [Documentation](https://ccrdesk.top/en/) · [GitHub](https://github.com/musistudio/claude-code-router)
-
+`@musistudio/claude-code-router` is the Node.js distribution of Claude Code Router. It provides the `ccr` command, the browser-based management UI, the local model gateway, and profile launch commands without requiring Electron.
-
-
-
-
-
-
-
-
- Kimi Code Subscription
- ·
- API Global
- ·
- API China
-
-
-
-
-
-
- Thanks to Kimi for sponsoring this project! Kimi K2.7 Code is an open-source, coding-focused agentic model developed by Moonshot AI, with substantial gains on real-world long-horizon coding tasks and higher end-to-end success across complex software engineering workflows. It also cuts thinking-token usage by approximately 30% compared with K2.6. Inside CCR, Kimi ships as built-in provider presets: import the pay-as-you-go API or the Kimi Code subscription in one click and route your coding agent's requests to Kimi, the subscription endpoint passes straight through natively with no protocol conversion, API endpoints are adapted automatically, and your balance and subscription usage show up right in the CCR dashboard.
-
-
- CCR already supports Kimi. Visit the Kimi Open Platform (中文站 | Global ) to try the API, or explore the cost-effective Coding Plan .
-
-
-
-
+Use the CLI on developer machines and headless hosts. If you want the tray, desktop notifications, automatic app updates, or desktop-only browser integrations, install the desktop application instead.
-
+## Requirements And Installation
-Claude Code Router Desktop is a local gateway and desktop control panel for routing agent requests from Claude Code, Codex, ZCode, and compatible clients to the model provider you actually want to use.
+- Node.js 22 or newer
+- A supported upstream model provider, or a locally logged-in agent account that CCR can import
+- A locally installed agent executable when using profile launch commands
-
-
-
+Install globally:
-## Why Use CCR
+```sh
+npm install -g @musistudio/claude-code-router
+ccr --help
+```
-- Use one local endpoint for multiple agent tools instead of configuring every client separately.
-- Route requests with default routing, conditional rules, fallback targets, and request rewrites instead of editing client configuration by hand.
-- Mix providers without changing your workflow. CCR supports OpenAI-compatible APIs, Anthropic Messages, Gemini Generate Content, OpenRouter, DeepSeek, SiliconFlow, Moonshot, Kimi Code, Mistral, Z.AI, Bailian, and custom providers.
-- Control cost and reliability with fallback routing, API key rotation, usage statistics, and request logs.
+Upgrade or remove it with npm:
-## Features
+```sh
+npm install -g @musistudio/claude-code-router@latest
+npm uninstall -g @musistudio/claude-code-router
+```
-- **Overview dashboard**: inspect system status, usage widgets, account balances, model distribution, and share cards.
-- **Provider management**: add provider presets or custom endpoints, probe protocol support, test model connectivity, manage credentials, and monitor supported account balances where available.
-- **Routing rules**: configure default routing, conditional and model-prefix rules, fallback handling, and request rewrites.
-- **Agent Config**: configure Claude Code, Codex, and ZCode launch entries, models, scopes, and multi-instance app profiles.
-- **Gateway compatibility**: translate supported client requests through the local CCR model gateway.
-- **Proxy mode**: capture supported API traffic through a local proxy with optional system proxy integration and network capture.
-- **Fusion models**: combine a base model with vision, web search, or MCP tools into a reusable selectable model.
-
-## Documentation
-
-Read the full documentation at [ccrdesk.top](https://ccrdesk.top/).
-
-## Download And Install
-
-1. Open the [GitHub Releases page](https://github.com/musistudio/claude-code-router/releases).
-2. Download the package for your platform:
- - macOS Apple Silicon: `Claude-Code-Router_-mac-Apple-Silicon-arm64.dmg` or `.zip`
- - macOS Intel: `Claude-Code-Router_-mac-Intel-x64.dmg` or `.zip`
- - Windows: `Claude Code Router_.exe`
- - Linux: `Claude Code Router_.AppImage`
-3. Install and launch **Claude Code Router**.
-4. On first launch, CCR creates its local configuration database:
- - macOS/Linux: `~/.claude-code-router/config.sqlite`
- - Windows: `%APPDATA%\Claude Code Router\config.sqlite`
-
-CCR stores runtime configuration in SQLite. A legacy `config.json` is read only once for migration when no SQLite config exists.
-
-After the service is started from the **Server** page, CCR listens on `http://localhost:8080` by default. The **Server** page controls the gateway `Host`, `Port`, proxy mode, system proxy, network capture, and CA certificate status.
+Removing the package does not delete CCR's local configuration or databases.
## Quick Start
-CCR can be configured entirely from the desktop UI. Use this setup order for a clean first run.
+Start the background service and open the management UI:
-### 1. Add a provider
+```sh
+ccr ui
+```
-Open **Providers**, click **Add Provider**, then choose a built-in preset or **Other / custom API endpoint**. Fill in the provider name, base URL, protocol, API key, and model list. Run protocol probing and model connectivity checks when available, then save the provider.
+Then:
-### 2. Configure routing
+1. Add an upstream provider and at least one model.
+2. Create a CCR client key under **API Keys**.
+3. Configure routing if the default provider/model is not sufficient.
+4. Confirm that the gateway is running under **Server**.
+5. Point your client at the gateway URL shown in the UI. The default gateway is `http://127.0.0.1:3456`; the management UI defaults to `http://127.0.0.1:3458`.
-Open **Routing** to add conditional rules, configure request rewrites, and set fallback behavior.
+The management token and CCR client API keys are different credentials. The management token protects the browser UI and RPC API. CCR client keys authenticate model requests sent to the gateway.
-Use **Add Routing Rule** for request conditions, model-prefix routing, or rule-level fallback targets.
+## Service Commands
-### 3. Start the gateway
+| Command | Behavior |
+| --- | --- |
+| `ccr start` | Starts a detached background management service and gateway, then prints its authenticated management URL. |
+| `ccr ui` | Reuses or starts the background service and opens the management UI. |
+| `ccr stop` | Stops the detached service started by `ccr start` or `ccr ui`. |
+| `ccr serve` | Runs the management service and gateway in the foreground. `ccr web` is an alias. |
+| `ccr ` | Opens an enabled Agent Config profile by name or ID. |
-Open **Server** and click **Start**. After the page shows Running, CCR listens on `http://localhost:8080`. Enable **Auto start** if you want CCR to start the local gateway whenever the desktop app opens.
+### `ccr start`
-### 4. Connect your agent tool
+```text
+ccr start [--host ] [--port ] [--open|--no-open] [--gateway|--no-gateway]
+```
-Open **Agent Config** and choose the client you want to use. Configure Claude Code, Codex, or ZCode, select the target model and effect scope, then apply the config. For app entries, use the **Open Agent** action to open the target app through CCR.
+- `--host `: management listener, default `127.0.0.1`.
+- `--port `: preferred management port, default `3458`.
+- `--open` / `--no-open`: enable or disable opening a browser.
+- `--gateway`: explicitly request gateway startup; this is the default.
+- `--no-gateway`: start only the management service.
-### 5. Monitor and adjust
+### `ccr ui`
-Use **Settings → Logs & Observability** to enable request logs and agent observability. Use **Logs** to confirm `request model`, `resolved provider`, `resolved model`, status, tokens, latency, and errors; use the tray window for quick token and account status.
+```text
+ccr ui [--host ] [--port ] [--open|--no-open] [--gateway|--no-gateway]
+```
-## Acknowledgements
+`ui` opens the browser by default. Use `--no-open` on SSH or other headless sessions.
-Codex support is powered by [musistudio/codexl](https://github.com/musistudio/codexl).
+### `ccr serve`
-## Support & Sponsoring
+```text
+ccr serve [--host ] [--port ] [--open|--no-open] [--gateway|--no-gateway]
+```
-
+`serve` stays attached to the current terminal and handles `SIGINT`/`SIGTERM`. It is the appropriate mode for a process supervisor. `ccr stop` only manages the detached service; stop a foreground server through its terminal or supervisor.
-
If you find this project helpful, please consider sponsoring its development. Your support is greatly appreciated.
+If the preferred management port is occupied, CCR tries the next available ports and prints the actual URL. When `start` or `ui` reuses an existing service, new host, port, and `--no-gateway` choices do not reconfigure that process. Run `ccr stop` first when those settings must change.
-
-
-
-
-
-
-
- One-time support via Ko-fi
-
-
-
-
-
-
- International sponsorship
-
-
-
+## Agent Config Profiles
-
-
-
- Alipay
-
-
-
-
- WeChat Pay
-
-
-
-
-
+Create and enable profiles in **Agent Config**, then launch one by name or ID:
-
+```sh
+ccr "Codex - Work"
+ccr "Codex - Work" app
+ccr "Claude - Review" cli -- --model sonnet
+ccr profile-id -- --help
+```
-### Our Sponsors
+The syntax is:
-
+```text
+ccr
[cli|app] [-- ]
+```
-A huge thank you to all our sponsors for their generous support.
+- `--cli` and `--app` are accepted alternatives to the positional surface.
+- Put agent-specific arguments after `--` so they cannot be confused with CCR options.
+- If the surface is omitted, CCR uses the first surface allowed by the profile: CLI for Claude Code, Codex, and Grok CLI; App for ZCode.
+- Grok supports CLI only. ZCode supports App only. Claude App and ZCode App do not accept trailing agent arguments.
+- Desktop App launches require that app to be installed and a graphical session to be available.
+- Start the CCR service before opening most profiles. A Grok CLI profile can start a temporary shared service automatically and stops it after the last managed session exits.
-
+The desktop application installs a related command named `ccr-app`. Commands copied from desktop Agent Config cards use `ccr-app`; the npm package documented here installs `ccr`.
-Community Sponsors
+## Configuration And Runtime Files
-
+| Platform | Config directory |
+| --- | --- |
+| macOS / Linux | `~/.claude-code-router` |
+| Windows | `%APPDATA%\claude-code-router` |
-If your name is masked, please contact me via my homepage email to update it with your GitHub username.
+Important files include:
-
+- `config.sqlite`: current application configuration.
+- `app-data/`: API key, usage, request-log, certificate, and other runtime databases/files.
+- `service.json`: state and private token for a detached CLI service.
+- `gateway.config.json`: generated gateway runtime configuration.
+- `profiles/` and `bin/`: isolated profile configuration and launch wrappers.
+
+Do not edit or copy live SQLite files while CCR is writing to them. Use the UI export feature, or stop CCR before taking a filesystem backup.
+
+## Environment And Security
+
+| Variable | Description |
+| --- | --- |
+| `CCR_WEB_HOST` | Default management listener when `--host` is omitted. |
+| `CCR_WEB_PORT` | Default management port when `--port` is omitted. |
+| `CCR_WEB_AUTH_TOKEN` | Fixes the management UI/RPC token instead of generating a random token for the process. |
+
+The authenticated management URL contains `ccr_web_token` in its query string. Treat that URL like a password and avoid copying it into logs, tickets, or shell history. Bind to `127.0.0.1` unless remote access is intentional. For remote access, use a firewall or private network plus TLS at a trusted reverse proxy.
+
+Do not expose the gateway without creating CCR client API keys. Upstream provider credentials are stored in CCR's local data directory, so protect that directory and its backups.
+
+## Troubleshooting
+
+### `ccr` is not found
+
+Confirm Node.js is version 22 or later and that npm's global binary directory is on `PATH`:
+
+```sh
+node --version
+npm prefix -g
+```
+
+Open a new shell after installation if your shell caches command paths.
+
+### The management URL changed ports
+
+The requested port was already occupied. Use the URL printed by CCR, or stop the conflicting process and restart CCR.
+
+### The UI opens but the gateway is unavailable
+
+The management service can run without a usable gateway. Add a provider and model, create a client API key, then start or restart the gateway from **Server**. Check the foreground output from `ccr serve` when diagnosing startup errors.
+
+### A profile cannot be found
+
+Only enabled profiles are launchable. Names are matched without case and sanitized names are accepted, but ambiguous names require the profile ID. Re-save the profile if its generated launcher is missing.
+
+### A background service uses old options
+
+Stop and recreate it:
+
+```sh
+ccr stop
+ccr start --host 127.0.0.1 --port 3458
+```
+
+## Docker
+
+The repository also includes a Docker image for gateway and browser-UI deployments. It does not install the npm `ccr` command into the runtime image. See the [Docker deployment guide](https://github.com/musistudio/claude-code-router/blob/main/docker/README.md).
## License
-This project is licensed under the [MIT License](LICENSE).
+[MIT](LICENSE)
diff --git a/packages/cli/README_zh.md b/packages/cli/README_zh.md
index 959c2393..6020d0ee 100644
--- a/packages/cli/README_zh.md
+++ b/packages/cli/README_zh.md
@@ -1,360 +1,184 @@
-Claude Code Router Desktop
+# Claude Code Router CLI
-
-
-
-
-
-
-
+[English](README.md) · [完整文档](https://ccrdesk.top/) · [GitHub](https://github.com/musistudio/claude-code-router)
-
+`@musistudio/claude-code-router` 是 Claude Code Router 的 Node.js 发行版。它通过 `ccr` 命令提供浏览器管理界面、本地模型网关和 Agent 配置启动能力,不需要安装 Electron。
-
-
-
-
-
-
-
-
- Kimi Code 订阅
- ·
- API 中文站
- ·
- API Global
-
-
-
-
-
-
- 感谢 Kimi 赞助本项目! Kimi K2.7 Code 是 Moonshot AI 推出的编程专用开源智能体模型,在真实长程编程与复杂软件工程工作流中显著提升端到端任务成功率,同时优化推理效率,相比 K2.6 平均减少约 30% 的推理 token 消耗。在 CCR 中,Kimi 已作为内置供应商预设开箱即用:无论按量付费 API 还是 Kimi Code 订阅,一键导入即可把你的编程 Agent 请求路由到 Kimi,订阅端点原生直通、无需协议转换,API 端点自动适配,账户余额与订阅用量也能直接在 CCR 面板中查看。
-
-
- CCR 已内置 Kimi 供应商预设。前往 Kimi 开放平台(中文站 |Global )体验 API,或了解高性价比 Coding Plan 套餐。
-
-
-
-
+CLI 适合开发机和无桌面的服务器。如果你需要系统托盘、桌面通知、应用自动更新或桌面端专属的浏览器集成,请安装桌面应用。
-
+## 环境要求与安装
-Claude Code Router Desktop 是一个本地网关和桌面控制台,用来把 Claude Code、Codex、ZCode 以及兼容客户端的 Agent 请求路由到你真正想使用的模型服务。
+- Node.js 22 或更高版本
+- 一个可用的上游模型供应商,或 CCR 支持导入的本机 Agent 登录态
+- 使用配置启动命令时,本机需要已经安装对应 Agent
-
-
-
+全局安装:
-## 为什么使用 CCR
+```sh
+npm install -g @musistudio/claude-code-router
+ccr --help
+```
-- 用一个本地入口连接多个 Agent 工具,不需要在每个客户端里重复配置 Provider。
-- 在不改变工作流的情况下混用不同 Provider。CCR 支持 OpenAI 兼容 API、Anthropic Messages、Gemini Generate Content、OpenRouter、DeepSeek、SiliconFlow、Moonshot、Kimi Code、Mistral、Z.AI、百炼以及自定义 Provider。
-- 通过 fallback 路由、API Key 轮换、用量统计和请求日志来控制成本和可靠性。
+升级或卸载:
-## 功能和特性
+```sh
+npm install -g @musistudio/claude-code-router@latest
+npm uninstall -g @musistudio/claude-code-router
+```
-- **概览仪表盘**:查看系统状态、用量组件、账号余额、模型分布和分享卡片。
-- **Provider 管理**:添加预设或自定义端点,探测协议支持,检测模型连通性,管理凭据,并在可用时查看账号余额。
-- **路由规则**:配置条件路由、模型前缀规则、失败降级和请求改写。
-- **Agent配置**:为 Claude Code、Codex 和 ZCode 配置启动入口、模型、作用范围和多开 App 配置。
-- **网关兼容层**:通过本地 CCR 模型网关转换支持的客户端请求。
-- **代理模式**:通过本地代理捕获支持的 API 流量,可选系统代理和网络捕获。
-- **Fusion 组合模型**:把基础模型与视觉、联网搜索或 MCP 工具组合成新的可选模型。
-
-## 文档
-
-完整文档见 [ccrdesk.top](https://ccrdesk.top/)。
-
-## 下载和安装
-
-1. 打开 [GitHub Releases 页面](https://github.com/musistudio/claude-code-router/releases)。
-2. 按系统下载对应安装包:
- - macOS Apple 芯片:`Claude-Code-Router_-mac-Apple-Silicon-arm64.dmg` 或 `.zip`
- - macOS Intel 芯片:`Claude-Code-Router_-mac-Intel-x64.dmg` 或 `.zip`
- - Windows:`Claude Code Router_.exe`
- - Linux:`Claude Code Router_.AppImage`
-3. 安装并启动 **Claude Code Router**。
-4. 首次启动后,CCR 会创建本地配置数据库:
- - macOS/Linux:`~/.claude-code-router/config.sqlite`
- - Windows:`%APPDATA%\Claude Code Router\config.sqlite`
-
-CCR 的运行配置存储在 SQLite 中。旧版 `config.json` 只会在没有 SQLite 配置时作为迁移来源读取一次。
-
-从 **服务** 页面启动后,CCR 默认监听 `http://localhost:8080`。**服务** 页面负责配置网关 `Host`、`Port`、代理模式、系统代理、网络捕获和 CA 证书状态。
+卸载 npm 包不会删除 CCR 的本地配置和数据库。
## 快速开始
-CCR 可以完全通过桌面 UI 完成配置。首次使用建议按下面顺序操作。
+启动后台服务并打开管理界面:
-### 1. 添加 Provider
+```sh
+ccr ui
+```
-打开 **供应商**,点击 **添加供应商**,选择内置预设或 **其他 / 自定义 API 端点**。按表单填写 Provider 名称、基础 URL、协议、API Key 和模型列表。可用时先运行协议探测和模型连通性检查,然后保存 Provider。
+然后按以下顺序配置:
-### 2. 设置路由
+1. 添加上游供应商和至少一个模型。
+2. 在 **API 密钥** 页面创建 CCR 客户端密钥。
+3. 如果默认供应商 / 模型不够用,再配置路由规则。
+4. 在 **服务** 页面确认网关已经运行。
+5. 把客户端指向界面显示的网关地址。网关默认是 `http://127.0.0.1:3456`,管理界面默认是 `http://127.0.0.1:3458`。
-打开 **路由**,添加条件规则,配置请求改写和失败降级。
+管理 Token 和 CCR 客户端 API Key 是两种不同凭据。管理 Token 保护浏览器 UI 和 RPC 接口,CCR 客户端 Key 用于验证发送到模型网关的请求。
-如果需要更细粒度控制,使用 **添加路由规则** 添加模型前缀、请求条件或规则级失败降级目标。
+## 服务命令
-### 3. 启动网关
+| 命令 | 行为 |
+| --- | --- |
+| `ccr start` | 在后台启动管理服务和网关,并打印带认证信息的管理 URL。 |
+| `ccr ui` | 复用或启动后台服务,然后打开管理界面。 |
+| `ccr stop` | 停止由 `ccr start` 或 `ccr ui` 启动的后台服务。 |
+| `ccr serve` | 在前台运行管理服务和网关;`ccr web` 是别名。 |
+| `ccr <配置>` | 按名称或 ID 打开一个已启用的 Agent 配置。 |
-打开 **服务**,点击 **启动**。页面显示运行中后,CCR 会在本机监听 `http://localhost:8080`。如果希望每次打开桌面应用时自动启动网关,可以启用自动启动。
+### `ccr start`
-### 4. 连接 Agent 工具
+```text
+ccr start [--host ] [--port ] [--open|--no-open] [--gateway|--no-gateway]
+```
-打开 **Agent配置**,选择要使用的客户端。配置 Claude Code、Codex 或 ZCode,选择目标模型和作用范围,然后应用配置。对于 App 入口,可以使用 **打开 Agent** 操作通过 CCR 打开目标应用。
+- `--host `:管理服务监听地址,默认 `127.0.0.1`。
+- `--port `:管理服务首选端口,默认 `3458`。
+- `--open` / `--no-open`:是否打开浏览器。
+- `--gateway`:明确要求启动模型网关;这是默认行为。
+- `--no-gateway`:只启动管理服务,不启动模型网关。
-### 5. 日常查看和调整
+### `ccr ui`
-到 **设置 → 日志与观测** 打开请求日志和 Agent 观测。使用 **日志** 确认 `request model`、`resolved provider`、`resolved model`、状态码、tokens、耗时和错误;使用托盘窗口快速查看 Token 和账号状态。
+```text
+ccr ui [--host ] [--port ] [--open|--no-open] [--gateway|--no-gateway]
+```
-## 致谢
+`ui` 默认会打开浏览器。在 SSH 或其他无桌面环境中使用 `--no-open`。
-对 Codex 的支持来自于 [musistudio/codexl](https://github.com/musistudio/codexl) 这个项目。
+### `ccr serve`
-## 支持与赞助
+```text
+ccr serve [--host ] [--port ] [--open|--no-open] [--gateway|--no-gateway]
+```
-
+`serve` 会留在当前终端并处理 `SIGINT` / `SIGTERM`,适合交给进程管理器托管。`ccr stop` 只管理后台服务;前台服务需要在终端或进程管理器中停止。
-
如果你觉得这个项目有帮助,欢迎赞助项目开发。非常感谢你的支持。
+如果首选管理端口已被占用,CCR 会继续尝试后续端口并打印实际 URL。`start` 或 `ui` 复用已运行服务时,新传入的 Host、Port 和 `--no-gateway` 不会重配该进程;要修改这些选项,请先运行 `ccr stop`。
-
-
-
-
-
-
-
- 通过 Ko-fi 单次赞助
-
-
-
-
-
-
- 国际赞助通道
-
-
-
+## Agent 配置启动
-
-
-
- 支付宝
-
-
-
-
- 微信支付
-
-
-
-
-
+先在 **Agent配置** 中创建并启用配置,然后按名称或 ID 启动:
-
+```sh
+ccr "Codex - Work"
+ccr "Codex - Work" app
+ccr "Claude - Review" cli -- --model sonnet
+ccr profile-id -- --help
+```
-### 我们的赞助商
+完整语法:
-
+```text
+ccr <配置名称或 ID> [cli|app] [--
]
+```
-非常感谢所有赞助商的慷慨支持。
+- `--cli` 和 `--app` 也可以代替位置形式的入口类型。
+- Agent 自己的参数建议统一放到 `--` 后,避免被识别为 CCR 参数。
+- 省略入口类型时,Claude Code、Codex、Grok CLI 默认使用 CLI,ZCode 默认使用 App。
+- Grok 只支持 CLI,ZCode 只支持 App。Claude App 和 ZCode App 不接受额外 Agent 参数。
+- 启动桌面 App 时,本机必须已安装对应应用,并且当前环境必须有图形会话。
+- 大多数配置需要先启动 CCR 服务。Grok CLI 配置可以自动启动一个临时共享服务,并在最后一个受管会话退出后停止。
-
+桌面应用会安装一个相关命令 `ccr-app`。桌面 Agent配置卡片复制出来的命令使用 `ccr-app`;本文介绍的 npm 包安装的是 `ccr`。
-社区赞助者
+## 配置与运行文件
-
+| 平台 | 配置目录 |
+| --- | --- |
+| macOS / Linux | `~/.claude-code-router` |
+| Windows | `%APPDATA%\claude-code-router` |
-如果你的名字被打码,请通过我的主页邮箱联系我更新为 GitHub 用户名。
+重要文件包括:
-
+- `config.sqlite`:当前应用配置。
+- `app-data/`:API Key、用量、请求日志、证书等运行数据库和文件。
+- `service.json`:后台 CLI 服务的状态和私有 Token。
+- `gateway.config.json`:生成的网关运行配置。
+- `profiles/` 和 `bin/`:隔离的 Agent 配置和启动包装器。
+
+CCR 写入 SQLite 时不要直接编辑或复制活跃数据库。优先使用 UI 导出;要做文件级备份,请先停止 CCR。
+
+## 环境变量与安全
+
+| 变量 | 说明 |
+| --- | --- |
+| `CCR_WEB_HOST` | 省略 `--host` 时使用的管理服务监听地址。 |
+| `CCR_WEB_PORT` | 省略 `--port` 时使用的管理服务端口。 |
+| `CCR_WEB_AUTH_TOKEN` | 固定管理 UI / RPC 的认证 Token;不设置时每个进程会生成随机 Token。 |
+
+认证后的管理 URL 会在查询参数中包含 `ccr_web_token`。请把这个 URL 当作密码,不要复制到日志、工单或公开的 Shell 历史中。除非确实需要远程访问,否则监听地址应保持 `127.0.0.1`。远程访问时,应同时使用防火墙或私网,并在可信反向代理上启用 TLS。
+
+不要在未创建 CCR 客户端 API Key 的情况下暴露网关。上游供应商凭据保存在 CCR 本地数据目录中,因此也要保护该目录及其备份。
+
+## 常见问题
+
+### 找不到 `ccr` 命令
+
+确认 Node.js 不低于 22,并检查 npm 全局可执行目录是否在 `PATH`:
+
+```sh
+node --version
+npm prefix -g
+```
+
+如果 Shell 缓存了命令路径,安装后请打开一个新终端。
+
+### 管理 URL 的端口发生变化
+
+首选端口已被占用。请使用 CCR 打印的实际 URL,或停止占用端口的进程后重启 CCR。
+
+### UI 能打开,但网关不可用
+
+管理服务可以在没有可用网关时单独运行。请添加供应商和模型、创建客户端 API Key,然后从 **服务** 页面启动或重启网关。排查启动错误时,可以使用 `ccr serve` 查看前台输出。
+
+### 找不到 Agent 配置
+
+只有已启用的配置才能启动。名称匹配不区分大小写,也接受清理后的名称;如果多个名称产生歧义,必须使用配置 ID。生成的启动器缺失时,请重新保存配置。
+
+### 后台服务仍使用旧参数
+
+停止并重新创建服务:
+
+```sh
+ccr stop
+ccr start --host 127.0.0.1 --port 3458
+```
+
+## Docker
+
+仓库还提供面向模型网关和浏览器 UI 的 Docker 镜像。运行时镜像不会安装 npm 的 `ccr` 命令。请参阅 [Docker 部署文档](https://github.com/musistudio/claude-code-router/blob/main/docker/README.md)。
## 许可证
-本项目基于 [MIT License](LICENSE) 发布。
+[MIT](LICENSE)
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 723d03dc..19fc95ca 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@musistudio/claude-code-router",
- "version": "3.0.2",
+ "version": "3.0.5",
"license": "MIT",
"description": "Local Claude Code Router gateway with CLI and web management UI.",
"repository": {
@@ -39,7 +39,7 @@
"prepublishOnly": "npm --prefix ../.. run typecheck"
},
"dependencies": {
- "@the-next-ai/ai-gateway": "^1.0.4",
+ "@the-next-ai/ai-gateway": "^1.0.9",
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
"better-sqlite3": "^12.11.1",
"node-forge": "^1.4.0",
diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts
index 2cd82f1c..b2399ac6 100644
--- a/packages/cli/src/cli.ts
+++ b/packages/cli/src/cli.ts
@@ -1,7 +1,7 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
-import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
+import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import path from "node:path";
import { botGatewayProfileEnv } from "@ccr/core/agents/bot-gateway/env";
import { applyClaudeAppGatewayConfig } from "@ccr/core/agents/claude-app/gateway-service";
@@ -11,10 +11,10 @@ import { loadAppConfig } from "@ccr/core/config/config";
import { CONFIGDIR } from "@ccr/core/config/constants";
import { installSocketTypeOfServiceCompat } from "@ccr/core/platform/socket-compat";
import { applyProfileConfig, applyProfileRuntimeConfig } from "@ccr/core/profiles/service";
-import { ensureProfileGateway } from "@ccr/core/profiles/launch-service";
-import { buildProfileLaunchPlan, defaultProfileOpenSurface, findProfileForOpen, profileLaunchSpawnCommand, resolveProfileOpenSurface } from "@ccr/core/profiles/launch-core";
+import { ensureProfileGateway, ProfileGatewayUnavailableError } from "@ccr/core/profiles/launch-service";
+import { buildProfileLaunchPlan, defaultProfileOpenSurface, findProfileForOpen, profileLaunchSpawnCommand, resolveProfileOpenSurface, shouldAutoStartProfileGateway } from "@ccr/core/profiles/launch-core";
import { openSystemExternal, startWebManagementServer } from "@ccr/core/web/management-server";
-import { assertAvailableGatewayModels, type ProfileConfig, type ProfileOpenSurface } from "@ccr/core/contracts/app";
+import { assertAvailableGatewayModels, type AppConfig, type GatewayStatus, type ProfileConfig, type ProfileOpenSurface } from "@ccr/core/contracts/app";
installSocketTypeOfServiceCompat();
@@ -29,10 +29,12 @@ type ProfileCliOptions = {
type WebCliOptions = {
command: "start" | "ui" | "web";
daemonChild: boolean;
+ ensureGatewayRunning: boolean;
help: boolean;
host?: string;
open: boolean;
port?: number;
+ profileManaged: boolean;
startGateway: boolean;
};
@@ -46,6 +48,7 @@ type CliOptions = ProfileCliOptions | StopCliOptions | WebCliOptions;
type ServiceState = {
host?: string;
pid: number;
+ profileManaged: boolean;
serviceToken?: string;
startedAt: string;
startGateway: boolean;
@@ -53,13 +56,18 @@ type ServiceState = {
};
const serviceStateFileName = "service.json";
+const serviceStartLockFileName = "service-start.lock";
+const profileGatewayLeaseDirName = "profile-gateway-leases";
const serviceInstanceTokenEnv = "CCR_SERVICE_INSTANCE_TOKEN";
const serviceRpcTimeoutMs = 2_000;
const serviceStartTimeoutMs = 30_000;
const serviceStopTimeoutMs = 10_000;
+const profileGatewayIdleGraceMs = 2_000;
+const profileGatewayLeasePollMs = 500;
const webAuthHeader = "x-ccr-web-auth";
const webAuthQueryParam = "ccr_web_token";
const defaultCliCommandName = "ccr";
+const prepareProfileOnlyEnv = "CCR_CLI_PREPARE_PROFILE_ONLY";
async function main(): Promise {
const options = parseArgs(process.argv.slice(2));
@@ -116,71 +124,102 @@ async function main(): Promise {
throw new Error("Claude App profiles do not support agent arguments.");
}
- const launchConfig = await ensureProfileGateway(config, profile, resolvedSurface === "app" ? profileAppName(profile) : profile.name || profile.id || "profile", {
- reuseExisting: true,
- startIfMissing: false
- });
- if (resolvedSurface === "cli") {
- const runtimeResult = applyProfileRuntimeConfig(launchConfig, profile, launchConfig.APIKEY);
- if (!runtimeResult.ok) {
- throw new Error(runtimeResult.message);
+ const autoStartProfileGateway = shouldAutoStartProfileGateway(profile, resolvedSurface);
+ let profileGatewayLease = autoStartProfileGateway ? acquireManagedProfileGatewayLease() : undefined;
+ try {
+ let launchConfig: AppConfig;
+ try {
+ launchConfig = await ensureProfileGateway(config, profile, resolvedSurface === "app" ? profileAppName(profile) : profile.name || profile.id || "profile", {
+ reuseExisting: true,
+ startIfMissing: false
+ });
+ } catch (error) {
+ if (!autoStartProfileGateway || !(error instanceof ProfileGatewayUnavailableError)) {
+ throw error;
+ }
+ profileGatewayLease ??= createProfileGatewayLease();
+ await startService({
+ command: "start",
+ daemonChild: false,
+ ensureGatewayRunning: true,
+ help: false,
+ open: false,
+ profileManaged: true,
+ startGateway: true
+ });
+ launchConfig = await ensureProfileGateway(config, profile, profile.name || profile.id || "profile", {
+ reuseExisting: true,
+ startIfMissing: false
+ });
}
+
+ if (resolvedSurface === "cli") {
+ const runtimeResult = applyProfileRuntimeConfig(launchConfig, profile, launchConfig.APIKEY);
+ if (!runtimeResult.ok) {
+ throw new Error(runtimeResult.message);
+ }
+ }
+ if (resolvedSurface === "cli" && process.env[prepareProfileOnlyEnv] === "1") {
+ return;
}
if (profile.agent === "claude-code" && resolvedSurface === "app") {
- applyClaudeAppGatewayConfig(launchConfig);
- applyClaudeAppGatewayConfig(launchConfig, {
- backup: false,
- dataDir: resolveClaudeAppProfileUserDataDir(configDir, profile),
- refreshModelDiscoveryCache: true
+ applyClaudeAppGatewayConfig(launchConfig);
+ applyClaudeAppGatewayConfig(launchConfig, {
+ backup: false,
+ dataDir: resolveClaudeAppProfileUserDataDir(configDir, profile),
+ refreshModelDiscoveryCache: true
+ });
+ const launch = await launchClaudeAppProfile(configDir, profile, launchConfig);
+ const spawnError = await waitForImmediateSpawnError(launch.child, 500);
+ if (spawnError) {
+ throw new Error(`Failed to open Claude App: ${spawnError}`);
+ }
+ process.stdout.write(`Opened Claude App with ${profile.name || profile.id}.\n`);
+ return;
+ }
+ if ((profile.agent === "codex" || profile.agent === "zcode") && resolvedSurface === "app" && profileOptions.agentArgs.length === 0) {
+ if (profile.agent === "zcode") {
+ const launch = launchZcodeAppProfile(configDir, profile, launchConfig);
+ const spawnError = await waitForImmediateSpawnError(launch.child, 500);
+ if (spawnError) {
+ throw new Error(`Failed to open ZCode App: ${spawnError}`);
+ }
+ process.stdout.write(`Opened ZCode App with ${profile.name || profile.id}.\n`);
+ } else {
+ const launch = launchCodexAppProfile(configDir, profile, launchConfig);
+ const spawnError = await waitForImmediateSpawnError(launch.child, 500);
+ if (spawnError) {
+ throw new Error(`Failed to open ${codexDesktopAppName}: ${spawnError}`);
+ }
+ process.stdout.write(`Opened ${codexDesktopAppName} with ${profile.name || profile.id}.\n`);
+ }
+ return;
+ }
+
+ const plan = buildProfileLaunchPlan(configDir, profile, resolvedSurface, profileOptions.agentArgs);
+
+ if (path.isAbsolute(plan.command) && !existsSync(plan.command)) {
+ throw new Error(`Profile launcher was not found: ${plan.command}. Open CCR once or re-save the profile.`);
+ }
+
+ const childEnv = {
+ ...process.env,
+ ...plan.env,
+ ...botGatewayProfileEnv(launchConfig, profile, resolvedSurface)
+ };
+ delete childEnv.ELECTRON_RUN_AS_NODE;
+
+ const launch = profileLaunchSpawnCommand(plan);
+ const child = spawn(launch.command, launch.args, {
+ env: childEnv,
+ stdio: "inherit",
+ windowsVerbatimArguments: !!launch.windowsVerbatimArguments
});
- const launch = await launchClaudeAppProfile(configDir, profile, launchConfig);
- const spawnError = await waitForImmediateSpawnError(launch.child, 500);
- if (spawnError) {
- throw new Error(`Failed to open Claude App: ${spawnError}`);
- }
- process.stdout.write(`Opened Claude App with ${profile.name || profile.id}.\n`);
- return;
+ const code = await waitForChild(child);
+ process.exitCode = code;
+ } finally {
+ profileGatewayLease?.release();
}
- if ((profile.agent === "codex" || profile.agent === "zcode") && resolvedSurface === "app" && profileOptions.agentArgs.length === 0) {
- if (profile.agent === "zcode") {
- const launch = launchZcodeAppProfile(configDir, profile, launchConfig);
- const spawnError = await waitForImmediateSpawnError(launch.child, 500);
- if (spawnError) {
- throw new Error(`Failed to open ZCode App: ${spawnError}`);
- }
- process.stdout.write(`Opened ZCode App with ${profile.name || profile.id}.\n`);
- } else {
- const launch = launchCodexAppProfile(configDir, profile, launchConfig);
- const spawnError = await waitForImmediateSpawnError(launch.child, 500);
- if (spawnError) {
- throw new Error(`Failed to open ${codexDesktopAppName}: ${spawnError}`);
- }
- process.stdout.write(`Opened ${codexDesktopAppName} with ${profile.name || profile.id}.\n`);
- }
- return;
- }
-
- const plan = buildProfileLaunchPlan(configDir, profile, resolvedSurface, profileOptions.agentArgs);
-
- if (path.isAbsolute(plan.command) && !existsSync(plan.command)) {
- throw new Error(`Profile launcher was not found: ${plan.command}. Open CCR once or re-save the profile.`);
- }
-
- const childEnv = {
- ...process.env,
- ...plan.env,
- ...botGatewayProfileEnv(launchConfig, profile, resolvedSurface)
- };
- delete childEnv.ELECTRON_RUN_AS_NODE;
-
- const launch = profileLaunchSpawnCommand(plan);
- const child = spawn(launch.command, launch.args, {
- env: childEnv,
- stdio: "inherit",
- windowsVerbatimArguments: !!launch.windowsVerbatimArguments
- });
- const code = await waitForChild(child);
- process.exitCode = code;
}
function parseArgs(args: string[]): CliOptions {
@@ -263,8 +302,10 @@ function parseWebArgs(args: string[], command: WebCliOptions["command"], default
const options: WebCliOptions = {
command,
daemonChild: false,
+ ensureGatewayRunning: false,
help: false,
open: defaultOpen,
+ profileManaged: false,
startGateway: true
};
for (let index = 0; index < args.length; index += 1) {
@@ -293,6 +334,10 @@ function parseWebArgs(args: string[], command: WebCliOptions["command"], default
options.daemonChild = true;
continue;
}
+ if (arg === "--profile-managed") {
+ options.profileManaged = true;
+ continue;
+ }
if (arg === "--host") {
index += 1;
options.host = requiredArg(args[index], "--host");
@@ -316,50 +361,75 @@ function parseWebArgs(args: string[], command: WebCliOptions["command"], default
return options;
}
-async function startService(options: WebCliOptions): Promise {
- const current = readServiceState();
- const currentVerification = current ? await verifyServiceState(current) : undefined;
- if (current && currentVerification?.ok) {
- process.stdout.write(`CCR service is already running at ${current.url} (pid ${current.pid}).\n`);
- if (options.open) {
- await openManagementUrl(current.url);
+async function startService(options: WebCliOptions): Promise {
+ const releaseStartLock = await acquireServiceStartLock();
+ try {
+ const current = readServiceState();
+ const currentVerification = current ? await verifyServiceState(current) : undefined;
+ if (current && currentVerification?.ok) {
+ return reuseRunningService(current, options);
+ }
+ if (current) {
+ clearServiceState(current.pid);
}
- return;
- }
- if (current) {
- clearServiceState(current.pid);
- }
- const serviceToken = generateServiceToken();
- const childArgs = [
- currentCliScript(),
- "serve",
- "--daemon-child",
- ...(options.host ? ["--host", options.host] : []),
- ...(options.port ? ["--port", String(options.port)] : []),
- "--no-open",
- ...(options.startGateway ? [] : ["--no-gateway"])
- ];
- const child = spawn(process.execPath, childArgs, {
- detached: true,
- env: serviceChildEnv(serviceToken),
- stdio: "ignore",
- windowsHide: true
- });
- const spawnError = await waitForImmediateSpawnError(child, 1000);
- if (spawnError) {
- throw new Error(`Failed to start CCR service: ${spawnError}`);
- }
- child.unref();
+ const serviceToken = generateServiceToken();
+ const childArgs = [
+ currentCliScript(),
+ "serve",
+ "--daemon-child",
+ ...(options.profileManaged ? ["--profile-managed"] : []),
+ ...(options.host ? ["--host", options.host] : []),
+ ...(options.port ? ["--port", String(options.port)] : []),
+ "--no-open",
+ ...(options.startGateway ? [] : ["--no-gateway"])
+ ];
+ const child = spawn(process.execPath, childArgs, {
+ detached: true,
+ env: serviceChildEnv(serviceToken),
+ stdio: "ignore",
+ windowsHide: true
+ });
+ const spawnError = await waitForImmediateSpawnError(child, 1000);
+ if (spawnError) {
+ throw new Error(`Failed to start CCR service: ${spawnError}`);
+ }
+ child.unref();
- const state = await waitForServiceState(child.pid, serviceStartTimeoutMs);
- if (!state) {
- throw new Error(`CCR service did not report ready within ${serviceStartTimeoutMs}ms.`);
+ const state = await waitForServiceState(child.pid, serviceStartTimeoutMs);
+ if (!state) {
+ throw new Error(`CCR service did not report ready within ${serviceStartTimeoutMs}ms.`);
+ }
+ process.stdout.write(`CCR service started at ${state.url} (pid ${state.pid}).\n`);
+ if (options.open) {
+ await openManagementUrl(state.url);
+ }
+ return state;
+ } finally {
+ releaseStartLock();
}
- process.stdout.write(`CCR service started at ${state.url} (pid ${state.pid}).\n`);
+}
+
+async function reuseRunningService(current: ServiceState, options: WebCliOptions): Promise {
+ let state = current;
+ if (options.startGateway && (!state.startGateway || options.ensureGatewayRunning)) {
+ const gatewayStatus = await callServiceRpc(state, "startGateway");
+ if (gatewayStatus.state !== "running") {
+ throw new Error(gatewayStatus.lastError || "CCR service did not start the gateway.");
+ }
+ state = { ...state, startGateway: true };
+ }
+ if (!options.profileManaged && state.profileManaged) {
+ state = { ...state, profileManaged: false };
+ }
+ if (state !== current) {
+ writeServiceState(state);
+ }
+ process.stdout.write(`CCR service is already running at ${state.url} (pid ${state.pid}).\n`);
if (options.open) {
await openManagementUrl(state.url);
}
+ return state;
}
async function openManagementUi(options: WebCliOptions): Promise {
@@ -402,6 +472,7 @@ async function runWebServer(options: WebCliOptions): Promise {
writeServiceState({
host: options.host,
pid: process.pid,
+ profileManaged: options.profileManaged,
...(serviceToken ? { serviceToken } : {}),
startedAt: new Date().toISOString(),
startGateway: options.startGateway,
@@ -411,11 +482,16 @@ async function runWebServer(options: WebCliOptions): Promise {
process.stdout.write(`CCR web management is running at ${runtime.url}\n`);
let closing = false;
+ let profileLeaseMonitor: NodeJS.Timeout | undefined;
+ let profileGatewayIdleSince: number | undefined;
const shutdown = (signal: NodeJS.Signals) => {
if (closing) {
return;
}
closing = true;
+ if (profileLeaseMonitor) {
+ clearInterval(profileLeaseMonitor);
+ }
void runtime.close().finally(() => {
if (options.daemonChild) {
clearServiceState(process.pid);
@@ -425,6 +501,27 @@ async function runWebServer(options: WebCliOptions): Promise {
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
+ if (options.daemonChild && options.profileManaged) {
+ profileLeaseMonitor = setInterval(() => {
+ const state = readServiceState();
+ if (!state || state.pid !== process.pid || !state.profileManaged) {
+ if (profileLeaseMonitor) {
+ clearInterval(profileLeaseMonitor);
+ profileLeaseMonitor = undefined;
+ }
+ return;
+ }
+ if (activeProfileGatewayLeaseCount() > 0) {
+ profileGatewayIdleSince = undefined;
+ return;
+ }
+ profileGatewayIdleSince ??= Date.now();
+ if (Date.now() - profileGatewayIdleSince >= profileGatewayIdleGraceMs) {
+ shutdown("SIGTERM");
+ }
+ }, profileGatewayLeasePollMs);
+ profileLeaseMonitor.unref?.();
+ }
await new Promise(() => undefined);
}
@@ -456,14 +553,21 @@ function printHelp(exitCode: number): void {
const command = cliCommandName();
const output = [
"Usage:",
- ` ${command} start [--host ] [--port ] [--open] [--no-gateway]`,
- ` ${command} ui [--host ] [--port ] [--no-gateway]`,
+ ` ${command} start [--host ] [--port ] [--open|--no-open] [--gateway|--no-gateway]`,
+ ` ${command} ui [--host ] [--port ] [--open|--no-open] [--gateway|--no-gateway]`,
+ ` ${command} serve [--host ] [--port ] [--open|--no-open] [--gateway|--no-gateway]`,
` ${command} stop`,
` ${command} [cli|app] [-- ]`,
"",
+ "Notes:",
+ ` ${command} web is an alias for ${command} serve.`,
+ " --cli and --app are alternatives to the positional profile surface.",
+ " Put agent-specific arguments after --.",
+ "",
"Examples:",
` ${command} start`,
` ${command} ui`,
+ ` ${command} serve --no-open`,
` ${command} stop`,
` ${command} Codex`,
` ${command} default-codex -- --model gpt-5-codex`,
@@ -478,16 +582,19 @@ function printStartHelp(exitCode: number): void {
const command = cliCommandName();
const output = [
"Usage:",
- ` ${command} start [--host ] [--port ] [--open] [--no-gateway]`,
+ ` ${command} start [--host ] [--port ] [--open|--no-open] [--gateway|--no-gateway]`,
"",
"Options:",
- " --host Management server host. Defaults to 127.0.0.1.",
- " --port Management server port. Defaults to 3458.",
+ " --host Management server host. Defaults to CCR_WEB_HOST or 127.0.0.1.",
+ " --port Management server port. Defaults to CCR_WEB_PORT or 3458.",
" --open Open the management page in the default browser.",
" --no-open Do not open the management page.",
+ " --gateway Start the configured model gateway (default).",
" --no-gateway Start only the web management server.",
"",
"Environment:",
+ " CCR_WEB_HOST Default management server host.",
+ " CCR_WEB_PORT Default management server port.",
" CCR_WEB_AUTH_TOKEN Use this token for management UI and RPC authentication."
].join("\n");
const stream = exitCode === 0 ? process.stdout : process.stderr;
@@ -499,17 +606,21 @@ function printUiHelp(exitCode: number): void {
const command = cliCommandName();
const output = [
"Usage:",
- ` ${command} ui [--host ] [--port ] [--no-gateway]`,
+ ` ${command} ui [--host ] [--port ] [--open|--no-open] [--gateway|--no-gateway]`,
"",
"Starts the background CCR service if needed and opens the management UI in the default browser.",
"",
"Options:",
- " --host Management server host. Defaults to 127.0.0.1.",
- " --port Management server port. Defaults to 3458.",
+ " --host Management server host. Defaults to CCR_WEB_HOST or 127.0.0.1.",
+ " --port Management server port. Defaults to CCR_WEB_PORT or 3458.",
+ " --open Open the management page (default).",
" --no-open Start or find the service and print the management URL without opening a browser.",
+ " --gateway Start the configured model gateway (default).",
" --no-gateway Start only the web management server when the service is not already running.",
"",
"Environment:",
+ " CCR_WEB_HOST Default management server host.",
+ " CCR_WEB_PORT Default management server port.",
" CCR_WEB_AUTH_TOKEN Use this token for management UI and RPC authentication."
].join("\n");
const stream = exitCode === 0 ? process.stdout : process.stderr;
@@ -534,15 +645,21 @@ function printWebHelp(exitCode: number): void {
const command = cliCommandName();
const output = [
"Usage:",
- ` ${command} serve [--host ] [--port ] [--open] [--no-gateway]`,
+ ` ${command} serve [--host ] [--port ] [--open|--no-open] [--gateway|--no-gateway]`,
+ "",
+ `Runs in the foreground. ${command} web is an alias.`,
"",
"Options:",
- " --host Management server host. Defaults to 127.0.0.1.",
- " --port Management server port. Defaults to 3458.",
+ " --host Management server host. Defaults to CCR_WEB_HOST or 127.0.0.1.",
+ " --port Management server port. Defaults to CCR_WEB_PORT or 3458.",
" --open Open the management page in the default browser.",
+ " --no-open Do not open the management page (default).",
+ " --gateway Start the configured model gateway (default).",
" --no-gateway Start only the web management server.",
"",
"Environment:",
+ " CCR_WEB_HOST Default management server host.",
+ " CCR_WEB_PORT Default management server port.",
" CCR_WEB_AUTH_TOKEN Use this token for management UI and RPC authentication."
].join("\n");
const stream = exitCode === 0 ? process.stdout : process.stderr;
@@ -571,6 +688,7 @@ function readServiceState(): ServiceState | undefined {
return {
host: parsed.host,
pid,
+ profileManaged: parsed.profileManaged === true,
serviceToken: typeof parsed.serviceToken === "string" && parsed.serviceToken.trim() ? parsed.serviceToken.trim() : undefined,
startedAt: parsed.startedAt || "",
startGateway: parsed.startGateway !== false,
@@ -603,6 +721,133 @@ function serviceStateFile(): string {
return path.join(CONFIGDIR, serviceStateFileName);
}
+type ProfileGatewayLease = {
+ release: () => void;
+};
+
+function acquireManagedProfileGatewayLease(): ProfileGatewayLease | undefined {
+ const state = readServiceState();
+ return state?.profileManaged && isProcessRunning(state.pid)
+ ? createProfileGatewayLease()
+ : undefined;
+}
+
+function createProfileGatewayLease(): ProfileGatewayLease {
+ const dir = profileGatewayLeaseDir();
+ mkdirSync(dir, { mode: 0o700, recursive: true });
+ const file = path.join(dir, `${process.pid}-${randomBytes(12).toString("hex")}.json`);
+ writeFileSync(file, `${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() })}\n`, {
+ encoding: "utf8",
+ flag: "wx",
+ mode: 0o600
+ });
+ let released = false;
+ return {
+ release: () => {
+ if (released) {
+ return;
+ }
+ released = true;
+ try {
+ unlinkSync(file);
+ } catch {
+ // The service also removes stale leases after an abnormal client exit.
+ }
+ }
+ };
+}
+
+function activeProfileGatewayLeaseCount(): number {
+ let entries: string[];
+ try {
+ entries = readdirSync(profileGatewayLeaseDir());
+ } catch {
+ return 0;
+ }
+ let active = 0;
+ for (const entry of entries) {
+ const file = path.join(profileGatewayLeaseDir(), entry);
+ const lease = readJsonRecord(file);
+ const pid = Number(lease?.pid);
+ if (!Number.isInteger(pid) || pid <= 0 || !isProcessRunning(pid)) {
+ try {
+ unlinkSync(file);
+ } catch {
+ // Stale lease cleanup is best effort.
+ }
+ continue;
+ }
+ active += 1;
+ }
+ return active;
+}
+
+function profileGatewayLeaseDir(): string {
+ return path.join(CONFIGDIR, profileGatewayLeaseDirName);
+}
+
+async function acquireServiceStartLock(): Promise<() => void> {
+ const file = path.join(CONFIGDIR, serviceStartLockFileName);
+ const token = randomBytes(16).toString("hex");
+ const deadline = Date.now() + serviceStartTimeoutMs + 5_000;
+ mkdirSync(path.dirname(file), { recursive: true });
+
+ while (Date.now() < deadline) {
+ try {
+ writeFileSync(file, `${JSON.stringify({ pid: process.pid, token })}\n`, {
+ encoding: "utf8",
+ flag: "wx",
+ mode: 0o600
+ });
+ return () => {
+ const lock = readJsonRecord(file);
+ if (lock?.token !== token) {
+ return;
+ }
+ try {
+ unlinkSync(file);
+ } catch {
+ // Lock release is best effort; stale owners are cleaned below.
+ }
+ };
+ } catch (error) {
+ const code = errorCode(error);
+ if (code !== "EEXIST") {
+ throw error;
+ }
+ const lock = readJsonRecord(file);
+ const ownerPid = Number(lock?.pid);
+ if (!Number.isInteger(ownerPid) || ownerPid <= 0 || !isProcessRunning(ownerPid)) {
+ try {
+ unlinkSync(file);
+ } catch {
+ // Another starter may have replaced the lock; retry normally.
+ }
+ continue;
+ }
+ await delay(100);
+ }
+ }
+ throw new Error(`Timed out waiting for the CCR service startup lock after ${serviceStartTimeoutMs + 5_000}ms.`);
+}
+
+function readJsonRecord(file: string): Record | undefined {
+ try {
+ const value = JSON.parse(readFileSync(file, "utf8")) as unknown;
+ return typeof value === "object" && value !== null && !Array.isArray(value)
+ ? value as Record
+ : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+function errorCode(error: unknown): string | undefined {
+ return typeof error === "object" && error !== null && "code" in error && typeof error.code === "string"
+ ? error.code
+ : undefined;
+}
+
function currentCliScript(): string {
return __filename;
}
diff --git a/packages/core/package.json b/packages/core/package.json
index ce6ce8dd..7b4a1749 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@claude-code-router/core",
- "version": "3.0.2",
+ "version": "3.0.5",
"private": true,
"description": "Claude Code Router core gateway, routing, provider, and storage services.",
"main": "dist/main/server.js",
@@ -11,7 +11,7 @@
"node": ">=22"
},
"dependencies": {
- "@the-next-ai/ai-gateway": "^1.0.4",
+ "@the-next-ai/ai-gateway": "^1.0.9",
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
"better-sqlite3": "^12.11.1",
"node-forge": "^1.4.0",
diff --git a/packages/core/src/agents/bot-gateway/env.ts b/packages/core/src/agents/bot-gateway/env.ts
index 1dd8e608..a36a3736 100644
--- a/packages/core/src/agents/bot-gateway/env.ts
+++ b/packages/core/src/agents/bot-gateway/env.ts
@@ -36,13 +36,21 @@ export function botGatewayProfileEnv(config: AppConfig, profile: ProfileConfig,
CCR_BOT_GATEWAY_ENABLED: "true",
CCR_BOT_GATEWAY_FORWARD_ALL_AGENT_MESSAGES: boolEnv(bot.forwardAllAgentMessages),
CCR_BOT_GATEWAY_INTEGRATION_ID: bot.integrationId ?? "",
+ CCR_BOT_GATEWAY_LANGUAGE: bot.language ?? "auto",
+ CCR_BOT_GATEWAY_MAX_ATTACHMENT_BYTES: String(bot.maxAttachmentBytes ?? 20 * 1024 * 1024),
+ CCR_BOT_GATEWAY_MAX_TURN_TIME_MS: String(bot.maxTurnTimeMs ?? 10 * 60 * 1000),
+ CCR_BOT_GATEWAY_MEDIA_ENABLED: boolEnv(bot.mediaEnabled),
+ CCR_BOT_GATEWAY_MESSAGE_CHUNK_CHARS: String(bot.messageChunkChars ?? 3500),
CCR_BOT_GATEWAY_PLATFORM: bot.platform,
CCR_BOT_GATEWAY_POLL_INTERVAL_MS: String(bot.pollIntervalMs ?? 2000),
CCR_BOT_GATEWAY_REQUEST_TIMEOUT_MS: String(bot.requestTimeoutMs ?? 600000),
+ CCR_BOT_GATEWAY_SESSION_IDLE_MINUTES: String(bot.sessionIdleMinutes ?? 0),
+ CCR_BOT_GATEWAY_SHELL_ENABLED: boolEnv(bot.shellEnabled),
CCR_BOT_GATEWAY_SOURCE_DIR: "",
...botGatewaySdkEnv(),
CCR_BOT_GATEWAY_STARTUP_TIMEOUT_MS: String(bot.startupTimeoutMs ?? 10000),
CCR_BOT_GATEWAY_STATE_DIR: stateDir,
+ CCR_BOT_GATEWAY_STREAM_REPLIES: boolEnv(bot.streamReplies),
CCR_BOT_GATEWAY_TENANT_ID: bot.tenantId ?? "ccr",
CCR_BOT_HANDOFF_ENABLED: boolEnv(handoff.enabled),
CCR_BOT_HANDOFF_IDLE_SECONDS: String(handoff.idleSeconds ?? 30),
@@ -217,6 +225,9 @@ function defaultBotGatewayAuthType(platform: string): string {
if (platform === "slack" || platform === "discord" || platform === "telegram" || platform === "line") {
return "bot_token";
}
+ if (platform === "imessage") {
+ return "local";
+ }
return "";
}
diff --git a/packages/core/src/agents/claude-app/gateway-routes.ts b/packages/core/src/agents/claude-app/gateway-routes.ts
index 3ccda7e4..1a220926 100644
--- a/packages/core/src/agents/claude-app/gateway-routes.ts
+++ b/packages/core/src/agents/claude-app/gateway-routes.ts
@@ -1,7 +1,7 @@
import type { AppConfig } from "@ccr/core/contracts/app";
-import { normalizeProfileScopeValue } from "@ccr/core/contracts/app";
+import { availableGatewayModelIds, normalizeProfileScopeValue } from "@ccr/core/contracts/app";
+import { modelRegistryForConfig } from "@ccr/core/routing/model-registry";
-export const CLAUDE_APP_FALLBACK_MODEL = "claude-sonnet-4-5";
export const CLAUDE_APP_ONE_MILLION_CONTEXT_SUFFIX = "[1m]";
const CLAUDE_APP_ENCODED_ROUTE_PREFIX = "anthropic/claude-ccr-h";
@@ -25,9 +25,14 @@ export type ClaudeAppGatewayInferenceModel = {
supports1m?: true;
};
-export function inferClaudeAppGatewayTargetModel(config: Pick): string {
- return inferGlobalClaudeProfileModel(config) ||
- CLAUDE_APP_FALLBACK_MODEL;
+export function inferClaudeAppGatewayTargetModel(
+ config: Pick
+): string | undefined {
+ const profileModel = inferGlobalClaudeProfileModel(config);
+ const resolvedProfileModel = profileModel
+ ? canonicalClaudeAppGatewayTargetModel(profileModel, config)
+ : undefined;
+ return resolvedProfileModel ?? availableGatewayModelIds(config)[0];
}
export function buildClaudeAppGatewayModelRoutes(
@@ -102,13 +107,11 @@ export function buildClaudeAppGatewayInferenceModels(
options: ClaudeAppGatewayModelRouteOptions = {}
): ClaudeAppGatewayInferenceModel[] {
const routes = buildClaudeAppGatewayModelRoutes(config, options);
- return routes.length
- ? routes.map((route) => ({
- labelOverride: route.displayName,
- name: route.id,
- ...(route.oneMillionContext ? { supports1m: true as const } : {})
- }))
- : [{ labelOverride: "Claude Sonnet 4.5", name: CLAUDE_APP_FALLBACK_MODEL }];
+ return routes.map((route) => ({
+ labelOverride: route.displayName,
+ name: route.id,
+ ...(route.oneMillionContext ? { supports1m: true as const } : {})
+ }));
}
export function hasClaudeAppGatewayOneMillionContextSuffix(id: string): boolean {
@@ -129,54 +132,28 @@ function inferGlobalClaudeProfileModel(config: Pick): stri
}
function claudeAppGatewayTargetModels(config: Pick): string[] {
- const baseEntries = config.Providers.flatMap((provider) => {
- const providerName = provider.name?.trim();
- if (!providerName || !Array.isArray(provider.models)) {
- return [];
- }
- return provider.models.flatMap((rawModel) => {
- const modelName = rawModel.trim();
- return modelName ? [{ modelName, providerName }] : [];
- });
- });
+ const defaultTargetModel = inferClaudeAppGatewayTargetModel(config);
return uniqueStrings([
- inferClaudeAppGatewayTargetModel(config),
- ...baseEntries.map((entry) => `${entry.providerName}/${entry.modelName}`),
- ...(config.virtualModelProfiles ?? []).flatMap((profile) => {
- if (
- profile.enabled === false ||
- profile.materialization?.enabled === false ||
- profile.materialization?.includeInGatewayModels === false
- ) {
- return [];
- }
- const derivedModels = baseEntries.flatMap((entry) => [
- ...(profile.match?.prefixes ?? []).flatMap((prefix) => {
- const normalizedPrefix = prefix.trim();
- return normalizedPrefix ? [`${entry.providerName}/${normalizedPrefix}${entry.modelName}`] : [];
- }),
- ...(profile.match?.suffixes ?? []).flatMap((suffix) => {
- const normalizedSuffix = suffix.trim();
- return normalizedSuffix ? [`${entry.providerName}/${entry.modelName}${normalizedSuffix}`] : [];
- })
- ]);
- return [
- ...derivedModels,
- ...(profile.match?.exactAliases ?? []).flatMap((alias) => {
- const normalizedAlias = alias.trim();
- if (!normalizedAlias) {
- return [];
- }
- return normalizedAlias.toLowerCase().startsWith("fusion/")
- ? [normalizedAlias]
- : [`Fusion/${normalizedAlias}`];
- })
- ];
- })
+ ...(defaultTargetModel ? [defaultTargetModel] : []),
+ ...availableGatewayModelIds(config)
]);
}
+function canonicalClaudeAppGatewayTargetModel(
+ model: string,
+ config: Pick
+): string | undefined {
+ const oneMillionContext = hasClaudeAppGatewayOneMillionContextSuffix(model);
+ const resolved = modelRegistryForConfig(config).resolve(stripClaudeAppGatewayOneMillionContextSuffix(model));
+ if (!resolved) {
+ return undefined;
+ }
+ return oneMillionContext
+ ? `${resolved.canonicalSelector}${CLAUDE_APP_ONE_MILLION_CONTEXT_SUFFIX}`
+ : resolved.canonicalSelector;
+}
+
function claudeAppGatewaySupportsOneMillionContext(
model: string,
options: ClaudeAppGatewayModelRouteOptions
diff --git a/packages/core/src/agents/codex/app-launch.ts b/packages/core/src/agents/codex/app-launch.ts
index 6db6a46a..316f2093 100644
--- a/packages/core/src/agents/codex/app-launch.ts
+++ b/packages/core/src/agents/codex/app-launch.ts
@@ -161,6 +161,10 @@ export function findInstalledCodexAppExecutable(profileAppPath?: string): CodexA
return findInstalledCodexCompatibleAppExecutable(codexAppSpec, profileAppPath);
}
+export function findInstalledZcodeAppExecutable(profileAppPath?: string): CodexAppLookupResult {
+ return findInstalledCodexCompatibleAppExecutable(zcodeAppSpec, profileAppPath);
+}
+
export function launchZcodeAppProfile(configDir: string, profile: ProfileConfig, config?: AppConfig): CodexAppLaunchResult {
return launchCodexCompatibleAppProfile(configDir, profile, zcodeAppSpec, config);
}
diff --git a/packages/core/src/agents/codex/cli-middleware-runtime.ts b/packages/core/src/agents/codex/cli-middleware-runtime.ts
index aac47769..af4203f5 100644
--- a/packages/core/src/agents/codex/cli-middleware-runtime.ts
+++ b/packages/core/src/agents/codex/cli-middleware-runtime.ts
@@ -13,17 +13,15 @@ const { pathToFileURL } = require("node:url");
const VERSION = "3.0.0";
const DEFAULT_MODEL = "claude-sonnet-4-5";
const PROTOCOL_VERSION = "2025-06-18";
-const BOT_SESSION_ENTRY_VERSION = 2;
+const BOT_SESSION_ENTRY_VERSION = 3;
+const OPENCODE_BOT_SESSION_STORE_VERSION = 3;
+const BOT_RUNTIME_STATE_VERSION = 1;
const REQUEST_TIMEOUT_MS = numberEnv("CCR_CODEX_APP_REQUEST_TIMEOUT_MS", 10 * 60 * 1000);
const TURN_IDLE_TIMEOUT_MS = numberEnv("CCR_CODEX_CLAUDE_TURN_IDLE_TIMEOUT_MS", 10 * 60 * 1000);
const CONFIG_DIR = resolveConfigDir();
const LOG_PATH = process.env.CCR_CODEX_CLI_MIDDLEWARE_LOG || "";
const CLAUDE_CODE_MCP_CONFIG_ENV = "CCR_CLAUDE_CODE_MCP_CONFIG";
const CODEXL_CLAUDE_CODE_MCP_CONFIG_ENV = "CODEXL_CLAUDE_CODE_MCP_CONFIG";
-const CLAUDE_CODE_SETTINGS_FILE_ENV = "CCR_CLAUDE_CODE_SETTINGS_FILE";
-const CODEXL_CLAUDE_CODE_SETTINGS_FILE_ENV = "CODEXL_CLAUDE_CODE_SETTINGS_FILE";
-const AGENT_CONSOLE_SUBAGENTS_START = "";
-const AGENT_CONSOLE_SUBAGENTS_END = " ";
const CLAUDE_CODE_CHINA_TIME_ZONES = new Set([
"asia/chongqing",
"asia/chungking",
@@ -80,11 +78,19 @@ function botBridge() {
}
async function main() {
- const args = process.argv.slice(2);
+ const args = directProfileDispatchArgs(process.argv.slice(2));
+ if (process.env.CCR_OPENCODE_BOT_WORKER === "1" || args[0] === "opencode-bot-worker") {
+ await runOpenCodeBotWorker(args);
+ return;
+ }
if (process.env.CCR_CLAUDE_CODE_BOT_WORKER === "1" || args[0] === "claude-bot-worker") {
await runClaudeCodeBotWorker(args);
return;
}
+ if (process.env.CCR_CODEX_BOT_WORKER === "1" || args[0] === "codex-bot-worker") {
+ await runCodexBotWorker(args);
+ return;
+ }
if (process.env.CCR_CLAUDE_CODE_WRAPPER === "1") {
await runClaudeCodeCliWrapper(args);
return;
@@ -96,6 +102,20 @@ async function main() {
await runCodexCliMiddleware(args.length === 0 ? defaultCodexArgs() : args);
}
+function directProfileDispatchArgs(args) {
+ if (process.env.CCR_CLI_DIRECT_PROFILE_DISPATCH !== "1") {
+ return args;
+ }
+ const forwarded = args.slice(1);
+ if (forwarded[0] === "cli" || forwarded[0] === "--cli") {
+ forwarded.shift();
+ }
+ if (forwarded[0] === "--") {
+ forwarded.shift();
+ }
+ return forwarded;
+}
+
async function runClaudeCodeCliWrapper(args) {
const realCli = expandHome(nonEmptyEnv("CCR_REAL_CLAUDE_CODE_BIN") || nonEmptyEnv("CCR_CLAUDE_CODE_BIN") || nonEmptyEnv("CODEXL_CLAUDE_CODE_BIN") || "claude");
const realArgs = claudeCodeCliWrapperArgs(args);
@@ -108,7 +128,7 @@ async function runClaudeCodeCliWrapper(args) {
title: nonEmptyEnv("CCR_REMOTE_SYNC_PROFILE_NAME") || "Claude Code"
});
const injectRemoteStdin = boolEnv("CCR_REMOTE_SYNC_INJECT_STDIN");
- const child = childProcess.spawn(realCli, realArgs, {
+ const child = spawnAgentCli(realCli, realArgs, {
env: {
...withoutKeys(process.env, ["CCR_CLAUDE_CODE_WRAPPER", "CCR_REAL_CLAUDE_CODE_BIN"]),
...claudeCodeUtcTimezoneEnvOverride()
@@ -131,6 +151,7 @@ async function runClaudeCodeCliWrapper(args) {
});
child.on("error", (error) => {
log("claude_code_wrapper_spawn_error", { error: formatError(error) });
+ process.stderr.write("Failed to start " + realCli + ": " + formatError(error) + "\n");
remoteSync.postEvent("claude.spawn.error", { error: formatError(error) }, { direction: "system" });
});
let pending = "";
@@ -158,19 +179,10 @@ async function runClaudeCodeCliWrapper(args) {
}
function claudeCodeCliWrapperArgs(args) {
- const settingsArgs = claudeCodeArgsWithSettings(args);
- const modelArgs = claudeCodeArgsWithModel(settingsArgs);
+ const modelArgs = claudeCodeArgsWithModel(args);
return claudeCodeArgsWithMcpConfig(modelArgs, process.env);
}
-function claudeCodeArgsWithSettings(args) {
- const settingsFile = nonEmptyEnv(CLAUDE_CODE_SETTINGS_FILE_ENV) || nonEmptyEnv(CODEXL_CLAUDE_CODE_SETTINGS_FILE_ENV);
- if (!settingsFile || claudeCodeArgsHaveSettings(args) || claudeCodeArgsShouldSkipModelInjection(args)) {
- return args;
- }
- return ["--settings", settingsFile, ...args];
-}
-
function claudeCodeArgsWithModel(args) {
const model = nonEmptyEnv("CCR_CLAUDE_CODE_MODEL") || nonEmptyEnv("CODEXL_CLAUDE_CODE_MODEL") || nonEmptyEnv("ANTHROPIC_MODEL");
if (!model || claudeCodeArgsHaveModel(args) || claudeCodeArgsShouldSkipModelInjection(args)) {
@@ -205,15 +217,6 @@ function claudeCodeArgsHaveMcpConfig(args) {
return false;
}
-function claudeCodeArgsHaveSettings(args) {
- for (const arg of args) {
- if (arg === "--settings" || arg.startsWith("--settings=")) {
- return true;
- }
- }
- return false;
-}
-
function claudeCodeArgsShouldSkipModelInjection(args) {
if (args.some((arg) => arg === "--help" || arg === "-h" || arg === "--version" || arg === "-v")) {
return true;
@@ -252,41 +255,19 @@ function firstClaudeCodePositionalArg(args) {
function claudeCodeOptionTakesValue(arg) {
return new Set([
"--add-dir",
- "--agent",
- "--agents",
- "--allowedTools",
- "--allowed-tools",
"--append-system-prompt",
- "--betas",
"--config",
"--continue",
"--debug-to",
- "--disallowedTools",
- "--disallowed-tools",
- "--effort",
"--fallback-model",
- "--file",
- "--input-format",
- "--json-schema",
- "--max-budget-usd",
"--model",
"--mcp-config",
- "--name",
"--output-format",
"--permission-mode",
- "--plugin-dir",
- "--plugin-url",
- "--remote-control",
- "--remote-control-session-name-prefix",
"--resume",
- "--resume-session-at",
- "--session-id",
"--settings",
- "--setting-sources",
"--system-prompt",
- "--tools",
"-c",
- "-n",
"-m",
"-p",
"-r"
@@ -334,13 +315,14 @@ async function runCodexCliMiddleware(args) {
}
const cleanupAuthBootstrap = createEphemeralCodexApiKeyBootstrap(runtimeAgent);
- const child = childProcess.spawn(realCli, realArgs, {
+ const child = spawnAgentCli(realCli, realArgs, {
env: childEnvForAgent(runtimeAgent),
stdio: ["pipe", "pipe", "inherit"]
});
child.on("error", (error) => {
cleanupAuthBootstrap();
log("codex_cli_spawn_error", { error: formatError(error) });
+ process.stderr.write("Failed to start " + realCli + ": " + formatError(error) + "\n");
});
const requestMap = new Map();
@@ -433,12 +415,13 @@ function createEphemeralCodexApiKeyBootstrap(runtimeAgent) {
async function runDirectCodexCli(realCli, realArgs) {
const runtimeAgent = codexRuntimeAgent();
- const child = childProcess.spawn(realCli, realArgs, {
+ const child = spawnAgentCli(realCli, realArgs, {
env: childEnvForAgent(runtimeAgent),
stdio: "inherit"
});
child.on("error", (error) => {
log("codex_cli_spawn_error", { error: formatError(error) });
+ process.stderr.write("Failed to start " + realCli + ": " + formatError(error) + "\n");
});
const exit = await waitForChildResult(child);
log("codex_cli_exit", { code: exit.code, signal: exit.signal, exitCode: exit.exitCode });
@@ -449,6 +432,69 @@ function shouldRunDirectCodexCli(args) {
return codexPositionalArgs(args)[0] !== "app-server";
}
+function spawnAgentCli(command, args, options) {
+ if (process.platform !== "win32") {
+ return childProcess.spawn(command, args, options);
+ }
+
+ const commandFile = resolveWindowsCommandFile(command, options && options.env);
+ if (commandFile && /\.(?:com|exe)$/i.test(commandFile)) {
+ return childProcess.spawn(commandFile, args, options);
+ }
+
+ const shellCommand = [escapeWindowsCmdCommand(commandFile || command)]
+ .concat(args.map(escapeWindowsCmdArgument))
+ .join(" ");
+ return childProcess.spawn(
+ process.env.ComSpec || process.env.COMSPEC || "cmd.exe",
+ ["/d", "/s", "/c", '"' + shellCommand + '"'],
+ { ...options, windowsVerbatimArguments: true }
+ );
+}
+
+function resolveWindowsCommandFile(command, env) {
+ const value = String(command || "").trim().replace(/^"|"$/g, "");
+ if (!value) return "";
+ const commandExt = path.extname(value);
+ const pathExt = String((env && (env.PATHEXT || env.Pathext)) || process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD")
+ .split(";")
+ .map((extension) => extension.trim())
+ .filter(Boolean);
+ const extensions = commandExt ? [""] : ["", ...pathExt];
+ const hasPath = path.isAbsolute(value) || value.includes("\\") || value.includes("/");
+ const directories = hasPath
+ ? [""]
+ : String((env && (env.PATH || env.Path)) || process.env.PATH || "")
+ .split(path.delimiter)
+ .map((directory) => directory.replace(/^"|"$/g, ""))
+ .filter(Boolean);
+
+ for (const directory of directories) {
+ for (const extension of extensions) {
+ const candidate = directory ? path.join(directory, value + extension) : value + extension;
+ try {
+ if (fs.statSync(candidate).isFile()) return candidate;
+ } catch {
+ }
+ }
+ }
+ return "";
+}
+
+const WINDOWS_CMD_META_CHARS = /([()\][%!^"\`<>&|;, *?])/g;
+
+function escapeWindowsCmdCommand(value) {
+ return String(value).replace(WINDOWS_CMD_META_CHARS, "^$1");
+}
+
+function escapeWindowsCmdArgument(value) {
+ let escaped = String(value);
+ escaped = escaped.replace(/(?=(\\+?)?)\1"/g, "$1$1\\\"");
+ escaped = escaped.replace(/(?=(\\+?)?)\1$/g, "$1$1");
+ escaped = '"' + escaped + '"';
+ return escaped.replace(WINDOWS_CMD_META_CHARS, "^$1");
+}
+
function realCliArgs(profile, modelProvider, configFormat, args) {
const realArgs = [];
if (profile) {
@@ -617,14 +663,11 @@ function cliThreadStartParams(params) {
"developerInstructions",
"personality",
"ephemeral",
- "persistExtendedHistory",
- "mcpServers",
- "agentConsoleSubagents"
+ "persistExtendedHistory"
]) {
copyJsonField(source, output, key);
}
copyJsonField(source, output, "additionalDeveloperInstructions", "developerInstructions");
- appendAgentConsoleSubagentRuntime(output, source);
ensureCliProjectlessOutputDirectory(source, output);
copyPermissionFields(source, output);
copyCollaborationModelFields(source, output);
@@ -657,14 +700,10 @@ function cliThreadResumeParams(params) {
"developerInstructions",
"personality",
"excludeTurns",
- "persistExtendedHistory",
- "mcpServers",
- "agentConsoleSubagents"
+ "persistExtendedHistory"
]) {
copyJsonField(source, output, key);
}
- copyJsonField(source, output, "additionalDeveloperInstructions", "developerInstructions");
- appendAgentConsoleSubagentRuntime(output, source);
copyPermissionFields(source, output);
copyCollaborationModelFields(source, output);
return output;
@@ -684,15 +723,10 @@ function cliTurnStartParamsForAppServer(params) {
"effort",
"reasoningEffort",
"workspaceKind",
- "projectlessOutputDirectory",
- "developerInstructions",
- "mcpServers",
- "agentConsoleSubagents"
+ "projectlessOutputDirectory"
]) {
copyJsonField(source, output, key);
}
- copyJsonField(source, output, "additionalDeveloperInstructions", "developerInstructions");
- appendAgentConsoleSubagentRuntime(output, source);
copyPermissionFields(source, output);
copyCollaborationModelFields(source, output);
return output;
@@ -721,222 +755,6 @@ function appendDeveloperInstruction(target, instruction) {
target.developerInstructions = existing ? existing + "\n\n" + instruction : instruction;
}
-function appendAgentConsoleSubagentRuntime(target, source) {
- const runtime = normalizeAgentConsoleSubagentRuntime(
- source.agentConsoleSubagents ||
- source.agent_console_subagents ||
- source.subagentRuntime ||
- source.subagent_runtime
- );
- const runtimeMcpServers = runtime ? normalizeAgentConsoleMcpServerMap(runtime.mcpServers) : null;
- const sourceMcpServers = normalizeAgentConsoleMcpServerMap(source.mcpServers || source.mcp_servers);
- const mergedMcpServers = mergeAgentConsoleMcpServerMaps(sourceMcpServers, runtimeMcpServers);
- if (mergedMcpServers) {
- target.mcpServers = mergeAgentConsoleMcpServerMaps(normalizeAgentConsoleMcpServerMap(target.mcpServers), mergedMcpServers);
- }
- if (!runtime) return;
- target.agentConsoleSubagents = runtime;
- target.developerInstructions = withAgentConsoleSubagentInstructions(target.developerInstructions, runtime);
-}
-
-function normalizeAgentConsoleSubagentRuntime(value) {
- if (!value) return null;
- const record = isPlainObject(value) ? value : {};
- const rawSubagents = Array.isArray(record.subagents) ? record.subagents : Array.isArray(value) ? value : [];
- const subagents = [];
- const seen = new Set();
- for (const rawSubagent of rawSubagents) {
- if (!isPlainObject(rawSubagent)) continue;
- const id = runtimeString(rawSubagent.id);
- const label = runtimeString(rawSubagent.label) || id;
- const systemPrompt = runtimeString(rawSubagent.systemPrompt || rawSubagent.system_prompt);
- if (!id || !label || !systemPrompt || seen.has(id)) continue;
- seen.add(id);
- subagents.push({
- approvalMode: runtimeString(rawSubagent.approvalMode || rawSubagent.approval_mode) || undefined,
- budget: normalizeAgentConsoleSubagentBudget(rawSubagent.budget || rawSubagent),
- capabilities: runtimeStringArray(rawSubagent.capabilities || rawSubagent.skills),
- contextScope: runtimeString(rawSubagent.contextScope || rawSubagent.context_scope || rawSubagent.context) || undefined,
- description: runtimeString(rawSubagent.description) || undefined,
- effort: runtimeString(rawSubagent.effort || rawSubagent.reasoningEffort || rawSubagent.reasoning_effort) || undefined,
- id,
- label,
- mcpServerIds: Array.isArray(rawSubagent.mcpServerIds || rawSubagent.mcp_server_ids)
- ? (rawSubagent.mcpServerIds || rawSubagent.mcp_server_ids).map(runtimeString).filter(Boolean)
- : [],
- model: runtimeString(rawSubagent.model) || undefined,
- outputContract: runtimeString(rawSubagent.outputContract || rawSubagent.output_contract) || undefined,
- providerId: runtimeString(rawSubagent.providerId || rawSubagent.provider_id) || undefined,
- providerLabel: runtimeString(rawSubagent.providerLabel || rawSubagent.provider_label) || undefined,
- providerSubagentMode: normalizeAgentConsoleSubagentProviderMode(rawSubagent.providerSubagentMode || rawSubagent.provider_subagent_mode),
- qualityGates: runtimeStringArray(rawSubagent.qualityGates || rawSubagent.quality_gates || rawSubagent.gates),
- runtimeMode: normalizeAgentConsoleSubagentRuntimeMode(rawSubagent.runtimeMode || rawSubagent.runtime_mode) || "auto",
- speed: runtimeString(rawSubagent.speed) || undefined,
- systemPrompt,
- timeoutMs: numberOrUndefined(rawSubagent.timeoutMs || rawSubagent.timeout_ms)
- });
- }
- if (!subagents.length) return null;
- return {
- instructions: runtimeString(record.instructions),
- mcpServers: normalizeAgentConsoleMcpServerMap(record.mcpServers || record.mcp_servers) || {},
- subagents,
- version: 1
- };
-}
-
-function normalizeAgentConsoleSubagentBudget(value) {
- const record = isPlainObject(value) ? value : {};
- const budget = {
- maxDurationMs: numberOrUndefined(record.maxDurationMs || record.max_duration_ms),
- maxTokens: numberOrUndefined(record.maxTokens || record.max_tokens),
- maxToolCalls: numberOrUndefined(record.maxToolCalls || record.max_tool_calls)
- };
- return budget.maxDurationMs || budget.maxTokens || budget.maxToolCalls ? budget : undefined;
-}
-
-function normalizeAgentConsoleSubagentRuntimeMode(value) {
- const mode = runtimeString(value);
- return mode === "auto" || mode === "native" || mode === "emulated" ? mode : "";
-}
-
-function normalizeAgentConsoleSubagentProviderMode(value) {
- const mode = runtimeString(value);
- return mode === "native" || mode === "emulated" || mode === "none" ? mode : undefined;
-}
-
-function normalizeAgentConsoleMcpServerMap(value) {
- if (!isPlainObject(value)) return null;
- const output = {};
- for (const [name, rawServer] of Object.entries(value)) {
- const serverName = runtimeString(name);
- if (!serverName || !isPlainObject(rawServer)) continue;
- output[serverName] = normalizeAgentConsoleMcpServer(rawServer);
- }
- return Object.keys(output).length ? output : null;
-}
-
-function normalizeAgentConsoleMcpServer(server) {
- const output = { ...server };
- const type = runtimeString(output.type || output.transport).toLowerCase();
- if (type === "streamable-http") {
- output.type = "http";
- } else if (type === "http" || type === "sse" || type === "stdio") {
- output.type = type;
- } else if (output.url) {
- output.type = "http";
- } else if (output.command) {
- output.type = "stdio";
- }
- return output;
-}
-
-function mergeAgentConsoleMcpServerMaps(...maps) {
- const output = {};
- for (const map of maps) {
- if (!isPlainObject(map)) continue;
- for (const [name, server] of Object.entries(map)) {
- if (runtimeString(name) && isPlainObject(server)) {
- output[name] = server;
- }
- }
- }
- return Object.keys(output).length ? output : null;
-}
-
-function withAgentConsoleSubagentInstructions(existing, runtime) {
- const base = removeAgentConsoleSubagentInstructionBlock(runtimeString(existing)).trim();
- const block = agentConsoleSubagentInstructionBlock(runtime);
- if (!block) return base || null;
- return base ? base + "\n\n" + block : block;
-}
-
-function removeAgentConsoleSubagentInstructionBlock(value) {
- const text = runtimeString(value);
- const start = text.indexOf(AGENT_CONSOLE_SUBAGENTS_START);
- if (start < 0) return text;
- const end = text.indexOf(AGENT_CONSOLE_SUBAGENTS_END, start);
- if (end < 0) return text.slice(0, start).trim();
- return (text.slice(0, start) + text.slice(end + AGENT_CONSOLE_SUBAGENTS_END.length)).trim();
-}
-
-function agentConsoleSubagentInstructionBlock(runtime) {
- const normalized = normalizeAgentConsoleSubagentRuntime(runtime);
- if (!normalized) return "";
- const lines = [
- AGENT_CONSOLE_SUBAGENTS_START,
- "Agent Console selected subagents are available for this task.",
- "Claude Code adaptation: use the native Agent/Task subagent capability when it is available. If a selected subagent has a model, start the delegated prompt with Provider/model on its own first line, replacing Provider/model with the configured model below.",
- "Codex adaptation: the detected Codex CLI has no native subagent flag, so emulate delegation by following the selected subagent profile and using its listed MCP servers.",
- ""
- ];
- normalized.subagents.forEach((subagent, index) => {
- lines.push("Subagent " + (index + 1) + ": " + subagent.id + " (" + subagent.label + ")");
- lines.push("Provider: " + (subagent.providerLabel || subagent.providerId || "unknown"));
- if (subagent.model) lines.push("Model: " + subagent.model);
- if (subagent.effort) lines.push("Effort: " + subagent.effort);
- if (subagent.speed) lines.push("Speed: " + subagent.speed);
- if (subagent.approvalMode) lines.push("Approval mode: " + subagent.approvalMode);
- lines.push("Runtime mode: " + (subagent.runtimeMode || "auto"));
- lines.push("Provider subagent mode: " + (subagent.providerSubagentMode || "none"));
- lines.push("Capabilities: " + (subagent.capabilities.length ? subagent.capabilities.join(", ") : "unspecified"));
- if (subagent.contextScope) lines.push("Context scope: " + subagent.contextScope);
- const budget = agentConsoleSubagentBudgetText(subagent.budget);
- if (budget) lines.push("Budget: " + budget);
- lines.push("Description: " + (subagent.description || "No description provided."));
- if (subagent.outputContract) lines.push("Output contract: " + subagent.outputContract);
- lines.push("Quality gates: " + (subagent.qualityGates.length ? subagent.qualityGates.join("; ") : "none"));
- lines.push("MCP servers: " + (subagent.mcpServerIds.length ? subagent.mcpServerIds.join(", ") : "none"));
- lines.push("System prompt:");
- lines.push(subagent.systemPrompt);
- lines.push("");
- });
- lines.push(AGENT_CONSOLE_SUBAGENTS_END);
- return lines.join("\n").trim();
-}
-
-function agentConsoleSubagentBudgetText(budget) {
- if (!budget) return "";
- return [
- budget.maxDurationMs ? "maxDurationMs=" + budget.maxDurationMs : "",
- budget.maxTokens ? "maxTokens=" + budget.maxTokens : "",
- budget.maxToolCalls ? "maxToolCalls=" + budget.maxToolCalls : ""
- ].filter(Boolean).join(", ");
-}
-
-function mergeDeveloperInstructionText(existing, addition) {
- const base = runtimeString(existing);
- const next = runtimeString(addition);
- if (!next) return base || null;
- if (!base) return next;
- if (base.includes(next)) return base;
- return base + "\n\n" + next;
-}
-
-function numberOrUndefined(value) {
- const number = typeof value === "number" ? value : Number.NaN;
- return Number.isFinite(number) && number > 0 ? number : undefined;
-}
-
-function runtimeString(value) {
- if (typeof value === "string") return value.trim();
- if (typeof value === "number" || typeof value === "boolean") return String(value);
- return "";
-}
-
-function runtimeStringArray(value) {
- if (!Array.isArray(value)) return [];
- const seen = new Set();
- const items = [];
- for (const rawItem of value) {
- const item = runtimeString(rawItem);
- if (!item || seen.has(item)) continue;
- seen.add(item);
- items.push(item);
- }
- return items;
-}
-
function copyPermissionFields(source, target) {
if (isPlainObject(source.permissions)) {
copyJsonField(source.permissions, target, "approvalPolicy");
@@ -1216,7 +1034,7 @@ async function runClaudeCodeAppServer(args) {
async function runClaudeCodeBotWorker(args) {
const options = parseAppServerOptions(args);
- const lock = acquireClaudeBotWorkerLock();
+ const lock = acquireBotWorkerLock("claude");
if (!lock) return;
try {
const server = new ClaudeCodeAppServer(options);
@@ -1233,12 +1051,1368 @@ async function runClaudeCodeBotWorker(args) {
await botBridge().stop();
log("claude_bot_worker_stop", { pid: process.pid });
} finally {
- releaseClaudeBotWorkerLock(lock);
+ releaseBotWorkerLock(lock);
}
}
-function acquireClaudeBotWorkerLock() {
- const lockPath = claudeBotWorkerLockPath();
+async function runOpenCodeBotWorker(args) {
+ const options = parseOpenCodeBotWorkerOptions(args);
+ const lock = acquireBotWorkerLock("opencode");
+ if (!lock) return;
+ try {
+ const worker = new OpenCodeBotWorker(options);
+ worker.ensureBotBridgeRegistered();
+ log("opencode_bot_worker_start", {
+ workspaceName: options.workspaceName,
+ pid: process.pid,
+ lockPath: lock.path,
+ cwd: worker.defaultCwd,
+ command: worker.command
+ });
+ await waitForTerminationSignal();
+ await botBridge().stop();
+ log("opencode_bot_worker_stop", { pid: process.pid });
+ } finally {
+ releaseBotWorkerLock(lock);
+ }
+}
+
+async function runCodexBotWorker(args) {
+ const options = parseCodexBotWorkerOptions(args);
+ const agent = codexRuntimeAgent();
+ const lock = acquireBotWorkerLock(agent);
+ if (!lock) return;
+ try {
+ const worker = new CodexBotWorker(options);
+ worker.ensureBotBridgeRegistered();
+ log(agent + "_bot_worker_start", {
+ workspaceName: options.workspaceName,
+ pid: process.pid,
+ lockPath: lock.path,
+ cwd: worker.defaultCwd,
+ command: worker.command
+ });
+ await waitForTerminationSignal();
+ await botBridge().stop();
+ log(agent + "_bot_worker_stop", { pid: process.pid });
+ } finally {
+ releaseBotWorkerLock(lock);
+ }
+}
+
+class OpenCodeBotWorker {
+ constructor(options) {
+ this.workspaceName = options.workspaceName || "OpenCode";
+ this.command = expandHome(nonEmptyEnv("CCR_OPENCODE_BIN") || nonEmptyEnv("OPENCODE_BIN") || "opencode");
+ this.defaultCwd = resolveOpenCodeBotCwd(
+ nonEmptyEnv("CCR_OPENCODE_BOT_CWD"),
+ nonEmptyEnv("CCR_BOT_GATEWAY_CWD")
+ );
+ this.store = null;
+ this.turnStates = new Map();
+ this.restoredPendingTurns = false;
+ }
+
+ ensureBotBridgeRegistered() {
+ const bridge = botBridge();
+ bridge.setInboundHandler((event, queued, eventId, activeBridge) => this.handleInbound(event, queued, eventId, activeBridge));
+ this.restorePendingTurns(bridge);
+ }
+
+ async handleInbound(event, _queued, eventId, bridge) {
+ let text = botEventText(event);
+ if (!text) {
+ log("bot_gateway_inbound_skip", { eventId, reason: "empty_text", agent: "opencode" });
+ return;
+ }
+ const commandReply = await this.handleCommand(event, text, bridge);
+ if (commandReply && typeof commandReply === "object" && commandReply.forwardText) {
+ text = commandReply.forwardText;
+ } else if (commandReply !== null) {
+ await bridge.sendReplyToEvent(event, commandReply, "ccr:opencode:command:" + eventId);
+ log("bot_gateway_command_replied", { eventId, agent: "opencode", textLen: commandReply.length });
+ return;
+ }
+
+ const position = this.enqueueTurn(event, eventId, bridge, text);
+ if (position > 0) {
+ await bridge.sendReplyToEvent(event, "Queued behind the active turn (position " + position + "). Use /session status or /session cancel.", "ccr:opencode:queued:" + eventId);
+ }
+ }
+
+ enqueueTurn(event, eventId, bridge, text) {
+ const key = botConversationKey(event);
+ const state = this.turnStates.get(key) || { active: null, pending: [] };
+ const job = { id: eventId || stableBotKey(botEventDedupeKey(event)), event, eventId, text, key, createdAt: Date.now(), bridge };
+ state.pending.push(job);
+ this.turnStates.set(key, state);
+ this.persistPendingTurn(job);
+ const position = (state.active ? 1 : 0) + state.pending.length - 1;
+ log("opencode_bot_turn_queued", { eventId, position });
+ void this.drainTurnQueue(key, state);
+ return position;
+ }
+
+ async drainTurnQueue(key, state) {
+ if (state.draining) return;
+ state.draining = true;
+ try {
+ while (state.pending.length) {
+ const job = state.pending.shift();
+ state.active = { job, child: null, startedAt: Date.now(), cancelRequested: false };
+ try {
+ await this.runTurn(job.event, job.eventId, job.bridge, job.text, key, state.active);
+ } catch (error) {
+ try {
+ await job.bridge.sendReplyToEvent(job.event, "Agent turn failed: " + conciseError(error), "ccr:opencode:error:" + job.eventId);
+ } catch (replyError) {
+ log("opencode_bot_turn_error_reply_failed", { eventId: job.eventId, error: formatError(error), replyError: formatError(replyError) });
+ }
+ log("opencode_bot_turn_failed", { eventId: job.eventId, error: formatError(error) });
+ } finally {
+ this.removePendingTurn(job.id);
+ state.active = null;
+ }
+ }
+ } finally {
+ state.draining = false;
+ if (!state.active && !state.pending.length) this.turnStates.delete(key);
+ }
+ }
+
+ async runTurn(event, eventId, bridge, text, key, activeTurn) {
+ let entry = this.conversationEntry(key);
+ if (entry && entry.sessionId && bridge.config.sessionIdleMinutes > 0 && Date.now() - Number(entry.updatedAt || 0) >= bridge.config.sessionIdleMinutes * 60_000) {
+ entry = { ...entry, sessionId: "", title: "", updatedAt: Date.now() };
+ this.setConversationEntry(key, entry);
+ }
+ const cwd = resolveOpenCodeBotCwd(entry && entry.projectDirectory, this.defaultCwd);
+ let prompt = await botPromptWithAttachments(event, text, bridge.config, path.join(bridge.config.stateDir || cwd, "attachments"));
+ if (!bridge.config.shellEnabled) {
+ prompt = "Bot policy: shell and terminal tools are disabled. Do not invoke shell commands; use non-shell tools only.\n\n" + prompt;
+ }
+ if (entry && Array.isArray(entry.memory) && entry.memory.length) {
+ prompt = "Persistent session context:\n" + entry.memory.map((item) => "- " + item).join("\n") + "\n\nUser message:\n" + prompt;
+ }
+ const args = ["run", "--format", "json", "--dir", cwd];
+ if (entry && entry.model) args.push("--model", entry.model);
+ if (entry && entry.effort) args.push("--variant", entry.effort);
+ if (entry && entry.mode) args.push("--agent", entry.mode);
+ if (entry && entry.sessionId) {
+ args.push("--session", entry.sessionId);
+ } else {
+ args.push("--title", stringValue(entry && entry.title) || "Bot: " + this.workspaceName);
+ }
+ if (boolEnv("CCR_OPENCODE_BOT_AUTO_APPROVE")) args.push("--auto");
+ args.push("--", prompt);
+
+ const streamId = "opencode-" + stableBotKey(eventId).slice(-16);
+ let streamedText = "";
+ let lastStreamAt = 0;
+ const result = await runOpenCodeBotCli(this.command, args, cwd, {
+ timeoutMs: bridge.config.maxTurnTimeMs,
+ onSpawn: (child) => { activeTurn.child = child; },
+ onJson: (value) => {
+ const parsedEvent = parseOpenCodeRunOutput(JSON.stringify(value));
+ if (!parsedEvent.text || parsedEvent.text === streamedText) return;
+ streamedText = parsedEvent.text;
+ if (Date.now() - lastStreamAt < 700) return;
+ lastStreamAt = Date.now();
+ void bridge.sendStreamToEvent(event, streamId, streamedText, false, "ccr:opencode:stream:" + eventId).catch((error) => bridge.logError("stream_failed", error));
+ }
+ });
+ const parsed = parseOpenCodeRunOutput(result.stdout);
+ const sessionId = parsed.sessionId || (entry && entry.sessionId) || "";
+ if (sessionId) {
+ this.setConversationEntry(key, {
+ ...(entry || {}),
+ sessionId,
+ projectDirectory: cwd,
+ title: (entry && entry.title) || "Bot: " + this.workspaceName,
+ updatedAt: Date.now()
+ });
+ }
+ const errorText = parsed.error || result.error || (result.exitCode !== 0
+ ? result.stderr || "OpenCode exited with code " + result.exitCode
+ : "");
+ const responseText = errorText
+ ? "Agent turn failed: " + errorText
+ : parsed.text || parsed.fallbackText || "OpenCode completed the turn without a text response.";
+ if (bridge.config.streamReplies && !errorText) {
+ await bridge.sendStreamToEvent(event, streamId, responseText, true, "ccr:opencode:stream:" + eventId).catch(() => undefined);
+ } else {
+ await bridge.sendReplyToEvent(event, responseText, "ccr:opencode:" + eventId + ":" + (sessionId || uuid()));
+ }
+ await sendBotTextArtifacts(event, bridge, responseText, cwd, "ccr:opencode:artifact:" + eventId);
+ log("bot_gateway_inbound_replied", {
+ eventId,
+ agent: "opencode",
+ sessionId,
+ exitCode: result.exitCode,
+ textLen: responseText.length
+ });
+ }
+
+ async handleCommand(event, text, bridge) {
+ const command = parseBotCommand(text);
+ if (!command) return null;
+ const key = botConversationKey(event);
+ try {
+ if (command.name === "unknown") return "Unknown Bot command. Send /project or /session to see available commands.";
+ if (command.domain === "project") {
+ if (command.name === "help") return projectCommandHelpText("OpenCode");
+ if (command.name === "current") return this.renderCurrentProject(key);
+ const sessions = await this.listSessions();
+ const projects = agentProjectsFromDirectories(
+ [this.defaultCwd, ...sessions.map((session) => session.directory)],
+ this.defaultCwd
+ );
+ for (const project of projects) project.name = this.projectLabel(project.directory, project.name);
+ if (command.name === "ls") {
+ return renderAgentProjectList("OpenCode", projects, this.projectDirectory(key), { args: command.args });
+ }
+ if (command.name === "search") {
+ return renderAgentProjectList("OpenCode", projects, this.projectDirectory(key), { query: command.args });
+ }
+ if (command.name === "rename") {
+ if (!command.args) return "Usage: /project name .";
+ this.loadStore().projectAliases[comparableProjectDirectory(this.projectDirectory(key))] = command.args.slice(0, 80);
+ this.saveStore();
+ return "Project label updated to " + command.args.slice(0, 80) + ".";
+ }
+ if (command.name === "select") {
+ if (!command.args) return "Usage: /project use . Send /project list to list projects.";
+ const project = resolveAgentProject(command.args, projects);
+ if (!project) return "Project '" + command.args + "' was not found. Send /project list to list projects.";
+ this.setConversationEntry(key, {
+ sessionId: "",
+ projectDirectory: project.directory,
+ title: "",
+ updatedAt: Date.now()
+ });
+ return "Selected project " + project.name + "\npath: " + project.directory + "\nUse /session list or /session new to choose a session.";
+ }
+ }
+ if (command.domain === "session") {
+ if (command.name === "help") return sessionCommandHelpText("OpenCode");
+ if (command.name === "status") return this.renderTurnStatus(key);
+ if (command.name === "cancel") return this.cancelTurns(key);
+ if (["approve", "deny", "answer"].includes(command.name)) return "No permission request is waiting for this conversation.";
+ if (command.name === "current") return this.renderCurrentSession(key);
+ if (command.name === "reset" || command.name === "new") {
+ const directory = this.projectDirectory(key);
+ this.setConversationEntry(key, {
+ sessionId: "",
+ projectDirectory: directory,
+ title: command.name === "new" ? command.args : "",
+ updatedAt: Date.now()
+ });
+ return command.name === "new"
+ ? "Ready. The next message will create a new OpenCode session in project " + projectNameFromDirectory(directory) + "."
+ : "Session selection cleared. The next message will create a new OpenCode session in the current project.";
+ }
+ const directory = this.projectDirectory(key);
+ const includeArchived = command.name === "ls" && command.args.toLowerCase() === "archived";
+ const sessions = (await this.listSessions(includeArchived)).filter((session) => sameProjectDirectory(session.directory, directory));
+ if (command.name === "ls") {
+ return renderOpenCodeSessionList(sessions, this.conversationEntry(key), directory, { args: includeArchived ? "1" : command.args });
+ }
+ if (command.name === "search") {
+ return renderOpenCodeSessionList(sessions, this.conversationEntry(key), directory, { query: command.args });
+ }
+ if (command.name === "select") {
+ if (!command.args) return "Usage: /session use . Send /session list to list sessions.";
+ const session = resolveOpenCodeSession(command.args, sessions);
+ if (!session) return "Session '" + command.args + "' was not found in the current project. Send /session list to list sessions.";
+ this.setConversationEntry(key, {
+ sessionId: session.id,
+ projectDirectory: resolveOpenCodeBotCwd(session.directory, directory),
+ title: session.title,
+ updatedAt: session.updatedAt || Date.now()
+ });
+ return "Selected session " + shortSessionId(session.id) + ": " + session.title + "\nNext message will continue in this OpenCode session.";
+ }
+ if (command.name === "rename") return this.renameCurrentSession(key, command.args);
+ if (["archive", "restore", "delete"].includes(command.name)) return this.mutateSession(command.name, command.args, sessions, key);
+ if (command.name === "history") return this.renderSessionHistory(key, command.args);
+ if (command.name === "model") return this.updateSessionSetting(key, "model", command.args);
+ if (command.name === "effort") return this.updateSessionSetting(key, "effort", command.args);
+ if (command.name === "mode") return this.updateSessionSetting(key, "mode", command.args);
+ if (command.name === "models") return this.renderModels(command.args);
+ if (command.name === "usage") return this.renderSessionUsage(key);
+ if (command.name === "memory") return this.updateSessionMemory(key, command.args);
+ if (command.name === "skills") return renderAgentSkills(directory, "opencode");
+ if (command.name === "skill") return forwardSkillCommand(command.args);
+ if (command.name === "shortcut") return this.handleSessionShortcut(key, command.args);
+ if (command.name === "doctor") return renderBotDiagnostics(bridge.diagnostics());
+ if (command.name === "deliveries") return renderBotDeliveries(bridge.diagnostics());
+ }
+ return null;
+ } catch (error) {
+ return "OpenCode bot command failed: " + conciseError(error);
+ }
+ }
+
+ async listSessions(includeArchived = false) {
+ const result = await runOpenCodeBotCli(this.command, ["session", "list", "--format", "json", "-n", "100"], this.defaultCwd);
+ if (result.exitCode !== 0) {
+ throw new Error(result.error || result.stderr || "OpenCode session list exited with code " + result.exitCode);
+ }
+ const store = this.loadStore();
+ return parseOpenCodeSessionList(result.stdout)
+ .map((session) => ({ ...session, title: store.sessionAliases[session.id] || session.title, archived: store.archivedSessionIds.includes(session.id) }))
+ .filter((session) => includeArchived ? session.archived : !session.archived);
+ }
+
+ loadStore() {
+ if (this.store) return this.store;
+ const value = readJsonFile(openCodeBotSessionStorePath());
+ const conversations = value && typeof value === "object" &&
+ Number(value.version || 0) >= 2 &&
+ value.conversations && typeof value.conversations === "object"
+ ? value.conversations
+ : {};
+ const pendingTurns = value && Array.isArray(value.pendingTurns) ? value.pendingTurns.filter((item) => item && typeof item === "object") : [];
+ const projectAliases = value && value.projectAliases && typeof value.projectAliases === "object" ? value.projectAliases : {};
+ const sessionAliases = value && value.sessionAliases && typeof value.sessionAliases === "object" ? value.sessionAliases : {};
+ const archivedSessionIds = value && Array.isArray(value.archivedSessionIds) ? value.archivedSessionIds.filter((item) => typeof item === "string") : [];
+ this.store = { version: OPENCODE_BOT_SESSION_STORE_VERSION, conversations, pendingTurns, projectAliases, sessionAliases, archivedSessionIds };
+ return this.store;
+ }
+
+ saveStore() {
+ const file = openCodeBotSessionStorePath();
+ const temporary = file + "." + process.pid + ".tmp";
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(temporary, JSON.stringify(this.loadStore(), null, 2) + "\n", { mode: 0o600 });
+ fs.renameSync(temporary, file);
+ }
+
+ conversationEntry(key) {
+ const entry = this.loadStore().conversations[key];
+ if (!entry || typeof entry !== "object") return null;
+ const projectDirectory = stringValue(entry.projectDirectory) || stringValue(entry.directory);
+ if (!projectDirectory) return null;
+ try {
+ return fs.statSync(expandHome(projectDirectory)).isDirectory()
+ ? { ...entry, projectDirectory }
+ : null;
+ } catch {
+ return null;
+ }
+ }
+
+ setConversationEntry(key, entry) {
+ this.loadStore().conversations[key] = entry;
+ this.saveStore();
+ }
+
+ projectDirectory(key) {
+ const entry = this.conversationEntry(key);
+ return resolveOpenCodeBotCwd(entry && entry.projectDirectory, this.defaultCwd);
+ }
+
+ renderCurrentProject(key) {
+ const directory = this.projectDirectory(key);
+ return [
+ "Current OpenCode project:",
+ projectNameFromDirectory(directory),
+ "path: " + directory
+ ].join("\n");
+ }
+
+ renderCurrentSession(key) {
+ const entry = this.conversationEntry(key);
+ const directory = this.projectDirectory(key);
+ if (!entry || !stringValue(entry.sessionId)) {
+ return "No selected OpenCode session in project " + projectNameFromDirectory(directory) + ". Use /session list, /session use , or send any message to create one.";
+ }
+ return [
+ "Current OpenCode session:",
+ shortSessionId(entry.sessionId) + " " + (entry.title || "OpenCode session"),
+ "project: " + projectNameFromDirectory(directory),
+ "path: " + directory
+ ].join("\n");
+ }
+
+ projectLabel(directory, fallback) {
+ return this.loadStore().projectAliases[comparableProjectDirectory(directory)] || fallback;
+ }
+
+ renameCurrentSession(key, label) {
+ const entry = this.conversationEntry(key);
+ if (!entry || !entry.sessionId) return "No session is selected.";
+ const name = String(label || "").trim().slice(0, 100);
+ if (!name) return "Usage: /session name .";
+ this.loadStore().sessionAliases[entry.sessionId] = name;
+ this.setConversationEntry(key, { ...entry, title: name, updatedAt: Date.now() });
+ return "Session renamed to " + name + ".";
+ }
+
+ async mutateSession(action, args, visibleSessions, key) {
+ const parsed = parseConfirmedTarget(args);
+ const all = action === "restore" ? await this.listSessions(true) : visibleSessions;
+ const session = resolveOpenCodeSession(parsed.target, all);
+ if (!session) return "Session '" + parsed.target + "' was not found.";
+ const store = this.loadStore();
+ if (action === "archive") {
+ if (!store.archivedSessionIds.includes(session.id)) store.archivedSessionIds.push(session.id);
+ if (this.conversationEntry(key) && this.conversationEntry(key).sessionId === session.id) {
+ this.setConversationEntry(key, { sessionId: "", projectDirectory: this.projectDirectory(key), title: "", updatedAt: Date.now() });
+ } else this.saveStore();
+ return "Session archived: " + session.title + ". Use /session list archived to view archived sessions.";
+ }
+ if (action === "restore") {
+ store.archivedSessionIds = store.archivedSessionIds.filter((id) => id !== session.id);
+ this.saveStore();
+ return "Session restored: " + session.title + ".";
+ }
+ if (!parsed.confirmed) return "Deletion is permanent. Send /session delete " + parsed.target + " confirm.";
+ const result = await runOpenCodeBotCli(this.command, ["session", "delete", session.id], this.defaultCwd, { timeoutMs: 60_000 });
+ if (result.exitCode !== 0) return "Session deletion failed: " + (result.error || result.stderr || "exit " + result.exitCode);
+ delete store.sessionAliases[session.id];
+ store.archivedSessionIds = store.archivedSessionIds.filter((id) => id !== session.id);
+ this.saveStore();
+ return "Session deleted: " + session.title + ".";
+ }
+
+ async renderSessionHistory(key, args) {
+ const entry = this.conversationEntry(key);
+ if (!entry || !entry.sessionId) return "No session is selected.";
+ const result = await runOpenCodeBotCli(this.command, ["export", entry.sessionId], this.projectDirectory(key), { timeoutMs: 60_000 });
+ if (result.exitCode !== 0) return "Unable to read session history: " + (result.error || result.stderr);
+ return renderExportedHistory(result.stdout, args);
+ }
+
+ updateSessionSetting(key, setting, args) {
+ const entry = this.conversationEntry(key) || { sessionId: "", projectDirectory: this.projectDirectory(key), title: "" };
+ const value = String(args || "").trim();
+ if (!value) return "Current " + setting + ": " + (entry[setting] || "default") + ".";
+ const allowed = setting === "effort" ? new Set(["low", "medium", "high", "xhigh", "max", "reset"]) : null;
+ if (allowed && !allowed.has(value)) return "Supported effort values: low, medium, high, xhigh, max, reset.";
+ entry[setting] = value === "reset" ? "" : value;
+ entry.updatedAt = Date.now();
+ this.setConversationEntry(key, entry);
+ return setting + " set to " + (entry[setting] || "default") + ".";
+ }
+
+ async renderModels(query) {
+ const args = ["models"];
+ if (String(query || "").trim()) args.push(String(query).trim());
+ const result = await runOpenCodeBotCli(this.command, args, this.defaultCwd, { timeoutMs: 60_000 });
+ if (result.exitCode !== 0) return "Unable to list models: " + (result.error || result.stderr);
+ return splitBotMessage(result.stdout, 3500)[0] || "No models were returned.";
+ }
+
+ async renderSessionUsage(key) {
+ const entry = this.conversationEntry(key);
+ if (!entry || !entry.sessionId) return "No session is selected.";
+ const result = await runOpenCodeBotCli(this.command, ["export", entry.sessionId], this.projectDirectory(key), { timeoutMs: 60_000 });
+ if (result.exitCode !== 0) return "Unable to read usage: " + (result.error || result.stderr);
+ return renderExportedUsage(result.stdout);
+ }
+
+ updateSessionMemory(key, args) {
+ const entry = this.conversationEntry(key) || { sessionId: "", projectDirectory: this.projectDirectory(key), title: "" };
+ const command = parseSubcommand(args);
+ const memory = Array.isArray(entry.memory) ? entry.memory : [];
+ if (!command.name || command.name === "list") return memory.length ? "Session memory:\n" + memory.map((item, index) => (index + 1) + ". " + item).join("\n") : "Session memory is empty.";
+ if (command.name === "clear") entry.memory = [];
+ else if (command.name === "add" && command.args) entry.memory = [...memory, command.args.slice(0, 2000)].slice(-20);
+ else return "Usage: /session memory list | add | clear.";
+ entry.updatedAt = Date.now();
+ this.setConversationEntry(key, entry);
+ return command.name === "clear" ? "Session memory cleared." : "Session memory added.";
+ }
+
+ handleSessionShortcut(key, args) {
+ const entry = this.conversationEntry(key) || { sessionId: "", projectDirectory: this.projectDirectory(key), title: "" };
+ const shortcuts = entry.shortcuts && typeof entry.shortcuts === "object" ? entry.shortcuts : {};
+ const command = parseSubcommand(args);
+ if (!command.name || command.name === "list") {
+ const names = Object.keys(shortcuts).sort();
+ return names.length ? "Session shortcuts:\n" + names.map((name) => "- " + name + ": " + shortcuts[name]).join("\n") : "No session shortcuts are configured.";
+ }
+ if (command.name === "add") {
+ const definition = parseSubcommand(command.args);
+ if (!definition.name || !definition.args) return "Usage: /session shortcut add .";
+ shortcuts[definition.name] = definition.args.slice(0, 2000);
+ entry.shortcuts = shortcuts;
+ this.setConversationEntry(key, { ...entry, updatedAt: Date.now() });
+ return "Shortcut saved: " + definition.name + ".";
+ }
+ if (command.name === "remove") {
+ delete shortcuts[String(command.args || "").trim().toLowerCase()];
+ entry.shortcuts = shortcuts;
+ this.setConversationEntry(key, { ...entry, updatedAt: Date.now() });
+ return "Shortcut removed.";
+ }
+ if (command.name === "run") {
+ const invocation = parseSubcommand(command.args);
+ const prompt = shortcuts[invocation.name];
+ return prompt ? { forwardText: prompt + (invocation.args ? "\n\n" + invocation.args : "") } : "Shortcut '" + invocation.name + "' was not found.";
+ }
+ return "Usage: /session shortcut list | add | remove | run [input].";
+ }
+
+ renderTurnStatus(key) {
+ const state = this.turnStates.get(key);
+ if (!state || (!state.active && !state.pending.length)) return "No Agent turn is running or queued for this conversation.";
+ const lines = [];
+ if (state.active) lines.push("Running for " + formatDuration(Date.now() - state.active.startedAt) + ": " + promptTitle(state.active.job.text));
+ lines.push("Queued turns: " + state.pending.length);
+ lines.push("Use /session cancel to stop the active turn and clear this conversation's queue.");
+ return lines.join("\n");
+ }
+
+ cancelTurns(key) {
+ const state = this.turnStates.get(key);
+ if (!state || (!state.active && !state.pending.length)) return "No Agent turn is running or queued for this conversation.";
+ const cleared = state.pending.splice(0);
+ for (const job of cleared) this.removePendingTurn(job.id);
+ if (state.active) {
+ state.active.cancelRequested = true;
+ interruptChildProcess(state.active.child);
+ }
+ return "Cancellation requested. Cleared " + cleared.length + " queued turn" + (cleared.length === 1 ? "" : "s") + ".";
+ }
+
+ persistPendingTurn(job) {
+ const store = this.loadStore();
+ if (!store.pendingTurns.some((item) => item.id === job.id)) {
+ store.pendingTurns.push({ id: job.id, event: job.event, eventId: job.eventId, text: job.text, key: job.key, createdAt: job.createdAt });
+ store.pendingTurns = store.pendingTurns.slice(-100);
+ this.saveStore();
+ }
+ }
+
+ removePendingTurn(id) {
+ const store = this.loadStore();
+ const next = store.pendingTurns.filter((item) => item.id !== id);
+ if (next.length !== store.pendingTurns.length) {
+ store.pendingTurns = next;
+ this.saveStore();
+ }
+ }
+
+ restorePendingTurns(bridge) {
+ if (this.restoredPendingTurns) return;
+ this.restoredPendingTurns = true;
+ for (const item of this.loadStore().pendingTurns.slice()) {
+ if (!item.event || !item.text) continue;
+ const key = item.key || botConversationKey(item.event);
+ const state = this.turnStates.get(key) || { active: null, pending: [] };
+ state.pending.push({ ...item, key, bridge });
+ this.turnStates.set(key, state);
+ void this.drainTurnQueue(key, state);
+ }
+ }
+}
+
+class CodexBotWorker extends OpenCodeBotWorker {
+ constructor(options) {
+ super(options);
+ this.agent = codexRuntimeAgent();
+ this.agentLabel = this.agent === "zcode" ? "ZCode" : "Codex";
+ this.workspaceName = options.workspaceName || this.agentLabel;
+ this.command = expandHome(codexRuntimeRealCli(this.agent));
+ this.defaultCwd = resolveOpenCodeBotCwd(nonEmptyEnv("CCR_BOT_GATEWAY_CWD"), os.homedir());
+ this.store = null;
+ }
+
+ async handleCommand(event, text, bridge) {
+ const parsed = parseBotCommand(text);
+ if (parsed && parsed.domain === "session" && parsed.name === "skills") {
+ return renderAgentSkills(this.projectDirectory(botConversationKey(event)), this.agent);
+ }
+ const response = await super.handleCommand(event, text, bridge);
+ return typeof response === "string" ? response.replace(/OpenCode/g, this.agentLabel) : response;
+ }
+
+ async runTurn(event, eventId, bridge, text, key, activeTurn) {
+ let entry = this.conversationEntry(key);
+ if (entry && entry.sessionId && bridge.config.sessionIdleMinutes > 0 && Date.now() - Number(entry.updatedAt || 0) >= bridge.config.sessionIdleMinutes * 60_000) {
+ entry = { ...entry, sessionId: "", title: "", updatedAt: Date.now() };
+ this.setConversationEntry(key, entry);
+ }
+ const cwd = resolveOpenCodeBotCwd(entry && entry.projectDirectory, this.defaultCwd);
+ const input = await botInputForEvent(event, text, bridge.config, path.join(bridge.config.stateDir || cwd, "attachments"));
+ let prompt = botPromptFromInput(input);
+ if (!bridge.config.shellEnabled) {
+ prompt = "Bot policy: shell and terminal tools are disabled. Do not invoke shell commands; use non-shell tools only.\n\n" + prompt;
+ }
+ if (entry && entry.mode === "plan") {
+ prompt = "Session mode: plan only. Analyze and propose a plan without modifying files.\n\n" + prompt;
+ }
+ if (entry && Array.isArray(entry.memory) && entry.memory.length) {
+ prompt = "Persistent session context:\n" + entry.memory.map((item) => "- " + item).join("\n") + "\n\nUser message:\n" + prompt;
+ }
+ const args = ["exec"];
+ if (entry && entry.sessionId) args.push("resume");
+ args.push("--json", "--skip-git-repo-check");
+ for (const imagePath of botImagePathsFromInput(input)) args.push("--image", imagePath);
+ if (entry && entry.model) args.push("--model", entry.model);
+ if (entry && entry.effort) args.push("-c", "model_reasoning_effort=" + JSON.stringify(entry.effort));
+ const sandboxMode = bridge.config.shellEnabled ? "workspace-write" : "read-only";
+ if (entry && entry.sessionId) args.push("-c", "sandbox_mode=" + JSON.stringify(sandboxMode));
+ else args.push("--sandbox", sandboxMode);
+ if (entry && entry.sessionId) args.push(entry.sessionId);
+ args.push(prompt);
+
+ const streamId = this.agent + "-" + stableBotKey(eventId).slice(-16);
+ const streamParts = new Map();
+ let lastStreamAt = 0;
+ const result = await runCodexBotCli(this.command, args, cwd, this.agent, {
+ timeoutMs: bridge.config.maxTurnTimeMs,
+ onSpawn: (child) => { activeTurn.child = child; },
+ onJson: (value) => {
+ rememberCodexBotOutput(value, streamParts);
+ const streamedText = Array.from(streamParts.values()).join("\n").trim();
+ if (!streamedText || Date.now() - lastStreamAt < 700) return;
+ lastStreamAt = Date.now();
+ void bridge.sendStreamToEvent(event, streamId, streamedText, false, "ccr:" + this.agent + ":stream:" + eventId).catch((error) => bridge.logError("stream_failed", error));
+ }
+ });
+ const parsed = parseCodexBotOutput(result.stdout);
+ const sessionId = parsed.sessionId || entry && entry.sessionId || "";
+ if (sessionId) {
+ this.setConversationEntry(key, {
+ ...(entry || {}),
+ sessionId,
+ projectDirectory: cwd,
+ title: entry && entry.title || "Bot: " + this.workspaceName,
+ updatedAt: Date.now()
+ });
+ }
+ const errorText = parsed.error || result.error || (result.exitCode !== 0 ? result.stderr || this.agentLabel + " exited with code " + result.exitCode : "");
+ const responseText = errorText
+ ? "Agent turn failed: " + errorText
+ : parsed.text || parsed.fallbackText || this.agentLabel + " completed the turn without a text response.";
+ if (bridge.config.streamReplies && !errorText) {
+ await bridge.sendStreamToEvent(event, streamId, responseText, true, "ccr:" + this.agent + ":stream:" + eventId).catch(() => undefined);
+ } else {
+ await bridge.sendReplyToEvent(event, responseText, "ccr:" + this.agent + ":" + eventId + ":" + (sessionId || uuid()));
+ }
+ await sendBotTextArtifacts(event, bridge, responseText, cwd, "ccr:" + this.agent + ":artifact:" + eventId);
+ log("bot_gateway_inbound_replied", { eventId, agent: this.agent, sessionId, exitCode: result.exitCode, textLen: responseText.length });
+ }
+
+ async listSessions(includeArchived = false) {
+ const store = this.loadStore();
+ return scanCodexBotSessions(codexRuntimeHome())
+ .map((session) => ({
+ ...session,
+ title: store.sessionAliases[session.id] || session.title,
+ archived: store.archivedSessionIds.includes(session.id)
+ }))
+ .filter((session) => includeArchived ? session.archived : !session.archived);
+ }
+
+ loadStore() {
+ if (this.store) return this.store;
+ const value = readJsonFile(codexBotSessionStorePath(this.agent));
+ const conversations = value && typeof value === "object" && value.conversations && typeof value.conversations === "object" ? value.conversations : {};
+ const pendingTurns = value && Array.isArray(value.pendingTurns) ? value.pendingTurns.filter((item) => item && typeof item === "object") : [];
+ const projectAliases = value && value.projectAliases && typeof value.projectAliases === "object" ? value.projectAliases : {};
+ const sessionAliases = value && value.sessionAliases && typeof value.sessionAliases === "object" ? value.sessionAliases : {};
+ const archivedSessionIds = value && Array.isArray(value.archivedSessionIds) ? value.archivedSessionIds.filter((item) => typeof item === "string") : [];
+ this.store = { version: OPENCODE_BOT_SESSION_STORE_VERSION, conversations, pendingTurns, projectAliases, sessionAliases, archivedSessionIds };
+ return this.store;
+ }
+
+ saveStore() {
+ writeJsonAtomic(codexBotSessionStorePath(this.agent), this.loadStore());
+ }
+
+ async mutateSession(action, args, visibleSessions, key) {
+ const parsed = parseConfirmedTarget(args);
+ const all = action === "restore" ? await this.listSessions(true) : visibleSessions;
+ const session = resolveOpenCodeSession(parsed.target, all);
+ if (!session) return "Session '" + parsed.target + "' was not found.";
+ const store = this.loadStore();
+ if (action === "archive") {
+ if (!store.archivedSessionIds.includes(session.id)) store.archivedSessionIds.push(session.id);
+ if (this.conversationEntry(key) && this.conversationEntry(key).sessionId === session.id) {
+ this.setConversationEntry(key, { sessionId: "", projectDirectory: this.projectDirectory(key), title: "", updatedAt: Date.now() });
+ } else this.saveStore();
+ return "Session archived: " + session.title + ". Use /session list archived to view archived sessions.";
+ }
+ if (action === "restore") {
+ store.archivedSessionIds = store.archivedSessionIds.filter((id) => id !== session.id);
+ this.saveStore();
+ return "Session restored: " + session.title + ".";
+ }
+ if (!parsed.confirmed) return "Deletion is permanent. Send /session delete " + parsed.target + " confirm.";
+ try {
+ fs.unlinkSync(session.file);
+ } catch (error) {
+ return "Session deletion failed: " + conciseError(error);
+ }
+ delete store.sessionAliases[session.id];
+ store.archivedSessionIds = store.archivedSessionIds.filter((id) => id !== session.id);
+ this.saveStore();
+ return "Session deleted: " + session.title + ".";
+ }
+
+ async renderSessionHistory(key, args) {
+ const entry = this.conversationEntry(key);
+ if (!entry || !entry.sessionId) return "No session is selected.";
+ const session = (await this.listSessions(true)).find((item) => item.id === entry.sessionId);
+ if (!session || !session.file) return "Unable to read session history.";
+ return renderCodexBotHistory(session.file, args);
+ }
+
+ async renderSessionUsage(key) {
+ const entry = this.conversationEntry(key);
+ if (!entry || !entry.sessionId) return "No session is selected.";
+ const session = (await this.listSessions(true)).find((item) => item.id === entry.sessionId);
+ if (!session || !session.file) return "Usage data is unavailable for this session.";
+ const values = readJsonLines(session.file, 2000);
+ return renderExportedUsage(JSON.stringify(values));
+ }
+
+ async renderModels(query) {
+ const file = this.agent === "zcode"
+ ? nonEmptyEnv("CCR_ZCODE_MODEL_CATALOG_FILE") || nonEmptyEnv("CODEXL_ZCODE_MODEL_CATALOG_FILE")
+ : nonEmptyEnv("CCR_CODEX_MODEL_CATALOG_FILE") || nonEmptyEnv("CODEXL_CODEX_MODEL_CATALOG_FILE");
+ const ids = Array.from(collectCodexModelIds(readJsonFile(file), new Set())).sort();
+ const search = String(query || "").trim().toLowerCase();
+ const filtered = search ? ids.filter((id) => id.toLowerCase().includes(search)) : ids;
+ return filtered.length ? this.agentLabel + " models:\n" + filtered.slice(0, 100).map((id) => "- " + id).join("\n") : "No matching models were found.";
+ }
+}
+
+function parseCodexBotWorkerOptions(args) {
+ let workspaceName = nonEmptyEnv("CCR_CODEX_WORKSPACE_NAME") || nonEmptyEnv("CODEXL_CODEX_WORKSPACE_NAME") || nonEmptyEnv("CODEXL_ZCODE_WORKSPACE_NAME") || "Codex";
+ for (let i = 0; i < args.length; i += 1) {
+ if (args[i] === "--workspace-name" && args[i + 1]) {
+ workspaceName = args[i + 1];
+ i += 1;
+ }
+ }
+ return { workspaceName };
+}
+
+function codexBotSessionStorePath(agent) {
+ const stateDir = nonEmptyEnv("CCR_BOT_GATEWAY_STATE_DIR") || nonEmptyEnv("CODEXL_BOT_GATEWAY_STATE_DIR") || nonEmptyEnv("BOT_GATEWAY_STATE_DIR") || path.join(CONFIG_DIR, "bot-gateway", safePathSegment(nonEmptyEnv("CCR_BOT_PROFILE_ID") || "default"));
+ return path.join(expandHome(stateDir), safePathSegment(agent) + "-bot-sessions.json");
+}
+
+async function runCodexBotCli(command, args, cwd, agent, options = {}) {
+ const env = childEnvForAgent(agent);
+ delete env.CCR_CODEX_BOT_WORKER;
+ delete env.CCR_CLI_DIRECT_PROFILE_DISPATCH;
+ delete env.ELECTRON_RUN_AS_NODE;
+ let child;
+ try {
+ child = spawnAgentCli(command, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
+ } catch (error) {
+ return { exitCode: 1, stdout: "", stderr: "", error: conciseError(error) };
+ }
+ if (typeof options.onSpawn === "function") options.onSpawn(child);
+ let stdout = "";
+ let stderr = "";
+ let pendingJson = "";
+ let spawnError = "";
+ const append = (current, chunk) => {
+ const next = current + chunk.toString("utf8");
+ return next.length > 4 * 1024 * 1024 ? next.slice(-4 * 1024 * 1024) : next;
+ };
+ child.stdout.on("data", (chunk) => {
+ stdout = append(stdout, chunk);
+ if (typeof options.onJson !== "function") return;
+ pendingJson += chunk.toString("utf8");
+ const lines = pendingJson.split(/\r?\n/g);
+ pendingJson = lines.pop() || "";
+ for (const line of lines) {
+ try { options.onJson(JSON.parse(line)); } catch { /* Ignore diagnostics. */ }
+ }
+ });
+ child.stderr.on("data", (chunk) => { stderr = append(stderr, chunk); });
+ child.once("error", (error) => { spawnError = conciseError(error); });
+ let timedOut = false;
+ let forceKillTimer = null;
+ const timeoutMs = Number(options.timeoutMs) || 10 * 60 * 1000;
+ const timer = setTimeout(() => {
+ timedOut = true;
+ interruptChildProcess(child);
+ forceKillTimer = setTimeout(() => { try { child.kill("SIGKILL"); } catch { /* Already exited. */ } }, 5000);
+ }, timeoutMs);
+ const result = await waitForChildResult(child);
+ clearTimeout(timer);
+ if (forceKillTimer) clearTimeout(forceKillTimer);
+ return { exitCode: result.exitCode, stdout: stdout.trim(), stderr: stderr.trim(), error: timedOut ? "Agent timed out after " + timeoutMs + "ms" : spawnError };
+}
+
+function rememberCodexBotOutput(value, parts) {
+ if (!value || typeof value !== "object") return;
+ const item = value.item && typeof value.item === "object" ? value.item : value;
+ const type = String(item.type || value.type || "").toLowerCase();
+ if (!type.includes("agent_message") && !(type.includes("message") && String(item.role || "").toLowerCase() === "assistant")) return;
+ const text = stringValue(item.text) || textFromContent(item.content);
+ if (text) parts.set(stringValue(item.id) || "message-" + parts.size, text);
+}
+
+function parseCodexBotOutput(output) {
+ let sessionId = "";
+ let error = "";
+ const parts = new Map();
+ const fallback = [];
+ for (const rawLine of String(output || "").split(/\r?\n/g)) {
+ const line = rawLine.trim();
+ if (!line) continue;
+ let value;
+ try { value = JSON.parse(line); } catch { fallback.push(line); continue; }
+ sessionId = valueStringAtPaths(value, ["/thread_id", "/threadId", "/session_id", "/sessionId", "/payload/id"]) || sessionId;
+ rememberCodexBotOutput(value, parts);
+ const type = String(value.type || "").toLowerCase();
+ if (type.includes("error") || type === "turn.failed") error = valueStringAtPaths(value, ["/message", "/error/message", "/error"]) || error;
+ }
+ return { sessionId, error, text: Array.from(parts.values()).join("\n").trim(), fallbackText: fallback.join("\n").trim() };
+}
+
+function scanCodexBotSessions(home) {
+ const root = path.join(expandHome(home), "sessions");
+ const files = [];
+ const visit = (directory, depth) => {
+ if (depth > 6 || files.length >= 500) return;
+ let entries = [];
+ try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch { return; }
+ for (const entry of entries) {
+ const file = path.join(directory, entry.name);
+ if (entry.isDirectory()) visit(file, depth + 1);
+ else if (entry.isFile() && entry.name.endsWith(".jsonl")) files.push(file);
+ }
+ };
+ visit(root, 0);
+ return files.map(parseCodexBotSessionFile).filter(Boolean).sort((left, right) => right.updatedAt - left.updatedAt);
+}
+
+function parseCodexBotSessionFile(file) {
+ const values = readJsonLines(file, -80);
+ if (!values.length) return null;
+ let id = "";
+ let directory = "";
+ let title = "";
+ let timestamp = "";
+ for (const value of values) {
+ const payload = value && value.payload && typeof value.payload === "object" ? value.payload : value;
+ id = id || valueStringAtPaths(payload, ["/id", "/session_id", "/sessionId"]);
+ directory = directory || valueStringAtPaths(payload, ["/cwd", "/directory", "/path"]);
+ timestamp = timestamp || valueStringAtPaths(payload, ["/timestamp", "/created_at", "/createdAt"]);
+ if (!title) {
+ const role = String(payload && payload.role || "").toLowerCase();
+ if (role === "user") title = (textFromContent(payload.content) || stringValue(payload.text)).slice(0, 100);
+ }
+ }
+ if (!id) id = path.basename(file, ".jsonl").replace(/^rollout-/, "");
+ let updatedAt = Date.parse(timestamp) || 0;
+ try { updatedAt = Math.max(updatedAt, fs.statSync(file).mtimeMs); } catch { /* Keep parsed time. */ }
+ return { id, title: title || "Untitled", directory: directory || os.homedir(), updatedAt, file };
+}
+
+function readJsonLines(file, limit) {
+ let text = "";
+ try { text = fs.readFileSync(file, "utf8"); } catch { return []; }
+ const lines = text.split(/\r?\n/g).filter(Boolean);
+ const count = Number(limit) || lines.length;
+ const selected = count < 0 ? lines.slice(0, Math.abs(count)) : lines.slice(Math.max(0, lines.length - count));
+ return selected.map((line) => {
+ try { return JSON.parse(line); } catch { return null; }
+ }).filter(Boolean);
+}
+
+function renderCodexBotHistory(file, countArg) {
+ const messages = [];
+ for (const value of readJsonLines(file, 2000)) {
+ const payload = value && value.payload && typeof value.payload === "object" ? value.payload : value;
+ const role = String(payload && payload.role || "").toLowerCase();
+ if (role !== "user" && role !== "assistant") continue;
+ const text = textFromContent(payload.content) || stringValue(payload.text);
+ if (text) messages.push({ role: role === "user" ? "User" : "Agent", text });
+ }
+ const requested = Number(countArg);
+ const count = Number.isInteger(requested) ? Math.min(30, Math.max(1, requested)) : 10;
+ const recent = messages.slice(-count);
+ return recent.length ? "Recent session history:\n" + recent.map((item) => item.role + ": " + item.text.slice(0, 500)).join("\n\n") : "Session history is empty.";
+}
+
+function collectCodexModelIds(value, output, depth = 0) {
+ if (!value || typeof value !== "object" || depth > 6) return output;
+ if (!Array.isArray(value)) {
+ const id = stringValue(value.id) || stringValue(value.slug) || stringValue(value.model);
+ if (id && id.length < 200) output.add(id);
+ }
+ for (const child of Array.isArray(value) ? value : Object.values(value)) collectCodexModelIds(child, output, depth + 1);
+ return output;
+}
+
+function parseOpenCodeBotWorkerOptions(args) {
+ let workspaceName = nonEmptyEnv("CCR_OPENCODE_WORKSPACE_NAME") || "OpenCode";
+ for (let i = 0; i < args.length; i += 1) {
+ if (args[i] === "--workspace-name" && args[i + 1]) {
+ workspaceName = args[i + 1];
+ i += 1;
+ }
+ }
+ return { workspaceName };
+}
+
+function openCodeBotSessionStorePath() {
+ const stateDir = nonEmptyEnv("CCR_BOT_GATEWAY_STATE_DIR") ||
+ nonEmptyEnv("CODEXL_BOT_GATEWAY_STATE_DIR") ||
+ nonEmptyEnv("BOT_GATEWAY_STATE_DIR") ||
+ path.join(CONFIG_DIR, "bot-gateway", safePathSegment(nonEmptyEnv("CCR_BOT_PROFILE_ID") || "default"));
+ return path.join(expandHome(stateDir), "opencode-bot-sessions.json");
+}
+
+function resolveOpenCodeBotCwd() {
+ const candidates = Array.from(arguments).concat([openCodeDesktopDefaultCwd(), os.homedir(), process.cwd()]);
+ for (const candidate of candidates) {
+ if (!candidate) continue;
+ const resolved = expandHome(candidate);
+ try {
+ if (fs.statSync(resolved).isDirectory()) return resolved;
+ } catch {
+ // Try the next configured directory.
+ }
+ }
+ return process.cwd();
+}
+
+function openCodeDesktopDefaultCwd() {
+ return path.parse(os.homedir()).root || process.cwd();
+}
+
+async function runOpenCodeBotCli(command, args, cwd, options = {}) {
+ const env = withoutKeys(process.env, [
+ "CCR_OPENCODE_BOT_WORKER",
+ "CCR_CLI_DIRECT_PROFILE_DISPATCH",
+ "ELECTRON_RUN_AS_NODE"
+ ]);
+ env.OPENCODE_CLIENT = "cli";
+ if (process.platform !== "win32") env.PWD = cwd;
+ let child;
+ try {
+ child = spawnAgentCli(command, args, {
+ cwd,
+ env,
+ stdio: ["ignore", "pipe", "pipe"],
+ windowsHide: true
+ });
+ } catch (error) {
+ return { exitCode: 1, stdout: "", stderr: "", error: conciseError(error) };
+ }
+ if (typeof options.onSpawn === "function") options.onSpawn(child);
+
+ let stdout = "";
+ let stderr = "";
+ let spawnError = "";
+ const append = (current, chunk) => {
+ const next = current + chunk.toString("utf8");
+ return next.length > 4 * 1024 * 1024 ? next.slice(-4 * 1024 * 1024) : next;
+ };
+ let pendingJson = "";
+ child.stdout.on("data", (chunk) => {
+ stdout = append(stdout, chunk);
+ if (typeof options.onJson !== "function") return;
+ pendingJson += chunk.toString("utf8");
+ const lines = pendingJson.split(/\r?\n/g);
+ pendingJson = lines.pop() || "";
+ for (const line of lines) {
+ try { options.onJson(JSON.parse(line)); } catch { /* Ignore diagnostics. */ }
+ }
+ });
+ child.stderr.on("data", (chunk) => { stderr = append(stderr, chunk); });
+ child.once("error", (error) => { spawnError = conciseError(error); });
+
+ let timedOut = false;
+ let forceKillTimer = null;
+ const timeoutMs = Number(options.timeoutMs) || numberEnv("CCR_OPENCODE_BOT_TURN_TIMEOUT_MS", 10 * 60 * 1000);
+ const timer = setTimeout(() => {
+ timedOut = true;
+ try {
+ child.kill("SIGTERM");
+ } catch {
+ // The process may have already exited.
+ }
+ forceKillTimer = setTimeout(() => {
+ try {
+ child.kill("SIGKILL");
+ } catch {
+ // The process may have already exited.
+ }
+ }, 5000);
+ }, timeoutMs);
+ const result = await waitForChildResult(child);
+ clearTimeout(timer);
+ if (forceKillTimer) clearTimeout(forceKillTimer);
+ return {
+ exitCode: result.exitCode,
+ stdout: stdout.trim(),
+ stderr: stderr.trim(),
+ error: timedOut ? "OpenCode timed out after " + timeoutMs + "ms" : spawnError
+ };
+}
+
+function parseOpenCodeRunOutput(output) {
+ let sessionId = "";
+ let error = "";
+ const textParts = new Map();
+ const fallback = [];
+ let unnamedPart = 0;
+ for (const rawLine of String(output || "").split(/\r?\n/g)) {
+ const line = rawLine.trim();
+ if (!line) continue;
+ let value;
+ try {
+ value = JSON.parse(line);
+ } catch {
+ fallback.push(line);
+ continue;
+ }
+ if (!value || typeof value !== "object") continue;
+ sessionId = valueStringAtPaths(value, ["/sessionID", "/sessionId", "/session/id", "/part/sessionID"]) || sessionId;
+ const type = stringValue(value.type) || "";
+ if (type === "text") {
+ const part = value.part && typeof value.part === "object" ? value.part : value;
+ const text = stringValue(part.text) || stringValue(value.text);
+ if (text) {
+ const partId = stringValue(part.id) || "part-" + (++unnamedPart);
+ textParts.set(partId, text);
+ }
+ } else if (type === "error") {
+ error = openCodeEventError(value) || error;
+ }
+ }
+ return {
+ sessionId,
+ error,
+ text: Array.from(textParts.values()).join("\n").trim(),
+ fallbackText: fallback.join("\n").trim()
+ };
+}
+
+function openCodeEventError(value) {
+ return valueStringAtPaths(value, [
+ "/error/data/message",
+ "/error/message",
+ "/error/data/name",
+ "/error/name",
+ "/message"
+ ]) || stringValue(value && value.error) || "OpenCode returned an error";
+}
+
+function parseOpenCodeSessionList(output) {
+ const text = String(output || "").trim();
+ if (!text) return [];
+ let value;
+ try {
+ value = JSON.parse(text);
+ } catch {
+ const lines = text.split(/\r?\n/g).map((line) => line.trim()).filter(Boolean);
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
+ try {
+ const parsed = JSON.parse(lines[i]);
+ if (Array.isArray(parsed)) {
+ value = parsed;
+ break;
+ }
+ } catch {
+ // Ignore non-JSON diagnostic lines.
+ }
+ }
+ }
+ const values = Array.isArray(value)
+ ? value
+ : value && Array.isArray(value.sessions)
+ ? value.sessions
+ : value && Array.isArray(value.data)
+ ? value.data
+ : [];
+ return values.map(normalizeOpenCodeSession).filter(Boolean).sort((left, right) => right.updatedAt - left.updatedAt);
+}
+
+function normalizeOpenCodeSession(value) {
+ if (!value || typeof value !== "object") return null;
+ const id = valueStringAtPaths(value, ["/id", "/sessionID", "/sessionId"]);
+ if (!id) return null;
+ const title = valueStringAtPaths(value, ["/title", "/name", "/summary/title"]) || "Untitled";
+ const directory = valueStringAtPaths(value, ["/directory", "/cwd", "/path"]);
+ const rawUpdatedAt = valueAtPointer(value, "/time/updated") ?? value.updatedAt ?? value.updated_at ?? valueAtPointer(value, "/time/created");
+ const numericUpdatedAt = Number(rawUpdatedAt);
+ const parsedUpdatedAt = typeof rawUpdatedAt === "string" ? Date.parse(rawUpdatedAt) : 0;
+ return {
+ id,
+ title,
+ directory,
+ updatedAt: Number.isFinite(numericUpdatedAt) && numericUpdatedAt > 0
+ ? numericUpdatedAt
+ : Number.isFinite(parsedUpdatedAt) ? parsedUpdatedAt : 0
+ };
+}
+
+function resolveOpenCodeSession(query, sessions) {
+ const text = String(query || "").trim();
+ const index = Number(text);
+ if (Number.isInteger(index) && index >= 1 && index <= sessions.length) return sessions[index - 1];
+ const lower = text.toLowerCase();
+ const matches = sessions.filter((session) =>
+ session.id.toLowerCase() === lower ||
+ session.id.toLowerCase().startsWith(lower) ||
+ session.title.toLowerCase().includes(lower)
+ );
+ if (!matches.length) return null;
+ matches.sort((left, right) => {
+ const leftExact = left.id.toLowerCase() === lower ? 0 : left.id.toLowerCase().startsWith(lower) ? 1 : 2;
+ const rightExact = right.id.toLowerCase() === lower ? 0 : right.id.toLowerCase().startsWith(lower) ? 1 : 2;
+ return leftExact - rightExact || right.updatedAt - left.updatedAt;
+ });
+ return matches[0];
+}
+
+function renderOpenCodeSessionList(sessions, current, directory, options = {}) {
+ const page = botListPage(sessions, options.args, options.query, (session) => session.title + " " + session.id);
+ sessions = page.items;
+ if (!sessions.length) {
+ return "No OpenCode sessions found in project " + projectNameFromDirectory(directory) + ". Send any message to create one.";
+ }
+ const lines = ["OpenCode sessions in " + projectNameFromDirectory(directory) + ":", "page " + page.page + "/" + page.pages];
+ for (let i = 0; i < sessions.length; i += 1) {
+ const session = sessions[i];
+ const selected = current && current.sessionId === session.id ? " [selected]" : "";
+ lines.push("[" + (page.offset + i + 1) + "] " + shortSessionId(session.id) + " " + session.title + selected);
+ }
+ lines.push("Commands: /session use , /session new, /session current, /session reset");
+ if (page.pages > 1) lines.push("Use /session list to see more.");
+ return lines.join("\n");
+}
+
+function agentProjectsFromDirectories(directories, fallbackDirectory) {
+ const projects = [];
+ for (const value of directories) {
+ const raw = stringValue(value);
+ if (!raw) continue;
+ const directory = path.resolve(expandHome(raw));
+ try {
+ if (!fs.statSync(directory).isDirectory()) continue;
+ } catch {
+ continue;
+ }
+ if (projects.some((project) => sameProjectDirectory(project.directory, directory))) continue;
+ projects.push({ directory, name: projectNameFromDirectory(directory) });
+ }
+ if (!projects.length && fallbackDirectory) {
+ const directory = path.resolve(expandHome(fallbackDirectory));
+ projects.push({ directory, name: projectNameFromDirectory(directory) });
+ }
+ return projects;
+}
+
+function sameProjectDirectory(left, right) {
+ const leftPath = stringValue(left);
+ const rightPath = stringValue(right);
+ if (!leftPath || !rightPath) return false;
+ return comparableProjectDirectory(leftPath) === comparableProjectDirectory(rightPath);
+}
+
+function comparableProjectDirectory(value) {
+ const resolved = path.resolve(expandHome(stringValue(value) || process.cwd()));
+ try {
+ return normalizeComparablePath(fs.realpathSync(resolved));
+ } catch {
+ return normalizeComparablePath(resolved);
+ }
+}
+
+function resolveExistingProjectDirectory(value, fallbackDirectory) {
+ for (const candidate of [value, fallbackDirectory, process.cwd()]) {
+ const raw = stringValue(candidate);
+ if (!raw) continue;
+ const directory = path.resolve(expandHome(raw));
+ try {
+ if (fs.statSync(directory).isDirectory()) return directory;
+ } catch {
+ // Try the next project candidate.
+ }
+ }
+ return path.resolve(expandHome(stringValue(fallbackDirectory) || process.cwd()));
+}
+
+function projectNameFromDirectory(directory) {
+ const resolved = path.resolve(expandHome(stringValue(directory) || process.cwd()));
+ return path.basename(resolved) || resolved;
+}
+
+function resolveAgentProject(query, projects) {
+ const value = String(query || "").trim();
+ if (!value) return null;
+ const numeric = Number(value);
+ if (Number.isInteger(numeric) && numeric >= 1 && numeric <= projects.length) {
+ return projects[numeric - 1];
+ }
+ const lower = value.toLowerCase();
+ const matches = projects.filter((project) =>
+ project.name.toLowerCase() === lower ||
+ comparableProjectDirectory(project.directory) === comparableProjectDirectory(value)
+ );
+ return matches.length === 1 ? matches[0] : null;
+}
+
+function renderAgentProjectList(agentName, projects, currentDirectory, options = {}) {
+ const page = botListPage(projects, options.args, options.query, (project) => project.name + " " + project.directory);
+ projects = page.items;
+ if (!projects.length) return "No " + agentName + " projects found.";
+ const lines = [agentName + " projects:", "page " + page.page + "/" + page.pages];
+ for (let i = 0; i < projects.length; i += 1) {
+ const project = projects[i];
+ const selected = sameProjectDirectory(project.directory, currentDirectory) ? " [selected]" : "";
+ lines.push("[" + (page.offset + i + 1) + "] " + project.name + selected);
+ lines.push(" path: " + project.directory);
+ }
+ lines.push("Commands: /project use , /project current");
+ if (page.pages > 1) lines.push("Use /project list to see more.");
+ return lines.join("\n");
+}
+
+function botListPage(values, args, query, label) {
+ const pageSize = 8;
+ const search = String(query || "").trim().toLowerCase();
+ const filtered = search ? values.filter((item) => String(label(item) || "").toLowerCase().includes(search)) : values;
+ const requested = Number(String(args || "").trim());
+ const pages = Math.max(1, Math.ceil(filtered.length / pageSize));
+ const page = Number.isInteger(requested) ? Math.min(pages, Math.max(1, requested)) : 1;
+ const offset = (page - 1) * pageSize;
+ return { items: filtered.slice(offset, page * pageSize), page, pages, total: filtered.length, offset };
+}
+
+function parseSubcommand(value) {
+ const input = String(value || "").trim();
+ const space = input.search(/\s/);
+ return space < 0
+ ? { name: input.toLowerCase(), args: "" }
+ : { name: input.slice(0, space).toLowerCase(), args: input.slice(space + 1).trim() };
+}
+
+function forwardSkillCommand(args) {
+ const command = parseSubcommand(args);
+ if (!command.name) return "Usage: /session skill [task].";
+ if (!/^[A-Za-z0-9_.-]+$/.test(command.name)) return "Skill names may contain letters, numbers, dots, underscores, and hyphens.";
+ return { forwardText: "/" + command.name + (command.args ? " " + command.args : "") };
+}
+
+function parseConfirmedTarget(value) {
+ const parts = String(value || "").trim().split(/\s+/).filter(Boolean);
+ const confirmed = parts.at(-1) && parts.at(-1).toLowerCase() === "confirm";
+ if (confirmed) parts.pop();
+ return { target: parts.join(" "), confirmed };
+}
+
+function renderExportedHistory(output, countArg) {
+ let value;
+ try { value = JSON.parse(String(output || "")); } catch { return splitBotMessage(String(output || ""), 3500)[0] || "Session history is empty."; }
+ const messages = [];
+ collectHistoryMessages(value, messages, new Set());
+ const requested = Number(countArg);
+ const count = Number.isInteger(requested) ? Math.min(30, Math.max(1, requested)) : 10;
+ const recent = messages.slice(-count);
+ return recent.length
+ ? "Recent session history:\n" + recent.map((item) => item.role + ": " + item.text.slice(0, 500)).join("\n\n")
+ : "Session history is empty.";
+}
+
+function collectHistoryMessages(value, output, seen) {
+ if (!value || typeof value !== "object" || seen.has(value)) return;
+ seen.add(value);
+ if (!Array.isArray(value)) {
+ const role = stringValue(value.role) || stringValue(value.type);
+ const text = stringValue(value.text) || stringValue(value.content) || stringValue(value.message);
+ if (role && text && ["user", "assistant", "agent", "text"].some((item) => role.toLowerCase().includes(item))) {
+ output.push({ role: role.toLowerCase().includes("user") ? "User" : "Agent", text });
+ }
+ }
+ for (const child of Array.isArray(value) ? value : Object.values(value)) collectHistoryMessages(child, output, seen);
+}
+
+function renderExportedUsage(output) {
+ let value;
+ try { value = JSON.parse(String(output || "")); } catch { return "Usage data is unavailable for this session."; }
+ const totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
+ collectUsageValues(value, totals, new Set());
+ return [
+ "Session usage:",
+ "input tokens: " + totals.input,
+ "output tokens: " + totals.output,
+ "cache read: " + totals.cacheRead,
+ "cache write: " + totals.cacheWrite,
+ "cost: " + (totals.cost ? "$" + totals.cost.toFixed(4) : "unavailable")
+ ].join("\n");
+}
+
+function collectUsageValues(value, totals, seen) {
+ if (!value || typeof value !== "object" || seen.has(value)) return;
+ seen.add(value);
+ if (!Array.isArray(value)) {
+ for (const [key, raw] of Object.entries(value)) {
+ const number = Number(raw);
+ if (!Number.isFinite(number)) continue;
+ const normalized = key.toLowerCase().replace(/[^a-z]/g, "");
+ if (normalized === "inputtokens") totals.input += number;
+ else if (normalized === "outputtokens") totals.output += number;
+ else if (normalized.includes("cacheread")) totals.cacheRead += number;
+ else if (normalized.includes("cachewrite")) totals.cacheWrite += number;
+ else if (normalized === "cost" || normalized === "costusd") totals.cost += number;
+ }
+ }
+ for (const child of Array.isArray(value) ? value : Object.values(value)) collectUsageValues(child, totals, seen);
+}
+
+function renderAgentSkills(directory, agent) {
+ const roots = agent === "opencode"
+ ? [path.join(directory, ".opencode", "skills"), path.join(os.homedir(), ".config", "opencode", "skills")]
+ : agent === "codex" || agent === "zcode"
+ ? [path.join(directory, ".agents", "skills"), path.join(codexRuntimeHome(), "skills"), path.join(os.homedir(), ".codex", "skills")]
+ : [path.join(directory, ".claude", "skills"), path.join(os.homedir(), ".claude", "skills")];
+ const skills = [];
+ for (const root of roots) {
+ let entries = [];
+ try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { continue; }
+ for (const entry of entries) {
+ if ((entry.isDirectory() || entry.name.endsWith(".md")) && !skills.includes(entry.name.replace(/\.md$/i, ""))) skills.push(entry.name.replace(/\.md$/i, ""));
+ }
+ }
+ return skills.length ? "Available skills:\n" + skills.sort().map((item) => "- " + item).join("\n") : "No project or user skills were found.";
+}
+
+function renderBotDiagnostics(value) {
+ return [
+ "Bot diagnostics:",
+ "connection: " + (value.state || "unknown"),
+ "platform: " + (value.platform || "unknown"),
+ "last event: " + (value.lastEventAt || "none"),
+ "last delivery: " + (value.lastDeliveryAt || "none") + (value.lastDeliveryStatus ? " (" + value.lastDeliveryStatus + ")" : ""),
+ "pending deliveries: " + Number(value.outboxCount || 0),
+ "processed events: " + Number(value.processedEventCount || 0),
+ "last error: " + (value.lastError || "none")
+ ].join("\n");
+}
+
+function renderBotDeliveries(value) {
+ const deliveries = Array.isArray(value.recentDeliveries) ? value.recentDeliveries : [];
+ if (!deliveries.length) return "No recent Bot deliveries.";
+ return "Recent Bot deliveries:\n" + deliveries.slice(-10).reverse().map((item) =>
+ new Date(item.deliveredAt).toISOString() + " " + item.kind + " " + item.status
+ ).join("\n");
+}
+
+function conciseError(error) {
+ return error && typeof error.message === "string" && error.message.trim()
+ ? error.message.trim()
+ : String(error || "Unknown error");
+}
+
+function acquireBotWorkerLock(agent) {
+ const lockPath = botWorkerLockPath(agent);
const token = uuid();
const payload = {
pid: process.pid,
@@ -1250,30 +2424,30 @@ function acquireClaudeBotWorkerLock() {
try {
fs.writeFileSync(lockPath, JSON.stringify(payload, null, 2), { flag: "wx" });
const lock = { path: lockPath, token };
- process.once("exit", () => releaseClaudeBotWorkerLock(lock));
+ process.once("exit", () => releaseBotWorkerLock(lock));
return lock;
} catch (error) {
if (!error || error.code !== "EEXIST") throw error;
const existing = readJsonFile(lockPath) || {};
const existingPid = Number(existing.pid);
if (existingPid && existingPid !== process.pid && processIsRunning(existingPid)) {
- log("claude_bot_worker_lock_held", { lockPath, pid: process.pid, ownerPid: existingPid });
+ log(agent + "_bot_worker_lock_held", { lockPath, pid: process.pid, ownerPid: existingPid });
return null;
}
try {
fs.unlinkSync(lockPath);
- log("claude_bot_worker_stale_lock_removed", { lockPath, pid: process.pid, ownerPid: existingPid || null });
+ log(agent + "_bot_worker_stale_lock_removed", { lockPath, pid: process.pid, ownerPid: existingPid || null });
} catch (unlinkError) {
- log("claude_bot_worker_lock_remove_failed", { lockPath, pid: process.pid, error: formatError(unlinkError) });
+ log(agent + "_bot_worker_lock_remove_failed", { lockPath, pid: process.pid, error: formatError(unlinkError) });
return null;
}
}
}
- log("claude_bot_worker_lock_failed", { lockPath, pid: process.pid });
+ log(agent + "_bot_worker_lock_failed", { lockPath, pid: process.pid });
return null;
}
-function releaseClaudeBotWorkerLock(lock) {
+function releaseBotWorkerLock(lock) {
if (!lock || !lock.path) return;
try {
const existing = readJsonFile(lock.path) || {};
@@ -1284,12 +2458,12 @@ function releaseClaudeBotWorkerLock(lock) {
}
}
-function claudeBotWorkerLockPath() {
+function botWorkerLockPath(agent) {
const stateDir = nonEmptyEnv("CCR_BOT_GATEWAY_STATE_DIR") ||
nonEmptyEnv("CODEXL_BOT_GATEWAY_STATE_DIR") ||
nonEmptyEnv("BOT_GATEWAY_STATE_DIR") ||
path.join(CONFIG_DIR, "bot-gateway", safePathSegment(nonEmptyEnv("CCR_BOT_PROFILE_ID") || "default"));
- return path.join(expandHome(stateDir), "claude-bot-worker.lock");
+ return path.join(expandHome(stateDir), safePathSegment(agent) + "-bot-worker.lock");
}
function processIsRunning(pid) {
@@ -1345,10 +2519,14 @@ class ClaudeCodeAppServer {
this.active = new Map();
this.appResponses = new Map();
this.botBridgeRegistered = false;
- this.botSessionStore = { version: 1, conversations: {} };
+ this.botSessionStore = { version: BOT_SESSION_ENTRY_VERSION, conversations: {}, pendingTurns: [], projectAliases: {} };
this.botSessionStoreLoaded = false;
this.botThreadKeys = new Map();
this.botThreads = new Map();
+ this.botTurnStates = new Map();
+ this.botPendingApprovals = new Map();
+ this.botSessionApprovals = new Set();
+ this.restoredBotPendingTurns = false;
this.configValues = {};
this.pollingEvents = false;
this.stdin = readline.createInterface({ input: process.stdin, crlfDelay: Infinity, terminal: false });
@@ -1604,23 +2782,82 @@ class ClaudeCodeAppServer {
}
async handleBotInbound(event, _queued, eventId, bridge) {
- const text = botEventText(event);
+ let text = botEventText(event) || botInteractionText(event);
if (!text) {
log("bot_gateway_inbound_skip", { eventId, reason: "empty_text" });
return;
}
- const commandReply = this.handleBotCommand(event, text);
- if (commandReply !== null) {
+ const commandReply = this.handleBotCommand(event, text, bridge);
+ if (commandReply && typeof commandReply === "object" && commandReply.forwardText) {
+ text = commandReply.forwardText;
+ } else if (commandReply !== null) {
await bridge.sendReplyToEvent(event, commandReply, "ccr:claude-code:command:" + eventId);
log("bot_gateway_command_replied", { eventId, textLen: commandReply.length });
return;
}
+ const position = this.enqueueBotTurn(event, eventId, bridge, text);
+ if (position > 0) {
+ await bridge.sendReplyToEvent(event, "Queued behind the active turn (position " + position + "). Use /session status or /session cancel.", "ccr:claude-code:queued:" + eventId);
+ }
+ }
+
+ enqueueBotTurn(event, eventId, bridge, text) {
+ const key = botConversationKey(event);
+ const state = this.botTurnStates.get(key) || { active: null, pending: [] };
+ const job = { id: eventId || stableBotKey(botEventDedupeKey(event)), event, eventId, text, key, createdAt: Date.now(), bridge };
+ state.pending.push(job);
+ this.botTurnStates.set(key, state);
+ this.persistBotPendingTurn(job);
+ const position = (state.active ? 1 : 0) + state.pending.length - 1;
+ void this.drainBotTurnQueue(key, state);
+ return position;
+ }
+
+ async drainBotTurnQueue(key, state) {
+ if (state.draining) return;
+ state.draining = true;
+ try {
+ while (state.pending.length) {
+ const job = state.pending.shift();
+ state.active = { job, threadId: "", turnId: "", startedAt: Date.now(), cancelRequested: false };
+ try {
+ await this.runBotTurn(job, state.active);
+ } catch (error) {
+ try {
+ await job.bridge.sendReplyToEvent(job.event, "Agent turn failed: " + conciseError(error), "ccr:claude-code:error:" + job.eventId);
+ } catch (replyError) {
+ log("claude_bot_turn_error_reply_failed", { eventId: job.eventId, error: formatError(error), replyError: formatError(replyError) });
+ }
+ } finally {
+ this.removeBotPendingTurn(job.id);
+ state.active = null;
+ }
+ }
+ } finally {
+ state.draining = false;
+ if (!state.active && !state.pending.length) this.botTurnStates.delete(key);
+ }
+ }
+
+ async runBotTurn(job, activeTurn) {
+ const { event, eventId, bridge, text, key } = job;
+ this.expireIdleBotSession(key, bridge.config.sessionIdleMinutes);
const thread = this.botThreadForEvent(event, text);
+ const entry = this.loadBotSessionStore().conversations[key];
+ let input = await botInputForEvent(event, text, bridge.config, thread.claudeAppSessionFile ? path.join(path.dirname(thread.claudeAppSessionFile), thread.claudeAppSessionId || "", "uploads") : path.join(thread.cwd, ".ccr-bot-uploads"));
+ if (entry && Array.isArray(entry.memory) && entry.memory.length) {
+ input = [{ type: "text", text: "Persistent session context:\n" + entry.memory.map((item) => "- " + item).join("\n") }, ...input];
+ }
const prepared = this.startTurn({
cwd: thread.cwd,
- input: [{ type: "text", text }],
+ input,
threadId: thread.id
});
+ prepared.work.botContext = { event, eventId, bridge, conversationKey: key };
+ prepared.work.botStream = { id: "claude-" + prepared.turn.id, lastSentAt: 0, pending: "", timer: null };
+ activeTurn.work = prepared.work;
+ activeTurn.threadId = thread.id;
+ activeTurn.turnId = prepared.turn.id;
for (const notification of prepared.notifications) writeRaw(notification);
bridge.suppressTurn(prepared.turn.id);
try {
@@ -1633,34 +2870,92 @@ class ClaudeCodeAppServer {
const responseText = completed.error
? "Agent turn failed: " + completed.error
: (completed.agentText || "").trim() || "Claude Code completed the turn without a text response.";
- await bridge.sendReplyToEvent(event, responseText, "ccr:claude-code:" + eventId + ":" + prepared.turn.id);
+ if (bridge.config.streamReplies && !completed.error) {
+ await bridge.sendStreamToEvent(event, "claude-" + prepared.turn.id, responseText, true, "ccr:claude-code:stream:" + eventId).catch(() => undefined);
+ } else {
+ await bridge.sendReplyToEvent(event, responseText, "ccr:claude-code:" + eventId + ":" + prepared.turn.id);
+ }
+ await sendBotTurnArtifacts(event, bridge, completed, thread.cwd, "ccr:claude-code:artifact:" + eventId);
log("bot_gateway_inbound_replied", { eventId, threadId: thread.id, turnId: prepared.turn.id, textLen: responseText.length });
}
- handleBotCommand(event, text) {
+ handleBotCommand(event, text, bridge) {
const command = parseBotCommand(text);
if (!command) return null;
const key = botConversationKey(event);
try {
- if (command.name === "help") return botCommandHelpText();
- if (command.name === "ls") return this.renderBotSessionList(key);
- if (command.name === "current" || command.name === "status") return this.renderCurrentBotSession(key);
- if (command.name === "reset") {
- this.clearBotThreadForConversation(key);
- return "Reset. The next message will create a new Claude App session.";
+ if (command.name === "unknown") return "Unknown Bot command. Send /project or /session to see available commands.";
+ if (command.domain === "project") {
+ if (command.name === "help") return projectCommandHelpText("Claude App");
+ if (command.name === "current") return this.renderCurrentBotProject(key);
+ const sessions = claudeAppLocalAgentSessions();
+ const projects = claudeAppProjects(sessions, this.defaultBotProjectDirectory());
+ for (const project of projects) project.name = this.botProjectLabel(project.directory, project.name);
+ if (command.name === "ls") {
+ return renderAgentProjectList("Claude App", projects, this.selectedBotProjectDirectory(key), { args: command.args });
+ }
+ if (command.name === "search") {
+ return renderAgentProjectList("Claude App", projects, this.selectedBotProjectDirectory(key), { query: command.args });
+ }
+ if (command.name === "rename") {
+ if (!command.args) return "Usage: /project name .";
+ this.loadBotSessionStore().projectAliases[comparableProjectDirectory(this.selectedBotProjectDirectory(key))] = command.args.slice(0, 80);
+ this.saveBotSessionStore();
+ return "Project label updated to " + command.args.slice(0, 80) + ".";
+ }
+ if (command.name === "select") {
+ if (!command.args) return "Usage: /project use . Send /project list to list projects.";
+ const project = resolveAgentProject(command.args, projects);
+ if (!project) return "Project '" + command.args + "' was not found. Send /project list to list projects.";
+ this.setBotProjectForConversation(key, project.directory);
+ return "Selected project " + project.name + "\npath: " + project.directory + "\nUse /session list or /session new to choose a session.";
+ }
}
- if (command.name === "new") {
- this.clearBotThreadForConversation(key);
- const seed = command.args.replace(/^session\b/i, "").trim() || "New Claude App bot session";
- const thread = this.botThreadForEvent(event, seed);
- return "Created session " + shortSessionId(thread.claudeAppSessionId || thread.sessionId || thread.id) + ": " + (thread.preview || "New Claude App session") + "\nNext message will continue in this Claude App session.";
- }
- if (command.name === "select" || command.name === "use") {
- if (!command.args) return "Usage: select . Send 'ls' to list sessions.";
- const session = resolveClaudeAppLocalAgentSession(command.args);
- if (!session) return "Session '" + command.args + "' was not found. Send 'ls' to list sessions.";
- const thread = this.bindBotConversationToClaudeAppSession(key, session);
- return "Selected session " + shortSessionId(session.sessionId) + ": " + botSessionTitle(session) + "\nNext message will continue in this Claude App session.";
+ if (command.domain === "session") {
+ if (command.name === "help") return sessionCommandHelpText("Claude App");
+ if (command.name === "status") return this.renderBotTurnStatus(key);
+ if (command.name === "cancel") return this.cancelBotTurns(key);
+ if (["approve", "deny", "answer"].includes(command.name)) return this.resolveBotApproval(key, command.name, command.args);
+ if (command.name === "ls") return this.renderBotSessionList(key, command.args);
+ if (command.name === "search") return this.renderBotSessionList(key, "", command.args);
+ if (command.name === "current") return this.renderCurrentBotSession(key);
+ if (command.name === "reset") {
+ const directory = this.selectedBotProjectDirectory(key);
+ this.setBotProjectForConversation(key, directory);
+ return "Session selection cleared. The next message will create a new Claude App session in the current project.";
+ }
+ if (command.name === "new") {
+ const directory = this.selectedBotProjectDirectory(key);
+ this.setBotProjectForConversation(key, directory);
+ const seed = command.args || "New Claude App bot session";
+ const thread = this.botThreadForEvent(event, seed);
+ return "Created session " + shortSessionId(thread.claudeAppSessionId || thread.sessionId || thread.id) + ": " + (thread.preview || "New Claude App session") + "\nProject: " + projectNameFromDirectory(thread.cwd) + "\nNext message will continue in this Claude App session.";
+ }
+ if (command.name === "select") {
+ if (!command.args) return "Usage: /session use . Send /session list to list sessions.";
+ const directory = this.selectedBotProjectDirectory(key);
+ const sessions = claudeAppLocalAgentSessions().filter((session) =>
+ sameProjectDirectory(claudeAppSessionProjectDirectory(session), directory)
+ );
+ const session = resolveClaudeAppLocalAgentSession(command.args, sessions);
+ if (!session) return "Session '" + command.args + "' was not found in the current project. Send /session list to list sessions.";
+ const thread = this.bindBotConversationToClaudeAppSession(key, session);
+ return "Selected session " + shortSessionId(session.sessionId) + ": " + botSessionTitle(session) + "\nNext message will continue in this Claude App session.";
+ }
+ if (command.name === "rename") return this.renameBotSession(key, command.args);
+ if (["archive", "restore", "delete"].includes(command.name)) return this.mutateClaudeBotSession(key, command.name, command.args);
+ if (command.name === "history") return this.renderClaudeBotHistory(key, command.args);
+ if (command.name === "model") return this.updateClaudeBotSetting(key, "model", command.args);
+ if (command.name === "effort") return this.updateClaudeBotSetting(key, "effort", command.args);
+ if (command.name === "mode") return this.updateClaudeBotSetting(key, "mode", command.args);
+ if (command.name === "models") return this.renderClaudeBotModels();
+ if (command.name === "usage") return this.renderClaudeBotUsage(key);
+ if (command.name === "memory") return this.updateClaudeBotMemory(key, command.args);
+ if (command.name === "skills") return renderAgentSkills(this.selectedBotProjectDirectory(key), "claude");
+ if (command.name === "skill") return forwardSkillCommand(command.args);
+ if (command.name === "shortcut") return this.handleClaudeBotShortcut(key, command.args);
+ if (command.name === "doctor") return renderBotDiagnostics(bridge.diagnostics());
+ if (command.name === "deliveries") return renderBotDeliveries(bridge.diagnostics());
}
return null;
} catch (error) {
@@ -1671,7 +2966,9 @@ class ClaudeCodeAppServer {
ensureBotBridgeRegistered() {
if (this.botBridgeRegistered) return;
this.botBridgeRegistered = true;
- botBridge().setInboundHandler((event, queued, eventId, bridge) => this.handleBotInbound(event, queued, eventId, bridge));
+ const bridge = botBridge();
+ bridge.setInboundHandler((event, queued, eventId, activeBridge) => this.handleBotInbound(event, queued, eventId, activeBridge));
+ this.restoreBotPendingTurns(bridge);
}
botThreadForEvent(event, text) {
@@ -1685,9 +2982,10 @@ class ClaudeCodeAppServer {
if (!restoredThread.preview) restoredThread.preview = text.slice(0, 160);
return restoredThread;
}
- const appThread = this.createBotThreadForNewClaudeAppSession(key, text);
+ const projectDirectory = this.selectedBotProjectDirectory(key);
+ const appThread = this.createBotThreadForNewClaudeAppSession(key, text, projectDirectory);
if (appThread) return appThread;
- const thread = this.createThread({ cwd: process.cwd(), workspaceKind: "local" });
+ const thread = this.createThread({ cwd: projectDirectory, workspaceKind: "local" });
if (!thread.preview) thread.preview = text.slice(0, 160);
this.botThreads.set(key, thread.id);
this.botThreadKeys.set(thread.id, key);
@@ -1699,7 +2997,7 @@ class ClaudeCodeAppServer {
const oldThreadId = this.botThreads.get(key);
if (oldThreadId) this.botThreadKeys.delete(oldThreadId);
const thread = this.createThread({
- cwd: session.cwd || process.cwd(),
+ cwd: claudeAppSessionProjectDirectory(session),
model: session.model || undefined,
workspaceKind: "local",
claudeConfigDir: session.claudeConfigDir || null
@@ -1718,38 +3016,84 @@ class ClaudeCodeAppServer {
return thread;
}
- clearBotThreadForConversation(key) {
+ defaultBotProjectDirectory() {
+ const configured = nonEmptyEnv("CCR_BOT_GATEWAY_CWD");
+ if (configured) return resolveExistingProjectDirectory(configured, process.cwd());
+ const latest = latestClaudeAppLocalAgentSession();
+ return latest ? claudeAppSessionProjectDirectory(latest) : process.cwd();
+ }
+
+ selectedBotProjectDirectory(key) {
+ const threadId = this.botThreads.get(key);
+ const thread = threadId ? this.threads.get(threadId) : null;
+ if (thread && thread.cwd) return resolveExistingProjectDirectory(thread.cwd, this.defaultBotProjectDirectory());
+ const entry = this.loadBotSessionStore().conversations[key];
+ return resolveExistingProjectDirectory(
+ entry && (entry.projectDirectory || entry.cwd),
+ this.defaultBotProjectDirectory()
+ );
+ }
+
+ setBotProjectForConversation(key, directory) {
const threadId = this.botThreads.get(key);
if (threadId) this.botThreadKeys.delete(threadId);
this.botThreads.delete(key);
+ const projectDirectory = resolveExistingProjectDirectory(directory, this.defaultBotProjectDirectory());
const store = this.loadBotSessionStore();
- delete store.conversations[key];
+ store.conversations[key] = {
+ entryVersion: BOT_SESSION_ENTRY_VERSION,
+ projectDirectory,
+ cwd: projectDirectory,
+ updatedAt: Date.now(),
+ updatedAtSeconds: nowSeconds()
+ };
this.saveBotSessionStore();
}
- renderBotSessionList(key) {
- const sessions = claudeAppLocalAgentSessions();
- if (!sessions.length) return "No Claude App sessions found. Send any message to create a new session.";
+ renderCurrentBotProject(key) {
+ const directory = this.selectedBotProjectDirectory(key);
+ return [
+ "Current Claude App project:",
+ projectNameFromDirectory(directory),
+ "path: " + directory
+ ].join("\n");
+ }
+
+ botProjectLabel(directory, fallback) {
+ return this.loadBotSessionStore().projectAliases[comparableProjectDirectory(directory)] || fallback;
+ }
+
+ renderBotSessionList(key, args = "", query = "") {
+ const directory = this.selectedBotProjectDirectory(key);
+ const includeArchived = String(args).trim().toLowerCase() === "archived";
+ let sessions = claudeAppLocalAgentSessions({ includeArchived }).filter((session) =>
+ sameProjectDirectory(claudeAppSessionProjectDirectory(session), directory)
+ );
+ const page = botListPage(sessions, includeArchived ? "1" : args, query, (session) => botSessionTitle(session) + " " + session.sessionId);
+ sessions = page.items;
+ if (!sessions.length) return "No Claude App sessions found in project " + projectNameFromDirectory(directory) + ". Send any message to create one.";
const current = this.currentBotSessionInfo(key);
- const lines = ["Claude App sessions:"];
+ const lines = ["Claude App sessions in " + projectNameFromDirectory(directory) + ":", "page " + page.page + "/" + page.pages];
for (let i = 0; i < sessions.length; i += 1) {
const session = sessions[i];
const selected = current && current.sessionId === session.sessionId ? " [selected]" : "";
- lines.push("[" + (i + 1) + "] " + shortSessionId(session.sessionId) + " " + botSessionTitle(session) + selected);
- lines.push(" cwd: " + (session.cwd || "(unknown)"));
+ lines.push("[" + (page.offset + i + 1) + "] " + shortSessionId(session.sessionId) + " " + botSessionTitle(session) + selected + (session.archived ? " [archived]" : ""));
}
- lines.push("Commands: select , new, current, reset, help");
+ lines.push("Commands: /session use , /session new, /session current, /session reset");
+ if (page.pages > 1) lines.push("Use /session list to see more.");
return lines.join("\n");
}
renderCurrentBotSession(key) {
const current = this.currentBotSessionInfo(key);
- if (!current) return "No selected Claude App session. Send any message to create a new session, or send 'ls' and then 'select '.";
+ const directory = this.selectedBotProjectDirectory(key);
+ if (!current) return "No selected Claude App session in project " + projectNameFromDirectory(directory) + ". Use /session list, /session use , or send any message to create one.";
const title = current.title || current.sessionId || "Claude App session";
return [
"Current Claude App session:",
shortSessionId(current.sessionId || current.threadId || "") + " " + title,
- "cwd: " + (current.cwd || "(unknown)")
+ "project: " + projectNameFromDirectory(directory),
+ "path: " + directory
].join("\n");
}
@@ -1766,7 +3110,8 @@ class ClaudeCodeAppServer {
}
const entry = this.loadBotSessionStore().conversations[key];
if (!entry || typeof entry !== "object") return null;
- if (Number(entry.entryVersion || 0) < BOT_SESSION_ENTRY_VERSION) return null;
+ if (Number(entry.entryVersion || 0) < 2) return null;
+ if (!entry.claudeAppSessionId && !entry.claudeSessionId && !entry.sessionId) return null;
return {
sessionId: entry.claudeAppSessionId || entry.sessionId || "",
threadId: entry.threadId || "",
@@ -1775,10 +3120,163 @@ class ClaudeCodeAppServer {
};
}
+ renameBotSession(key, label) {
+ const current = this.currentBotSessionInfo(key);
+ const name = String(label || "").trim().slice(0, 100);
+ if (!current) return "No session is selected.";
+ if (!name) return "Usage: /session name .";
+ const session = claudeAppLocalAgentSessions({ includeArchived: true }).find((item) => item.sessionId === current.sessionId);
+ if (session) updateClaudeSessionFile(session.file, { title: name });
+ const threadId = this.botThreads.get(key);
+ const thread = threadId ? this.threads.get(threadId) : null;
+ if (thread) {
+ thread.name = name;
+ thread.preview = name;
+ this.persistBotThread(thread.id);
+ } else {
+ const entry = this.loadBotSessionStore().conversations[key];
+ if (entry) {
+ entry.preview = name;
+ entry.updatedAt = Date.now();
+ this.saveBotSessionStore();
+ }
+ }
+ return "Session renamed to " + name + ".";
+ }
+
+ mutateClaudeBotSession(key, action, args) {
+ const directory = this.selectedBotProjectDirectory(key);
+ const sessions = claudeAppLocalAgentSessions({ includeArchived: true }).filter((session) => sameProjectDirectory(claudeAppSessionProjectDirectory(session), directory));
+ const parsed = parseConfirmedTarget(args);
+ const session = resolveClaudeAppLocalAgentSession(parsed.target, sessions);
+ if (!session) return "Session '" + parsed.target + "' was not found in the current project.";
+ if (action === "archive" || action === "restore") {
+ updateClaudeSessionFile(session.file, { isArchived: action === "archive", archived: action === "archive", lastActivityAt: Date.now() });
+ if (action === "archive" && this.currentBotSessionInfo(key) && this.currentBotSessionInfo(key).sessionId === session.sessionId) {
+ this.setBotProjectForConversation(key, directory);
+ }
+ return "Session " + (action === "archive" ? "archived" : "restored") + ": " + botSessionTitle(session) + ".";
+ }
+ if (!parsed.confirmed) return "Deletion is permanent. Send /session delete " + parsed.target + " confirm.";
+ try {
+ fs.rmSync(session.file, { force: true });
+ const sessionDir = path.join(path.dirname(session.file), session.sessionId);
+ if (pathIsInside(sessionDir, path.dirname(session.file))) fs.rmSync(sessionDir, { force: true, recursive: true });
+ } catch (error) {
+ return "Session deletion failed: " + conciseError(error);
+ }
+ if (this.currentBotSessionInfo(key) && this.currentBotSessionInfo(key).sessionId === session.sessionId) this.setBotProjectForConversation(key, directory);
+ return "Session deleted: " + botSessionTitle(session) + ".";
+ }
+
+ renderClaudeBotHistory(key, countArg) {
+ const threadId = this.botThreads.get(key);
+ const thread = threadId ? this.threads.get(threadId) : null;
+ if (!thread) return "No loaded session history is available. Select or send a message to the session first.";
+ const requested = Number(countArg);
+ const count = Number.isInteger(requested) ? Math.min(30, Math.max(1, requested)) : 10;
+ const turns = thread.turns.slice(-count);
+ if (!turns.length) return "Session history is empty.";
+ return "Recent session history:\n" + turns.map((turn) => {
+ const user = turn.input.map((item) => promptTextForItem(item)).join(" ").slice(0, 500);
+ return "User: " + user + "\nAgent: " + String(turn.agentText || turn.error || "(no response)").slice(0, 1000);
+ }).join("\n\n");
+ }
+
+ updateClaudeBotSetting(key, setting, args) {
+ const value = String(args || "").trim();
+ const entry = this.loadBotSessionStore().conversations[key] || { entryVersion: BOT_SESSION_ENTRY_VERSION, projectDirectory: this.selectedBotProjectDirectory(key), cwd: this.selectedBotProjectDirectory(key) };
+ const threadId = this.botThreads.get(key);
+ const thread = threadId ? this.threads.get(threadId) : null;
+ const current = setting === "mode"
+ ? entry.permissionMode || thread && thread.permissionMode
+ : setting === "effort"
+ ? entry.effort || thread && thread.reasoningEffort
+ : entry[setting] || thread && thread[setting];
+ if (!value) return "Current " + setting + ": " + (current || "default") + ".";
+ if (setting === "effort" && !["low", "medium", "high", "xhigh", "max", "reset"].includes(value)) return "Supported effort values: low, medium, high, xhigh, max, reset.";
+ if (setting === "mode" && !["manual", "acceptEdits", "plan", "auto", "dontAsk", "reset"].includes(value)) return "Supported modes: manual, acceptEdits, plan, auto, dontAsk, reset.";
+ const next = value === "reset" ? "" : value;
+ if (setting === "mode") entry.permissionMode = next;
+ else entry[setting] = next;
+ entry.updatedAt = Date.now();
+ this.loadBotSessionStore().conversations[key] = entry;
+ if (thread) {
+ if (setting === "mode") thread.permissionMode = next;
+ else if (setting === "effort") thread.reasoningEffort = next;
+ else thread[setting] = next;
+ this.persistBotThread(thread.id);
+ } else this.saveBotSessionStore();
+ return setting + " set to " + (next || "default") + ".";
+ }
+
+ renderClaudeBotModels() {
+ const result = modelList({});
+ const items = Array.isArray(result && result.data) ? result.data : [];
+ return items.length ? "Available models:\n" + items.slice(0, 30).map((item) => "- " + (item.model || item.id || item.name)).join("\n") : "No models were returned by the current profile.";
+ }
+
+ renderClaudeBotUsage(key) {
+ const threadId = this.botThreads.get(key);
+ const thread = threadId ? this.threads.get(threadId) : null;
+ const usage = thread && thread.latestTokenUsageInfo;
+ if (!usage) return "No usage data is available for the selected session yet.";
+ return "Latest session usage:\nmodel: " + (usage.model || thread.model || "default") + "\n" + JSON.stringify(usage.usage, null, 2);
+ }
+
+ updateClaudeBotMemory(key, args) {
+ const entry = this.loadBotSessionStore().conversations[key] || { entryVersion: BOT_SESSION_ENTRY_VERSION, projectDirectory: this.selectedBotProjectDirectory(key), cwd: this.selectedBotProjectDirectory(key) };
+ const command = parseSubcommand(args);
+ const memory = Array.isArray(entry.memory) ? entry.memory : [];
+ if (!command.name || command.name === "list") return memory.length ? "Session memory:\n" + memory.map((item, index) => (index + 1) + ". " + item).join("\n") : "Session memory is empty.";
+ if (command.name === "clear") entry.memory = [];
+ else if (command.name === "add" && command.args) entry.memory = [...memory, command.args.slice(0, 2000)].slice(-20);
+ else return "Usage: /session memory list | add | clear.";
+ entry.updatedAt = Date.now();
+ this.loadBotSessionStore().conversations[key] = entry;
+ this.saveBotSessionStore();
+ return command.name === "clear" ? "Session memory cleared." : "Session memory added.";
+ }
+
+ handleClaudeBotShortcut(key, args) {
+ const store = this.loadBotSessionStore();
+ const entry = store.conversations[key] || { entryVersion: BOT_SESSION_ENTRY_VERSION, projectDirectory: this.selectedBotProjectDirectory(key), cwd: this.selectedBotProjectDirectory(key) };
+ const shortcuts = entry.shortcuts && typeof entry.shortcuts === "object" ? entry.shortcuts : {};
+ const command = parseSubcommand(args);
+ if (!command.name || command.name === "list") {
+ const names = Object.keys(shortcuts).sort();
+ return names.length ? "Session shortcuts:\n" + names.map((name) => "- " + name + ": " + shortcuts[name]).join("\n") : "No session shortcuts are configured.";
+ }
+ if (command.name === "add") {
+ const definition = parseSubcommand(command.args);
+ if (!definition.name || !definition.args) return "Usage: /session shortcut add .";
+ shortcuts[definition.name] = definition.args.slice(0, 2000);
+ entry.shortcuts = shortcuts;
+ entry.updatedAt = Date.now();
+ store.conversations[key] = entry;
+ this.saveBotSessionStore();
+ return "Shortcut saved: " + definition.name + ".";
+ }
+ if (command.name === "remove") {
+ delete shortcuts[String(command.args || "").trim().toLowerCase()];
+ entry.shortcuts = shortcuts;
+ entry.updatedAt = Date.now();
+ store.conversations[key] = entry;
+ this.saveBotSessionStore();
+ return "Shortcut removed.";
+ }
+ if (command.name === "run") {
+ const invocation = parseSubcommand(command.args);
+ const prompt = shortcuts[invocation.name];
+ return prompt ? { forwardText: prompt + (invocation.args ? "\n\n" + invocation.args : "") } : "Shortcut '" + invocation.name + "' was not found.";
+ }
+ return "Usage: /session shortcut list | add | remove | run [input].";
+ }
+
restoreBotThreadForConversation(key) {
const entry = this.loadBotSessionStore().conversations[key];
if (!entry || typeof entry !== "object") return null;
- if (Number(entry.entryVersion || 0) < BOT_SESSION_ENTRY_VERSION) {
+ if (Number(entry.entryVersion || 0) < 2) {
log("bot_gateway_session_legacy_skip", {
conversationKeyPrefix: key.slice(0, 80),
threadId: entry.threadId || "",
@@ -1811,6 +3309,8 @@ class ClaudeCodeAppServer {
thread.claudeAppSessionId = entry.claudeAppSessionId || null;
thread.claudeAppSessionFile = entry.claudeAppSessionFile || "";
thread.preview = entry.preview || "";
+ thread.reasoningEffort = entry.effort || entry.reasoningEffort || null;
+ thread.permissionMode = entry.permissionMode || "";
thread.updatedAt = entry.updatedAtSeconds || nowSeconds();
this.botThreads.set(key, thread.id);
this.botThreadKeys.set(thread.id, key);
@@ -1822,8 +3322,8 @@ class ClaudeCodeAppServer {
return thread;
}
- createBotThreadForNewClaudeAppSession(key, text) {
- const session = createClaudeAppLocalAgentSession(text);
+ createBotThreadForNewClaudeAppSession(key, text, projectDirectory) {
+ const session = createClaudeAppLocalAgentSession(text, projectDirectory);
if (!session) return null;
const thread = this.createThread({
cwd: session.cwd || process.cwd(),
@@ -1836,6 +3336,10 @@ class ClaudeCodeAppServer {
thread.claudeConfigDir = session.claudeConfigDir || null;
thread.claudeAppSessionId = session.sessionId || null;
thread.claudeAppSessionFile = session.file || "";
+ const entry = this.loadBotSessionStore().conversations[key] || {};
+ if (entry.model) thread.model = entry.model;
+ if (entry.effort) thread.reasoningEffort = entry.effort;
+ if (entry.permissionMode) thread.permissionMode = entry.permissionMode;
thread.preview = session.title || text.slice(0, 160);
thread.name = session.title || this.workspaceName;
thread.updatedAt = Math.floor((session.lastActivityAt || Date.now()) / 1000);
@@ -1865,15 +3369,102 @@ class ClaudeCodeAppServer {
try {
this.botSessionStore = normalizeBotSessionStore(JSON.parse(fs.readFileSync(botSessionStorePath(), "utf8")));
} catch {
- this.botSessionStore = { version: 1, conversations: {} };
+ this.botSessionStore = { version: BOT_SESSION_ENTRY_VERSION, conversations: {}, pendingTurns: [], projectAliases: {} };
}
return this.botSessionStore;
}
saveBotSessionStore() {
const file = botSessionStorePath();
- fs.mkdirSync(path.dirname(file), { recursive: true });
- fs.writeFileSync(file, JSON.stringify(this.botSessionStore, null, 2));
+ writeJsonAtomic(file, this.botSessionStore);
+ }
+
+ persistBotPendingTurn(job) {
+ const store = this.loadBotSessionStore();
+ if (!Array.isArray(store.pendingTurns)) store.pendingTurns = [];
+ if (!store.pendingTurns.some((item) => item.id === job.id)) {
+ store.pendingTurns.push({ id: job.id, event: job.event, eventId: job.eventId, text: job.text, key: job.key, createdAt: job.createdAt });
+ store.pendingTurns = store.pendingTurns.slice(-100);
+ this.saveBotSessionStore();
+ }
+ }
+
+ removeBotPendingTurn(id) {
+ const store = this.loadBotSessionStore();
+ if (!Array.isArray(store.pendingTurns)) return;
+ const next = store.pendingTurns.filter((item) => item.id !== id);
+ if (next.length !== store.pendingTurns.length) {
+ store.pendingTurns = next;
+ this.saveBotSessionStore();
+ }
+ }
+
+ restoreBotPendingTurns(bridge) {
+ if (this.restoredBotPendingTurns) return;
+ this.restoredBotPendingTurns = true;
+ const pending = this.loadBotSessionStore().pendingTurns;
+ for (const item of Array.isArray(pending) ? pending.slice() : []) {
+ if (!item.event || !item.text) continue;
+ const key = item.key || botConversationKey(item.event);
+ const state = this.botTurnStates.get(key) || { active: null, pending: [] };
+ state.pending.push({ ...item, key, bridge });
+ this.botTurnStates.set(key, state);
+ void this.drainBotTurnQueue(key, state);
+ }
+ }
+
+ renderBotTurnStatus(key) {
+ const state = this.botTurnStates.get(key);
+ const approval = this.botPendingApprovals.get(key);
+ if ((!state || (!state.active && !state.pending.length)) && !approval) return "No Agent turn is running or queued for this conversation.";
+ const lines = [];
+ if (state && state.active) lines.push("Running for " + formatDuration(Date.now() - state.active.startedAt) + ": " + promptTitle(state.active.job.text));
+ if (approval) lines.push("Waiting for permission: " + approval.label);
+ lines.push("Queued turns: " + (state ? state.pending.length : 0));
+ lines.push("Use /session cancel to stop the active turn and clear this conversation's queue.");
+ return lines.join("\n");
+ }
+
+ cancelBotTurns(key) {
+ const state = this.botTurnStates.get(key);
+ if (!state || (!state.active && !state.pending.length)) return "No Agent turn is running or queued for this conversation.";
+ const cleared = state.pending.splice(0);
+ for (const job of cleared) this.removeBotPendingTurn(job.id);
+ if (state.active) {
+ state.active.cancelRequested = true;
+ if (state.active.work) state.active.work.cancelRequested = true;
+ const active = findActiveForThread(this.active, state.active.threadId);
+ interruptChildProcess(active && active.child);
+ }
+ const approval = this.botPendingApprovals.get(key);
+ if (approval) {
+ this.botPendingApprovals.delete(key);
+ approval.resolve({ decision: "deny", reason: "Turn canceled" });
+ }
+ return "Cancellation requested. Cleared " + cleared.length + " queued turn" + (cleared.length === 1 ? "" : "s") + ".";
+ }
+
+ expireIdleBotSession(key, idleMinutes) {
+ const minutes = Number(idleMinutes) || 0;
+ if (minutes <= 0) return;
+ const entry = this.loadBotSessionStore().conversations[key];
+ if (!entry || !entry.updatedAt || Date.now() - Number(entry.updatedAt) < minutes * 60_000) return;
+ this.setBotProjectForConversation(key, entry.projectDirectory || entry.cwd || this.defaultBotProjectDirectory());
+ }
+
+ resolveBotApproval(key, action, args) {
+ const pending = this.botPendingApprovals.get(key);
+ if (!pending) return "No permission request is waiting for this conversation.";
+ this.botPendingApprovals.delete(key);
+ const input = String(args || "").trim();
+ if (action === "answer") {
+ pending.resolve({ action: "accept", content: input, value: input });
+ return "Answer sent to the Agent.";
+ }
+ const sessionScope = input.toLowerCase() === "session";
+ if (action === "approve" && sessionScope) this.botSessionApprovals.add(key);
+ pending.resolve({ decision: action === "approve" ? "allow" : "deny", scope: sessionScope ? "session" : "once" });
+ return action === "approve" ? "Permission approved." : "Permission denied.";
}
persistBotThread(threadId) {
@@ -1882,7 +3473,9 @@ class ClaudeCodeAppServer {
const thread = this.threads.get(threadId);
if (!thread) return;
const store = this.loadBotSessionStore();
+ const existing = store.conversations[key] && typeof store.conversations[key] === "object" ? store.conversations[key] : {};
store.conversations[key] = {
+ ...existing,
entryVersion: BOT_SESSION_ENTRY_VERSION,
threadId: thread.id,
sessionId: thread.sessionId || thread.id,
@@ -1890,8 +3483,11 @@ class ClaudeCodeAppServer {
claudeAppSessionId: thread.claudeAppSessionId || null,
claudeAppSessionFile: thread.claudeAppSessionFile || null,
claudeConfigDir: thread.claudeConfigDir || null,
+ projectDirectory: thread.cwd || process.cwd(),
cwd: thread.cwd || process.cwd(),
model: thread.model || "",
+ effort: thread.reasoningEffort || "",
+ permissionMode: thread.permissionMode || "",
preview: thread.preview || "",
updatedAt: Date.now(),
updatedAtSeconds: thread.updatedAt || nowSeconds()
@@ -1914,11 +3510,6 @@ class ClaudeCodeAppServer {
const id = uuid();
const cwd = normalizeCwd(params.cwd);
const now = nowSeconds();
- const agentConsoleSubagents = normalizeAgentConsoleSubagentRuntime(params.agentConsoleSubagents || params.agent_console_subagents);
- const mcpServers = mergeAgentConsoleMcpServerMaps(
- normalizeAgentConsoleMcpServerMap(params.mcpServers || params.mcp_servers),
- agentConsoleSubagents ? normalizeAgentConsoleMcpServerMap(agentConsoleSubagents.mcpServers) : null
- ) || {};
const thread = {
id,
sessionId: id,
@@ -1935,9 +3526,7 @@ class ClaudeCodeAppServer {
workspaceBrowserRoot: params.workspaceBrowserRoot || params.workspaceRoot || cwd,
projectlessOutputDirectory: params.projectlessOutputDirectory || null,
baseInstructions: params.baseInstructions || null,
- developerInstructions: withAgentConsoleSubagentInstructions(combinedDeveloperInstructions(params), agentConsoleSubagents),
- agentConsoleSubagents,
- mcpServers,
+ developerInstructions: combinedDeveloperInstructions(params),
personality: params.personality ?? null,
persistExtendedHistory: params.persistExtendedHistory ?? null,
model: params.model || agentEnv(codexRuntimeAgent(), "MODEL") || DEFAULT_MODEL,
@@ -2028,10 +3617,9 @@ class ClaudeCodeAppServer {
resumeExisting: Boolean(thread.claudeSessionId),
claudeSessionId: thread.claudeSessionId,
claudeConfigDir: thread.claudeConfigDir,
- developerInstructions: thread.developerInstructions,
- agentConsoleSubagents: thread.agentConsoleSubagents,
- mcpServers: thread.mcpServers,
- model: thread.model
+ model: thread.model,
+ reasoningEffort: thread.reasoningEffort,
+ permissionMode: thread.permissionMode || ""
};
const userItem = userItemJson(turn);
const notifications = [
@@ -2116,6 +3704,15 @@ class ClaudeCodeAppServer {
writeNotification("thread/stream/state", threadStreamState(thread));
}
}, 1000);
+ const wallTimeoutMs = work.botContext && work.botContext.bridge
+ ? Number(work.botContext.bridge.config.maxTurnTimeMs) || TURN_IDLE_TIMEOUT_MS
+ : TURN_IDLE_TIMEOUT_MS;
+ const wallTimeout = setTimeout(() => {
+ work.timedOut = true;
+ log("claude_turn_wall_timeout", { threadId: work.threadId, turnId: work.turnId, wallTimeoutMs });
+ interruptChildProcess(child);
+ }, wallTimeoutMs);
+ if (typeof wallTimeout.unref === "function") wallTimeout.unref();
const childDone = waitForChild(child).then((code) => ({ kind: "exit", code }));
const resultDone = resultSeen.then(() => sleep(250).then(() => ({ kind: "result", code: 0 })));
@@ -2134,13 +3731,19 @@ class ClaudeCodeAppServer {
}
const code = done.code;
clearInterval(idle);
+ clearTimeout(wallTimeout);
+ if (work.botStream && work.botStream.timer) clearTimeout(work.botStream.timer);
stdoutRl.close();
stderrRl.close();
this.active.delete(key);
const text = stream.resultText || stream.emitted || stream.pending;
turn.agentText = text;
- turn.error = stream.resultError || (childSpawnError ? formatError(childSpawnError) : code === 0 ? null : stderr.trim() || "Claude Code exited with code " + code);
- turn.status = turn.error ? "failed" : "completed";
+ turn.error = work.cancelRequested
+ ? "Turn canceled."
+ : work.timedOut
+ ? "Turn timed out after " + formatDuration(wallTimeoutMs) + "."
+ : stream.resultError || (childSpawnError ? formatError(childSpawnError) : code === 0 ? null : stderr.trim() || "Claude Code exited with code " + code);
+ turn.status = work.cancelRequested ? "interrupted" : turn.error ? "failed" : "completed";
turn.completedAt = nowSeconds();
turn.durationMs = Date.now() - started;
turn.toolItems = Array.from(stream.tools.values()).map((tool) => toolItemJson(work.threadId, work.cwd, tool));
@@ -2210,6 +3813,17 @@ class ClaudeCodeAppServer {
const params = method === "item/permissions/requestApproval"
? permissionRequestParams(work, requestId, message)
: elicitationRequestParams(work, requestId, message);
+ if (work.botContext) {
+ this.requestBotControl(work, requestId, method, params).then((approval) => {
+ const response = method === "item/permissions/requestApproval"
+ ? claudeControlPermissionResponse(message, requestId, approval)
+ : claudeControlElicitationResponse(requestId, approval);
+ child.stdin.write(JSON.stringify(response) + "\n");
+ }).catch((error) => {
+ child.stdin.write(JSON.stringify({ type: "control_response", response: { subtype: "error", request_id: requestId, error: formatError(error) } }) + "\n");
+ });
+ return;
+ }
writeRaw({ id: requestId, method, params });
waitForAppResponse(this.appResponses, requestId, REQUEST_TIMEOUT_MS).then((approval) => {
const response = method === "item/permissions/requestApproval"
@@ -2220,6 +3834,56 @@ class ClaudeCodeAppServer {
child.stdin.write(JSON.stringify({ type: "control_response", response: { subtype: "error", request_id: requestId, error: formatError(error) } }) + "\n");
});
}
+
+ async requestBotControl(work, requestId, method, params) {
+ const context = work.botContext;
+ const key = context.conversationKey;
+ if (method === "item/permissions/requestApproval" && !context.bridge.config.shellEnabled && isShellPermissionRequest(params)) {
+ return { decision: "deny", reason: "Agent shell tools are disabled in Bot Settings." };
+ }
+ if (method === "item/permissions/requestApproval" && this.botSessionApprovals.has(key)) {
+ return { decision: "allow", scope: "session" };
+ }
+ if (this.botPendingApprovals.has(key)) throw new Error("Another Agent request is already waiting for this conversation.");
+ const isPermission = method === "item/permissions/requestApproval";
+ const label = isPermission ? String(params.reason || "Agent permission") : String(params.message || "Agent input request");
+ const fallbackText = isPermission
+ ? label + "\nReply /session approve, /session approve session, or /session deny."
+ : label + "\nReply /session answer or /session deny.";
+ const actions = isPermission
+ ? [
+ { type: "button", label: "Approve once", value: "/session approve" },
+ { type: "button", label: "Approve for session", value: "/session approve session" },
+ { type: "button", label: "Deny", value: "/session deny" }
+ ]
+ : [{ type: "button", label: "Deny", value: "/session deny" }];
+ const promise = new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ if (this.botPendingApprovals.get(key) && this.botPendingApprovals.get(key).requestId === requestId) {
+ this.botPendingApprovals.delete(key);
+ }
+ reject(new Error("Bot approval timed out."));
+ }, Math.min(REQUEST_TIMEOUT_MS, context.bridge.config.maxTurnTimeMs));
+ if (typeof timeout.unref === "function") timeout.unref();
+ this.botPendingApprovals.set(key, {
+ requestId,
+ label,
+ kind: isPermission ? "permission" : "elicitation",
+ resolve: (value) => { clearTimeout(timeout); resolve(value); },
+ reject
+ });
+ });
+ await context.bridge.sendCardToEvent(context.event, {
+ title: isPermission ? "Agent permission required" : "Agent needs input",
+ body: label,
+ fields: [
+ { label: "Project", value: projectNameFromDirectory(work.cwd) },
+ { label: "Session", value: shortSessionId(work.threadId) }
+ ],
+ actions
+ }, fallbackText, "ccr:claude-code:control:" + requestId);
+ return promise;
+ }
}
function handleClaudeStreamEvent(event, work, stream) {
@@ -2304,6 +3968,7 @@ function handleClaudeContentBlock(block, work, stream) {
result: ""
};
stream.tools.set(id, tool);
+ queueBotProgress(work, "Using " + tool.name + "…");
writeNotification("item/started", { threadId: work.threadId, turnId: work.turnId, item: toolItemJson(work.threadId, work.cwd, tool), startedAtMs: Date.now() });
} else if (String(type || "").includes("tool_result")) {
handleClaudeToolResults([block], work, stream);
@@ -2321,6 +3986,7 @@ function emitAgentDelta(work, stream, text) {
});
}
stream.emitted += text;
+ queueBotStreamUpdate(work, stream.emitted);
writeNotification("item/updated", {
threadId: work.threadId,
turnId: work.turnId,
@@ -2330,6 +3996,36 @@ function emitAgentDelta(work, stream, text) {
});
}
+function queueBotProgress(work, text) {
+ if (!work.botContext || !work.botContext.bridge.config.streamReplies) return;
+ const current = work.botStream && work.botStream.pending || "";
+ if (!current) queueBotStreamUpdate(work, text);
+}
+
+function queueBotStreamUpdate(work, text) {
+ if (!work.botContext || !work.botStream || !work.botContext.bridge.config.streamReplies) return;
+ work.botStream.pending = String(text || "").slice(-12000);
+ const send = () => {
+ work.botStream.timer = null;
+ const value = work.botStream.pending;
+ if (!value) return;
+ work.botStream.lastSentAt = Date.now();
+ void work.botContext.bridge.sendStreamToEvent(
+ work.botContext.event,
+ work.botStream.id,
+ value,
+ false,
+ "ccr:claude-code:stream:" + work.botContext.eventId
+ ).catch((error) => work.botContext.bridge.logError("stream_failed", error));
+ };
+ const delay = Math.max(0, 700 - (Date.now() - work.botStream.lastSentAt));
+ if (delay === 0) {
+ send();
+ } else if (!work.botStream.timer) {
+ work.botStream.timer = setTimeout(send, delay);
+ }
+}
+
function emitAgentSnapshot(work, stream, text) {
if (!stream.agentStarted && !stream.emitted) {
stream.pending = text;
@@ -2358,12 +4054,8 @@ function claudeCommand(work) {
];
const model = nonEmptyEnv("CCR_CLAUDE_CODE_MODEL") || nonEmptyEnv("CODEXL_CLAUDE_CODE_MODEL") || work.model;
if (model) args.push("--model", model);
- const developerInstructions = runtimeString(work.developerInstructions);
- if (developerInstructions) args.push("--append-system-prompt", developerInstructions);
- const agentConsoleAgents = claudeCodeAgentsJson(work.agentConsoleSubagents);
- if (agentConsoleAgents && !claudeCodeArgsHaveAgents(args)) args.push("--agents", agentConsoleAgents);
- const agentConsoleMcpConfig = claudeCodeMcpConfigJson(work.mcpServers);
- if (agentConsoleMcpConfig) args.push("--mcp-config", agentConsoleMcpConfig);
+ if (work.reasoningEffort) args.push("--effort", work.reasoningEffort);
+ if (work.permissionMode) args.push("--permission-mode", work.permissionMode);
if (work.resumeExisting && work.claudeSessionId) args.push("--resume", work.claudeSessionId);
const extra = splitShellLike(nonEmptyEnv("CCR_CLAUDE_CODE_EXTRA_ARGS") || nonEmptyEnv("CODEXL_CLAUDE_CODE_EXTRA_ARGS") || "");
args.push(...extra);
@@ -2386,85 +4078,6 @@ function claudeCommand(work) {
};
}
-function claudeCodeAgentsJson(runtime) {
- const normalized = normalizeAgentConsoleSubagentRuntime(runtime);
- if (!normalized) return "";
- const agents = {};
- const usedNames = new Set();
- for (const subagent of normalized.subagents) {
- const name = uniqueClaudeCodeAgentName(subagent.id || subagent.label, usedNames);
- usedNames.add(name);
- agents[name] = {
- description: subagent.description || "Agent Console subagent " + subagent.label,
- prompt: claudeCodeAgentPrompt(subagent)
- };
- }
- return Object.keys(agents).length ? JSON.stringify(agents) : "";
-}
-
-function uniqueClaudeCodeAgentName(value, usedNames) {
- const base = runtimeString(value)
- .trim()
- .toLowerCase()
- .replace(/[^a-z0-9_-]+/g, "-")
- .replace(/^-+/, "")
- .replace(/-+$/g, "")
- .slice(0, 48) || "agent-console-subagent";
- if (!usedNames.has(base)) return base;
- for (let index = 2; index < 1000; index += 1) {
- const next = (base + "-" + index).slice(0, 58);
- if (!usedNames.has(next)) return next;
- }
- return (base + "-" + Date.now()).slice(0, 64);
-}
-
-function claudeCodeAgentPrompt(subagent) {
- const lines = [
- "You are the Agent Console subagent " + subagent.label + " (" + subagent.id + ").",
- subagent.systemPrompt
- ];
- if (subagent.model) {
- lines.push("Preferred CCR model for this subagent: " + subagent.model + ".");
- }
- lines.push("Runtime mode requested by Agent Console: " + (subagent.runtimeMode || "auto") + ".");
- if (subagent.capabilities && subagent.capabilities.length) {
- lines.push("Worker capabilities: " + subagent.capabilities.join(", ") + ".");
- }
- if (subagent.contextScope) {
- lines.push("Context scope: " + subagent.contextScope);
- }
- const budget = agentConsoleSubagentBudgetText(subagent.budget);
- if (budget) {
- lines.push("Budget limits: " + budget + ".");
- }
- if (subagent.outputContract) {
- lines.push("Output contract: " + subagent.outputContract);
- }
- if (subagent.qualityGates && subagent.qualityGates.length) {
- lines.push("Quality gates: " + subagent.qualityGates.join("; ") + ".");
- }
- if (subagent.mcpServerIds && subagent.mcpServerIds.length) {
- lines.push("Prefer the MCP servers scoped to this subagent: " + subagent.mcpServerIds.join(", ") + ".");
- } else {
- lines.push("No MCP servers are scoped to this subagent; stay within the main task context and available native tools.");
- }
- return lines.join("\n\n").trim();
-}
-
-function claudeCodeMcpConfigJson(mcpServers) {
- const normalized = normalizeAgentConsoleMcpServerMap(mcpServers);
- return normalized ? JSON.stringify({ mcpServers: normalized }) : "";
-}
-
-function claudeCodeArgsHaveAgents(args) {
- for (const arg of args) {
- if (arg === "--agents" || arg.startsWith("--agents=")) {
- return true;
- }
- }
- return false;
-}
-
function claudeInputMessage(input, sessionId = "") {
return {
type: "user",
@@ -2632,22 +4245,6 @@ function applyThreadMetadata(thread, params) {
if (params.approvalPolicy) thread.approvalPolicy = params.approvalPolicy;
if (params.approvalsReviewer) thread.approvalsReviewer = params.approvalsReviewer;
if (params.name !== undefined || params.title !== undefined) thread.name = params.name || params.title || null;
- const runtime = normalizeAgentConsoleSubagentRuntime(params.agentConsoleSubagents || params.agent_console_subagents);
- const mcpServers = mergeAgentConsoleMcpServerMaps(
- normalizeAgentConsoleMcpServerMap(params.mcpServers || params.mcp_servers),
- runtime ? normalizeAgentConsoleMcpServerMap(runtime.mcpServers) : null
- );
- if (mcpServers) {
- thread.mcpServers = mergeAgentConsoleMcpServerMaps(normalizeAgentConsoleMcpServerMap(thread.mcpServers), mcpServers) || {};
- }
- const developerInstructions = combinedDeveloperInstructions(params);
- if (developerInstructions) {
- thread.developerInstructions = mergeDeveloperInstructionText(thread.developerInstructions, developerInstructions);
- }
- if (runtime) {
- thread.agentConsoleSubagents = runtime;
- thread.developerInstructions = withAgentConsoleSubagentInstructions(thread.developerInstructions, runtime);
- }
thread.updatedAt = nowSeconds();
}
@@ -2693,7 +4290,7 @@ function modelList(params, existingResult) {
const fallbackIds = isClaudeCodeRuntime
? [configured].filter(Boolean)
: [configured].filter((model) => model && !isClaudeCodeOnlyModel(model));
- const models = mergeModelListItems(extractModelListItems(existingResult), [...catalogModelItems(), ...fallbackIds], selected);
+ const models = mergeModelListItems(extractModelListItems(existingResult), [...catalogModelIds(), ...fallbackIds], selected);
const offset = Number(params.cursor || 0) || 0;
const limit = Number(params.limit || models.length) || models.length;
const data = models.slice(offset, offset + limit);
@@ -2706,15 +4303,8 @@ function modelList(params, existingResult) {
}
function catalogModelIds() {
- return catalogModelItems()
- .map(modelItemId)
- .map(normalizeModelSelector)
- .filter(Boolean);
-}
-
-function catalogModelItems() {
- return parseModelCatalogEnv()
- .filter((item) => normalizeModelSelector(modelItemId(item)));
+ const values = parseModelCatalogEnv();
+ return values.map(normalizeModelSelector).filter(Boolean);
}
function parseModelCatalogEnv() {
@@ -2722,14 +4312,14 @@ function parseModelCatalogEnv() {
if (file) {
const parsed = readJsonFile(file);
if (parsed) {
- return modelItemsFromJson(parsed);
+ return modelIdsFromJson(parsed);
}
log("model_catalog_parse_error", { source: "file", file });
}
const encoded = agentEnv(codexRuntimeAgent(), "MODEL_CATALOG_B64");
if (encoded) {
try {
- return modelItemsFromJson(JSON.parse(Buffer.from(encoded, "base64").toString("utf8")));
+ return modelIdsFromJson(JSON.parse(Buffer.from(encoded, "base64").toString("utf8")));
} catch (error) {
log("model_catalog_parse_error", { source: "base64", error: formatError(error) });
}
@@ -2737,7 +4327,7 @@ function parseModelCatalogEnv() {
const raw = agentEnv(codexRuntimeAgent(), "MODEL_CATALOG");
if (raw) {
try {
- return modelItemsFromJson(JSON.parse(raw));
+ return modelIdsFromJson(JSON.parse(raw));
} catch (error) {
log("model_catalog_parse_error", { source: "json", error: formatError(error) });
}
@@ -2745,16 +4335,16 @@ function parseModelCatalogEnv() {
return [];
}
-function modelItemsFromJson(value) {
+function modelIdsFromJson(value) {
const output = [];
- collectModelItemsFromJson(value, output);
+ collectModelIdsFromJson(value, output);
return output;
}
-function collectModelItemsFromJson(value, output) {
+function collectModelIdsFromJson(value, output) {
if (Array.isArray(value)) {
for (const item of value) {
- collectModelItemFromJson(item, output);
+ collectModelIdFromJsonItem(item, output);
}
return;
}
@@ -2763,83 +4353,44 @@ function collectModelItemsFromJson(value, output) {
for (const key of ["models", "data", "items", "results", "model_list"]) {
if (Array.isArray(value[key])) {
foundList = true;
- collectModelItemsFromJson(value[key], output);
+ collectModelIdsFromJson(value[key], output);
}
}
if (!foundList) {
- collectModelItemFromJson(value, output);
+ collectModelIdFromJsonItem(value, output);
}
}
}
-function collectModelItemFromJson(item, output) {
+function collectModelIdFromJsonItem(item, output) {
if (typeof item === "string") {
output.push(item);
return;
}
if (item && typeof item === "object") {
const id = firstString(item, ["/model", "/id", "/slug", "/display_name", "/displayName", "/name", "/label"]);
- if (id) output.push(item);
+ if (id) output.push(id);
}
}
-function mergeModelListItems(existingItems, catalogItems, selectedModel) {
+function mergeModelListItems(existingItems, catalogIds, selectedModel) {
const seen = new Set();
const output = [];
for (const item of existingItems) {
const id = normalizeModelSelector(modelItemId(item));
if (!id || seen.has(id.toLowerCase())) continue;
seen.add(id.toLowerCase());
- output.push(typeof item === "object" && item !== null ? normalizeModelListItem(item, id, selectedModel) : codexModelItem(id, selectedModel));
+ output.push(typeof item === "object" && item !== null ? { ...item, id: item.id || id, model: item.model || id } : codexModelItem(id, selectedModel));
}
- for (const item of catalogItems) {
- const id = normalizeModelSelector(modelItemId(item));
+ for (const rawId of catalogIds) {
+ const id = normalizeModelSelector(rawId);
if (!id || seen.has(id.toLowerCase())) continue;
seen.add(id.toLowerCase());
- output.push(typeof item === "object" && item !== null ? normalizeModelListItem(item, id, selectedModel) : codexModelItem(id, selectedModel));
+ output.push(codexModelItem(id, selectedModel));
}
return output;
}
-function normalizeModelListItem(item, id, selectedModel) {
- const normalized = { ...item, id: item.id || id, model: item.model || id };
- if (!normalized.displayName && typeof normalized.display_name === "string") {
- normalized.displayName = normalized.display_name;
- }
- if (normalized.defaultReasoningEffort === undefined) {
- normalized.defaultReasoningEffort = firstString(normalized, ["/default_reasoning_effort", "/default_reasoning_level", "/defaultReasoningLevel"]) || null;
- }
- const supportedReasoningEfforts =
- normalizeSupportedReasoningEfforts(normalized.supportedReasoningEfforts) ||
- normalizeSupportedReasoningEfforts(normalized.supported_reasoning_efforts) ||
- normalizeSupportedReasoningEfforts(normalized.supported_reasoning_levels) ||
- [];
- normalized.supportedReasoningEfforts = supportedReasoningEfforts;
- if (normalized.isDefault === undefined) normalized.isDefault = id === selectedModel;
- return normalized;
-}
-
-function normalizeSupportedReasoningEfforts(value) {
- if (!Array.isArray(value)) return undefined;
- const seen = new Set();
- const output = [];
- for (const item of value) {
- const record = item && typeof item === "object" ? item : {};
- const effort = typeof item === "string"
- ? item.trim()
- : firstString(record, ["/reasoningEffort", "/reasoning_effort", "/effort", "/value", "/id"]);
- if (!effort || seen.has(effort)) continue;
- seen.add(effort);
- const description = firstString(record, ["/description", "/label", "/name"]);
- output.push({
- ...(description ? { description } : {}),
- reasoningEffort: effort,
- reasoning_effort: effort
- });
- }
- return output.length ? output : undefined;
-}
-
function extractModelListItems(result) {
if (Array.isArray(result)) return result;
if (!result || typeof result !== "object") return [];
@@ -2962,17 +4513,10 @@ function modelCatalogFileEnv() {
function modelCatalogConfigItem(model, priority) {
return {
- id: model,
- model,
slug: model,
- displayName: model,
display_name: model,
description: "CCR gateway model " + model,
- defaultReasoningEffort: null,
default_reasoning_level: null,
- default_reasoning_effort: null,
- supportedReasoningEfforts: [],
- supported_reasoning_efforts: [],
supported_reasoning_levels: [],
shell_type: "shell_command",
visibility: "list",
@@ -3116,12 +4660,18 @@ function permissionRequestParams(work, requestId, message) {
threadId: work.threadId,
turnId: work.turnId,
itemId: firstString(message, ["/request/tool_use_id", "/request/toolUseId", "/params/tool_use_id"]) || requestId,
+ toolName,
cwd: work.cwd,
reason: "Claude Code wants to use " + label + ".",
permissions: { network: { enabled: true }, fileSystem: { read: [work.cwd], write: [work.cwd] } }
};
}
+function isShellPermissionRequest(params) {
+ const name = String(params && params.toolName || "").trim().toLowerCase();
+ return ["bash", "shell", "shell_command", "terminal", "execute_command", "run_command"].includes(name);
+}
+
function elicitationRequestParams(work, requestId, message) {
return {
threadId: work.threadId,
@@ -3424,18 +4974,33 @@ function readBotGatewayBridgeConfig() {
},
integrationConfig: websocketBotGatewayIntegrationConfig(platform, jsonObjectEnv("CCR_BOT_GATEWAY_CONFIG_JSON") || {}),
integrationId: nonEmptyEnv("CCR_BOT_GATEWAY_INTEGRATION_ID") || nonEmptyEnv("CODEXL_BOT_GATEWAY_INTEGRATION_ID") || "",
+ language: normalizeBotLanguage(nonEmptyEnv("CCR_BOT_GATEWAY_LANGUAGE") || "auto"),
+ maxAttachmentBytes: numberEnv("CCR_BOT_GATEWAY_MAX_ATTACHMENT_BYTES", 20 * 1024 * 1024),
+ maxTurnTimeMs: numberEnv("CCR_BOT_GATEWAY_MAX_TURN_TIME_MS", 10 * 60 * 1000),
+ mediaEnabled: boolEnv("CCR_BOT_GATEWAY_MEDIA_ENABLED"),
+ messageChunkChars: numberEnv("CCR_BOT_GATEWAY_MESSAGE_CHUNK_CHARS", 3500),
platform,
pollIntervalMs: numberEnv("CCR_BOT_GATEWAY_POLL_INTERVAL_MS", 2000),
profileId: nonEmptyEnv("CCR_BOT_PROFILE_ID") || agentEnv(codexRuntimeAgent(), "PROFILE") || "default",
profileName: nonEmptyEnv("CCR_BOT_PROFILE_NAME") || agentEnv(codexRuntimeAgent(), "WORKSPACE_NAME") || "CCR",
requestTimeoutMs: numberEnv("CCR_BOT_GATEWAY_REQUEST_TIMEOUT_MS", 600000),
+ sessionIdleMinutes: numberEnv("CCR_BOT_GATEWAY_SESSION_IDLE_MINUTES", 0),
+ shellEnabled: boolEnv("CCR_BOT_GATEWAY_SHELL_ENABLED"),
sourceDir: nonEmptyEnv("CCR_BOT_GATEWAY_SOURCE_DIR") || "",
startupTimeoutMs: numberEnv("CCR_BOT_GATEWAY_STARTUP_TIMEOUT_MS", 10000),
stateDir: nonEmptyEnv("CCR_BOT_GATEWAY_STATE_DIR") || nonEmptyEnv("CODEXL_BOT_GATEWAY_STATE_DIR") || nonEmptyEnv("BOT_GATEWAY_STATE_DIR") || "",
+ streamReplies: boolEnv("CCR_BOT_GATEWAY_STREAM_REPLIES"),
tenantId: nonEmptyEnv("CCR_BOT_GATEWAY_TENANT_ID") || nonEmptyEnv("CODEXL_BOT_GATEWAY_TENANT_ID") || "ccr"
};
}
+function normalizeBotLanguage(value) {
+ const normalized = String(value || "").trim().toLowerCase();
+ if (normalized === "zh" || normalized === "zh-cn" || normalized === "zh_cn") return "zh-CN";
+ if (normalized === "en" || normalized === "en-us" || normalized === "en_us") return "en";
+ return "auto";
+}
+
function normalizeBotGatewayPlatform(value) {
const normalized = String(value || "").trim().toLowerCase();
if (!normalized || normalized === "off" || normalized === "disabled") return "none";
@@ -3463,6 +5028,7 @@ function defaultBotGatewayAuthType(platform) {
if (platform === "weixin-ilink") return "qr_login";
if (platform === "feishu" || platform === "dingtalk" || platform === "wecom") return "app_secret";
if (platform === "slack" || platform === "discord" || platform === "telegram" || platform === "line") return "bot_token";
+ if (platform === "imessage") return "local";
return "";
}
@@ -3499,13 +5065,15 @@ class BotGatewayBridge {
this.config = config;
this.child = null;
this.client = null;
- this.forwarded = new Set();
+ this.runtimeState = loadBotRuntimeState(config);
+ this.forwarded = new Set(Object.keys(this.runtimeState.forwarded || {}));
this.inboundHandler = null;
- this.inboundEvents = new Set();
+ this.inboundEvents = new Set(Object.keys(this.runtimeState.processedEvents || {}));
this.latestEvent = null;
this.messageCounter = 0;
this.pollTimer = null;
this.startPromise = null;
+ this.flushingOutbox = null;
this.suppressedTurnIds = new Set();
this.claudeCliCapture = { finalText: "", resultCount: 0, text: "" };
this.turnCaptures = new Map();
@@ -3534,6 +5102,7 @@ class BotGatewayBridge {
const client = this.client;
this.client = null;
this.startPromise = null;
+ this.updateDiagnostics({ state: "stopped", stoppedAt: new Date().toISOString() });
await closeBotGatewayClient(client);
}
@@ -3690,11 +5259,11 @@ class BotGatewayBridge {
}
forwardDecision() {
- if (!this.config.forwardAllAgentMessages) {
- return { shouldForward: false, reason: "forward_all_disabled" };
+ if (this.config.forwardAllAgentMessages) {
+ return { shouldForward: true, reason: "forward_all" };
}
if (!this.config.handoff.enabled) {
- return { shouldForward: false, reason: "handoff_disabled" };
+ return { shouldForward: false, reason: "forwarding_disabled" };
}
const presence = evaluateHandoffPresence(this.config.handoff);
return {
@@ -3708,7 +5277,6 @@ class BotGatewayBridge {
if (!conversationRef) {
throw new Error("No Bot Gateway conversationRef is configured and no inbound bot event context is available.");
}
- this.messageCounter += 1;
const outbound = {
tenantId: this.resolveTenantId(),
integrationId: this.resolveIntegrationId(),
@@ -3717,9 +5285,10 @@ class BotGatewayBridge {
type: "text",
text
},
- idempotencyKey: "ccr:handoff:" + this.config.profileId + ":" + key + ":" + this.messageCounter
+ idempotencyKey: "ccr:handoff:" + this.config.profileId + ":" + stableBotKey(key)
};
- await withTimeout(this.client.send(outbound), this.config.requestTimeoutMs, "Bot Gateway request timed out: outbound.send");
+ await this.sendDurable(outbound, { kind: "handoff", sourceKey: key });
+ this.rememberForwarded(key);
log("bot_gateway_forward_sent", {
key,
reason: decision.reason,
@@ -3732,22 +5301,118 @@ class BotGatewayBridge {
async sendReplyToEvent(event, text, key) {
if (!text || !String(text).trim()) return;
await this.ensureStarted();
+ text = localizeBotReply(String(text), botLanguageForEvent(this.config.language, event));
const conversationRef = conversationRefFromEvent(event) || this.config.conversationRef;
if (!conversationRef) {
throw new Error("No Bot Gateway conversationRef is available for inbound bot response.");
}
- this.messageCounter += 1;
- const outbound = {
+ const chunks = splitBotMessage(String(text), this.config.messageChunkChars);
+ for (let index = 0; index < chunks.length; index += 1) {
+ const chunk = chunks[index];
+ const outbound = this.outboundForEvent(event, conversationRef, botTextIntent(chunk), key + ":part:" + (index + 1));
+ await this.sendDurable(outbound, { kind: "reply", sourceKey: key });
+ }
+ }
+
+ async sendCardToEvent(event, card, fallbackText, key) {
+ await this.ensureStarted();
+ const conversationRef = conversationRefFromEvent(event) || this.config.conversationRef;
+ if (!conversationRef) throw new Error("No Bot Gateway conversationRef is available for card response.");
+ const language = botLanguageForEvent(this.config.language, event);
+ const localizedFallback = localizeBotReply(fallbackText, language);
+ const localizedCard = language === "zh-CN" ? localizeBotCard(card) : card;
+ const outbound = this.outboundForEvent(event, conversationRef, { type: "card", card: localizedCard, fallbackText: localizedFallback }, key);
+ await this.sendDurable(outbound, { kind: "card", sourceKey: key });
+ }
+
+ async sendMediaToEvent(event, media, caption, key) {
+ if (!this.config.mediaEnabled) return;
+ await this.ensureStarted();
+ const conversationRef = conversationRefFromEvent(event) || this.config.conversationRef;
+ if (!conversationRef) throw new Error("No Bot Gateway conversationRef is available for media response.");
+ const fallbackText = caption || media.filename || media.url || "Attachment";
+ const outbound = this.outboundForEvent(event, conversationRef, { type: "media", media, caption, fallbackText }, key);
+ await this.sendDurable(outbound, { kind: "media", sourceKey: key });
+ }
+
+ async sendStreamToEvent(event, streamId, text, final, key) {
+ if (!this.config.streamReplies || !text) return;
+ await this.ensureStarted();
+ const conversationRef = conversationRefFromEvent(event) || this.config.conversationRef;
+ if (!conversationRef) return;
+ const outbound = this.outboundForEvent(event, conversationRef, {
+ type: "stream_text",
+ streamId,
+ text,
+ final: Boolean(final),
+ fallbackText: text
+ }, key + ":" + (final ? "final" : stableBotKey(text.slice(-160))));
+ await this.sendDurable(outbound, { kind: "stream", sourceKey: key });
+ }
+
+ outboundForEvent(event, conversationRef, intent, key) {
+ return {
tenantId: eventString(event, "tenantId") || this.config.tenantId || "ccr",
integrationId: eventString(event, "integrationId") || this.config.integrationId,
conversationRef,
- intent: {
- type: "text",
- text
- },
- idempotencyKey: key + ":" + this.messageCounter
+ intent,
+ idempotencyKey: stableBotKey(key)
};
- await withTimeout(this.client.send(outbound), this.config.requestTimeoutMs, "Bot Gateway request timed out: inbound outbound.send");
+ }
+
+ async sendDurable(outbound, metadata) {
+ const id = outbound.idempotencyKey || stableBotKey(JSON.stringify(outbound));
+ let entry = this.runtimeState.outbox.find((item) => item.id === id);
+ if (!entry) {
+ entry = { id, outbound, metadata, attempts: 0, createdAt: Date.now(), nextAttemptAt: 0 };
+ this.runtimeState.outbox.push(entry);
+ this.saveRuntimeState();
+ }
+ return this.deliverOutboxEntry(entry);
+ }
+
+ async deliverOutboxEntry(entry) {
+ entry.attempts += 1;
+ entry.lastAttemptAt = Date.now();
+ this.saveRuntimeState();
+ try {
+ const response = await withTimeout(this.client.send(entry.outbound), this.config.requestTimeoutMs, "Bot Gateway request timed out: outbound.send");
+ const result = response && response.result && typeof response.result === "object" ? response.result : response || {};
+ this.runtimeState.outbox = this.runtimeState.outbox.filter((item) => item.id !== entry.id);
+ this.runtimeState.deliveries.push({
+ id: entry.id,
+ kind: entry.metadata && entry.metadata.kind || "message",
+ status: stringValue(result.status) || "sent",
+ platformMessageId: stringValue(result.platformMessageId),
+ deliveredAt: Date.now()
+ });
+ this.runtimeState.deliveries = this.runtimeState.deliveries.slice(-100);
+ this.updateDiagnostics({ lastDeliveryAt: new Date().toISOString(), lastDeliveryStatus: stringValue(result.status) || "sent" }, false);
+ this.saveRuntimeState();
+ return response;
+ } catch (error) {
+ entry.lastError = formatError(error);
+ entry.nextAttemptAt = Date.now() + Math.min(60_000, 1000 * 2 ** Math.min(entry.attempts, 6));
+ this.updateDiagnostics({ lastError: entry.lastError, lastErrorAt: new Date().toISOString() }, false);
+ this.saveRuntimeState();
+ throw error;
+ }
+ }
+
+ async flushOutbox() {
+ if (this.flushingOutbox) return this.flushingOutbox;
+ this.flushingOutbox = (async () => {
+ const now = Date.now();
+ for (const entry of this.runtimeState.outbox.slice()) {
+ if (entry.nextAttemptAt && entry.nextAttemptAt > now) continue;
+ try {
+ await this.deliverOutboxEntry(entry);
+ } catch {
+ // Retain the entry for the next retry window.
+ }
+ }
+ })().finally(() => { this.flushingOutbox = null; });
+ return this.flushingOutbox;
}
resolveTenantId() {
@@ -3782,9 +5447,12 @@ class BotGatewayBridge {
const clientOptions = botGatewaySdkClientOptions(this.config, env, sdk);
this.client = sdk.createBotGatewayClient(clientOptions);
await withTimeout(this.client.health(), this.config.startupTimeoutMs, "Bot Gateway health check timed out.");
+ this.updateDiagnostics({ state: "connected", connectedAt: new Date().toISOString(), lastError: "" });
await this.ensureIntegration();
+ await this.flushOutbox();
await this.pollEvents();
this.pollTimer = setInterval(() => {
+ this.flushOutbox().catch((error) => this.logError("outbox_flush_failed", error));
this.pollEvents().catch((error) => this.logError("poll_failed", error));
}, Math.max(500, this.config.pollIntervalMs));
log("bot_gateway_started", { platform: this.config.platform, sdkTransport: clientOptions.transport, command: clientOptions.command || "sdk-bundled" });
@@ -3823,6 +5491,7 @@ class BotGatewayBridge {
if (!event || !this.matchesEvent(event)) continue;
if (event.actor && event.actor.isBot === true) continue;
this.latestEvent = event;
+ this.updateDiagnostics({ lastEventAt: new Date().toISOString(), lastEventType: eventString(event, "type") }, false);
const eventId = eventIdFromQueued(queued, event);
if (this.inboundHandler) {
await this.dispatchInboundEvent(queued, event, eventId);
@@ -3837,10 +5506,14 @@ class BotGatewayBridge {
async dispatchInboundEvent(queued, event, eventId) {
const key = eventId || botEventDedupeKey(event);
- if (this.inboundEvents.has(key)) return;
+ if (this.inboundEvents.has(key)) {
+ await this.ackEvent(eventId);
+ return;
+ }
this.inboundEvents.add(key);
try {
await this.inboundHandler(event, queued, eventId || key, this);
+ this.rememberProcessedEvent(key);
await this.ackEvent(eventId);
} catch (error) {
this.inboundEvents.delete(key);
@@ -3862,18 +5535,41 @@ class BotGatewayBridge {
return true;
}
- stop() {
- if (this.pollTimer) {
- clearInterval(this.pollTimer);
- this.pollTimer = null;
- }
- if (this.client && typeof this.client.close === "function") {
- this.client.close();
- }
- this.client = null;
+ rememberProcessedEvent(key) {
+ this.runtimeState.processedEvents[key] = Date.now();
+ pruneTimestampRecord(this.runtimeState.processedEvents, 2000, 7 * 24 * 60 * 60 * 1000);
+ this.saveRuntimeState();
+ }
+
+ rememberForwarded(key) {
+ this.runtimeState.forwarded[key] = Date.now();
+ pruneTimestampRecord(this.runtimeState.forwarded, 1000, 24 * 60 * 60 * 1000);
+ this.forwarded = new Set(Object.keys(this.runtimeState.forwarded));
+ this.saveRuntimeState();
+ }
+
+ diagnostics() {
+ return {
+ ...this.runtimeState.diagnostics,
+ outboxCount: this.runtimeState.outbox.length,
+ recentDeliveries: this.runtimeState.deliveries.slice(-10),
+ processedEventCount: Object.keys(this.runtimeState.processedEvents).length,
+ platform: this.config.platform,
+ integrationId: this.config.integrationId
+ };
+ }
+
+ updateDiagnostics(patch, save = true) {
+ this.runtimeState.diagnostics = { ...this.runtimeState.diagnostics, ...patch, updatedAt: new Date().toISOString() };
+ if (save) this.saveRuntimeState();
+ }
+
+ saveRuntimeState() {
+ writeJsonAtomic(botRuntimeStatePath(this.config), this.runtimeState);
}
logError(event, error) {
+ this.updateDiagnostics({ lastError: formatError(error), lastErrorAt: new Date().toISOString(), lastErrorEvent: event });
log("bot_gateway_" + event, { error: formatError(error) });
}
}
@@ -4136,48 +5832,306 @@ function botSessionStorePath() {
return path.join(expandHome(stateDir), "claude-bot-sessions.json");
}
+function botRuntimeStatePath(config) {
+ const stateDir = stringValue(config && config.stateDir) ||
+ nonEmptyEnv("CCR_BOT_GATEWAY_STATE_DIR") ||
+ nonEmptyEnv("BOT_GATEWAY_STATE_DIR") ||
+ path.join(CONFIG_DIR, "bot-gateway", safePathSegment(config && config.profileId || "default"));
+ return path.join(expandHome(stateDir), "bot-runtime-state.json");
+}
+
+function loadBotRuntimeState(config) {
+ const value = readJsonFile(botRuntimeStatePath(config));
+ const state = value && typeof value === "object" ? value : {};
+ const processedEvents = state.processedEvents && typeof state.processedEvents === "object" ? state.processedEvents : {};
+ const forwarded = state.forwarded && typeof state.forwarded === "object" ? state.forwarded : {};
+ pruneTimestampRecord(processedEvents, 2000, 7 * 24 * 60 * 60 * 1000);
+ pruneTimestampRecord(forwarded, 1000, 24 * 60 * 60 * 1000);
+ return {
+ version: BOT_RUNTIME_STATE_VERSION,
+ processedEvents,
+ forwarded,
+ outbox: Array.isArray(state.outbox) ? state.outbox.filter((item) => item && typeof item === "object" && item.id && item.outbound).slice(-500) : [],
+ deliveries: Array.isArray(state.deliveries) ? state.deliveries.slice(-100) : [],
+ diagnostics: state.diagnostics && typeof state.diagnostics === "object" ? state.diagnostics : { state: "starting" }
+ };
+}
+
+function pruneTimestampRecord(record, maxEntries, maxAgeMs) {
+ const now = Date.now();
+ const entries = Object.entries(record || {})
+ .filter(([, value]) => Number.isFinite(Number(value)) && now - Number(value) <= maxAgeMs)
+ .sort((left, right) => Number(right[1]) - Number(left[1]))
+ .slice(0, maxEntries);
+ for (const key of Object.keys(record || {})) delete record[key];
+ for (const [key, value] of entries) record[key] = Number(value);
+}
+
+function writeJsonAtomic(file, value) {
+ const temporary = file + "." + process.pid + ".tmp";
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(temporary, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 });
+ fs.renameSync(temporary, file);
+}
+
+function stableBotKey(value) {
+ return "ccr:" + crypto.createHash("sha256").update(String(value || "")).digest("hex").slice(0, 32);
+}
+
+function splitBotMessage(value, maxChars) {
+ const text = String(value || "").trim();
+ const limit = Math.max(500, Number(maxChars) || 3500);
+ if (!text || text.length <= limit) return text ? [text] : [];
+ const chunks = [];
+ let remaining = text;
+ while (remaining.length > limit) {
+ const window = remaining.slice(0, limit + 1);
+ const newline = window.lastIndexOf("\n");
+ const space = window.lastIndexOf(" ");
+ const cut = Math.max(newline >= limit * 0.55 ? newline : 0, space >= limit * 0.7 ? space : 0) || limit;
+ chunks.push(remaining.slice(0, cut).trim());
+ remaining = remaining.slice(cut).trim();
+ }
+ if (remaining) chunks.push(remaining);
+ return chunks;
+}
+
+function botTextIntent(text) {
+ const value = String(text || "");
+ const markdown = /(^|\n)\s{0,3}(#{1,6}\s|[-*+]\s|\d+\.\s|\x60{3}|>\s)|\[[^\]]+\]\([^)]+\)|\*\*[^*]+\*\*/m.test(value);
+ return markdown
+ ? { type: "markdown", markdown: value, fallbackText: value }
+ : { type: "text", text: value };
+}
+
+function botLanguageForEvent(configured, event) {
+ if (configured === "en" || configured === "zh-CN") return configured;
+ const locale = valueStringAtPaths(event, ["/actor/locale", "/raw/locale", "/raw/language", "/raw/user/locale"]) || process.env.LANG || "";
+ return /^zh(?:[-_]|$)/i.test(locale) ? "zh-CN" : "en";
+}
+
+function localizeBotReply(text, language) {
+ if (language !== "zh-CN") return text;
+ const replacements = [
+ ["Unknown Bot command. Send /project or /session to see available commands.", "未知的 Bot 命令。发送 /project 或 /session 查看可用命令。"],
+ ["CCR App project commands", "CCR App 项目命令"],
+ ["CCR App session commands", "CCR App 会话命令"],
+ ["Projects are managed separately with /project. The relay is available only while this App is opened through CCR.", "项目通过 /project 单独管理。只有通过 CCR 打开此 App 时,消息接力才在线。"],
+ ["Sessions are managed separately with /session. The relay is available only while this App is opened through CCR.", "会话通过 /session 单独管理。只有通过 CCR 打开此 App 时,消息接力才在线。"],
+ [" - list Agent projects", " - 列出 Agent 项目"],
+ [" - search Agent projects", " - 搜索 Agent 项目"],
+ [" - show the selected project", " - 显示当前项目"],
+ [" - select a listed project", " - 选择列表中的项目"],
+ [" - set a Bot display label for the current project", " - 设置当前项目的 Bot 显示名称"],
+ [" - list sessions in the current project", " - 列出当前项目中的会话"],
+ [" - search sessions in the current project", " - 搜索当前项目中的会话"],
+ [" - show the selected session", " - 显示当前会话"],
+ [" - show the active turn and queue", " - 显示正在运行和排队的任务"],
+ [" - stop the active turn and clear queued turns", " - 停止当前任务并清空队列"],
+ [" - approve a pending Agent permission", " - 允许待处理的 Agent 权限"],
+ [" - deny a pending Agent permission or input request", " - 拒绝待处理的权限或输入请求"],
+ [" - answer a pending Agent input request", " - 回答待处理的 Agent 输入请求"],
+ [" - start a session in the current project", " - 在当前项目中新建会话"],
+ [" - continue a listed session", " - 继续列表中的会话"],
+ [" - clear the selected session", " - 清除当前会话选择"],
+ [" - rename the selected session", " - 重命名当前会话"],
+ [" - show recent turns", " - 显示最近的对话"],
+ [" - show or change model/provider", " - 查看或切换模型/Provider"],
+ [" - show the latest token and cost data", " - 显示最近的 Token 与费用数据"],
+ [" - manage persistent session context", " - 管理持久会话上下文"],
+ [" - list project and user skills", " - 列出项目和用户 Skills"],
+ [" - invoke an Agent-native skill", " - 调用 Agent 原生 Skill"],
+ [" - manage conversation shortcuts", " - 管理会话快捷指令"],
+ [" - show Bot connection and delivery diagnostics", " - 显示 Bot 连接和投递诊断"],
+ [" - show recent outbound delivery results", " - 显示最近的出站投递结果"],
+ ["No Agent turn is running or queued for this conversation.", "当前会话没有正在运行或排队的 Agent 任务。"],
+ ["No permission request is waiting for this conversation.", "当前会话没有待处理的权限请求。"],
+ ["Permission approved.", "已允许本次权限请求。"],
+ ["Permission denied.", "已拒绝本次权限请求。"],
+ ["Answer sent to the Agent.", "已将回答发送给 Agent。"],
+ ["Bot diagnostics:", "Bot 诊断:"],
+ ["Recent Bot deliveries:", "最近的 Bot 投递:"],
+ ["No recent Bot deliveries.", "暂无最近的 Bot 投递记录。"],
+ ["Current Claude App project:", "当前 Claude App 项目:"],
+ ["Current OpenCode project:", "当前 OpenCode 项目:"],
+ ["Current Claude App session:", "当前 Claude App 会话:"],
+ ["Current OpenCode session:", "当前 OpenCode 会话:"],
+ ["Available models:", "可用模型:"],
+ ["Available skills:", "可用 Skills:"],
+ ["Session memory:", "会话记忆:"],
+ ["Session memory is empty.", "会话记忆为空。"],
+ ["Session memory cleared.", "会话记忆已清空。"],
+ ["Session memory added.", "已添加会话记忆。"],
+ ["Recent session history:", "最近会话历史:"],
+ ["Session history is empty.", "会话历史为空。"],
+ ["Queued turns:", "排队任务:"],
+ ["Running for ", "已运行 "],
+ ["Waiting for permission:", "等待权限确认:"],
+ ["Agent turn failed:", "Agent 运行失败:"]
+ ];
+ let output = String(text || "");
+ for (const [source, target] of replacements) output = output.split(source).join(target);
+ return output;
+}
+
+function localizeBotCard(card) {
+ const labelMap = {
+ "Agent permission required": "Agent 需要权限",
+ "Agent needs input": "Agent 需要输入",
+ "Approve once": "允许一次",
+ "Approve for session": "本会话始终允许",
+ "Deny": "拒绝",
+ "Project": "项目",
+ "Session": "会话"
+ };
+ return {
+ ...card,
+ title: labelMap[card.title] || card.title,
+ fields: Array.isArray(card.fields) ? card.fields.map((field) => ({ ...field, label: labelMap[field.label] || field.label })) : card.fields,
+ actions: Array.isArray(card.actions) ? card.actions.map((action) => ({ ...action, label: labelMap[action.label] || action.label })) : card.actions
+ };
+}
+
function normalizeBotSessionStore(value) {
const conversations = value && typeof value === "object" && value.conversations && typeof value.conversations === "object"
? value.conversations
: {};
- return { version: BOT_SESSION_ENTRY_VERSION, conversations };
+ const pendingTurns = value && typeof value === "object" && Array.isArray(value.pendingTurns)
+ ? value.pendingTurns.filter((item) => item && typeof item === "object")
+ : [];
+ const projectAliases = value && typeof value === "object" && value.projectAliases && typeof value.projectAliases === "object"
+ ? value.projectAliases
+ : {};
+ return { version: BOT_SESSION_ENTRY_VERSION, conversations, pendingTurns, projectAliases };
}
function parseBotCommand(text) {
- let trimmed = String(text || "").trim();
- if (!trimmed) return null;
- if (trimmed.startsWith("/")) trimmed = trimmed.slice(1).trim();
+ const input = String(text || "").trim();
+ if (!input || !input.startsWith("/")) return null;
+ const trimmed = input.slice(1).trim();
const space = trimmed.search(/\s/);
const rawName = space >= 0 ? trimmed.slice(0, space) : trimmed;
- const name = rawName.toLowerCase();
+ const domain = rawName.toLowerCase();
const args = space >= 0 ? trimmed.slice(space + 1).trim() : "";
- if (["help", "?", "h"].includes(name)) return { name: "help", args };
- if (["ls", "list", "sessions"].includes(name)) return { name: "ls", args };
- if (["current", "status", "pwd"].includes(name)) return { name: "current", args };
- if (["new", "create"].includes(name)) return { name: "new", args };
- if (name === "reset") return { name, args };
- if (name === "select" || name === "use") return { name, args };
- return null;
+ if (domain !== "project" && domain !== "session") return { name: "unknown", args: trimmed };
+ const actionSpace = args.search(/\s/);
+ const action = (actionSpace >= 0 ? args.slice(0, actionSpace) : args).toLowerCase();
+ const actionArgs = actionSpace >= 0 ? args.slice(actionSpace + 1).trim() : "";
+ if (!action || ["help", "?"].includes(action)) return { domain, name: "help", args: "" };
+ if (["list", "ls"].includes(action)) return { domain, name: "ls", args: actionArgs };
+ if (["find", "search"].includes(action)) return { domain, name: "search", args: actionArgs };
+ if (action === "current") return { domain, name: "current", args: actionArgs };
+ if (domain === "session" && action === "status") return { domain, name: "status", args: actionArgs };
+ if (domain === "session" && ["cancel", "stop"].includes(action)) return { domain, name: "cancel", args: actionArgs };
+ if (domain === "session" && action === "approve") return { domain, name: "approve", args: actionArgs };
+ if (domain === "session" && action === "deny") return { domain, name: "deny", args: actionArgs };
+ if (domain === "session" && action === "answer") return { domain, name: "answer", args: actionArgs };
+ if (domain === "project" && ["name", "rename"].includes(action)) return { domain, name: "rename", args: actionArgs };
+ if (domain === "session" && ["name", "rename"].includes(action)) return { domain, name: "rename", args: actionArgs };
+ if (domain === "session" && ["archive", "restore", "delete", "history", "model", "models", "effort", "mode", "usage", "memory", "skills", "skill", "shortcut", "doctor", "deliveries"].includes(action)) {
+ return { domain, name: action, args: actionArgs };
+ }
+ if (["use", "select"].includes(action)) return { domain, name: "select", args: actionArgs };
+ if (domain === "session" && ["new", "create"].includes(action)) return { domain, name: "new", args: actionArgs };
+ if (domain === "session" && action === "reset") return { domain, name: "reset", args: actionArgs };
+ return { domain, name: "unknown", args };
}
-function botCommandHelpText() {
+function projectCommandHelpText(agentName) {
return [
- "Bot commands:",
- "ls - list Claude App sessions",
- "new - create and select a new Claude App session",
- "select - continue a listed session",
- "use - alias for select",
- "current - show selected session",
- "reset - clear selected session; next message creates a new Claude App session",
- "help - show this message"
+ "CCR App project commands (" + agentName + "):",
+ "/project list - list Agent projects",
+ "/project find - search Agent projects",
+ "/project current - show the selected project",
+ "/project use - select a listed project",
+ "/project name - set a Bot display label for the current project",
+ "",
+ "Sessions are managed separately with /session. The relay is available only while this App is opened through CCR."
].join("\n");
}
+function sessionCommandHelpText(agentName) {
+ return [
+ "CCR App session commands (" + agentName + "):",
+ "/session list - list sessions in the current project",
+ "/session find - search sessions in the current project",
+ "/session current - show the selected session",
+ "/session status - show the active turn and queue",
+ "/session cancel - stop the active turn and clear queued turns",
+ "/session approve [session] - approve a pending Agent permission",
+ "/session deny - deny a pending Agent permission or input request",
+ "/session answer - answer a pending Agent input request",
+ "/session new [title] - start a session in the current project",
+ "/session use - continue a listed session",
+ "/session reset - clear the selected session",
+ "/session name - rename the selected session",
+ "/session archive | restore | delete confirm",
+ "/session history [count] - show recent turns",
+ "/session model [selector|reset] - show or change model/provider",
+ "/session effort [low|medium|high|xhigh|max|reset]",
+ "/session mode [manual|acceptEdits|plan|auto|dontAsk|reset]",
+ "/session usage - show the latest token and cost data",
+ "/session memory [list|add |clear] - manage persistent session context",
+ "/session skills - list project and user skills",
+ "/session skill [task] - invoke an Agent-native skill",
+ "/session shortcut list|add|remove|run - manage conversation shortcuts",
+ "/session doctor - show Bot connection and delivery diagnostics",
+ "/session deliveries - show recent outbound delivery results",
+ "",
+ "Projects are managed separately with /project. The relay is available only while this App is opened through CCR."
+ ].join("\n");
+}
+
+function formatDuration(ms) {
+ const seconds = Math.max(0, Math.floor(Number(ms || 0) / 1000));
+ if (seconds < 60) return seconds + "s";
+ const minutes = Math.floor(seconds / 60);
+ const rest = seconds % 60;
+ return minutes + "m " + rest + "s";
+}
+
+function interruptChildProcess(child) {
+ if (!child || child.killed) return false;
+ try {
+ if (process.platform !== "win32" && child.pid) {
+ try { process.kill(-child.pid, "SIGTERM"); } catch { child.kill("SIGTERM"); }
+ } else {
+ child.kill("SIGTERM");
+ }
+ const timer = setTimeout(() => {
+ if (child.exitCode === null && !child.killed) {
+ try {
+ if (process.platform !== "win32" && child.pid) process.kill(-child.pid, "SIGKILL");
+ else child.kill("SIGKILL");
+ } catch { /* Process already exited. */ }
+ }
+ }, 5000);
+ if (typeof timer.unref === "function") timer.unref();
+ return true;
+ } catch {
+ return false;
+ }
+}
+
function latestClaudeAppLocalAgentSession() {
return claudeAppLocalAgentSessions()[0] || null;
}
-function claudeAppLocalAgentSessions() {
+function claudeAppSessionProjectDirectory(session) {
+ const metadata = session && session.metadata && typeof session.metadata === "object" ? session.metadata : {};
+ const selectedFolders = Array.isArray(metadata.userSelectedFolders) ? metadata.userSelectedFolders : [];
+ const selected = selectedFolders.find((item) => stringValue(item));
+ return resolveExistingProjectDirectory(selected || (session && session.cwd), process.cwd());
+}
+
+function claudeAppProjects(sessions, fallbackDirectory) {
+ return agentProjectsFromDirectories(
+ [fallbackDirectory, ...sessions.map((session) => claudeAppSessionProjectDirectory(session))],
+ fallbackDirectory
+ );
+}
+
+function claudeAppLocalAgentSessions(options = {}) {
const baseDir = currentClaudeAppUserDataDir();
if (!baseDir) return [];
const root = path.join(baseDir, "local-agent-mode-sessions");
@@ -4190,7 +6144,9 @@ function claudeAppLocalAgentSessions() {
} catch {
continue;
}
- if (!value || typeof value !== "object" || value.isArchived === true || value.archived === true) continue;
+ if (!value || typeof value !== "object") continue;
+ const archived = value.isArchived === true || value.archived === true;
+ if (archived && !options.includeArchived) continue;
const cliSessionId = stringValue(value.cliSessionId) || stringValue(value.cli_session_id);
if (!cliSessionId) continue;
const sessionId = stringValue(value.sessionId) || path.basename(file, ".json");
@@ -4205,7 +6161,8 @@ function claudeAppLocalAgentSessions() {
initialMessage: stringValue(value.initialMessage) || "",
lastActivityAt,
claudeConfigDir: claudeAppSessionConfigDir(file, value),
- metadata: value
+ metadata: value,
+ archived
};
sessions.push(item);
}
@@ -4213,6 +6170,13 @@ function claudeAppLocalAgentSessions() {
return sessions;
}
+function updateClaudeSessionFile(file, patch) {
+ const value = readJsonFile(file);
+ if (!value || typeof value !== "object") return false;
+ writeJsonAtomic(file, { ...value, ...patch });
+ return true;
+}
+
function currentClaudeAppUserDataDir() {
return expandHome(nonEmptyEnv("CCR_CLAUDE_APP_USER_DATA_PATH") || nonEmptyEnv("CLAUDE_USER_DATA_DIR") || "");
}
@@ -4244,10 +6208,10 @@ function normalizeComparablePath(value) {
return process.platform === "win32" ? value.toLowerCase() : value;
}
-function resolveClaudeAppLocalAgentSession(selector) {
+function resolveClaudeAppLocalAgentSession(selector, availableSessions) {
const query = String(selector || "").trim();
if (!query) return null;
- const sessions = claudeAppLocalAgentSessions();
+ const sessions = Array.isArray(availableSessions) ? availableSessions : claudeAppLocalAgentSessions();
const numeric = Number(query);
if (Number.isInteger(numeric) && numeric >= 1 && numeric <= sessions.length) {
return sessions[numeric - 1];
@@ -4291,7 +6255,7 @@ function shortSessionId(value) {
return text.slice(0, 8);
}
-function createClaudeAppLocalAgentSession(text) {
+function createClaudeAppLocalAgentSession(text, projectDirectory) {
const baseDir = nonEmptyEnv("CCR_CLAUDE_APP_USER_DATA_PATH") || nonEmptyEnv("CLAUDE_USER_DATA_DIR");
if (!baseDir) return null;
const root = path.join(expandHome(baseDir), "local-agent-mode-sessions");
@@ -4299,12 +6263,13 @@ function createClaudeAppLocalAgentSession(text) {
const parentDir = template && template.file ? path.dirname(template.file) : defaultClaudeAppLocalAgentParentDir(root);
const sessionId = "local_" + uuid();
const sessionDir = path.join(parentDir, sessionId);
- const cwd = path.join(sessionDir, "outputs");
+ const outputDirectory = path.join(sessionDir, "outputs");
+ const cwd = resolveExistingProjectDirectory(projectDirectory, outputDirectory);
const claudeConfigDir = path.join(sessionDir, ".claude");
const file = path.join(parentDir, sessionId + ".json");
const now = Date.now();
const title = promptTitle(text);
- fs.mkdirSync(cwd, { recursive: true });
+ fs.mkdirSync(outputDirectory, { recursive: true });
fs.mkdirSync(path.join(sessionDir, "uploads"), { recursive: true });
fs.mkdirSync(claudeConfigDir, { recursive: true });
copyClaudeConfigTemplate(claudeConfigDir, template);
@@ -4314,7 +6279,7 @@ function createClaudeAppLocalAgentSession(text) {
processName: "ccr-bot-" + sessionId.slice(6, 14),
cliSessionId: "",
cwd,
- userSelectedFolders: [],
+ userSelectedFolders: [cwd],
createdAt: now,
lastActivityAt: now,
model: nonEmptyEnv("CCR_CLAUDE_CODE_MODEL") || nonEmptyEnv("CODEXL_CLAUDE_CODE_MODEL") || agentEnv(codexRuntimeAgent(), "MODEL") || DEFAULT_MODEL,
@@ -4569,6 +6534,150 @@ function botEventText(event) {
]) || "";
}
+function botInteractionText(event) {
+ if (!event || !String(event.type || "").includes("interaction")) return "";
+ return valueStringAtPaths(event, [
+ "/raw/value",
+ "/raw/action/value",
+ "/raw/actions/0/value",
+ "/raw/data/value",
+ "/message/richText/value"
+ ]);
+}
+
+function botEventAttachments(event) {
+ const candidates = [
+ valueAtPointer(event, "/message/attachments"),
+ valueAtPointer(event, "/raw/attachments"),
+ valueAtPointer(event, "/raw/message/attachments")
+ ];
+ const values = candidates.find(Array.isArray) || [];
+ return values.filter((item) => item && typeof item === "object").map((item, index) => ({
+ id: stringValue(item.id) || "attachment-" + (index + 1),
+ type: stringValue(item.type) || "unknown",
+ url: stringValue(item.url) || stringValue(item.href) || stringValue(item.downloadUrl),
+ name: stringValue(item.name) || stringValue(item.filename) || "attachment-" + (index + 1),
+ mimeType: stringValue(item.mimeType) || stringValue(item.contentType),
+ sizeBytes: Number(item.sizeBytes || item.size || 0) || 0,
+ raw: item.raw
+ }));
+}
+
+async function botInputForEvent(event, text, config, destinationDir) {
+ const input = [{ type: "text", text }];
+ if (!config.mediaEnabled) return input;
+ const attachments = botEventAttachments(event);
+ for (const attachment of attachments) {
+ if (attachment.sizeBytes > config.maxAttachmentBytes) {
+ input.push({ type: "text", text: "Attachment skipped because it exceeds the configured size limit: " + attachment.name });
+ continue;
+ }
+ const file = await materializeBotAttachment(attachment, destinationDir, config.maxAttachmentBytes);
+ if (file) {
+ if (attachment.type === "image" || String(attachment.mimeType).startsWith("image/")) {
+ input.push({ type: "image", path: file, mimeType: attachment.mimeType || mimeTypeForPath(file) });
+ } else {
+ input.push({ type: "text", text: "User attachment available at: " + file + (attachment.mimeType ? " (" + attachment.mimeType + ")" : "") });
+ }
+ } else if (attachment.url) {
+ input.push({ type: "text", text: "User attachment: " + attachment.name + "\nURL: " + attachment.url });
+ }
+ }
+ return input;
+}
+
+async function botPromptWithAttachments(event, text, config, destinationDir) {
+ const input = await botInputForEvent(event, text, config, destinationDir);
+ return botPromptFromInput(input);
+}
+
+function botPromptFromInput(input) {
+ const lines = [];
+ for (const item of Array.isArray(input) ? input : []) {
+ if (item.type === "text") lines.push(item.text);
+ else if (item.path) lines.push("User image attachment: " + item.path);
+ else if (item.url) lines.push("User image attachment: " + item.url);
+ }
+ return lines.filter(Boolean).join("\n\n");
+}
+
+function botImagePathsFromInput(input) {
+ return (Array.isArray(input) ? input : [])
+ .filter((item) => item && (item.type === "image" || item.type === "localImage") && item.path)
+ .map((item) => String(item.path));
+}
+
+async function materializeBotAttachment(attachment, destinationDir, maxBytes) {
+ if (!attachment.url || !/^https?:\/\//i.test(attachment.url)) return "";
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), 30_000);
+ try {
+ const response = await fetch(attachment.url, { signal: controller.signal });
+ if (!response.ok) return "";
+ const contentLength = Number(response.headers.get("content-length") || 0);
+ if (contentLength > maxBytes) return "";
+ const buffer = Buffer.from(await response.arrayBuffer());
+ if (buffer.length > maxBytes) return "";
+ const filename = safeAttachmentFilename(attachment.name || attachment.id);
+ const directory = path.resolve(expandHome(destinationDir));
+ fs.mkdirSync(directory, { recursive: true });
+ const file = path.join(directory, Date.now() + "-" + filename);
+ fs.writeFileSync(file, buffer, { mode: 0o600 });
+ return file;
+ } catch (error) {
+ log("bot_attachment_download_failed", { name: attachment.name, error: formatError(error) });
+ return "";
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+function safeAttachmentFilename(value) {
+ const basename = path.basename(String(value || "attachment")).replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
+ return basename || "attachment";
+}
+
+async function sendBotTurnArtifacts(event, bridge, turn, cwd, keyPrefix) {
+ const results = Array.isArray(turn && turn.toolItems) ? turn.toolItems.map((item) => String(item.result || "")) : [];
+ return sendBotTextArtifacts(event, bridge, [String(turn && turn.agentText || ""), ...results].join("\n"), cwd, keyPrefix);
+}
+
+async function sendBotTextArtifacts(event, bridge, text, cwd, keyPrefix) {
+ if (!bridge.config.mediaEnabled) return;
+ const files = localArtifactPaths(text, cwd).slice(0, 8);
+ for (let index = 0; index < files.length; index += 1) {
+ const file = files[index];
+ const stat = fs.statSync(file);
+ if (!stat.isFile() || stat.size > bridge.config.maxAttachmentBytes) continue;
+ await bridge.sendMediaToEvent(event, {
+ url: pathToFileURL(file).href,
+ filename: path.basename(file),
+ mimeType: mimeTypeForPath(file),
+ sizeBytes: stat.size,
+ raw: { path: file }
+ }, path.basename(file), keyPrefix + ":" + index);
+ }
+}
+
+function localArtifactPaths(text, cwd) {
+ const matches = [];
+ const value = String(text || "");
+ const patterns = [/\]\((\/[^)]+)\)/g, /\x60(\/[^\x60]+)\x60/g, /(?:^|\s)(\/[^\s"'<>]+\.[A-Za-z0-9]{1,10})(?=\s|$)/gm];
+ for (const pattern of patterns) {
+ let match;
+ while ((match = pattern.exec(value))) {
+ let file = match[1].trim();
+ try { file = decodeURIComponent(file); } catch { /* Keep the original path. */ }
+ try {
+ const resolved = fs.realpathSync(file);
+ if (!pathIsInside(resolved, cwd) || !fs.statSync(resolved).isFile()) continue;
+ if (!matches.includes(resolved)) matches.push(resolved);
+ } catch { /* Ignore non-local paths. */ }
+ }
+ }
+ return matches;
+}
+
function conversationRefFromEvent(event) {
if (!event || !event.conversation || typeof event.conversation !== "object") return null;
const conversation = event.conversation;
@@ -4729,12 +6838,7 @@ function normalizeWorkspaceRoots(value, cwd) {
}
function combinedDeveloperInstructions(params) {
- const primary = runtimeString(params.developerInstructions || params.developer_instructions);
- const additional = runtimeString(params.additionalDeveloperInstructions || params.additional_developer_instructions);
- if (primary && additional && !primary.includes(additional)) {
- return primary + "\n\n" + additional;
- }
- return primary || additional || null;
+ return params.developerInstructions || params.developer_instructions || null;
}
function normalizeCwd(value) {
diff --git a/packages/core/src/agents/local-providers/grok.ts b/packages/core/src/agents/local-providers/grok.ts
new file mode 100644
index 00000000..dc87632a
--- /dev/null
+++ b/packages/core/src/agents/local-providers/grok.ts
@@ -0,0 +1,777 @@
+import { existsSync, readFileSync, writeFileSync } from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import type {
+ GatewayProviderConfig,
+ LocalAgentProviderCandidate,
+ LocalAgentProviderImportResult,
+ ProviderAccountConfig,
+ ProviderAccountConnectorConfig,
+ ProviderAccountMappingConfig,
+ ProviderModelMetadata
+} from "@ccr/core/contracts/app";
+import {
+ bearerAuthPlugin,
+ firstString,
+ isRecord,
+ localAgentProviderApiKey,
+ missingCandidate,
+ modelDisplayNamesForModels,
+ modelMetadataForModels,
+ providerInternalNamePlaceholder,
+ providerPayload,
+ readBoolean,
+ readJsonRecord,
+ readString,
+ uniqueProviderName,
+ uniqueStrings,
+ type OAuthTokenSet
+} from "@ccr/core/agents/local-providers/shared";
+import { fetchWithSystemProxy } from "@ccr/core/proxy/system-proxy-fetch";
+import { normalizeProviderBaseUrl } from "@ccr/core/providers/url";
+
+export const grokDefaultBaseUrl = "https://cli-chat-proxy.grok.com/v1";
+export const grokDefaultBillingEndpoint = "https://cli-chat-proxy.grok.com/v1/billing?format=credits";
+export const grokDefaultSubscriptionEndpoint = "https://cli-chat-proxy.grok.com/v1/user?include=subscription";
+
+const grokDefaultModels = ["grok-4.5"];
+const grokProviderId = "grok-cli-api";
+const grokProviderName = "Grok CLI API";
+const grokDefaultOidcIssuer = "https://auth.x.ai";
+const grokOauthDefaultTimeoutMs = 8_000;
+const grokFallbackClientVersion = "0.2.93";
+
+const grokBillingResetPaths = [
+ "$.billingPeriodEnd",
+ "$.currentPeriod.end",
+ "$.currentPeriod.billingPeriodEnd",
+ "$.config.billingPeriodEnd",
+ "$.config.currentPeriod.end",
+ "$.end"
+];
+
+const grokBillingMapping: ProviderAccountMappingConfig = {
+ meters: [
+ {
+ id: "grok_credit_usage_percent",
+ kind: "quota",
+ label: "Credit usage",
+ limit: 100,
+ remaining: [
+ "100 - $.creditUsagePercent",
+ "100 - $.config.creditUsagePercent",
+ "100 - $.config.creditUsagePercent.val"
+ ],
+ resetAt: grokBillingResetPaths,
+ unit: "%",
+ used: [
+ "$.creditUsagePercent",
+ "$.config.creditUsagePercent",
+ "$.config.creditUsagePercent.val"
+ ],
+ window: "monthly"
+ },
+ {
+ id: "grok_included_credits",
+ kind: "quota",
+ label: "Included credits",
+ limit: [
+ "$.monthlyLimit",
+ "$.monthlyLimit.val",
+ "$.currentPeriod.monthlyLimit",
+ "$.currentPeriod.monthlyLimit.val",
+ "$.config.monthlyLimit",
+ "$.config.monthlyLimit.val",
+ "$.config.currentPeriod.monthlyLimit",
+ "$.config.currentPeriod.monthlyLimit.val"
+ ],
+ resetAt: grokBillingResetPaths,
+ unit: "credits",
+ used: [
+ "$.includedUsed",
+ "$.includedUsed.val",
+ "$.currentPeriod.includedUsed",
+ "$.currentPeriod.includedUsed.val",
+ "$.config.includedUsed",
+ "$.config.includedUsed.val",
+ "$.config.currentPeriod.includedUsed",
+ "$.config.currentPeriod.includedUsed.val"
+ ],
+ window: "monthly"
+ },
+ {
+ id: "grok_total_credits",
+ kind: "quota",
+ label: "Total credits",
+ limit: [
+ "$.monthlyLimit",
+ "$.monthlyLimit.val",
+ "$.currentPeriod.monthlyLimit",
+ "$.currentPeriod.monthlyLimit.val",
+ "$.config.monthlyLimit",
+ "$.config.monthlyLimit.val",
+ "$.config.currentPeriod.monthlyLimit",
+ "$.config.currentPeriod.monthlyLimit.val"
+ ],
+ resetAt: grokBillingResetPaths,
+ unit: "credits",
+ used: [
+ "$.totalUsed",
+ "$.totalUsed.val",
+ "$.currentPeriod.totalUsed",
+ "$.currentPeriod.totalUsed.val",
+ "$.config.totalUsed",
+ "$.config.totalUsed.val",
+ "$.config.currentPeriod.totalUsed",
+ "$.config.currentPeriod.totalUsed.val"
+ ],
+ window: "monthly"
+ },
+ {
+ id: "grok_pay_as_you_go_cap",
+ kind: "quota",
+ label: "Pay-as-you-go cap",
+ limit: [
+ "$.onDemandCap",
+ "$.onDemandCap.val",
+ "$.currentPeriod.onDemandCap",
+ "$.currentPeriod.onDemandCap.val",
+ "$.config.onDemandCap",
+ "$.config.onDemandCap.val",
+ "$.config.currentPeriod.onDemandCap",
+ "$.config.currentPeriod.onDemandCap.val"
+ ],
+ resetAt: grokBillingResetPaths,
+ unit: "credits",
+ used: [
+ "$.onDemandUsed",
+ "$.onDemandUsed.val",
+ "$.currentPeriod.onDemandUsed",
+ "$.currentPeriod.onDemandUsed.val",
+ "$.config.onDemandUsed",
+ "$.config.onDemandUsed.val",
+ "$.config.currentPeriod.onDemandUsed",
+ "$.config.currentPeriod.onDemandUsed.val"
+ ],
+ window: "monthly"
+ },
+ {
+ id: "grok_prepaid_balance",
+ kind: "balance",
+ label: "Prepaid balance",
+ remaining: [
+ "$.prepaidBalance",
+ "$.prepaidBalance.val",
+ "$.currentPeriod.prepaidBalance",
+ "$.currentPeriod.prepaidBalance.val",
+ "$.config.prepaidBalance",
+ "$.config.prepaidBalance.val",
+ "$.config.currentPeriod.prepaidBalance",
+ "$.config.currentPeriod.prepaidBalance.val"
+ ],
+ resetAt: grokBillingResetPaths,
+ unit: "credits",
+ window: "monthly"
+ }
+ ]
+};
+
+export type GrokTokenSet = OAuthTokenSet & {
+ authRecordKey?: string;
+ oidcClientId?: string;
+ oidcIssuer?: string;
+ expiresAt?: string;
+};
+
+type GrokModelCatalog = {
+ baseUrl: string;
+ modelDisplayNames?: Record;
+ modelMetadata?: Record;
+ models: string[];
+};
+
+export function grokCandidate(): LocalAgentProviderCandidate {
+ const auth = readGrokAuth();
+ const catalog = readGrokLocalModelCatalog();
+ if ((auth?.accessToken && !grokAccessTokenExpired(auth)) || auth?.refreshToken) {
+ return {
+ detail: "Grok CLI login detected. Click Import to add it as a gateway provider.",
+ id: grokProviderId,
+ importable: true,
+ kind: "grok",
+ modelDisplayNames: catalog.modelDisplayNames,
+ modelMetadata: catalog.modelMetadata,
+ models: catalog.models,
+ name: grokProviderName,
+ protocol: "openai_responses",
+ sourceFile: auth.sourceFile,
+ status: "available"
+ };
+ }
+ if (auth?.accessToken || auth?.refreshToken) {
+ return {
+ detail: auth.accessToken && grokAccessTokenExpired(auth)
+ ? "Grok CLI login was detected, but the access token is expired. Run grok login again, then rescan."
+ : "Grok CLI login was detected, but no usable access token was found.",
+ id: grokProviderId,
+ importable: false,
+ kind: "grok",
+ modelDisplayNames: catalog.modelDisplayNames,
+ modelMetadata: catalog.modelMetadata,
+ models: catalog.models,
+ name: grokProviderName,
+ protocol: "openai_responses",
+ sourceFile: auth.sourceFile,
+ status: "locked"
+ };
+ }
+ return missingCandidate("grok", grokProviderId, grokProviderName, "openai_responses", catalog.models, catalog.modelDisplayNames);
+}
+
+export async function importGrokProvider(candidate: LocalAgentProviderCandidate, providerNames: string[]): Promise {
+ const auth = await resolveGrokAuth();
+ if (!auth?.accessToken || grokAccessTokenExpired(auth)) {
+ throw new Error("Grok CLI access token was not found or is expired.");
+ }
+ return importGrokProviderWithAuth(candidate, providerNames, auth);
+}
+
+export function readGrokAuth(): GrokTokenSet | undefined {
+ const candidates = grokCredentialFiles()
+ .flatMap((sourceFile) => readGrokAuthRecords(sourceFile));
+ return candidates.find((item) => item.accessToken && !grokAccessTokenExpired(item)) ??
+ candidates.find((item) => item.refreshToken) ??
+ candidates.find((item) => item.accessToken);
+}
+
+export async function resolveGrokAuth(): Promise {
+ const auth = readGrokAuth();
+ if (!auth?.refreshToken || (auth.accessToken && !grokAccessTokenExpired(auth))) {
+ return auth;
+ }
+ return refreshGrokAuth(auth);
+}
+
+export function readGrokLocalModelCatalog(): GrokModelCatalog {
+ const preferredModel = readGrokDefaultModel();
+ const catalog = grokModelCatalogFromPayload(readJsonRecord(grokModelsCacheFile()), preferredModel);
+ const models = uniqueStrings([
+ preferredModel,
+ ...catalog.models,
+ ...grokDefaultModels
+ ]);
+ return {
+ baseUrl: catalog.baseUrl || grokRuntimeDefaultBaseUrl(),
+ modelDisplayNames: modelDisplayNamesForModels(catalog.modelDisplayNames, models),
+ modelMetadata: modelMetadataForModels(catalog.modelMetadata, models),
+ models
+ };
+}
+
+function readGrokAuthRecords(sourceFile: string): GrokTokenSet[] {
+ const record = readJsonRecord(sourceFile);
+ if (!record) {
+ return [];
+ }
+ return [
+ record,
+ ...Object.entries(record)
+ .filter((entry): entry is [string, Record] => isRecord(entry[1]))
+ .map(([key, value]) => ({ ...value, __ccr_auth_record_key: key }))
+ ]
+ .map((item) => grokAuthFromRecord(item, sourceFile))
+ .filter((item): item is GrokTokenSet => Boolean(item));
+}
+
+function grokAuthFromRecord(record: Record, sourceFile: string): GrokTokenSet | undefined {
+ const accessToken =
+ readString(record.key) ||
+ readString(record.access_token) ||
+ readString(record.accessToken) ||
+ readString(record.token) ||
+ readString(record.id_token) ||
+ readString(record.idToken);
+ const refreshToken =
+ readString(record.refresh_token) ||
+ readString(record.refreshToken);
+ if (!accessToken && !refreshToken) {
+ return undefined;
+ }
+ return {
+ accessToken,
+ authRecordKey: readString(record.__ccr_auth_record_key),
+ expiresAt: readString(record.expires_at) || readString(record.expiresAt),
+ oidcClientId: readString(record.oidc_client_id) || readString(record.oidcClientId) || readString(process.env.GROK_OIDC_CLIENT_ID),
+ oidcIssuer: readString(record.oidc_issuer) || readString(record.oidcIssuer) || readString(process.env.GROK_OIDC_ISSUER),
+ refreshToken,
+ sourceFile
+ };
+}
+
+export function grokAccessTokenExpired(auth: GrokTokenSet): boolean {
+ const expiresAtMs = dateMs(auth.expiresAt) ?? jwtExpiresAtMs(auth.accessToken);
+ return expiresAtMs !== undefined && expiresAtMs <= Date.now() + 60_000;
+}
+
+function grokModelCatalogFromPayload(payload: unknown, preferredModel?: string): GrokModelCatalog {
+ const models: string[] = [];
+ const modelDisplayNames: Record = {};
+ const modelMetadata: Record = {};
+ const baseUrlsByModel: Record = {};
+
+ for (const item of grokModelCatalogItems(payload)) {
+ const info = isRecord(item.value) && isRecord(item.value.info) ? item.value.info : isRecord(item.value) ? item.value : {};
+ if (readBoolean(info.hidden) || readBoolean(info.supported_in_api) === false || readBoolean(info.supportedInApi) === false) {
+ continue;
+ }
+ const apiBackend = readString(info.api_backend) || readString(info.apiBackend);
+ if (apiBackend && !apiBackend.toLowerCase().includes("responses")) {
+ continue;
+ }
+ const model = readString(info.model) || readString(info.id) || readString(info.name) || item.key;
+ if (!model) {
+ continue;
+ }
+ models.push(model);
+ const displayName = readString(info.display_name) || readString(info.displayName) || readString(info.label) || readString(info.title) || readString(info.name);
+ if (displayName && displayName !== model) {
+ modelDisplayNames[model] = displayName;
+ }
+ const baseUrl = readString(info.base_url) || readString(info.baseUrl);
+ if (baseUrl) {
+ baseUrlsByModel[model] = baseUrl;
+ }
+ const metadata = grokModelMetadataFromInfo(info);
+ if (metadata) {
+ modelMetadata[model] = metadata;
+ }
+ }
+
+ const uniqueModels = uniqueStrings(models);
+ const preferredBaseUrl = preferredModel ? baseUrlsByModel[preferredModel] : undefined;
+ const baseUrl = preferredBaseUrl || firstString(uniqueModels.map((model) => baseUrlsByModel[model])) || grokRuntimeDefaultBaseUrl();
+ const filteredModels = uniqueModels.filter((model) => !baseUrlsByModel[model] || baseUrlsByModel[model] === baseUrl);
+ return {
+ baseUrl,
+ modelDisplayNames: modelDisplayNamesForModels(modelDisplayNames, filteredModels),
+ modelMetadata: modelMetadataForModels(modelMetadata, filteredModels),
+ models: filteredModels
+ };
+}
+
+function grokModelMetadataFromInfo(info: Record): ProviderModelMetadata | undefined {
+ const defaultReasoningLevel = readNullableString(info.reasoning_effort) ?? readNullableString(info.reasoningEffort);
+ const metadata: ProviderModelMetadata = {
+ ...(defaultReasoningLevel !== undefined ? { defaultReasoningLevel } : {})
+ };
+ return Object.keys(metadata).length > 0 ? metadata : undefined;
+}
+
+function grokModelCatalogItems(payload: unknown): Array<{ key?: string; value: unknown }> {
+ if (Array.isArray(payload)) {
+ return payload.map((value) => ({ value }));
+ }
+ if (!isRecord(payload)) {
+ return [];
+ }
+ const models = payload.models;
+ if (Array.isArray(models)) {
+ return models.map((value) => ({ value }));
+ }
+ if (isRecord(models)) {
+ return Object.entries(models).map(([key, value]) => ({ key, value }));
+ }
+ return [];
+}
+
+function readGrokDefaultModel(): string | undefined {
+ for (const sourceFile of grokConfigFiles()) {
+ if (!existsSync(sourceFile)) {
+ continue;
+ }
+ try {
+ const text = readFileSync(sourceFile, "utf8");
+ const match = text.match(/^\s*default\s*=\s*"([^"]+)"\s*$/m) ?? text.match(/^\s*default\s*=\s*'([^']+)'\s*$/m);
+ const model = match?.[1]?.trim();
+ if (model) {
+ return model;
+ }
+ } catch {
+ continue;
+ }
+ }
+ return undefined;
+}
+
+function importGrokProviderWithAuth(
+ candidate: LocalAgentProviderCandidate,
+ providerNames: string[],
+ auth: GrokTokenSet
+): LocalAgentProviderImportResult {
+ const catalog = readGrokLocalModelCatalog();
+ const provider = providerPayload(
+ {
+ ...candidate,
+ modelDisplayNames: catalog.modelDisplayNames,
+ modelMetadata: catalog.modelMetadata,
+ models: catalog.models
+ },
+ uniqueProviderName(providerNames, grokProviderName),
+ catalog.baseUrl,
+ grokProviderAccountConfig()
+ );
+ return {
+ candidate: {
+ ...candidate,
+ modelDisplayNames: catalog.modelDisplayNames,
+ modelMetadata: catalog.modelMetadata,
+ models: catalog.models
+ },
+ provider,
+ providerPlugins: [
+ grokOauthPlugin("grok-cli-oauth", auth.accessToken ?? ""),
+ grokOauthPlugin("grok-cli-oauth-internal", auth.accessToken ?? "", providerInternalNamePlaceholder)
+ ]
+ };
+}
+
+export function grokProviderAccountConfig(): ProviderAccountConfig {
+ const clientVersion = grokClientVersion();
+ return {
+ connectors: [
+ {
+ auth: "provider-api-key",
+ endpoint: grokBillingEndpoint(),
+ headers: {
+ "x-grok-client-identifier": "xai-grok-cli",
+ "x-grok-client-version": clientVersion
+ },
+ mapping: grokBillingMapping,
+ type: "http-json"
+ },
+ {
+ auth: "provider-api-key",
+ endpoint: grokSubscriptionEndpoint(),
+ headers: {
+ "x-grok-client-identifier": "xai-grok-cli",
+ "x-grok-client-version": clientVersion
+ },
+ mapping: { meters: [] },
+ parser: "grok-subscription",
+ type: "http-json"
+ }
+ ],
+ enabled: true
+ };
+}
+
+export function normalizeGrokProviderAccountConfig(provider: GatewayProviderConfig): GatewayProviderConfig {
+ if (!isLocalGrokProvider(provider) || !shouldUseCurrentGrokAccountConfig(provider.account)) {
+ return provider;
+ }
+ const account = grokProviderAccountConfig();
+ return {
+ ...provider,
+ account: {
+ ...account,
+ refreshIntervalMs: provider.account?.refreshIntervalMs ?? account.refreshIntervalMs
+ }
+ };
+}
+
+function isLocalGrokProvider(provider: GatewayProviderConfig): boolean {
+ if (providerApiKey(provider) !== localAgentProviderApiKey) {
+ return false;
+ }
+ const baseUrl = normalizeProviderBaseUrl(providerBaseUrl(provider)).toLowerCase();
+ const name = provider.name?.toLowerCase() ?? "";
+ return baseUrl.includes("cli-chat-proxy.grok.com") || name.includes("grok");
+}
+
+function shouldUseCurrentGrokAccountConfig(account: ProviderAccountConfig | undefined): boolean {
+ if (account?.enabled === false) {
+ return false;
+ }
+ const connectors = account?.connectors ?? [];
+ if (connectors.length === 0) {
+ return true;
+ }
+ return connectors.every(isGrokAccountConnector);
+}
+
+function isGrokAccountConnector(connector: ProviderAccountConnectorConfig): boolean {
+ if (connector.type === "standard") {
+ return !connector.endpoint?.trim() && !connector.endpoints?.length && !connector.headers && !connector.id;
+ }
+ if (connector.type !== "http-json") {
+ return false;
+ }
+ return /^https:\/\/grok\.com\/(?:billing|user)(?:$|[?#/])/i.test(connector.endpoint.trim()) ||
+ /^https:\/\/cli-chat-proxy\.grok\.com\/v1\/(?:billing|user)(?:$|[?#/])/i.test(connector.endpoint.trim());
+}
+
+function providerBaseUrl(provider: GatewayProviderConfig): string {
+ return provider.api_base_url || provider.baseurl || provider.baseUrl || "";
+}
+
+function providerApiKey(provider: GatewayProviderConfig): string {
+ return provider.api_key || provider.apiKey || provider.apikey || "";
+}
+
+function grokOauthPlugin(suffix: string, token: string, providerName?: string): Record {
+ return {
+ ...bearerAuthPlugin(suffix, token, {}, providerName),
+ request: {
+ headers: {
+ "x-grok-client-identifier": "xai-grok-cli",
+ "x-grok-client-version": grokClientVersion(),
+ "x-grok-model-override": "{{ model }}"
+ },
+ strict: true
+ }
+ };
+}
+
+export function grokClientVersion(): string {
+ const explicit = process.env.GROK_CLI_VERSION?.trim();
+ if (explicit) {
+ return explicit;
+ }
+ const payload = readJsonRecord(path.join(grokStorageRoot(), "version.json"));
+ return readString(payload?.version) || grokFallbackClientVersion;
+}
+
+async function refreshGrokAuth(auth: GrokTokenSet): Promise {
+ const refreshToken = auth.refreshToken;
+ if (!refreshToken) {
+ throw new Error("Grok CLI refresh token was not found.");
+ }
+ const clientId = auth.oidcClientId || readString(process.env.GROK_OIDC_CLIENT_ID);
+ if (!clientId) {
+ throw new Error("Grok CLI OAuth client id was not found.");
+ }
+
+ const tokenEndpoint = await grokTokenEndpoint(auth);
+ const timeoutMs = normalizeGrokOauthTimeout(process.env.GROK_OIDC_REFRESH_TIMEOUT_MS);
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
+ try {
+ const response = await fetchWithSystemProxy(tokenEndpoint, {
+ body: new URLSearchParams({
+ client_id: clientId,
+ grant_type: "refresh_token",
+ refresh_token: refreshToken
+ }).toString(),
+ headers: {
+ "content-type": "application/x-www-form-urlencoded"
+ },
+ method: "POST",
+ signal: controller.signal
+ });
+ const text = await response.text();
+ const payload = parseJsonRecord(text);
+ if (!response.ok) {
+ throw new Error(`Grok CLI OAuth token refresh returned HTTP ${response.status}${tokenRefreshErrorMessage(payload, text)}`);
+ }
+ const accessToken = readString(payload?.access_token) || readString(payload?.accessToken);
+ if (!accessToken) {
+ throw new Error("Grok CLI OAuth token refresh did not return an access token.");
+ }
+ const refreshed: GrokTokenSet = {
+ ...auth,
+ accessToken,
+ expiresAt: refreshedGrokExpiresAt(accessToken, payload),
+ refreshToken: readString(payload?.refresh_token) || readString(payload?.refreshToken) || refreshToken
+ };
+ persistRefreshedGrokAuth(refreshed);
+ return refreshed;
+ } catch (error) {
+ if (error instanceof Error && error.name === "AbortError") {
+ throw new Error(`Grok CLI OAuth token refresh timed out after ${timeoutMs}ms.`);
+ }
+ throw error;
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+async function grokTokenEndpoint(auth: GrokTokenSet): Promise {
+ const configured = readString(process.env.GROK_OIDC_TOKEN_ENDPOINT);
+ if (configured) {
+ return configured;
+ }
+ const issuer = (auth.oidcIssuer || readString(process.env.GROK_OIDC_ISSUER) || grokDefaultOidcIssuer).replace(/\/+$/, "");
+ const metadataUrl = `${issuer}/.well-known/openid-configuration`;
+ const timeoutMs = normalizeGrokOauthTimeout(process.env.GROK_OIDC_REFRESH_TIMEOUT_MS);
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
+ try {
+ const response = await fetchWithSystemProxy(metadataUrl, {
+ headers: { accept: "application/json" },
+ signal: controller.signal
+ });
+ const text = await response.text();
+ const payload = parseJsonRecord(text);
+ if (!response.ok) {
+ throw new Error(`Grok CLI OIDC discovery returned HTTP ${response.status}${tokenRefreshErrorMessage(payload, text)}`);
+ }
+ const tokenEndpoint = readString(payload?.token_endpoint) || readString(payload?.tokenEndpoint);
+ if (!tokenEndpoint) {
+ throw new Error("Grok CLI OIDC discovery did not return a token endpoint.");
+ }
+ return tokenEndpoint;
+ } catch (error) {
+ if (error instanceof Error && error.name === "AbortError") {
+ throw new Error(`Grok CLI OIDC discovery timed out after ${timeoutMs}ms.`);
+ }
+ throw error;
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+function grokCredentialFiles(): string[] {
+ const explicitFile = process.env.GROK_AUTH_FILE?.trim();
+ return uniqueStrings([
+ explicitFile,
+ path.join(grokStorageRoot(), "auth.json"),
+ path.join(grokStorageRoot(), "credentials.json")
+ ]);
+}
+
+function grokConfigFiles(): string[] {
+ const explicitFile = process.env.GROK_CONFIG_FILE?.trim();
+ return uniqueStrings([
+ explicitFile,
+ path.join(grokStorageRoot(), "config.toml")
+ ]);
+}
+
+function grokModelsCacheFile(): string {
+ return process.env.GROK_MODELS_CACHE_FILE?.trim() || path.join(grokStorageRoot(), "models_cache.json");
+}
+
+function grokStorageRoot(): string {
+ const explicitRoot = process.env.GROK_HOME?.trim() || process.env.GROK_STORAGE_DIR?.trim() || process.env.GROK_CONFIG_DIR?.trim();
+ if (explicitRoot) {
+ return explicitRoot;
+ }
+ const homeDir = process.env.CCR_INTERNAL_HOME_DIR?.trim() || process.env.HOME?.trim() || process.env.USERPROFILE?.trim() || os.homedir();
+ return path.join(homeDir, ".grok");
+}
+
+function grokRuntimeDefaultBaseUrl(): string {
+ return process.env.GROK_CLI_CHAT_PROXY_BASE_URL?.trim() || grokDefaultBaseUrl;
+}
+
+function grokBillingEndpoint(): string {
+ return process.env.GROK_BILLING_ENDPOINT?.trim() || grokDefaultBillingEndpoint;
+}
+
+function grokSubscriptionEndpoint(): string {
+ return process.env.GROK_SUBSCRIPTION_ENDPOINT?.trim() || grokDefaultSubscriptionEndpoint;
+}
+
+function readNullableString(value: unknown): string | null | undefined {
+ if (value === null) {
+ return null;
+ }
+ return readString(value) || undefined;
+}
+
+function dateMs(value: string | undefined): number | undefined {
+ if (!value) {
+ return undefined;
+ }
+ const timestamp = new Date(value).getTime();
+ return Number.isFinite(timestamp) ? timestamp : undefined;
+}
+
+function jwtExpiresAtMs(token: string | undefined): number | undefined {
+ const encoded = token?.split(".")[1];
+ if (!encoded) {
+ return undefined;
+ }
+ try {
+ const padded = encoded.padEnd(encoded.length + ((4 - encoded.length % 4) % 4), "=");
+ const payload = JSON.parse(Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")) as unknown;
+ const exp = isRecord(payload) && typeof payload.exp === "number" ? payload.exp : undefined;
+ return exp ? exp * 1000 : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+function refreshedGrokExpiresAt(accessToken: string, payload: Record | undefined): string | undefined {
+ const expiresAtMs = jwtExpiresAtMs(accessToken) ?? expiresInMs(payload?.expires_in) ?? expiresInMs(payload?.expiresIn);
+ return expiresAtMs ? new Date(expiresAtMs).toISOString() : undefined;
+}
+
+function expiresInMs(value: unknown): number | undefined {
+ const seconds = typeof value === "number"
+ ? value
+ : typeof value === "string" && value.trim()
+ ? Number(value)
+ : undefined;
+ return seconds && Number.isFinite(seconds) ? Date.now() + seconds * 1000 : undefined;
+}
+
+function persistRefreshedGrokAuth(auth: GrokTokenSet): void {
+ if (!auth.sourceFile || !auth.accessToken) {
+ return;
+ }
+ try {
+ const parsed = JSON.parse(readFileSync(auth.sourceFile, "utf8")) as unknown;
+ if (!isRecord(parsed)) {
+ return;
+ }
+ let target: Record = parsed;
+ if (auth.authRecordKey) {
+ const authRecord = parsed[auth.authRecordKey];
+ if (isRecord(authRecord)) {
+ target = authRecord;
+ }
+ }
+ target.key = auth.accessToken;
+ if (auth.refreshToken) {
+ target.refresh_token = auth.refreshToken;
+ }
+ if (auth.expiresAt) {
+ target.expires_at = auth.expiresAt;
+ }
+ writeFileSync(auth.sourceFile, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
+ } catch {
+ // Best effort. The refreshed token is still used for this CCR run.
+ }
+}
+
+function parseJsonRecord(text: string): Record | undefined {
+ try {
+ const payload = JSON.parse(text) as unknown;
+ return isRecord(payload) ? payload : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+function tokenRefreshErrorMessage(payload: Record | undefined, text: string): string {
+ const message =
+ readString(payload?.error_description) ||
+ readString(payload?.error) ||
+ readString(payload?.message) ||
+ readableResponseSnippet(text);
+ return message ? `: ${message}` : "";
+}
+
+function readableResponseSnippet(text: string): string {
+ return text.replace(/\s+/g, " ").trim().slice(0, 200);
+}
+
+function normalizeGrokOauthTimeout(value: unknown): number {
+ const numeric = Number(value);
+ return Math.max(1, Number.isFinite(numeric) ? numeric : grokOauthDefaultTimeoutMs);
+}
+
+export function grokModelCatalogFromPayloadForTest(payload: unknown, preferredModel?: string): GrokModelCatalog {
+ return grokModelCatalogFromPayload(payload, preferredModel);
+}
diff --git a/packages/core/src/agents/local-providers/opencode.ts b/packages/core/src/agents/local-providers/opencode.ts
new file mode 100644
index 00000000..c596d91b
--- /dev/null
+++ b/packages/core/src/agents/local-providers/opencode.ts
@@ -0,0 +1,538 @@
+import { existsSync, readFileSync } from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import type {
+ GatewayProviderConfig,
+ GatewayProviderProtocol,
+ LocalAgentProviderCandidate,
+ LocalAgentProviderImportResult,
+ ProviderAccountConnectorConfig
+} from "@ccr/core/contracts/app";
+import {
+ apiKeyAuthPlugin,
+ bearerAuthPlugin,
+ isRecord,
+ missingCandidate,
+ parseJsoncRecord,
+ providerInternalNamePlaceholder,
+ providerNamePlaceholder,
+ providerNameSlugPlaceholder,
+ providerPayload,
+ readJsonRecord,
+ readJsoncRecord,
+ readString,
+ uniqueProviderName,
+ uniqueStrings
+} from "@ccr/core/agents/local-providers/shared";
+
+type OpenCodeCredential = {
+ apiKey?: string;
+ hasCredential: boolean;
+ sourceFile: string;
+};
+
+type OpenCodeConfig = {
+ record: Record;
+ sourceFile?: string;
+};
+
+type OpenCodeCatalog = {
+ baseUrl: string;
+ modelDisplayNames: Partial>>;
+ models: Record;
+ name: string;
+};
+
+type OpenCodeProtocol = Exclude;
+
+const openCodeProviderId = "opencode";
+const openCodeDefaultBaseUrl = "https://opencode.ai/zen/v1";
+const openCodeProtocolOrder: OpenCodeProtocol[] = [
+ "openai_responses",
+ "anthropic_messages",
+ "openai_chat_completions",
+ "gemini_generate_content"
+];
+const openCodeProtocolLabels: Record = {
+ anthropic_messages: "Anthropic",
+ gemini_generate_content: "Gemini",
+ openai_chat_completions: "Chat Completions",
+ openai_responses: "Responses"
+};
+const openCodeFallbackModels: Record = {
+ anthropic_messages: ["claude-sonnet-4-5"],
+ gemini_generate_content: ["gemini-3-flash"],
+ openai_chat_completions: ["big-pickle"],
+ openai_responses: ["gpt-5.2"]
+};
+
+export function opencodeCandidates(): LocalAgentProviderCandidate[] {
+ const credential = readOpenCodeCredential();
+ const invalidCredential = Boolean(credential?.hasCredential && !credential.apiKey);
+ const publicOnly = !credential;
+ const catalog = readOpenCodeCatalog({ publicOnly });
+ const sourceFile = credential?.sourceFile || openCodeModelsCacheFile();
+ return openCodeProtocolOrder.map((protocol) => {
+ const providerName = publicOnly ? "OpenCode Public" : catalog.name;
+ const name = `${providerName} (${openCodeProtocolLabels[protocol]})`;
+ const id = `opencode-api-${protocol.replaceAll("_", "-")}`;
+ const models = catalog.models[protocol];
+ const modelDisplayNames = catalog.modelDisplayNames[protocol];
+ if (publicOnly && models.length > 0) {
+ return {
+ detail: "OpenCode CLI public models detected. No login is required.",
+ id,
+ importable: true,
+ kind: "opencode",
+ modelDisplayNames,
+ models,
+ name,
+ protocol,
+ sourceFile,
+ status: "available"
+ };
+ }
+ if (invalidCredential) {
+ return {
+ detail: "OpenCode CLI credential was found, but no usable API key was detected.",
+ id,
+ importable: false,
+ kind: "opencode",
+ modelDisplayNames,
+ models,
+ name,
+ protocol,
+ sourceFile,
+ status: "locked"
+ };
+ }
+ if (credential?.apiKey) {
+ return {
+ detail: "OpenCode CLI login detected. Click Import to add it as a gateway provider.",
+ id,
+ importable: true,
+ kind: "opencode",
+ modelDisplayNames,
+ models,
+ name,
+ protocol,
+ sourceFile: credential.sourceFile,
+ status: "available"
+ };
+ }
+ return missingCandidate("opencode", id, name, protocol, models, modelDisplayNames);
+ });
+}
+
+export function importOpenCodeProvider(
+ candidate: LocalAgentProviderCandidate,
+ providerNames: string[]
+): LocalAgentProviderImportResult {
+ const credential = readOpenCodeCredential();
+ if (credential?.hasCredential && !credential.apiKey) {
+ throw new Error("OpenCode CLI API key was not found.");
+ }
+ const publicOnly = !credential;
+ const catalog = readOpenCodeCatalog({ publicOnly });
+ if (!isOpenCodeProtocol(candidate.protocol)) {
+ throw new Error(`Unsupported OpenCode protocol: ${candidate.protocol}`);
+ }
+ const protocol = candidate.protocol;
+ if (publicOnly && !candidate.models.every((model) => catalog.models[protocol].includes(model))) {
+ throw new Error("OpenCode CLI public models were not found.");
+ }
+ const apiKey = credential?.apiKey || "public";
+ const authSuffix = `opencode-${candidate.protocol.replaceAll("_", "-")}-api-key`;
+ const provider = providerPayload(
+ candidate,
+ uniqueProviderName(providerNames, candidate.name),
+ catalog.baseUrl
+ );
+ return {
+ candidate,
+ provider,
+ providerPlugins: [
+ openCodeAuthPlugin(candidate.protocol, authSuffix, apiKey),
+ openCodeAuthPlugin(candidate.protocol, `${authSuffix}-internal`, apiKey, providerInternalNamePlaceholder)
+ ]
+ };
+}
+
+export function removeOpenCodeProviderAccountConfig(provider: GatewayProviderConfig): GatewayProviderConfig {
+ const account = provider.account;
+ if (!account?.connectors?.some(isGeneratedOpenCodeAccountConnector)) {
+ return provider;
+ }
+ const connectors = account.connectors.filter((connector) => !isGeneratedOpenCodeAccountConnector(connector));
+ return {
+ ...provider,
+ account: connectors.length > 0 ? { ...account, connectors } : undefined
+ };
+}
+
+function isGeneratedOpenCodeAccountConnector(connector: ProviderAccountConnectorConfig): boolean {
+ if (connector.type !== "local-estimate") {
+ return false;
+ }
+ const ids = new Set(connector.windows.map((window) => window.id));
+ return ids.has("opencode_monthly_spend") &&
+ ids.has("opencode_monthly_tokens") &&
+ ids.has("opencode_monthly_requests");
+}
+
+function openCodeAuthPlugin(
+ protocol: GatewayProviderProtocol,
+ suffix: string,
+ apiKey: string,
+ providerName = providerNamePlaceholder
+): Record {
+ if (protocol === "anthropic_messages") {
+ return apiKeyAuthPlugin(suffix, apiKey, providerName);
+ }
+ if (protocol === "gemini_generate_content" || protocol === "gemini_interactions") {
+ return {
+ auth: {
+ headers: {
+ "x-goog-api-key": apiKey
+ },
+ query: {
+ key: apiKey
+ },
+ removeHeaders: ["authorization", "x-api-key"],
+ strict: true
+ },
+ key: `ccr-local-agent-${providerNameSlugPlaceholder}-${suffix}`,
+ providerName
+ };
+ }
+ return bearerAuthPlugin(suffix, apiKey, {}, providerName);
+}
+
+function readOpenCodeCredential(): OpenCodeCredential | undefined {
+ const config = readOpenCodeConfig();
+ const configuredApiKey = configuredOpenCodeApiKey(config);
+ const configuredApiKeyPresent = configuredOpenCodeApiKeyIsPresent(config);
+ if (configuredApiKey) {
+ return {
+ apiKey: configuredApiKey,
+ hasCredential: true,
+ sourceFile: config.sourceFile || "OpenCode config"
+ };
+ }
+
+ const inlineAuth = process.env.OPENCODE_AUTH_CONTENT?.trim();
+ if (inlineAuth) {
+ const record = parseJsoncRecord(inlineAuth);
+ const credential = openCodeCredentialFromRecord(record, "env:OPENCODE_AUTH_CONTENT");
+ if (credential) {
+ return credential;
+ }
+ }
+
+ for (const sourceFile of openCodeAuthFiles()) {
+ const record = readJsonRecord(sourceFile);
+ if (!record) {
+ continue;
+ }
+ const credential = openCodeCredentialFromRecord(record, sourceFile);
+ if (credential) {
+ return credential;
+ }
+ }
+
+ const environmentApiKey = process.env.OPENCODE_API_KEY?.trim();
+ if (environmentApiKey) {
+ return { apiKey: environmentApiKey, hasCredential: true, sourceFile: "env:OPENCODE_API_KEY" };
+ }
+
+ return configuredApiKeyPresent
+ ? { hasCredential: true, sourceFile: config.sourceFile || "OpenCode config" }
+ : undefined;
+}
+
+function openCodeCredentialFromRecord(
+ record: Record | undefined,
+ sourceFile: string
+): OpenCodeCredential | undefined {
+ if (!record || !(openCodeProviderId in record)) {
+ return undefined;
+ }
+ const value = record[openCodeProviderId];
+ if (typeof value === "string") {
+ return {
+ apiKey: readString(value),
+ hasCredential: true,
+ sourceFile
+ };
+ }
+ if (!isRecord(value)) {
+ return { hasCredential: true, sourceFile };
+ }
+ return {
+ apiKey: readString(value.key) || readString(value.access) || readString(value.token),
+ hasCredential: true,
+ sourceFile
+ };
+}
+
+function configuredOpenCodeApiKey(config: OpenCodeConfig): string | undefined {
+ const value = configuredOpenCodeApiKeyValue(config);
+ if (!value) {
+ return undefined;
+ }
+ const environmentReference = value.match(/^\{env:([^}]+)\}$/);
+ if (environmentReference) {
+ return process.env[environmentReference[1]]?.trim() || undefined;
+ }
+ const fileReference = value.match(/^\{file:([^}]+)\}$/);
+ if (fileReference) {
+ try {
+ const sourceDirectory = config.sourceFile && !config.sourceFile.startsWith("env:")
+ ? path.dirname(config.sourceFile)
+ : undefined;
+ return readFileSync(resolveOpenCodeReferencePath(fileReference[1], sourceDirectory), "utf8").trim() || undefined;
+ } catch {
+ return undefined;
+ }
+ }
+ return value;
+}
+
+function configuredOpenCodeApiKeyIsPresent(config: OpenCodeConfig): boolean {
+ return Boolean(configuredOpenCodeApiKeyValue(config));
+}
+
+function configuredOpenCodeApiKeyValue(config: OpenCodeConfig): string | undefined {
+ const provider = openCodeProviderConfig(config.record);
+ const options = isRecord(provider?.options) ? provider.options : {};
+ return readString(options.apiKey) || readString(options.api_key);
+}
+
+function readOpenCodeCatalog(options: { publicOnly: boolean }): OpenCodeCatalog {
+ const cache = readJsonRecord(openCodeModelsCacheFile());
+ const cachedProvider = isRecord(cache?.[openCodeProviderId]) ? cache[openCodeProviderId] : {};
+ const config = readOpenCodeConfig().record;
+ const configuredProvider = openCodeProviderConfig(config) ?? {};
+ const configuredOptions = isRecord(configuredProvider.options) ? configuredProvider.options : {};
+ const baseUrl =
+ readString(configuredOptions.baseURL) ||
+ readString(configuredOptions.baseUrl) ||
+ readString(cachedProvider.api) ||
+ openCodeDefaultBaseUrl;
+ const name = readString(configuredProvider.name) || readString(cachedProvider.name) || "OpenCode Zen";
+ const providerNpm = readString(configuredProvider.npm) || readString(cachedProvider.npm) || "@ai-sdk/openai-compatible";
+ const cachedModels = isRecord(cachedProvider.models) ? cachedProvider.models : {};
+ const configuredModels = isRecord(configuredProvider.models) ? configuredProvider.models : {};
+ const configuredModelIds = new Set(Object.keys(configuredModels));
+ const mergedModels = new Map>();
+ for (const [modelId, value] of Object.entries(cachedModels)) {
+ if (isRecord(value)) {
+ mergedModels.set(modelId, value);
+ }
+ }
+ for (const [modelId, value] of Object.entries(configuredModels)) {
+ const previous = mergedModels.get(modelId) ?? {};
+ mergedModels.set(modelId, isRecord(value) ? deepMergeRecords(previous, value) : previous);
+ }
+
+ const selectedModels = uniqueStrings([
+ openCodeModelId(readString(config.model)),
+ openCodeModelId(readString(config.small_model))
+ ]);
+ const orderedModelIds = uniqueStrings([...selectedModels, ...mergedModels.keys()]);
+ const models = emptyOpenCodeProtocolRecord(() => []);
+ const modelDisplayNames = emptyOpenCodeProtocolRecord>(() => ({}));
+
+ for (const configuredModelId of orderedModelIds) {
+ const model = mergedModels.get(configuredModelId);
+ if (!model || (readString(model.status) === "deprecated" && !configuredModelIds.has(configuredModelId) && !selectedModels.includes(configuredModelId))) {
+ continue;
+ }
+ if (options.publicOnly && !openCodeModelIsFree(model)) {
+ continue;
+ }
+ const modelId = readString(model.id) || configuredModelId;
+ const modelProvider = isRecord(model.provider) ? model.provider : {};
+ const protocol = openCodeProtocolFromNpm(readString(modelProvider.npm) || readString(model.npm) || providerNpm);
+ models[protocol].push(modelId);
+ const displayName = readString(model.name);
+ if (displayName && displayName !== modelId) {
+ modelDisplayNames[protocol][modelId] = displayName;
+ }
+ }
+
+ for (const protocol of openCodeProtocolOrder) {
+ models[protocol] = uniqueStrings(
+ models[protocol].length > 0
+ ? models[protocol]
+ : options.publicOnly ? [] : openCodeFallbackModels[protocol]
+ );
+ const allowedModels = new Set(models[protocol]);
+ modelDisplayNames[protocol] = Object.fromEntries(
+ Object.entries(modelDisplayNames[protocol]).filter(([modelId]) => allowedModels.has(modelId))
+ );
+ }
+
+ return { baseUrl, modelDisplayNames, models, name };
+}
+
+function openCodeModelIsFree(model: Record): boolean {
+ const cost = isRecord(model.cost) ? model.cost : undefined;
+ if (!cost) {
+ return false;
+ }
+ return requiredOpenCodeCostIsFree(cost.input) &&
+ requiredOpenCodeCostIsFree(cost.output) &&
+ optionalOpenCodeCostFieldsAreFree(cost, [
+ "cache_read",
+ "cache_write",
+ "cacheRead",
+ "cacheWrite",
+ "input_cache_read",
+ "input_cache_write"
+ ]);
+}
+
+function requiredOpenCodeCostIsFree(value: unknown): boolean {
+ return openCodeCostValue(value) === 0;
+}
+
+function optionalOpenCodeCostIsFree(value: unknown): boolean {
+ const cost = openCodeCostValue(value);
+ return cost === undefined || cost === 0;
+}
+
+function optionalOpenCodeCostFieldsAreFree(cost: Record, fields: string[]): boolean {
+ return fields.every((field) => optionalOpenCodeCostIsFree(cost[field]));
+}
+
+function openCodeCostValue(value: unknown): number | undefined {
+ if (typeof value === "number" && Number.isFinite(value)) {
+ return value;
+ }
+ const parsed = Number(readString(value));
+ return Number.isFinite(parsed) ? parsed : undefined;
+}
+
+function openCodeProtocolFromNpm(value: string): OpenCodeProtocol {
+ const normalized = value.trim().toLowerCase();
+ if (normalized.includes("anthropic")) {
+ return "anthropic_messages";
+ }
+ if (normalized.includes("google")) {
+ return "gemini_generate_content";
+ }
+ if (normalized === "@ai-sdk/openai" || normalized.endsWith("/openai")) {
+ return "openai_responses";
+ }
+ return "openai_chat_completions";
+}
+
+function isOpenCodeProtocol(protocol: GatewayProviderProtocol): protocol is OpenCodeProtocol {
+ return protocol !== "gemini_interactions";
+}
+
+function openCodeModelId(value: string | undefined): string | undefined {
+ if (!value?.startsWith(`${openCodeProviderId}/`)) {
+ return undefined;
+ }
+ return readString(value.slice(openCodeProviderId.length + 1));
+}
+
+function openCodeProviderConfig(config: Record): Record | undefined {
+ const providers = isRecord(config.provider) ? config.provider : undefined;
+ return isRecord(providers?.[openCodeProviderId]) ? providers[openCodeProviderId] : undefined;
+}
+
+function readOpenCodeConfig(): OpenCodeConfig {
+ let record: Record = {};
+ let sourceFile: string | undefined;
+ for (const file of openCodeConfigFiles()) {
+ const next = readJsoncRecord(file);
+ if (!next) {
+ continue;
+ }
+ record = deepMergeRecords(record, next);
+ if (openCodeProviderConfig(next)) {
+ sourceFile = file;
+ }
+ }
+ const inlineConfig = process.env.OPENCODE_CONFIG_CONTENT?.trim();
+ if (inlineConfig) {
+ const next = parseJsoncRecord(inlineConfig);
+ if (next) {
+ record = deepMergeRecords(record, next);
+ if (openCodeProviderConfig(next)) {
+ sourceFile = "env:OPENCODE_CONFIG_CONTENT";
+ }
+ }
+ }
+ return { record, sourceFile };
+}
+
+function deepMergeRecords(left: Record, right: Record): Record {
+ const result = { ...left };
+ for (const [key, value] of Object.entries(right)) {
+ result[key] = isRecord(result[key]) && isRecord(value)
+ ? deepMergeRecords(result[key], value)
+ : value;
+ }
+ return result;
+}
+
+function emptyOpenCodeProtocolRecord(factory: (protocol: OpenCodeProtocol) => T): Record {
+ return Object.fromEntries(openCodeProtocolOrder.map((protocol) => [protocol, factory(protocol)])) as Record;
+}
+
+function openCodeAuthFiles(): string[] {
+ return uniqueStrings([
+ path.join(openCodeDataRoot(), "auth.json")
+ ]);
+}
+
+function openCodeConfigFiles(): string[] {
+ const customConfig = process.env.OPENCODE_CONFIG?.trim();
+ return uniqueStrings([
+ path.join(openCodeConfigRoot(), "opencode.json"),
+ path.join(openCodeConfigRoot(), "opencode.jsonc"),
+ path.join(openCodeDataRoot(), "opencode.json"),
+ path.join(openCodeDataRoot(), "opencode.jsonc"),
+ customConfig ? resolveOpenCodeReferencePath(customConfig) : undefined
+ ]).filter((file) => existsSync(file));
+}
+
+function openCodeDataRoot(): string {
+ return path.join(openCodeXdgRoot("XDG_DATA_HOME", path.join(".local", "share")), "opencode");
+}
+
+function openCodeConfigRoot(): string {
+ return path.join(openCodeXdgRoot("XDG_CONFIG_HOME", ".config"), "opencode");
+}
+
+function openCodeModelsCacheFile(): string {
+ return path.join(openCodeXdgRoot("XDG_CACHE_HOME", ".cache"), "opencode", "models.json");
+}
+
+function openCodeXdgRoot(environmentName: "XDG_CACHE_HOME" | "XDG_CONFIG_HOME" | "XDG_DATA_HOME", fallback: string): string {
+ const internalHome = process.env.CCR_INTERNAL_HOME_DIR?.trim();
+ if (internalHome) {
+ return path.join(internalHome, fallback);
+ }
+ const explicitRoot = process.env[environmentName]?.trim();
+ return explicitRoot || path.join(openCodeHomeDir(), fallback);
+}
+
+function openCodeHomeDir(): string {
+ return process.env.HOME?.trim() || process.env.USERPROFILE?.trim() || os.homedir();
+}
+
+function resolveOpenCodeReferencePath(value: string, baseDirectory?: string): string {
+ const trimmed = value.trim();
+ if (trimmed === "~") {
+ return openCodeHomeDir();
+ }
+ if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
+ return path.join(openCodeHomeDir(), trimmed.slice(2));
+ }
+ return path.resolve(baseDirectory || process.cwd(), trimmed);
+}
diff --git a/packages/core/src/agents/local-providers/service.ts b/packages/core/src/agents/local-providers/service.ts
index 6f1ab138..c9a77ba6 100644
--- a/packages/core/src/agents/local-providers/service.ts
+++ b/packages/core/src/agents/local-providers/service.ts
@@ -7,15 +7,21 @@ import type {
} from "@ccr/core/contracts/app";
import { claudeCodeCandidate, importClaudeCodeProvider } from "@ccr/core/agents/local-providers/claude-code";
import { codexCandidate, importCodexProvider, probeCodexProvider } from "@ccr/core/agents/local-providers/codex";
+import { grokCandidate, importGrokProvider } from "@ccr/core/agents/local-providers/grok";
+import { importOpenCodeProvider, opencodeCandidates } from "@ccr/core/agents/local-providers/opencode";
import { importZcodeProvider, zcodeCandidate } from "@ccr/core/agents/local-providers/zcode";
export { codexDefaultBaseUrl, readCodexAuth } from "@ccr/core/agents/local-providers/codex";
+export { grokDefaultBaseUrl, readGrokAuth, resolveGrokAuth } from "@ccr/core/agents/local-providers/grok";
+export { readZcodeLocalProviderCredential, zcodeDefaultBaseUrl } from "@ccr/core/agents/local-providers/zcode";
export { localAgentProviderApiKey, type OAuthTokenSet } from "@ccr/core/agents/local-providers/shared";
export function getLocalAgentProviderCandidates(): LocalAgentProviderCandidate[] {
return [
codexCandidate(),
claudeCodeCandidate(),
+ grokCandidate(),
+ ...opencodeCandidates(),
zcodeCandidate()
].filter((candidate) => candidate.status !== "missing");
}
@@ -35,6 +41,12 @@ export async function importLocalAgentProvider(request: LocalAgentProviderImport
if (candidate.kind === "claude-code") {
return importClaudeCodeProvider(candidate, request.providerNames ?? []);
}
+ if (candidate.kind === "grok") {
+ return importGrokProvider(candidate, request.providerNames ?? []);
+ }
+ if (candidate.kind === "opencode") {
+ return importOpenCodeProvider(candidate, request.providerNames ?? []);
+ }
return importZcodeProvider(candidate, request.providerNames ?? []);
}
diff --git a/packages/core/src/agents/local-providers/shared.ts b/packages/core/src/agents/local-providers/shared.ts
index 1432c015..fbbbf044 100644
--- a/packages/core/src/agents/local-providers/shared.ts
+++ b/packages/core/src/agents/local-providers/shared.ts
@@ -166,6 +166,113 @@ export function readJsonRecord(file: string): Record | undefine
}
}
+export function readJsoncRecord(file: string): Record | undefined {
+ if (!existsSync(file)) {
+ return undefined;
+ }
+ try {
+ return parseJsoncRecord(readFileSync(file, "utf8"));
+ } catch {
+ return undefined;
+ }
+}
+
+export function parseJsoncRecord(value: string): Record | undefined {
+ try {
+ const parsed = JSON.parse(stripJsonCommentsAndTrailingCommas(value)) as unknown;
+ return isRecord(parsed) ? parsed : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+function stripJsonCommentsAndTrailingCommas(value: string): string {
+ let withoutComments = "";
+ let inString = false;
+ let escaped = false;
+
+ for (let index = 0; index < value.length; index += 1) {
+ const character = value[index];
+ const nextCharacter = value[index + 1];
+ if (inString) {
+ withoutComments += character;
+ if (escaped) {
+ escaped = false;
+ } else if (character === "\\") {
+ escaped = true;
+ } else if (character === '"') {
+ inString = false;
+ }
+ continue;
+ }
+ if (character === '"') {
+ inString = true;
+ withoutComments += character;
+ continue;
+ }
+ if (character === "/" && nextCharacter === "/") {
+ withoutComments += " ";
+ index += 1;
+ while (index + 1 < value.length && value[index + 1] !== "\n" && value[index + 1] !== "\r") {
+ withoutComments += " ";
+ index += 1;
+ }
+ continue;
+ }
+ if (character === "/" && nextCharacter === "*") {
+ withoutComments += " ";
+ index += 1;
+ while (index + 1 < value.length) {
+ const commentCharacter = value[index + 1];
+ const commentNextCharacter = value[index + 2];
+ if (commentCharacter === "*" && commentNextCharacter === "/") {
+ withoutComments += " ";
+ index += 2;
+ break;
+ }
+ withoutComments += commentCharacter === "\n" || commentCharacter === "\r" ? commentCharacter : " ";
+ index += 1;
+ }
+ continue;
+ }
+ withoutComments += character;
+ }
+
+ let result = "";
+ inString = false;
+ escaped = false;
+ for (let index = 0; index < withoutComments.length; index += 1) {
+ const character = withoutComments[index];
+ if (inString) {
+ result += character;
+ if (escaped) {
+ escaped = false;
+ } else if (character === "\\") {
+ escaped = true;
+ } else if (character === '"') {
+ inString = false;
+ }
+ continue;
+ }
+ if (character === '"') {
+ inString = true;
+ result += character;
+ continue;
+ }
+ if (character === ",") {
+ let lookahead = index + 1;
+ while (lookahead < withoutComments.length && /\s/.test(withoutComments[lookahead])) {
+ lookahead += 1;
+ }
+ if (withoutComments[lookahead] === "}" || withoutComments[lookahead] === "]") {
+ continue;
+ }
+ }
+ result += character;
+ }
+ return result;
+}
+
export function uniqueProviderName(existingNames: string[], baseName: string): string {
const existing = new Set(existingNames.map((name) => name.trim().toLowerCase()).filter(Boolean));
if (!existing.has(baseName.toLowerCase())) {
diff --git a/packages/core/src/agents/local-providers/zcode.ts b/packages/core/src/agents/local-providers/zcode.ts
index f32f3c03..db173c28 100644
--- a/packages/core/src/agents/local-providers/zcode.ts
+++ b/packages/core/src/agents/local-providers/zcode.ts
@@ -39,7 +39,12 @@ type LocalAgentModelCatalog = {
};
const zcodeDefaultModels = ["GLM-5.2", "GLM-5-Turbo"];
-const zcodeDefaultBaseUrl = "https://zcode.z.ai/api/v1/zcode-plan/anthropic";
+export const zcodeDefaultBaseUrl = "https://zcode.z.ai/api/v1/zcode-plan/anthropic";
+
+export type ZcodeLocalProviderCredential = {
+ apiKey: string;
+ baseUrl: string;
+};
export function zcodeCandidate(): LocalAgentProviderCandidate {
const configuredProvider = readZcodeConfiguredProvider();
@@ -108,6 +113,11 @@ export function importZcodeProvider(candidate: LocalAgentProviderCandidate, prov
};
}
+export function readZcodeLocalProviderCredential(): ZcodeLocalProviderCredential | undefined {
+ const provider = readZcodeConfiguredProvider();
+ return provider ? { apiKey: provider.apiKey, baseUrl: provider.baseUrl } : undefined;
+}
+
function zcodeProviderAccountConfig(baseUrl: string): ProviderAccountConfig | undefined {
return cloneProviderAccountConfig(findProviderPresetByBaseUrl(baseUrl)?.account);
}
@@ -177,7 +187,7 @@ function readZcodeConfiguredProviders(sourceFile: string): ZcodeConfiguredProvid
}
function readZcodeRuntime(): { baseUrl: string } & LocalAgentModelCatalog {
- const cache = readJsonRecord(path.join(os.homedir(), ".zcode", "v2", "bots-model-cache.v2.json"));
+ const cache = readJsonRecord(path.join(zcodeStorageRoot(), "v2", "bots-model-cache.v2.json"));
const providers = Array.isArray(cache?.providers)
? cache.providers.filter((provider): provider is Record => isRecord(provider))
: [];
@@ -292,15 +302,26 @@ function isZcodeModelProvider(providerId: string, provider: Record process.env[key]?.trim() || "").filter(Boolean).map(resolveUserPath),
+ ...platformCandidates()
+ ];
+ for (const candidate of candidates) {
+ if (!candidate || checked.includes(candidate)) {
+ continue;
+ }
+ checked.push(candidate);
+ const executable = normalizeCandidate(candidate);
+ if (executable) {
+ return { checked, executable };
+ }
+ }
+ return { checked };
+}
+
+export function findRunningOpenCodeAppPid(profileAppPath?: string): number | undefined {
+ const executable = findInstalledOpenCodeAppExecutable(profileAppPath).executable;
+ if (!executable) {
+ return undefined;
+ }
+ return process.platform === "win32"
+ ? findWindowsExecutablePid(executable)
+ : findPosixExecutablePid(executable);
+}
+
+export function launchOpenCodeAppProfile(
+ _configDir: string,
+ profile: ProfileConfig,
+ configFile: string,
+ inlineConfig: string,
+ extraEnv: Record = {}
+): OpenCodeAppLaunchResult {
+ const lookup = findInstalledOpenCodeAppExecutable(profile.appPath);
+ if (!lookup.executable) {
+ throw new Error([
+ "OpenCode App was not found. Install OpenCode App or set OPENCODE_APP_PATH to its executable, then try again.",
+ lookup.checked.length ? `Checked: ${lookup.checked.join(", ")}` : ""
+ ].filter(Boolean).join(" "));
+ }
+ const userDataDir = resolveOpenCodeDesktopUserDataDir();
+ const env: NodeJS.ProcessEnv = {
+ ...process.env,
+ ...profile.env,
+ ...extraEnv,
+ CCR_PROFILE_SURFACE: "app",
+ OPENCODE_CLIENT: "desktop",
+ OPENCODE_CONFIG: configFile,
+ OPENCODE_CONFIG_CONTENT: inlineConfig
+ };
+ delete env.ELECTRON_RUN_AS_NODE;
+ const child = spawn(lookup.executable, openCodeAppLaunchArgs(), {
+ detached: true,
+ env,
+ stdio: "ignore"
+ });
+ child.unref();
+ return {
+ child,
+ command: lookup.executable,
+ pid: child.pid,
+ userDataDir
+ };
+}
+
+export function openCodeAppLaunchSignature(
+ profile: ProfileConfig,
+ configFile: string,
+ inlineConfig: string,
+ extraEnv: Record = {}
+): string {
+ const env = Object.fromEntries(Object.entries({
+ ...profile.env,
+ ...extraEnv,
+ CCR_PROFILE_SURFACE: "app",
+ OPENCODE_CLIENT: "desktop",
+ OPENCODE_CONFIG: configFile,
+ OPENCODE_CONFIG_CONTENT: inlineConfig
+ }).sort(([left], [right]) => left.localeCompare(right)));
+ return createHash("sha256")
+ .update(JSON.stringify({
+ appPath: profile.appPath?.trim() || "",
+ env
+ }))
+ .digest("hex");
+}
+
+export function openCodeAppLaunchArgs(): string[] {
+ // OpenCode Desktop resets Electron's userData path before acquiring its
+ // single-instance lock, so --user-data-dir is ignored. CCR treats the app as
+ // single-instance and switches managed profiles in launch-service instead.
+ return [
+ "--remote-debugging-port=0",
+ "--disable-renderer-backgrounding",
+ "--disable-background-timer-throttling"
+ ];
+}
+
+export function resolveOpenCodeDesktopUserDataDir(): string {
+ const appDataDir = process.platform === "win32"
+ ? process.env.APPDATA?.trim() || path.join(os.homedir(), "AppData", "Roaming")
+ : process.platform === "darwin"
+ ? path.join(os.homedir(), "Library", "Application Support")
+ : process.env.XDG_CONFIG_HOME?.trim() || path.join(os.homedir(), ".config");
+ return path.join(resolveUserPath(appDataDir), "ai.opencode.desktop");
+}
+
+export function openCodeDesktopCommandNames(platform: NodeJS.Platform = process.platform): string[] {
+ return platform === "linux"
+ ? ["ai.opencode.desktop", "opencode-desktop", "OpenCode"]
+ : ["opencode-desktop", "OpenCode"];
+}
+
+function platformCandidates(): string[] {
+ if (process.platform === "darwin") {
+ return [
+ "/Applications/OpenCode.app",
+ path.join(os.homedir(), "Applications", "OpenCode.app")
+ ];
+ }
+ if (process.platform === "win32") {
+ return windowsDesktopAppCandidates({
+ appDirs: ["OpenCode", "opencode", "OpenCode Desktop"],
+ exeNames: windowsExeNames,
+ packageKeywords: windowsPackageKeywords,
+ vendorDirs: ["OpenCode", "Anomaly"],
+ whereNames: ["OpenCode", "opencode-desktop"]
+ });
+ }
+ const commandNames = openCodeDesktopCommandNames("linux");
+ return [
+ ...pathCommandCandidates(commandNames),
+ ...["/opt/OpenCode", "/usr/local/bin", "/usr/bin"]
+ .flatMap((directory) => commandNames.map((name) => path.join(directory, name)))
+ ];
+}
+
+function normalizeCandidate(candidate: string): string | undefined {
+ if (process.platform === "darwin") {
+ if (candidate.endsWith(".app")) {
+ return executableFromMacAppBundle(candidate);
+ }
+ return isFile(candidate) ? candidate : undefined;
+ }
+ if (process.platform === "win32") {
+ return normalizeWindowsDesktopAppCandidate(candidate, { exeNames: windowsExeNames, packageKeywords: windowsPackageKeywords });
+ }
+ return isFile(candidate) ? candidate : undefined;
+}
+
+function executableFromMacAppBundle(appPath: string): string | undefined {
+ if (!isDirectory(appPath)) {
+ return undefined;
+ }
+ const macosDir = path.join(appPath, "Contents", "MacOS");
+ const bundleExecutable = readBundleExecutable(path.join(appPath, "Contents", "Info.plist"));
+ for (const name of [bundleExecutable, "OpenCode", "opencode"].filter((value): value is string => Boolean(value))) {
+ const candidate = path.join(macosDir, name);
+ if (isFile(candidate)) {
+ return candidate;
+ }
+ }
+ try {
+ return readdirSync(macosDir).map((entry) => path.join(macosDir, entry)).find(isFile);
+ } catch {
+ return undefined;
+ }
+}
+
+function readBundleExecutable(infoPath: string): string | undefined {
+ if (!isFile(infoPath)) {
+ return undefined;
+ }
+ try {
+ return readFileSync(infoPath, "utf8").match(/CFBundleExecutable<\/key>\s*([^<]+)<\/string>/)?.[1]?.trim();
+ } catch {
+ return undefined;
+ }
+}
+
+function pathCommandCandidates(names: string[]): string[] {
+ const directories = (process.env.PATH || "").split(path.delimiter).filter(Boolean);
+ return directories.flatMap((directory) => names.map((name) => path.join(directory, name)));
+}
+
+function findPosixExecutablePid(executable: string): number | undefined {
+ try {
+ const result = spawnSync("ps", ["-Ao", "pid=,command="], { encoding: "utf8" });
+ if (result.error || result.status !== 0) {
+ return undefined;
+ }
+ const normalizedExecutable = path.resolve(executable);
+ for (const line of result.stdout.split(/\r?\n/g)) {
+ const match = line.match(/^\s*(\d+)\s+(.+)$/);
+ if (!match) {
+ continue;
+ }
+ const pid = Number(match[1]);
+ const command = match[2].trim();
+ if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid || command.includes(" --type=")) {
+ continue;
+ }
+ if (command === normalizedExecutable || command.startsWith(`${normalizedExecutable} `)) {
+ return pid;
+ }
+ }
+ } catch {
+ return undefined;
+ }
+ return undefined;
+}
+
+function findWindowsExecutablePid(executable: string): number | undefined {
+ const script = [
+ "$ErrorActionPreference = 'SilentlyContinue'",
+ `$executable = ${powershellString(path.resolve(executable))}`,
+ `$hostPid = ${process.pid}`,
+ "$selfPid = $PID",
+ "Get-CimInstance Win32_Process | Where-Object {",
+ " $_.ProcessId -ne $selfPid -and",
+ " $_.ProcessId -ne $hostPid -and",
+ " $_.ExecutablePath -and",
+ " $_.ExecutablePath.Equals($executable, [System.StringComparison]::OrdinalIgnoreCase) -and",
+ " ($_.CommandLine -notmatch '\\s--type=')",
+ "} | Sort-Object ProcessId | Select-Object -First 1 -ExpandProperty ProcessId"
+ ].join("\n");
+ try {
+ const result = spawnSync(windowsSystemCommand("powershell.exe"), [
+ "-NoProfile",
+ "-NonInteractive",
+ "-ExecutionPolicy",
+ "Bypass",
+ "-Command",
+ script
+ ], {
+ encoding: "utf8",
+ maxBuffer: 1024 * 1024,
+ windowsHide: true
+ });
+ if (result.error || result.status !== 0) {
+ return undefined;
+ }
+ return result.stdout
+ .split(/\r?\n/g)
+ .map((line) => Number(line.trim()))
+ .find((pid) => Number.isFinite(pid) && pid > 0 && pid !== process.pid);
+ } catch {
+ return undefined;
+ }
+}
+
+function powershellString(value: string): string {
+ return `'${value.replace(/'/g, "''")}'`;
+}
+
+function resolveUserPath(value: string): string {
+ const trimmed = value.trim();
+ if (trimmed === "~") {
+ return os.homedir();
+ }
+ if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
+ return path.join(os.homedir(), trimmed.slice(2));
+ }
+ return path.resolve(trimmed);
+}
+
+function isFile(file: string): boolean {
+ try {
+ return existsSync(file) && statSync(file).isFile();
+ } catch {
+ return false;
+ }
+}
+
+function isDirectory(file: string): boolean {
+ try {
+ return existsSync(file) && statSync(file).isDirectory();
+ } catch {
+ return false;
+ }
+}
diff --git a/packages/core/src/agents/opencode/profile-config.ts b/packages/core/src/agents/opencode/profile-config.ts
new file mode 100644
index 00000000..1df3d64d
--- /dev/null
+++ b/packages/core/src/agents/opencode/profile-config.ts
@@ -0,0 +1,257 @@
+import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { buildCodexModelCatalogIds } from "@ccr/core/agents/codex/model-catalog";
+import { parseJsoncRecord } from "@ccr/core/agents/local-providers/shared";
+import type { AppConfig, ProfileConfig } from "@ccr/core/contracts/app";
+
+export type OpenCodeProfileConfigWriteResult = {
+ backupFile?: string;
+ changed: boolean;
+ file: string;
+ inlineConfig: string;
+};
+
+const originalBackupSuffix = ".ccr-original";
+const originalMissingSuffix = ".ccr-original-missing";
+
+export function resolveOpenCodeConfigFile(configDir: string, profile: ProfileConfig): string {
+ if (profile.scope === "ccr" || profile.scope === "custom") {
+ const slug = sanitizePathSegment(profile.id || profile.name || "opencode") || "opencode";
+ const baseDir = path.join(configDir, "profiles", slug);
+ return path.join(profile.scope === "custom" ? path.join(baseDir, "custom") : baseDir, "opencode", "opencode.jsonc");
+ }
+
+ const configured = profile.configFile?.trim();
+ if (configured) {
+ return resolveUserPath(configured);
+ }
+ const root = path.join(openCodeXdgRoot("XDG_CONFIG_HOME", ".config"), "opencode");
+ const jsonc = path.join(root, "opencode.jsonc");
+ const json = path.join(root, "opencode.json");
+ return existsSync(jsonc) || !existsSync(json) ? jsonc : json;
+}
+
+export function writeOpenCodeGatewayConfig(
+ configDir: string,
+ config: AppConfig,
+ profile: ProfileConfig,
+ token: string,
+ options: { backup?: boolean } = {}
+): OpenCodeProfileConfigWriteResult {
+ const file = resolveOpenCodeConfigFile(configDir, profile);
+ const source = readJsoncObject(file);
+ const overrides = openCodeGatewayOverrides(config, profile, token);
+ const providers = isRecord(source.provider) ? { ...source.provider } : {};
+ const providerId = openCodeProviderId(profile);
+ providers[providerId] = (overrides.provider as Record)[providerId];
+ const next = {
+ ...source,
+ $schema: typeof source.$schema === "string" && source.$schema.trim()
+ ? source.$schema
+ : "https://opencode.ai/config.json",
+ model: overrides.model,
+ provider: providers,
+ small_model: overrides.small_model
+ };
+ const content = `${JSON.stringify(next, null, 2)}\n`;
+ const writeResult = writeJsonFile(file, content, options);
+ return {
+ ...writeResult,
+ file,
+ inlineConfig: JSON.stringify(overrides)
+ };
+}
+
+export function isManagedOpenCodeConfigContent(content: string, providerId: string): boolean {
+ const config = parseJsoncRecord(content);
+ if (!config || !isRecord(config.provider)) {
+ return false;
+ }
+ const provider = config.provider[providerId];
+ if (!isRecord(provider) || !isRecord(provider.options)) {
+ return false;
+ }
+ const headers = isRecord(provider.options.headers) ? provider.options.headers : {};
+ return headers["x-ccr-client"] === "opencode" || headers["X-CCR-Client"] === "opencode";
+}
+
+export function openCodeProviderId(profile: Pick): string {
+ return sanitizeProviderId(profile.providerId || "") || "claude-code-router";
+}
+
+function openCodeGatewayOverrides(config: AppConfig, profile: ProfileConfig, token: string): Record {
+ const providerId = openCodeProviderId(profile);
+ const providerName = profile.providerName?.trim() || "Claude Code Router";
+ const model = normalizeClientModel(profile.model) || defaultClientModel(config);
+ const modelRef = `${providerId}/${model}`;
+ const models = buildCodexModelCatalogIds(config, model);
+ return {
+ $schema: "https://opencode.ai/config.json",
+ model: modelRef,
+ provider: {
+ [providerId]: {
+ models: Object.fromEntries(uniqueStrings(models).map((modelId) => [modelId, { name: modelId }])),
+ name: providerName,
+ npm: "@ai-sdk/openai-compatible",
+ options: {
+ apiKey: token,
+ baseURL: `${gatewayEndpoint(config).replace(/\/+$/g, "")}/v1`,
+ headers: {
+ "x-ccr-client": "opencode",
+ "x-ccr-profile": profile.id || profile.name || "opencode"
+ }
+ }
+ }
+ },
+ small_model: modelRef
+ };
+}
+
+function readJsoncObject(file: string): Record {
+ if (!existsSync(file)) {
+ return {};
+ }
+ try {
+ return parseJsoncRecord(readFileSync(file, "utf8")) ?? {};
+ } catch {
+ return {};
+ }
+}
+
+function writeJsonFile(
+ file: string,
+ content: string,
+ options: { backup?: boolean }
+): { backupFile?: string; changed: boolean } {
+ mkdirSync(path.dirname(file), { recursive: true });
+ const previous = existsSync(file) ? readFileSync(file, "utf8") : undefined;
+ chmodPrivateConfigArtifacts(file);
+ if (previous === content) {
+ return { changed: false };
+ }
+ if (options.backup !== false) {
+ ensureOriginalSnapshot(file, previous);
+ }
+ const backupFile = options.backup === false || previous === undefined ? undefined : backupFilePath(file);
+ if (backupFile) {
+ copyFileSync(file, backupFile);
+ chmodPrivateFile(backupFile);
+ }
+ writeFileSync(file, content, { encoding: "utf8", mode: 0o600 });
+ chmodPrivateFile(file);
+ return { backupFile, changed: true };
+}
+
+function ensureOriginalSnapshot(file: string, previous: string | undefined): void {
+ const originalBackup = `${file}${originalBackupSuffix}`;
+ const originalMissing = `${file}${originalMissingSuffix}`;
+ if (existsSync(originalBackup) || existsSync(originalMissing)) {
+ return;
+ }
+ if (previous === undefined) {
+ writeFileSync(originalMissing, "", { encoding: "utf8", mode: 0o600 });
+ chmodPrivateFile(originalMissing);
+ return;
+ }
+ copyFileSync(file, originalBackup);
+ chmodPrivateFile(originalBackup);
+}
+
+function chmodPrivateFile(file: string): void {
+ if (process.platform === "win32" || !existsSync(file)) {
+ return;
+ }
+ chmodSync(file, 0o600);
+}
+
+function chmodPrivateConfigArtifacts(file: string): void {
+ chmodPrivateFile(file);
+ if (process.platform === "win32") {
+ return;
+ }
+ const basename = path.basename(file);
+ let entries: string[];
+ try {
+ entries = readdirSync(path.dirname(file));
+ } catch {
+ // The config write can still proceed if artifact discovery is unavailable.
+ return;
+ }
+ for (const entry of entries) {
+ if (
+ entry === `${basename}${originalBackupSuffix}` ||
+ entry === `${basename}${originalMissingSuffix}` ||
+ entry.startsWith(`${basename}.ccr-backup-`)
+ ) {
+ chmodPrivateFile(path.join(path.dirname(file), entry));
+ }
+ }
+}
+
+function backupFilePath(file: string): string {
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
+ return `${file}.ccr-backup-${timestamp}`;
+}
+
+function gatewayEndpoint(config: AppConfig): string {
+ const host = config.gateway.host === "0.0.0.0" || config.gateway.host === "::" ? "127.0.0.1" : config.gateway.host;
+ const normalizedHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
+ return `http://${normalizedHost}:${config.gateway.port}`;
+}
+
+function defaultClientModel(config: AppConfig): string {
+ const provider = config.Providers.find((item) => item.name === config.preferredProvider) ?? config.Providers[0];
+ const model = provider?.models[0] ?? "default";
+ return provider?.name ? `${provider.name}/${model}` : model;
+}
+
+function normalizeClientModel(value: string | undefined): string {
+ const trimmed = value?.trim() || "";
+ if (!trimmed) {
+ return "";
+ }
+ const commaIndex = trimmed.indexOf(",");
+ if (commaIndex > 0 && commaIndex < trimmed.length - 1) {
+ const provider = trimmed.slice(0, commaIndex).trim();
+ const model = trimmed.slice(commaIndex + 1).trim();
+ return provider && model ? `${provider}/${model}` : "";
+ }
+ return trimmed;
+}
+
+function openCodeXdgRoot(environmentName: "XDG_CONFIG_HOME", fallback: string): string {
+ const internalHome = process.env.CCR_INTERNAL_HOME_DIR?.trim();
+ if (internalHome) {
+ return path.join(internalHome, fallback);
+ }
+ const configured = process.env[environmentName]?.trim();
+ return configured ? resolveUserPath(configured) : path.join(os.homedir(), fallback);
+}
+
+function resolveUserPath(value: string): string {
+ const trimmed = value.trim();
+ if (trimmed === "~") {
+ return os.homedir();
+ }
+ if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
+ return path.join(os.homedir(), trimmed.slice(2));
+ }
+ return path.resolve(trimmed || ".");
+}
+
+function sanitizeProviderId(value: string): string {
+ return value.trim().replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
+}
+
+function sanitizePathSegment(value: string): string {
+ return value.trim().toLowerCase().replace(/[^a-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
+}
+
+function uniqueStrings(values: string[]): string[] {
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
+}
+
+function isRecord(value: unknown): value is Record {
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
+}
diff --git a/packages/core/src/agents/request-enricher.ts b/packages/core/src/agents/request-enricher.ts
new file mode 100644
index 00000000..9686d3f9
--- /dev/null
+++ b/packages/core/src/agents/request-enricher.ts
@@ -0,0 +1,20 @@
+export type AgentRequestEnricher = {
+ enrich: (request: TRequest) => void;
+ id: string;
+ matches: (request: TRequest) => boolean;
+};
+
+export function applyAgentRequestEnrichers(
+ request: TRequest,
+ enrichers: AgentRequestEnricher[]
+): string[] {
+ const applied: string[] = [];
+ for (const enricher of enrichers) {
+ if (!enricher.matches(request)) {
+ continue;
+ }
+ enricher.enrich(request);
+ applied.push(enricher.id);
+ }
+ return applied;
+}
diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts
index 91df9a10..0c84f671 100644
--- a/packages/core/src/config/config.ts
+++ b/packages/core/src/config/config.ts
@@ -4,6 +4,8 @@ import { loadPersistedAppConfig, replacePersistedAppConfig } from "@ccr/core/con
import { loadPersistedApiKeys, replacePersistedApiKeys } from "@ccr/core/config/api-key-store";
import { CONFIG_FILE, GATEWAY_CONFIG_FILE, LEGACY_CONFIG_FILE, LEGACY_WINDOWS_CONFIG_FILE } from "@ccr/core/config/constants";
import { normalizeCodexProviderAccountConfig } from "@ccr/core/agents/local-providers/codex";
+import { normalizeGrokProviderAccountConfig } from "@ccr/core/agents/local-providers/grok";
+import { removeOpenCodeProviderAccountConfig } from "@ccr/core/agents/local-providers/opencode";
import { CLAUDE_CODE_DEFAULT_ENV, CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, DEFAULT_OVERVIEW_WIDGETS, DEFAULT_TRAY_COMPONENT_VARIANTS, DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, GATEWAY_PLUGIN_PERMISSION_IDS, GATEWAY_PLUGIN_SURFACE_IDS, OVERVIEW_WIDGET_SIZE_VALUES, ROUTER_FALLBACK_MAX_RETRY_COUNT, TRAY_SINGLETON_WIDGET_TYPES, TRAY_TOP_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS, enforceSingleEnabledGlobalProfilePerAgent, knownGatewayPluginDefaultPermissions, knownGatewayPluginDefaultSurfaces } from "@ccr/core/contracts/app";
import { createDefaultAppConfig } from "@ccr/core/config/default-config";
import { findProviderPresetByBaseUrl, providerApiKeySafetyIssue, providerEndpointCanReceiveProviderApiKey } from "@ccr/core/providers/presets/index";
@@ -175,6 +177,9 @@ function defaultBotGatewayAuthType(platform: string): string {
if (platform === "slack" || platform === "discord" || platform === "telegram" || platform === "line") {
return "bot_token";
}
+ if (platform === "imessage") {
+ return "local";
+ }
return "";
}
@@ -522,7 +527,7 @@ function sanitizeProfileConfigForDisk(profile: AppConfig["profile"]): AppConfig[
...profile,
codex,
profiles: profile.profiles.map((profileItem) => {
- if (profileItem.agent !== "codex" && profileItem.agent !== "zcode") {
+ if (profileItem.agent !== "codex" && profileItem.agent !== "opencode" && profileItem.agent !== "zcode") {
return profileItem;
}
const {
@@ -1070,7 +1075,9 @@ function parseProviders(value: unknown): GatewayProviderConfig[] | undefined {
transformer: item.transformer,
type: readString(item.type)
};
- return normalizeCodexProviderAccountConfig(provider);
+ return removeOpenCodeProviderAccountConfig(
+ normalizeGrokProviderAccountConfig(normalizeCodexProviderAccountConfig(provider))
+ );
})
.filter((item): item is GatewayProviderConfig => Boolean(item));
@@ -1742,6 +1749,22 @@ function parseBotGateway(value: unknown): LoadedBotGatewayConfig | undefined {
config.forwardAllAgentMessages = Boolean(value.forward_all_agent_messages ?? value.forward_all_codex_messages);
}
+ if (typeof value.mediaEnabled === "boolean") {
+ config.mediaEnabled = value.mediaEnabled;
+ }
+ if (typeof value.streamReplies === "boolean") {
+ config.streamReplies = value.streamReplies;
+ }
+ if (typeof value.shellEnabled === "boolean") {
+ config.shellEnabled = value.shellEnabled;
+ } else if (typeof value.shell_enabled === "boolean") {
+ config.shellEnabled = value.shell_enabled;
+ }
+ const language = readString(value.language);
+ if (language === "auto" || language === "en" || language === "zh-CN") {
+ config.language = language;
+ }
+
const requestTimeoutMs = readNumber(value.requestTimeoutMs ?? value.request_timeout_ms);
if (requestTimeoutMs !== undefined) {
config.requestTimeoutMs = clampNumber(requestTimeoutMs, 1000, 3_600_000);
@@ -1754,6 +1777,22 @@ function parseBotGateway(value: unknown): LoadedBotGatewayConfig | undefined {
if (pollIntervalMs !== undefined) {
config.pollIntervalMs = clampNumber(pollIntervalMs, 500, 60_000);
}
+ const maxTurnTimeMs = readNumber(value.maxTurnTimeMs ?? value.max_turn_time_ms);
+ if (maxTurnTimeMs !== undefined) {
+ config.maxTurnTimeMs = clampNumber(maxTurnTimeMs, 10_000, 3_600_000);
+ }
+ const maxAttachmentBytes = readNumber(value.maxAttachmentBytes ?? value.max_attachment_bytes);
+ if (maxAttachmentBytes !== undefined) {
+ config.maxAttachmentBytes = clampNumber(maxAttachmentBytes, 1024, 100 * 1024 * 1024);
+ }
+ const messageChunkChars = readNumber(value.messageChunkChars ?? value.message_chunk_chars);
+ if (messageChunkChars !== undefined) {
+ config.messageChunkChars = clampNumber(messageChunkChars, 500, 20_000);
+ }
+ const sessionIdleMinutes = readNumber(value.sessionIdleMinutes ?? value.session_idle_minutes);
+ if (sessionIdleMinutes !== undefined) {
+ config.sessionIdleMinutes = clampNumber(sessionIdleMinutes, 0, 43_200);
+ }
const handoff = parseBotGatewayHandoff(value.handoff);
if (handoff) {
@@ -1971,6 +2010,10 @@ function parseProxy(value: unknown): Partial | undefined {
} else if (typeof value.systemProxyEnabled === "boolean") {
proxy.systemProxy = value.systemProxyEnabled;
}
+ const upstream = parseProxyUpstream(value.upstream ?? value.upstreamProxy ?? value.outboundProxy);
+ if (upstream) {
+ proxy.upstream = upstream;
+ }
const targets = parseProxyTargets(value.targets);
if (targets) {
proxy.targets = targets;
@@ -1978,6 +2021,58 @@ function parseProxy(value: unknown): Partial | undefined {
return proxy;
}
+function parseProxyUpstream(value: unknown): ProxyRuntimeConfig["upstream"] | undefined {
+ const fallback = DEFAULT_CONFIG.proxy.upstream;
+ if (typeof value === "string") {
+ const mode = parseProxyUpstreamMode(value);
+ return mode ? { ...fallback, mode } : undefined;
+ }
+ if (!isObject(value)) {
+ return undefined;
+ }
+
+ const mode = parseProxyUpstreamMode(value.mode ?? value.type);
+ const customInput = isObject(value.custom) ? value.custom : value;
+ const server = readString(customInput.server ?? customInput.host ?? customInput.hostname);
+ const port = readPort(customInput.port);
+ const username = readString(customInput.username ?? customInput.user);
+ const password = typeof customInput.password === "string"
+ ? customInput.password
+ : typeof customInput.pass === "string"
+ ? customInput.pass
+ : undefined;
+ const hasCustomInput = server !== undefined || port !== undefined || username !== undefined || password !== undefined;
+
+ return {
+ ...fallback,
+ custom: {
+ ...fallback.custom,
+ ...(server !== undefined ? { server } : {}),
+ ...(port !== undefined ? { port } : {}),
+ ...(username !== undefined ? { username } : {}),
+ ...(password !== undefined ? { password } : {})
+ },
+ mode: mode ?? (hasCustomInput ? "custom" : fallback.mode)
+ };
+}
+
+function parseProxyUpstreamMode(value: unknown): ProxyRuntimeConfig["upstream"]["mode"] | undefined {
+ if (typeof value !== "string") {
+ return undefined;
+ }
+ const normalized = value.trim().toLowerCase().replace(/[\s_-]+/g, "");
+ if (["none", "off", "disabled", "direct", "noproxy"].includes(normalized)) {
+ return "none";
+ }
+ if (["system", "systemproxy", "os", "osproxy", "env", "environment"].includes(normalized)) {
+ return "system";
+ }
+ if (["custom", "manual", "http", "httpproxy"].includes(normalized)) {
+ return "custom";
+ }
+ return undefined;
+}
+
function parseProxyTargets(value: unknown): ProxyRouteTarget[] | undefined {
if (!Array.isArray(value)) {
return undefined;
@@ -2506,6 +2601,19 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined {
};
}
+ if (agent === "grok") {
+ return {
+ agent,
+ enabled,
+ env: codexCompatibleProfileEnv(env),
+ id,
+ model,
+ name,
+ scope: "ccr",
+ surface: "cli"
+ };
+ }
+
const appPath = readProfileAppPath(item, agent);
return {
agent,
@@ -2526,7 +2634,7 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined {
providerName: readString(item.providerName) || "Claude Code Router",
remoteFrontendMode: parseCodexRemoteFrontendMode(readString(item.remoteFrontendMode) || readString(item.frontendMode) || readString(item.coreMode)) || "app",
scope: parseProfileScope(readString(item.scope) || readString(item.applyScope) || readString(item.effectScope)) || "global",
- showAllSessions: agent === "zcode"
+ showAllSessions: agent === "zcode" || agent === "opencode"
? false
: typeof item.showAllSessions === "boolean"
? item.showAllSessions
@@ -2548,6 +2656,8 @@ function readProfileAppPath(item: Record, agent: ProfileConfig[
? readString(item.claudeAppPath) || readString(item.claude_app_path)
: agent === "codex"
? readString(item.chatgptAppPath) || readString(item.chatgpt_app_path) || readString(item.codexAppPath) || readString(item.codex_app_path)
+ : agent === "opencode"
+ ? readString(item.openCodeAppPath) || readString(item.opencodeAppPath) || readString(item.opencode_app_path)
: readString(item.zcodeAppPath) || readString(item.zcode_app_path));
}
@@ -2562,6 +2672,12 @@ function parseProfileAgent(value: unknown): ProfileConfig["agent"] | undefined {
if (normalized === "codex") {
return "codex";
}
+ if (normalized === "grok" || normalized === "grok-cli" || normalized === "grok cli") {
+ return "grok";
+ }
+ if (normalized === "opencode" || normalized === "open-code" || normalized === "open code") {
+ return "opencode";
+ }
if (normalized === "zcode" || normalized === "z-code" || normalized === "z code") {
return "zcode";
}
@@ -2575,11 +2691,21 @@ function defaultProfileAgentName(agent: ProfileConfig["agent"]): string {
if (agent === "zcode") {
return "ZCode";
}
+ if (agent === "grok") {
+ return "Grok CLI";
+ }
+ if (agent === "opencode") {
+ return "OpenCode";
+ }
return "Codex";
}
function defaultCodexConfigFile(agent: ProfileConfig["agent"]): string {
- return agent === "zcode" ? "~/.zcode/cli/config.json" : "~/.codex/config.toml";
+ return agent === "zcode"
+ ? "~/.zcode/cli/config.json"
+ : agent === "opencode"
+ ? "~/.config/opencode/opencode.jsonc"
+ : "~/.codex/config.toml";
}
function normalizeCodexConfigFileForAgent(agent: ProfileConfig["agent"], value: string | undefined): string {
diff --git a/packages/core/src/config/default-config.ts b/packages/core/src/config/default-config.ts
index 58a38681..a0a83db7 100644
--- a/packages/core/src/config/default-config.ts
+++ b/packages/core/src/config/default-config.ts
@@ -74,12 +74,20 @@ export function createDefaultAppConfig(options: DefaultAppConfigOptions): AppCon
},
integrationConfig: {},
integrationId: "",
+ language: "auto",
+ maxAttachmentBytes: 20 * 1024 * 1024,
+ maxTurnTimeMs: 10 * 60 * 1000,
+ mediaEnabled: true,
+ messageChunkChars: 3500,
platform: "none",
pollIntervalMs: 2000,
requestTimeoutMs: 600000,
+ sessionIdleMinutes: 0,
+ shellEnabled: false,
sourceDir: "",
startupTimeoutMs: 10000,
stateDir: "",
+ streamReplies: true,
tenantId: "ccr"
},
gateway: {
@@ -158,7 +166,16 @@ export function createDefaultAppConfig(options: DefaultAppConfigOptions): AppCon
mode: "gateway",
port: 7890,
systemProxy: false,
- targets: DEFAULT_PROXY_TARGETS
+ targets: DEFAULT_PROXY_TARGETS,
+ upstream: {
+ custom: {
+ password: "",
+ port: 7890,
+ server: "",
+ username: ""
+ },
+ mode: "system"
+ }
},
providerPlugins: [],
overviewWidgets: DEFAULT_OVERVIEW_WIDGETS,
diff --git a/packages/core/src/contracts/app.ts b/packages/core/src/contracts/app.ts
index 64e38ccb..fa680947 100644
--- a/packages/core/src/contracts/app.ts
+++ b/packages/core/src/contracts/app.ts
@@ -1,6 +1,7 @@
export type AppInfo = {
appConfigDbFile: string;
apiKeysDbFile: string;
+ chatgptAppPath?: string;
configDir: string;
configFile: string;
dataDir: string;
@@ -8,6 +9,7 @@ export type AppInfo = {
launchAtLoginSupported: boolean;
requestLogsDbFile: string;
name: string;
+ opencodeAppPath?: string;
platform: string;
usageDbFile: string;
version: string;
@@ -175,7 +177,7 @@ export type ProviderAccountStatus = "ok" | "warning" | "critical" | "error" | "u
export type ProviderAccountMeterKind = "balance" | "subscription" | "quota" | "time_window" | "tokens" | "requests";
export type ProviderAccountMeterUnit = "USD" | "CNY" | "hours" | "minutes" | "tokens" | "requests" | string;
export type ProviderAccountMeterWindow = "5h" | "daily" | "weekly" | "monthly" | string;
-export type ProviderAccountHttpJsonParser = "kimi-code-usages" | "new-api-key-usage" | "new-api-user-self";
+export type ProviderAccountHttpJsonParser = "grok-subscription" | "kimi-code-usages" | "new-api-key-usage" | "new-api-user-self";
export type ProviderAccountConfig = {
connectors?: ProviderAccountConnectorConfig[];
@@ -329,7 +331,7 @@ export type ProviderManifestFetchResult = {
url: string;
};
-export type LocalAgentProviderKind = "claude-code" | "codex" | "zcode";
+export type LocalAgentProviderKind = "claude-code" | "codex" | "grok" | "opencode" | "zcode";
export type LocalAgentProviderStatus = "available" | "locked" | "missing";
@@ -442,6 +444,7 @@ export type GatewayProviderProbeRequest = {
forceRefresh?: boolean;
mode?: "connectivity" | "models" | "protocols";
models?: string[];
+ providerPlugins?: unknown[];
protocols?: GatewayProviderProtocol[];
skipModelDiscovery?: boolean;
};
@@ -460,6 +463,7 @@ export type GatewayProviderProbeCandidatesRequest = {
forceRefresh?: boolean;
mode?: "connectivity" | "models" | "protocols";
models?: string[];
+ providerPlugins?: unknown[];
protocols?: GatewayProviderProtocol[];
};
@@ -515,6 +519,7 @@ export type GatewayProviderConnectivityCheckRequest = {
candidates: GatewayProviderProbeCandidate[];
forceRefresh?: boolean;
models: string[];
+ providerPlugins?: unknown[];
protocols?: GatewayProviderProtocol[];
};
@@ -613,6 +618,20 @@ export type ProxyMode = "gateway" | "transparent";
export type ProxyForwardMode = ProxyMode | "plugin";
+export type ProxyUpstreamMode = "none" | "system" | "custom";
+
+export type ProxyUpstreamCustomConfig = {
+ password: string;
+ port: number;
+ server: string;
+ username: string;
+};
+
+export type ProxyUpstreamConfig = {
+ custom: ProxyUpstreamCustomConfig;
+ mode: ProxyUpstreamMode;
+};
+
export type ProxyRouteTarget = {
host: string;
paths?: string[];
@@ -995,6 +1014,7 @@ export type ProxyRuntimeConfig = {
port: number;
systemProxy: boolean;
targets: ProxyRouteTarget[];
+ upstream: ProxyUpstreamConfig;
};
export type ObservabilityConfig = {
@@ -1173,7 +1193,7 @@ export const DEFAULT_TRAY_WIDGETS: TrayWidgetConfig[] = [
{ id: "model-share", type: "model-share", variant: DEFAULT_TRAY_COMPONENT_VARIANTS.modelShare }
];
-export type ProfileClientKind = "claude-code" | "codex" | "zcode";
+export type ProfileClientKind = "claude-code" | "codex" | "grok" | "opencode" | "zcode";
export type CodexProfileConfigFormat = "legacy" | "separate_profile_files";
export type CodexRemoteFrontendMode = "app" | "cli" | "claude-code";
export type ProfileScope = "ccr" | "global" | "custom";
@@ -1302,6 +1322,7 @@ export type ProfileOpenResult = {
export type ProfileRuntimeEntry = {
agent: AgentKind;
+ botGateway?: BotGatewayRuntimeStatus;
pid?: number;
profileId: string;
profileName: string;
@@ -1310,6 +1331,18 @@ export type ProfileRuntimeEntry = {
surface: ProfileOpenSurface;
};
+export type BotGatewayRuntimeStatus = {
+ lastDeliveryAt?: string;
+ lastDeliveryStatus?: string;
+ lastError?: string;
+ lastErrorAt?: string;
+ lastEventAt?: string;
+ lastEventType?: string;
+ outboxCount: number;
+ state: "connected" | "error" | "starting" | "stopped" | "unknown";
+ updatedAt?: string;
+};
+
export type ProfileRuntimeStatus = {
profiles: ProfileRuntimeEntry[];
};
@@ -1404,12 +1437,20 @@ export type BotGatewayRuntimeConfig = {
handoff: BotGatewayHandoffConfig;
integrationConfig: Record;
integrationId: string;
+ language: "auto" | "en" | "zh-CN";
+ maxAttachmentBytes: number;
+ maxTurnTimeMs: number;
+ mediaEnabled: boolean;
+ messageChunkChars: number;
platform: string;
pollIntervalMs: number;
requestTimeoutMs: number;
+ sessionIdleMinutes: number;
+ shellEnabled: boolean;
sourceDir: string;
startupTimeoutMs: number;
stateDir: string;
+ streamReplies: boolean;
tenantId: string;
};
@@ -1815,6 +1856,7 @@ export type UsageComparisonRow = UsageTotals & {
credentialId?: string;
key: string;
label: string;
+ logicalModel?: string;
maxShare: number;
model?: string;
provider?: string;
@@ -1831,7 +1873,7 @@ export type UsageStatsSnapshot = {
totals: UsageTotals;
};
-export type AgentKind = "claude-code" | "codex" | "zcode" | "claude-design" | "unknown";
+export type AgentKind = "claude-code" | "codex" | "grok" | "opencode" | "zcode" | "claude-design" | "unknown";
export type AgentAnalysisFilter = {
agent?: AgentKind | "all";
diff --git a/packages/core/src/contracts/i18n.ts b/packages/core/src/contracts/i18n.ts
index 4cc9c576..fe60cf91 100644
--- a/packages/core/src/contracts/i18n.ts
+++ b/packages/core/src/contracts/i18n.ts
@@ -33,6 +33,8 @@ const zhExactErrorMessages: Record = {
"Network capture MCP is disabled.": "网络捕获 MCP 已禁用。",
"No available models": "没有可用模型",
"No available models. Configure at least one provider with a model before starting CCR Gateway or opening an agent through CCR.": "没有可用模型。请先配置至少一个包含模型的供应商,再启动 CCR 网关或通过 CCR 打开 Agent。",
+ "OpenCode CLI API key was not found.": "未找到 OpenCode CLI API key。",
+ "OpenCode CLI public models were not found.": "未找到 OpenCode CLI 公共模型。",
"No Bot Gateway conversationRef is available for inbound bot response.": "没有可用于入站 Bot 响应的 Bot Gateway conversationRef。",
"No Bot Gateway conversationRef is configured and no inbound bot event context is available.": "未配置 Bot Gateway conversationRef,且没有可用的入站 Bot 事件上下文。",
"No endpoint candidates available.": "没有可用的端点候选项。",
diff --git a/packages/core/src/gateway/application/gateway-service.ts b/packages/core/src/gateway/application/gateway-service.ts
new file mode 100644
index 00000000..f5e054e6
--- /dev/null
+++ b/packages/core/src/gateway/application/gateway-service.ts
@@ -0,0 +1,279 @@
+/**
+ * Extracted from gateway/service.ts. Keep this module focused on its named gateway boundary.
+ */
+import type { ChildProcess } from "node:child_process";
+import { randomUUID } from "node:crypto";
+import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
+import type { ApiKeyConfig, AppConfig, GatewayStatus } from "@ccr/core/contracts/app";
+import { NO_AVAILABLE_GATEWAY_MODELS_MESSAGE, hasAvailableGatewayModels } from "@ccr/core/contracts/app";
+import { backendService } from "@ccr/core/plugins/backend-service";
+import { getSystemProxyUrlForProtocol } from "@ccr/core/proxy/system-proxy-fetch";
+import { pluginService } from "@ccr/core/plugins/service";
+import { proxyService } from "@ccr/core/proxy/service";
+import { ClaudeCodeRouterPlugin } from "@ccr/core/gateway/claude-code-router-plugin";
+import { writeCoreGatewayConfig } from "@ccr/core/gateway/core-runtime/config-writer";
+import { closeServer, formatError } from "@ccr/core/gateway/http/io";
+import { RawTraceSynchronizer } from "@ccr/core/observability/raw-trace-sync";
+import { GatewayBillingSynchronizer } from "@ccr/core/usage/billing-sync";
+import { assertLoopbackCoreHost, endpoint, gatewayNetworkEndpoints, generateCoreGatewayAuthToken, isCoreGatewayHealthy, loopbackCoreHostError, removeManagedCoreGatewayMarker, shouldRunGatewayRuntime, shouldRunUnifiedServer, spawnGatewayProcess, stopPreviousManagedCoreGateway, writeManagedCoreGatewayMarker } from "@ccr/core/gateway/core-runtime/supervisor";
+import type { BrowserAutomationMcpIntegration, BrowserWebSearchMcpIntegration, GatewayStopOptions } from "@ccr/core/gateway/internal/shared";
+import { GatewayRequestPipeline } from "@ccr/core/gateway/request/pipeline";
+import { GatewayHttpRequestHandler } from "@ccr/core/gateway/http/request-handler";
+
+
+class GatewayService {
+ private readonly requestHandler = new GatewayHttpRequestHandler({
+ getBrowserAutomationMcpIntegration: () => this.browserAutomationMcpIntegration,
+ getConfig: () => this.config,
+ getPlugin: () => this.plugin,
+ getStatus: () => ({
+ coreEndpoint: this.status.coreEndpoint,
+ coreManagedExternally: this.status.coreManagedExternally,
+ endpoint: this.status.endpoint,
+ state: this.status.state
+ }),
+ handleRawTraceSync: (request, response) => this.rawTraceSynchronizer.handle(request, response),
+ handleBillingUsageSync: (request, response) => this.billingSynchronizer.handle(request, response),
+ proxyRequest: (request, response, path, apiKey) => this.proxyRequest(request, response, path, apiKey)
+ });
+
+ private readonly requestPipeline = new GatewayRequestPipeline({
+ getBrowserWebSearchMcpIntegration: () => this.browserWebSearchMcpIntegration,
+ getConfig: () => this.config,
+ getCoreAuthToken: () => this.coreAuthToken,
+ getPlugin: () => this.plugin,
+ getStatus: () => ({ coreEndpoint: this.status.coreEndpoint, endpoint: this.status.endpoint }),
+ takePendingRawTraceUpdate: (requestId) => this.rawTraceSynchronizer.take(requestId)
+ });
+
+ private browserAutomationMcpIntegration?: BrowserAutomationMcpIntegration;
+ private browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration;
+ private readonly billingSynchronizer = new GatewayBillingSynchronizer({
+ getConfig: () => this.config,
+ getGlobalBillingConfig: () => pluginService.getCoreGatewayConfig().billing
+ });
+ private child?: ChildProcess;
+ private config?: AppConfig;
+ private coreAuthToken = "";
+ private plugin?: ClaudeCodeRouterPlugin;
+ private readonly rawTraceSynchronizer = new RawTraceSynchronizer();
+ private server?: Server;
+ private status: GatewayStatus = {
+ coreEndpoint: "",
+ endpoint: "",
+ generatedConfigFile: "",
+ networkEndpoints: [],
+ state: "stopped"
+ };
+
+ setBrowserWebSearchMcpIntegration(integration: BrowserWebSearchMcpIntegration): void {
+ this.browserWebSearchMcpIntegration = integration;
+ }
+
+ setBrowserAutomationMcpIntegration(integration: BrowserAutomationMcpIntegration): void {
+ this.browserAutomationMcpIntegration = integration;
+ }
+
+ async start(config: AppConfig): Promise {
+ const coreHostError = loopbackCoreHostError(config.gateway.coreHost);
+ if (coreHostError) {
+ this.status = {
+ ...this.getStatus(),
+ lastError: coreHostError,
+ state: "error"
+ };
+ return this.status;
+ }
+ await this.stop();
+ this.config = config;
+ const coreAuthToken = generateCoreGatewayAuthToken();
+ this.plugin = new ClaudeCodeRouterPlugin(config);
+ this.status = {
+ coreEndpoint: endpoint(config.gateway.coreHost, config.gateway.corePort),
+ endpoint: endpoint(config.gateway.host, config.gateway.port),
+ generatedConfigFile: config.gateway.generatedConfigFile,
+ networkEndpoints: gatewayNetworkEndpoints(config.gateway.host, config.gateway.port),
+ state: "starting"
+ };
+
+ try {
+ await pluginService.start(config);
+ const shouldRunServer = shouldRunUnifiedServer(config) || pluginService.hasGatewayRoutes();
+ const shouldRunGateway = shouldRunGatewayRuntime(config);
+ if (shouldRunGateway && !hasAvailableGatewayModels(config)) {
+ throw new Error(NO_AVAILABLE_GATEWAY_MODELS_MESSAGE);
+ }
+ if (!shouldRunServer) {
+ await pluginService.stop();
+ await backendService.stopAll();
+ this.coreAuthToken = "";
+ this.status = {
+ ...this.status,
+ state: "stopped"
+ };
+ return this.status;
+ }
+
+ await this.listen(config);
+ if (this.server) {
+ const proxyStatus = await proxyService.attach(config, this.server);
+ if (proxyStatus.state === "error" && !config.gateway.enabled) {
+ throw new Error(proxyStatus.lastError || "Proxy service failed to start.");
+ }
+ }
+
+ if (shouldRunGateway) {
+ await proxyService.refreshUpstreamProxyFromCurrentSystem();
+ const upstreamProxyUrl = proxyService.getUpstreamProxyUrl("https") ?? await getSystemProxyUrlForProtocol("https", config);
+ await writeCoreGatewayConfig(
+ config,
+ this.rawTraceSynchronizer.token,
+ this.billingSynchronizer.token,
+ coreAuthToken,
+ this.browserWebSearchMcpIntegration,
+ upstreamProxyUrl
+ );
+ await stopPreviousManagedCoreGateway(config, this.status.coreEndpoint);
+ if (await isCoreGatewayHealthy(this.status.coreEndpoint)) {
+ throw new Error(`Core gateway endpoint is already in use: ${this.status.coreEndpoint}`);
+ }
+ const runtimeId = randomUUID();
+ this.child = spawnGatewayProcess(config, upstreamProxyUrl, runtimeId, coreAuthToken);
+ this.coreAuthToken = coreAuthToken;
+ const managedChild = this.child;
+ writeManagedCoreGatewayMarker(config, this.child, runtimeId);
+ this.child.stdout?.on("data", (chunk) => console.info(`[gateway] ${chunk.toString().trimEnd()}`));
+ this.child.stderr?.on("data", (chunk) => console.warn(`[gateway] ${chunk.toString().trimEnd()}`));
+ this.child.on("exit", (code, signal) => {
+ void this.handleCoreGatewayExit(managedChild, code, signal);
+ });
+ }
+
+ this.status = {
+ ...this.status,
+ coreManagedExternally: this.status.coreManagedExternally,
+ lastStartedAt: new Date().toISOString(),
+ pid: this.child?.pid,
+ state: "running"
+ };
+ return this.status;
+ } catch (error) {
+ await this.stop();
+ this.status = {
+ ...this.status,
+ lastError: formatError(error),
+ state: "error"
+ };
+ return this.status;
+ }
+ }
+
+ async stop(options: GatewayStopOptions = {}): Promise {
+ const child = this.child;
+ const config = this.config;
+ this.child = undefined;
+ this.coreAuthToken = "";
+ if (child && !child.killed) {
+ child.kill();
+ }
+ removeManagedCoreGatewayMarker(config);
+
+ const server = this.server;
+ this.server = undefined;
+ if (server) {
+ await closeServer(server);
+ }
+
+ await proxyService.stop(options.proxyRestoreTimeoutMs);
+ await pluginService.stop();
+ await backendService.stopAll();
+ await this.browserWebSearchMcpIntegration?.stopBrowserWebSearchMcpServers().catch((error) => {
+ console.warn(`[gateway] Failed to stop browser web search MCP: ${formatError(error)}`);
+ });
+ await this.browserAutomationMcpIntegration?.stopBrowserAutomationMcpServer().catch((error) => {
+ console.warn(`[gateway] Failed to stop browser automation MCP: ${formatError(error)}`);
+ });
+
+ this.status = {
+ ...this.status,
+ coreManagedExternally: undefined,
+ pid: undefined,
+ state: "stopped"
+ };
+ return this.getStatus();
+ }
+
+ getStatus(): GatewayStatus {
+ return {
+ ...this.status,
+ networkEndpoints: this.config
+ ? gatewayNetworkEndpoints(this.config.gateway.host, this.config.gateway.port)
+ : this.status.networkEndpoints
+ };
+ }
+
+ updateConfig(config: AppConfig): void {
+ assertLoopbackCoreHost(config.gateway.coreHost);
+ this.config = config;
+ this.plugin = new ClaudeCodeRouterPlugin(config);
+ proxyService.updateConfig(config);
+ this.status = {
+ ...this.status,
+ coreEndpoint: endpoint(config.gateway.coreHost, config.gateway.corePort),
+ endpoint: endpoint(config.gateway.host, config.gateway.port),
+ generatedConfigFile: config.gateway.generatedConfigFile,
+ networkEndpoints: gatewayNetworkEndpoints(config.gateway.host, config.gateway.port)
+ };
+ }
+
+ private async listen(config: AppConfig): Promise {
+ this.server = createServer((request, response) => {
+ if (proxyService.shouldHandleHttpRequest(request)) {
+ void proxyService.handleHttpRequest(request, response).catch((error) => {
+ response.writeHead(502, { "content-type": "application/json" });
+ response.end(JSON.stringify({ error: { message: formatError(error) } }));
+ });
+ return;
+ }
+
+ void this.handleRequest(request, response).catch((error) => {
+ response.writeHead(502, { "content-type": "application/json" });
+ response.end(JSON.stringify({ error: { message: formatError(error) } }));
+ });
+ });
+
+ await new Promise((resolve, reject) => {
+ this.server?.once("error", reject);
+ this.server?.listen(config.gateway.port, config.gateway.host, () => {
+ this.server?.off("error", reject);
+ resolve();
+ });
+ });
+ }
+
+ private async handleCoreGatewayExit(child: ChildProcess, code: number | null, signal: NodeJS.Signals | null): Promise {
+ if (this.child !== child || this.status.state === "stopped") {
+ return;
+ }
+ removeManagedCoreGatewayMarker(this.config);
+ this.status = {
+ ...this.status,
+ coreManagedExternally: undefined,
+ lastError: `Core gateway exited with ${signal ?? code ?? "unknown status"}`,
+ pid: undefined,
+ state: "error"
+ };
+ }
+
+ private async handleRequest(request: IncomingMessage, response: ServerResponse): Promise {
+ return this.requestHandler.handleRequest(request, response);
+ }
+
+ private async proxyRequest(request: IncomingMessage, response: ServerResponse, path: string, apiKey?: ApiKeyConfig): Promise {
+ return this.requestPipeline.proxyRequest(request, response, path, apiKey);
+ }
+
+}
+
+
+export const gatewayService = new GatewayService();
diff --git a/packages/core/src/gateway/auth/api-key-authorizer.ts b/packages/core/src/gateway/auth/api-key-authorizer.ts
new file mode 100644
index 00000000..8fc67105
--- /dev/null
+++ b/packages/core/src/gateway/auth/api-key-authorizer.ts
@@ -0,0 +1,132 @@
+import type { IncomingMessage, ServerResponse } from "node:http";
+import type { ApiKeyConfig, AppConfig } from "@ccr/core/contracts/app";
+import { loadPersistedApiKeys } from "@ccr/core/config/api-key-store";
+import { formatError, readAuthToken, readRemoteControlQueryAuthToken, sendJson } from "@ccr/core/gateway/http/io";
+import { estimateLimitUsage, limitRules, readWindowCounter } from "@ccr/core/gateway/limits/window-limiter";
+import type { ApiKeyAuthorizationResult, ApiKeyLimitRule, ApiKeyLimitUsage } from "@ccr/core/gateway/internal/shared";
+
+const persistedApiKeyCacheTtlMs = 1000;
+let persistedApiKeyCache: { loadedAt: number; values: ApiKeyConfig[] } | undefined;
+
+export async function authorize(
+ request: IncomingMessage,
+ response: ServerResponse,
+ config: AppConfig
+): Promise {
+ let apiKeys = await configuredApiKeys(config);
+ if (apiKeys.length === 0) {
+ sendJson(response, 403, {
+ error: {
+ message: "CCR API key is not initialized. Save a gateway API key or restart CCR to generate one."
+ }
+ });
+ return { ok: false };
+ }
+
+ const token = readAuthToken(request.headers) || readRemoteControlQueryAuthToken(request);
+ let apiKey = token ? apiKeys.find((item) => item.key === token) : undefined;
+ if (!apiKey && token) {
+ apiKeys = await configuredApiKeys(config, { refresh: true });
+ apiKey = apiKeys.find((item) => item.key === token);
+ }
+ if (apiKey) {
+ if (isApiKeyExpired(apiKey)) {
+ sendJson(response, 401, { error: { message: "API key is expired." } });
+ return { ok: false };
+ }
+ return { ok: true, apiKey };
+ }
+
+ sendJson(response, 401, { error: { message: token ? "Invalid API key." : "API key is missing." } });
+ return { ok: false };
+}
+
+export function reserveApiKeyLimits(
+ apiKey: ApiKeyConfig | undefined,
+ request: IncomingMessage,
+ response: ServerResponse,
+ requestBody: Buffer
+): boolean {
+ if (!apiKey?.limits) return true;
+
+ const usage = estimateLimitUsage(request.method ?? "GET", requestBody);
+ const rules = apiKeyLimitRules(apiKey, usage);
+ const now = Date.now();
+ const checks = rules.map((rule) => {
+ const windowStart = Math.floor(now / rule.windowMs) * rule.windowMs;
+ return {
+ counterKey: ["api-key", apiKey.id, rule.name, rule.metric, rule.windowMs, windowStart].join("|"),
+ rule,
+ windowStart
+ };
+ });
+
+ for (const check of checks) {
+ const counter = readWindowCounter(check.counterKey, check.windowStart, check.rule.windowMs, now);
+ if (counter.value + check.rule.requested > check.rule.limit) {
+ sendJson(response, 429, {
+ error: {
+ code: "rate_limit_exceeded",
+ message: `API key ${check.rule.name} limit exceeded.`,
+ details: {
+ limit: check.rule.limit,
+ limit_name: check.rule.name,
+ metric: check.rule.metric,
+ requested: check.rule.requested,
+ used: counter.value,
+ window_ms: check.rule.windowMs
+ }
+ }
+ });
+ return false;
+ }
+ }
+
+ for (const check of checks) {
+ readWindowCounter(check.counterKey, check.windowStart, check.rule.windowMs, now).value += check.rule.requested;
+ }
+ return true;
+}
+
+async function configuredApiKeys(config: AppConfig, options: { refresh?: boolean } = {}): Promise {
+ const persistedApiKeys = await loadPersistedApiKeysCached(options);
+ const values = [
+ ...persistedApiKeys,
+ ...(Array.isArray(config.APIKEYS) ? config.APIKEYS : []),
+ ...(config.APIKEY ? [{ createdAt: new Date(0).toISOString(), id: "legacy", key: config.APIKEY }] : [])
+ ];
+ const seen = new Set