claude-code-router/src/main/profile-launch-service.ts
2026-06-29 16:45:47 +08:00

1432 lines
46 KiB
TypeScript

import { spawn, spawnSync, type ChildProcess } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import type { AppConfig, ProfileOpenCommandResult, ProfileOpenRequest, ProfileOpenResult, ProfileRuntimeEntry, ProfileRuntimeStatus, ProfileStopResult } from "../shared/app";
import { botGatewayProfileEnv } from "./bot-gateway-env";
import { applyClaudeAppGatewayConfig, readClaudeAppGatewayApiKeyCandidates } from "./claude-app-gateway-service";
import { launchClaudeAppProfile, resolveClaudeAppProfileUserDataDir } from "./claude-app-launch";
import { launchCodexAppProfile, launchZcodeAppProfile, refreshCodexCompatibleAppProfileFiles } from "./codex-app-launch";
import { codexCliMiddlewareRuntimeScript } from "./codex-cli-middleware-runtime";
import { CONFIGDIR } from "./constants";
import { gatewayService } from "../server/gateway/service";
import { buildProfileLaunchPlan, findProfileForOpen, profileLaunchSpawnCommand, profileOpenCommand, resolveClaudeCodeSettingsFile, resolveProfileOpenSurface } from "./profile-launch-core";
import { applyProfileConfig } from "./profile-service";
import { broadcastWindowsEnvironmentChanged, windowsSystemCommand } from "./windows-system";
const ccrPathBlockStart = "# >>> Claude Code Router CLI >>>";
const ccrPathBlockEnd = "# <<< Claude Code Router CLI <<<";
let claudeAppBotWorker: ChildProcess | undefined;
let claudeAppBotWorkerProfileId: string | undefined;
type ProfileAppLaunchResult = {
child: ChildProcess;
claudeDesignProxy?: boolean;
command: string;
pidIsLauncher?: boolean;
pid?: number;
userDataDir: string;
};
type RunningProfileApp = ProfileRuntimeEntry & {
child: ChildProcess;
claudeDesignProxy?: boolean;
command: string;
pidIsLauncher?: boolean;
spawnError?: string;
stopRequested?: boolean;
userDataDir: string;
};
process.once("exit", () => stopClaudeAppBotWorker());
export async function getProfileOpenCommand(config: AppConfig, request: ProfileOpenRequest): Promise<ProfileOpenCommandResult> {
await applyProfileConfig(config);
const profile = findProfileForOpen(config, request.profileId);
const surface = resolveProfileOpenSurface(profile, request.surface);
ensureCcrCliLauncher();
return {
command: profileOpenCommand(profile, surface, "ccr", commandProfileRef(config, profile)),
profileId: profile.id,
profileName: profile.name,
surface
};
}
export async function openProfileFromCcr(config: AppConfig, request: ProfileOpenRequest): Promise<ProfileOpenResult> {
await applyProfileConfig(config);
const profile = findProfileForOpen(config, request.profileId);
const surface = resolveProfileOpenSurface(profile, request.surface);
if (profile.agent === "claude-code" && surface === "app") {
return openClaudeAppProfile(config, profile);
}
if ((profile.agent === "codex" || profile.agent === "zcode") && surface === "app") {
return await openCodexAppProfile(config, profile);
}
const plan = buildProfileLaunchPlan(CONFIGDIR, profile, surface);
if (path.isAbsolute(plan.command) && !existsSync(plan.command)) {
throw new Error(`Profile launcher was not found: ${plan.command}. Re-save the profile and try again.`);
}
const launch = profileLaunchSpawnCommand(plan);
const child = spawn(launch.command, launch.args, {
detached: true,
env: {
...process.env,
...plan.env,
...botGatewayProfileEnv(config, profile, surface)
},
stdio: "ignore"
});
const spawnError = await waitForImmediateSpawnError(child, 500);
if (spawnError) {
throw new Error(`Failed to open ${profile.name || profile.id}: ${spawnError}`);
}
child.unref();
return {
message: `Opened ${profile.name || profile.id}.`,
profileId: profile.id,
profileName: profile.name,
surface
};
}
async function openCodexAppProfile(config: AppConfig, profile: ReturnType<typeof findProfileForOpen>): Promise<ProfileOpenResult> {
const appName = profile.agent === "zcode" ? "ZCode App" : "Codex App";
const profileGatewayConfig = await ensureProfileGateway(config, profile, appName);
const existing = runningProfileApp(profile.id, "app");
if (existing) {
refreshCodexCompatibleAppProfileFiles(CONFIGDIR, profile, profileGatewayConfig);
activateProfileAppWindow(existing);
return {
message: `${appName} is already running with ${profile.name || profile.id}.`,
profileId: profile.id,
profileName: profile.name,
surface: "app"
};
}
const launch = profile.agent === "zcode"
? launchZcodeAppProfile(CONFIGDIR, profile, profileGatewayConfig)
: launchCodexAppProfile(CONFIGDIR, profile, profileGatewayConfig);
const entry = registerProfileApp(profile, "app", launch);
const started = await waitForProfileAppStart(entry, 12000);
if (!started) {
cleanupProfileAppEntry(profileRuntimeKey(profile.id, "app"), entry);
sendProfileProcessSignal(entry.pid, "SIGTERM");
throw new Error([
`${appName} did not open a window for ${profile.name || profile.id}.`,
...(entry.spawnError ? [`Error: ${entry.spawnError}`] : []),
`Command: ${entry.command}`,
`User data: ${entry.userDataDir}`
].join(" "));
}
activateProfileAppWindow(entry);
return {
message: `Opened ${appName} with ${profile.name || profile.id}.`,
profileId: profile.id,
profileName: profile.name,
surface: "app"
};
}
async function openClaudeAppProfile(config: AppConfig, profile: ReturnType<typeof findProfileForOpen>): Promise<ProfileOpenResult> {
const profileGatewayConfig = claudeAppGatewayConfigFor(config, profile);
const existing = runningProfileApp(profile.id, "app");
if (existing) {
if (!claudeAppDesignProxyRequired(profileGatewayConfig) || existing.claudeDesignProxy) {
activateProfileAppWindow(existing);
return {
message: `Claude App is already running with ${profile.name || profile.id}.`,
profileId: profile.id,
profileName: profile.name,
surface: "app"
};
}
const stopped = await stopRunningProfileApp(profileRuntimeKey(profile.id, "app"), existing);
if (!stopped) {
throw new Error("Claude App is already running without the Claude Design proxy. Close Claude App and try again.");
}
stopClaudeAppBotWorker(profile.id);
}
applyClaudeAppGatewayConfig(profileGatewayConfig);
applyClaudeAppGatewayConfig(profileGatewayConfig, {
backup: false,
dataDir: resolveClaudeAppProfileUserDataDir(CONFIGDIR, profile),
refreshModelDiscoveryCache: true
});
await ensureGatewayConfigRunning(profileGatewayConfig, profile, "Claude App");
const entry = registerProfileApp(profile, "app", await launchClaudeAppProfile(CONFIGDIR, profile, profileGatewayConfig));
const started = await waitForProfileAppStart(entry, 12000);
if (!started) {
cleanupProfileAppEntry(profileRuntimeKey(profile.id, "app"), entry);
sendProfileProcessSignal(entry.pid, "SIGTERM");
throw new Error([
`Claude App did not open a window for ${profile.name || profile.id}.`,
...(entry.spawnError ? [`Error: ${entry.spawnError}`] : []),
`Command: ${entry.command}`,
`User data: ${entry.userDataDir}`
].join(" "));
}
activateProfileAppWindow(entry);
startClaudeAppBotWorker(config, profile);
return {
message: `Opened Claude App with ${profile.name || profile.id}.`,
profileId: profile.id,
profileName: profile.name,
surface: "app"
};
}
export async function ensureProfileGateway(
config: AppConfig,
profile: ReturnType<typeof findProfileForOpen>,
appName: string,
options: { reuseExisting?: boolean; startIfMissing?: boolean } = {}
): Promise<AppConfig> {
const profileGatewayConfig = profileGatewayConfigFor(config, profile);
const result = await ensureGatewayConfigRunning(profileGatewayConfig, profile, appName, options, config);
return result.acceptedApiKey && result.acceptedApiKey !== result.config.APIKEY
? profileGatewayConfigWithToken(result.config, profile, result.acceptedApiKey)
: result.config;
}
type EnsureGatewayConfigRunningResult = {
acceptedApiKey?: string;
config: AppConfig;
};
async function ensureGatewayConfigRunning(
config: AppConfig,
profile: ReturnType<typeof findProfileForOpen>,
appName: string,
options: { reuseExisting?: boolean; startIfMissing?: boolean } = {},
candidateConfig: AppConfig = config
): Promise<EnsureGatewayConfigRunningResult> {
const startIfMissing = options.startIfMissing !== false;
if (options.reuseExisting) {
const existingGateway = await probeExistingProfileGateway(config, profile, candidateConfig);
if (existingGateway.state === "usable") {
return { acceptedApiKey: existingGateway.apiKey, config };
}
if (existingGateway.state === "unavailable") {
if (!startIfMissing) {
throw new Error(`CCR gateway is not running at ${profileGatewayEndpoint(config)}. Start CCR Desktop or run ccr start before opening ${appName}.`);
}
} else {
throw new Error(existingGatewayConflictMessage(existingGateway, appName));
}
}
if (!startIfMissing) {
throw new Error(`CCR gateway is not running at ${profileGatewayEndpoint(config)}. Start CCR Desktop or run ccr start before opening ${appName}.`);
}
const startedStatus = await gatewayService.start(config);
if (startedStatus.state === "running") {
return { config };
}
if (options.reuseExisting && isAddressInUseError(startedStatus.lastError)) {
const existingGateway = await probeExistingProfileGateway(config, profile, candidateConfig);
if (existingGateway.state === "usable") {
return { acceptedApiKey: existingGateway.apiKey, config };
}
throw new Error(existingGatewayConflictMessage(existingGateway, appName));
}
throw new Error(startedStatus.lastError || `CCR gateway did not start for ${appName}.`);
}
type ExistingProfileGatewayProbe =
| { endpoint: string; reason?: string; state: "unavailable" }
| { endpoint: string; status?: number; state: "not-ccr" }
| { endpoint: string; message?: string; status: number; state: "unauthorized" }
| { endpoint: string; status: number; state: "unusable" }
| { apiKey?: string; endpoint: string; state: "usable" };
type ExistingGatewayHttpProbe = {
payload?: unknown;
reason?: string;
status?: number;
};
async function probeExistingProfileGateway(
config: AppConfig,
profile: ReturnType<typeof findProfileForOpen>,
candidateConfig: AppConfig = config
): Promise<ExistingProfileGatewayProbe> {
const endpoint = profileGatewayEndpoint(config);
const root = await fetchExistingGateway(endpoint, "/");
if (root.status === undefined) {
return { endpoint, reason: root.reason, state: "unavailable" };
}
let ccrGateway = isCcrGatewayRoot(root.payload);
if (!ccrGateway) {
const health = await fetchExistingGateway(endpoint, "/health");
ccrGateway = isCcrGatewayHealth(health.payload);
}
if (!ccrGateway) {
return { endpoint, status: root.status, state: "not-ccr" };
}
let lastUnauthorized: ExistingGatewayHttpProbe | undefined;
for (const apiKey of existingGatewayApiKeyCandidates(config, profile, candidateConfig)) {
const headers: Record<string, string> = {
"user-agent": "Claude Code"
};
if (apiKey) {
headers.authorization = `Bearer ${apiKey}`;
}
const models = await fetchExistingGateway(endpoint, "/v1/models", { headers });
if (models.status === 200) {
return { apiKey, endpoint, state: "usable" };
}
if (models.status === 401 || models.status === 403) {
lastUnauthorized = models;
continue;
}
return { endpoint, status: models.status ?? 0, state: "unusable" };
}
if (lastUnauthorized?.status === 401 || lastUnauthorized?.status === 403) {
return {
endpoint,
message: readGatewayErrorMessage(lastUnauthorized.payload),
status: lastUnauthorized.status,
state: "unauthorized"
};
}
return { endpoint, status: 0, state: "unusable" };
}
async function fetchExistingGateway(
endpoint: string,
pathname: string,
init: RequestInit = {}
): Promise<ExistingGatewayHttpProbe> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1200);
try {
const response = await fetch(new URL(pathname, endpoint).toString(), {
...init,
signal: controller.signal
});
return {
payload: await readResponseJson(response),
status: response.status
};
} catch (error) {
return { reason: formatError(error) };
} finally {
clearTimeout(timeout);
}
}
async function readResponseJson(response: Response): Promise<unknown> {
const text = await response.text();
if (!text.trim()) {
return undefined;
}
try {
return JSON.parse(text) as unknown;
} catch {
return undefined;
}
}
function isCcrGatewayRoot(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
return value.name === "claude-code-router" || value.plugin === "claude-code-router";
}
function isCcrGatewayHealth(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
return typeof value.core === "string" && typeof value.status === "string" && typeof value.timestamp === "string";
}
function readGatewayErrorMessage(value: unknown): string | undefined {
if (!isRecord(value) || !isRecord(value.error)) {
return undefined;
}
return typeof value.error.message === "string" ? value.error.message : undefined;
}
function existingGatewayApiKeyCandidates(
config: AppConfig,
profile: ReturnType<typeof findProfileForOpen>,
candidateConfig: AppConfig
): Array<string | undefined> {
const values = [
config.APIKEY,
...(Array.isArray(config.APIKEYS) ? config.APIKEYS.map((apiKey) => apiKey.key) : []),
candidateConfig.APIKEY,
...(Array.isArray(candidateConfig.APIKEYS) ? candidateConfig.APIKEYS.map((apiKey) => apiKey.key) : []),
...readClaudeAppGatewayApiKeyCandidates(),
...readClaudeCodeApiKeyHelperCandidates(profile)
];
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
const key = value?.trim();
if (!key || seen.has(key)) {
continue;
}
seen.add(key);
result.push(key);
}
return result.length > 0 ? result : [undefined];
}
function readClaudeCodeApiKeyHelperCandidates(profile: ReturnType<typeof findProfileForOpen>): string[] {
const file = path.join(CONFIGDIR, "bin", claudeCodeApiKeyHelperFilename(profile));
const files = [
file,
...readBackupFiles(file)
];
return uniqueStrings(files.map(readClaudeCodeApiKeyHelperToken));
}
function claudeCodeApiKeyHelperFilename(profile: ReturnType<typeof findProfileForOpen>): string {
const slug = sanitizeProfilePathSegment(profile.id || profile.name || profile.agent) || "claude-code";
return process.platform === "win32"
? `ccr-claude-code-api-key-${slug}.cmd`
: `ccr-claude-code-api-key-${slug}`;
}
function readBackupFiles(file: string): string[] {
const dir = path.dirname(file);
const prefix = `${path.basename(file)}.ccr-backup-`;
try {
return readdirSync(dir)
.filter((entry) => entry.startsWith(prefix))
.sort()
.reverse()
.map((entry) => path.join(dir, entry));
} catch {
return [];
}
}
function readClaudeCodeApiKeyHelperToken(file: string): string {
if (!existsSync(file)) {
return "";
}
try {
const content = readFileSync(file, "utf8");
for (const line of content.split(/\r?\n/g)) {
const token = parseClaudeCodeApiKeyHelperLine(line);
if (token) {
return token;
}
}
} catch {
return "";
}
return "";
}
function parseClaudeCodeApiKeyHelperLine(line: string): string {
const trimmed = line.trim();
const shellPrefix = "printf '%s\\n' ";
if (trimmed.startsWith(shellPrefix)) {
return unquoteShellValue(trimmed.slice(shellPrefix.length).trim());
}
if (/^echo\s+/i.test(trimmed)) {
return trimmed.replace(/^echo\s+/i, "").trim().replace(/^"|"$/g, "");
}
return "";
}
function unquoteShellValue(value: string): string {
if (value.startsWith("'") && value.endsWith("'")) {
return value.slice(1, -1).replace(/'\\''/g, "'");
}
return value.replace(/^"|"$/g, "");
}
function existingGatewayConflictMessage(probe: ExistingProfileGatewayProbe, appName: string): string {
if (probe.state === "unauthorized") {
const details = probe.message ? ` ${probe.message}` : "";
return `CCR gateway is already running at ${probe.endpoint}, but it does not accept the API key for ${appName}.${details} Restart CCR Desktop or run ccr start to refresh the gateway before opening this profile.`;
}
if (probe.state === "unusable") {
return `CCR gateway is already running at ${probe.endpoint}, but it cannot serve ${appName} right now (HTTP ${probe.status}). Restart CCR Desktop or run ccr start to refresh the gateway before opening this profile.`;
}
if (probe.state === "not-ccr") {
return `Port ${probe.endpoint} is already in use by a non-CCR service. Stop that process or change the CCR gateway port.`;
}
if (probe.state === "unavailable") {
return `CCR gateway is not reachable at ${probe.endpoint}${probe.reason ? `: ${probe.reason}` : ""}.`;
}
return `CCR gateway is already running at ${probe.endpoint}.`;
}
function isAddressInUseError(message: string | undefined): boolean {
return /\bEADDRINUSE\b/i.test(message || "");
}
function profileGatewayEndpoint(config: AppConfig): string {
const host = probeGatewayHost(config.gateway.host);
return `http://${formatEndpointHost(host)}:${config.gateway.port}/`;
}
function probeGatewayHost(host: string): string {
if (!host || host === "0.0.0.0") {
return "127.0.0.1";
}
if (host === "::") {
return "::1";
}
return host;
}
function formatEndpointHost(host: string): string {
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
}
function profileGatewayConfigFor(config: AppConfig, profile: ReturnType<typeof findProfileForOpen>): AppConfig {
const token = findProfileApiKey(config, profile);
if (!token) {
throw new Error(`No CCR API key was found for profile "${profile.name || profile.id}". Re-save the profile and try again.`);
}
return profileGatewayConfigWithToken(config, profile, token);
}
function profileGatewayConfigWithToken(config: AppConfig, profile: ReturnType<typeof findProfileForOpen>, token: string): AppConfig {
return {
...config,
APIKEY: token,
APIKEYS: [
{
createdAt: new Date().toISOString(),
id: profileApiKeyId(profile),
key: token,
name: `Profile: ${profile.name?.trim() || profile.id || profile.agent}`
}
],
Router: {
...config.Router,
...(profile.model.trim() ? { default: profile.model.trim() } : {})
}
};
}
function claudeAppGatewayConfigFor(config: AppConfig, profile: ReturnType<typeof findProfileForOpen>): AppConfig {
const profileGatewayConfig = profileGatewayConfigFor(config, profile);
if (!claudeAppDesignProxyRequired(profileGatewayConfig)) {
return profileGatewayConfig;
}
return {
...profileGatewayConfig,
proxy: {
...profileGatewayConfig.proxy,
enabled: true,
mode: "transparent",
systemProxy: false
}
};
}
function claudeAppDesignProxyRequired(config: AppConfig): boolean {
return config.plugins.some((plugin) => plugin.enabled !== false && plugin.id === "claude-design");
}
export function getProfileRuntimeStatus(): ProfileRuntimeStatus {
cleanupExitedProfileApps();
return {
profiles: [...runningProfileApps.values()]
.filter((entry) => !entry.stopRequested)
.map((entry) => ({
agent: entry.agent,
pid: entry.pid,
profileId: entry.profileId,
profileName: entry.profileName,
startedAt: entry.startedAt,
state: entry.state,
surface: entry.surface
}))
};
}
export async function stopProfileFromCcr(config: AppConfig, request: ProfileOpenRequest): Promise<ProfileStopResult> {
const profile = findProfileForOpen(config, request.profileId);
const surface = resolveProfileOpenSurface(profile, request.surface);
if (surface !== "app") {
throw new Error(`${profile.name || profile.id} does not support stopping ${surface.toUpperCase()} from CCR.`);
}
const key = profileRuntimeKey(profile.id, surface);
const entry = runningProfileApps.get(key);
if (!entry) {
return {
message: `No running app was found for ${profile.name || profile.id}.`,
profileId: profile.id,
profileName: profile.name,
stopped: false,
surface
};
}
const stopped = await stopRunningProfileApp(key, entry);
if (stopped && profile.agent === "claude-code") {
stopClaudeAppBotWorker(profile.id);
}
return {
message: stopped
? `Stopped ${profile.name || profile.id}.`
: `Stop requested for ${profile.name || profile.id}. It may take a moment to close.`,
profileId: profile.id,
profileName: profile.name,
stopped,
surface
};
}
const runningProfileApps = new Map<string, RunningProfileApp>();
function registerProfileApp(
profile: ReturnType<typeof findProfileForOpen>,
surface: ProfileOpenRequest["surface"],
launch: ProfileAppLaunchResult
): RunningProfileApp {
const key = profileRuntimeKey(profile.id, surface);
const existing = runningProfileApps.get(key);
if (existing && isProcessAlive(existing.pid)) {
sendProfileProcessSignal(existing.pid, "SIGTERM");
}
const entry: RunningProfileApp = {
agent: profile.agent,
child: launch.child,
claudeDesignProxy: launch.claudeDesignProxy,
command: launch.command,
pid: launch.pid,
pidIsLauncher: launch.pidIsLauncher,
profileId: profile.id,
profileName: profile.name,
startedAt: new Date().toISOString(),
state: "running",
surface,
userDataDir: launch.userDataDir
};
runningProfileApps.set(key, entry);
launch.child.once("exit", () => {
if (process.platform === "win32" && entry.userDataDir) {
setTimeout(() => {
if (isProfileAppRunning(entry)) {
return;
}
cleanupProfileAppEntry(key, entry);
}, 1500).unref();
return;
}
if (entry.pidIsLauncher && isProfileAppRunning(entry)) {
return;
}
cleanupProfileAppEntry(key, entry);
});
launch.child.once("error", (error) => {
entry.spawnError = formatError(error);
cleanupProfileAppEntry(key, entry);
});
return entry;
}
function activateProfileAppWindow(entry: Pick<RunningProfileApp, "pid" | "userDataDir">): void {
if (process.platform !== "darwin") {
return;
}
for (const delayMs of [250, 1200]) {
setTimeout(() => {
const pid = profileAppMainPid(entry) ?? entry.pid;
if (!isProcessAlive(pid)) {
return;
}
try {
const child = spawn("/usr/bin/osascript", [
"-e",
`tell application "System Events" to set frontmost of the first process whose unix id is ${pid} to true`
], {
detached: true,
stdio: "ignore"
});
child.unref();
} catch {
// Activation is best-effort; the app process itself has already been started.
}
}, delayMs).unref();
}
}
function runningProfileApp(profileId: string, surface: ProfileOpenRequest["surface"]): RunningProfileApp | undefined {
const key = profileRuntimeKey(profileId, surface);
const entry = runningProfileApps.get(key);
if (!entry) {
return undefined;
}
if (isProfileAppRunning(entry)) {
entry.stopRequested = false;
return entry;
}
cleanupProfileAppEntry(key, entry);
return undefined;
}
function cleanupExitedProfileApps(): void {
for (const [key, entry] of runningProfileApps) {
if (!isProfileAppRunning(entry)) {
cleanupProfileAppEntry(key, entry);
}
}
}
function cleanupProfileAppEntry(key: string, entry: RunningProfileApp): void {
if (runningProfileApps.get(key) !== entry) {
return;
}
runningProfileApps.delete(key);
if (entry.stopRequested && entry.agent === "claude-code") {
stopClaudeAppBotWorker(entry.profileId);
}
}
async function stopRunningProfileApp(key: string, entry: RunningProfileApp): Promise<boolean> {
if (!isProfileAppRunning(entry)) {
runningProfileApps.delete(key);
return false;
}
entry.stopRequested = true;
sendProfileProcessSignal(profileAppMainPid(entry) ?? entry.pid, "SIGTERM");
if (await waitForProfileAppExit(entry, 5000)) {
runningProfileApps.delete(key);
return true;
}
return false;
}
function profileRuntimeKey(profileId: string, surface: ProfileOpenRequest["surface"]): string {
return `${surface}:${profileId}`;
}
function isProcessAlive(pid: number | undefined): boolean {
if (!pid) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch (error) {
return nodeErrorCode(error) === "EPERM";
}
}
function isProfileAppRunning(entry: Pick<RunningProfileApp, "pid" | "pidIsLauncher" | "userDataDir">): boolean {
if (profileAppMainPid(entry)) {
return true;
}
return !entry.pidIsLauncher && isProcessAlive(entry.pid);
}
function profileAppMainPid(entry: Pick<RunningProfileApp, "userDataDir">): number | undefined {
if (!entry.userDataDir) {
return undefined;
}
const marker = normalizeProcessPath(entry.userDataDir);
if (process.platform === "win32") {
return windowsProfileAppMainPid(marker);
}
return posixProfileAppMainPid(marker);
}
function posixProfileAppMainPid(marker: string): number | undefined {
try {
const result = spawnSync("ps", ["-Ao", "pid=,command="], {
encoding: "utf8"
});
if (result.error || result.status !== 0) {
return undefined;
}
for (const line of result.stdout.split(/\r?\n/)) {
const match = line.match(/^\s*(\d+)\s+(.+)$/);
if (!match) {
continue;
}
const pid = Number(match[1]);
const command = match[2];
if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid) {
continue;
}
if (path.basename(command.trim().split(/\s+/)[0] || "") === "open") {
continue;
}
if (command.includes(" --type=")) {
continue;
}
if (normalizeProcessPath(command).includes(marker)) {
return pid;
}
}
} catch {
return undefined;
}
return undefined;
}
function normalizeProcessPath(value: string): string {
return process.platform === "win32" ? value.replace(/\\/g, "/").toLowerCase() : value;
}
function windowsProfileAppMainPid(marker: string): number | undefined {
const script = [
"$ErrorActionPreference = 'SilentlyContinue'",
`$marker = ${powershellString(marker)}`,
`$hostPid = ${process.pid}`,
"$selfPid = $PID",
"Get-CimInstance Win32_Process | Where-Object {",
" $_.ProcessId -ne $selfPid -and",
" $_.ProcessId -ne $hostPid -and",
" $_.CommandLine -and",
" (($_.CommandLine -replace '\\\\', '/').ToLowerInvariant().Contains($marker)) -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;
}
const pid = result.stdout
.split(/\r?\n/)
.map((line) => Number(line.trim()))
.find((value) => Number.isFinite(value) && value > 0 && value !== process.pid);
return pid;
} catch {
return undefined;
}
}
function sendProfileProcessSignal(pid: number | undefined, signal: NodeJS.Signals): void {
if (!pid) {
return;
}
if (process.platform === "win32") {
const args = ["/PID", String(pid), "/T"];
if (signal === "SIGKILL") {
args.push("/F");
}
spawnSync(windowsSystemCommand("taskkill.exe"), args, {
stdio: "ignore",
windowsHide: true
});
return;
}
try {
process.kill(pid, signal);
} catch {
// The app process may have already exited.
}
}
async function waitForProcessExit(pid: number | undefined, timeoutMs: number): Promise<boolean> {
if (!pid) {
return true;
}
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (!isProcessAlive(pid)) {
return true;
}
await sleep(100);
}
return !isProcessAlive(pid);
}
async function waitForProfileAppStart(entry: Pick<RunningProfileApp, "pid" | "pidIsLauncher" | "spawnError" | "userDataDir">, timeoutMs: number): Promise<boolean> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (entry.spawnError) {
return false;
}
if (profileAppMainPid(entry)) {
return true;
}
if (!entry.pidIsLauncher && isProcessAlive(entry.pid)) {
return true;
}
if (process.platform !== "win32" && !entry.pidIsLauncher && !isProcessAlive(entry.pid)) {
return false;
}
await sleep(100);
}
return !entry.spawnError && (Boolean(profileAppMainPid(entry)) || (!entry.pidIsLauncher && isProcessAlive(entry.pid)));
}
function waitForImmediateSpawnError(child: ChildProcess, timeoutMs: number): Promise<string | undefined> {
return new Promise((resolve) => {
let settled = false;
let timer: NodeJS.Timeout | undefined;
const finish = (message: string | undefined) => {
if (settled) {
return;
}
settled = true;
if (timer) {
clearTimeout(timer);
}
child.off("error", onError);
child.off("spawn", onSpawn);
resolve(message);
};
const onError = (error: Error) => finish(formatError(error));
const onSpawn = () => finish(undefined);
child.once("error", onError);
child.once("spawn", onSpawn);
timer = setTimeout(() => finish(undefined), timeoutMs);
timer.unref?.();
});
}
async function waitForProfileAppExit(entry: Pick<RunningProfileApp, "pid" | "pidIsLauncher" | "userDataDir">, timeoutMs: number): Promise<boolean> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (!isProfileAppRunning(entry)) {
return true;
}
await sleep(100);
}
return !isProfileAppRunning(entry);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function nodeErrorCode(error: unknown): string | undefined {
return typeof error === "object" && error !== null && "code" in error
? String((error as { code?: unknown }).code)
: undefined;
}
function startClaudeAppBotWorker(config: AppConfig, profile: ReturnType<typeof findProfileForOpen>): void {
const botEnv = botGatewayProfileEnv(config, profile, "app");
stopClaudeAppBotWorker();
if (botEnv.CCR_BOT_GATEWAY_ENABLED !== "true") {
return;
}
const runtimeFile = path.join(CONFIGDIR, "bin", "ccr-codex-cli-middleware.js");
ensureClaudeBotWorkerRuntime(runtimeFile);
const settingsFile = resolveClaudeCodeSettingsFile(CONFIGDIR, profile);
const settingsEnv = readClaudeCodeSettingsEnv(settingsFile);
const claudeAppUserDataDir = resolveClaudeAppProfileUserDataDir(CONFIGDIR, profile);
const nodeLaunch = nodeRuntimeLaunch();
const env: NodeJS.ProcessEnv = {
...process.env,
...stringRecord(profile.env),
...settingsEnv,
...botEnv,
...(nodeLaunch.electronRunAsNode ? { ELECTRON_RUN_AS_NODE: "1" } : {}),
CCR_CLAUDE_BASE_CONFIG_DIR: path.dirname(settingsFile),
CLAUDE_CONFIG_DIR: path.dirname(settingsFile),
CLAUDE_USER_DATA_DIR: claudeAppUserDataDir,
CCR_CLAUDE_APP_USER_DATA_PATH: claudeAppUserDataDir,
CCR_CLAUDE_CODE_BOT_WORKER: "1",
CCR_CLAUDE_CODE_MODEL: profile.model.trim(),
CCR_CODEX_MODEL: profile.model.trim(),
CCR_CODEX_WORKSPACE_NAME: profile.name || profile.id,
CCR_PROFILE_SURFACE: "app",
CODEXL_CODEX_WORKSPACE_NAME: profile.name || profile.id,
CODEXL_PROFILE_SURFACE: "app"
};
delete env.ELECTRON_NO_ATTACH_CONSOLE;
const child = spawn(nodeLaunch.command, [runtimeFile, "claude-bot-worker", "--workspace-name", profile.name || profile.id], {
detached: false,
env,
stdio: ["ignore", "ignore", "pipe"],
windowsHide: true
});
claudeAppBotWorker = child;
claudeAppBotWorkerProfileId = profile.id;
child.stderr?.on("data", (chunk) => {
console.warn(`[profile] Claude App bot worker stderr: ${chunk.toString("utf8").trim()}`);
});
child.once("exit", (code, signal) => {
if (claudeAppBotWorker === child) {
claudeAppBotWorker = undefined;
claudeAppBotWorkerProfileId = undefined;
}
if (code && code !== 0) {
console.warn(`[profile] Claude App bot worker exited: code=${code}${signal ? ` signal=${signal}` : ""}`);
}
});
child.once("error", (error) => {
if (claudeAppBotWorker === child) {
claudeAppBotWorker = undefined;
claudeAppBotWorkerProfileId = undefined;
}
console.warn(`[profile] Claude App bot worker failed: ${formatError(error)}`);
});
}
function readClaudeCodeSettingsEnv(settingsFile: string): Record<string, string> {
if (!existsSync(settingsFile)) {
return {};
}
try {
const parsed = JSON.parse(readFileSync(settingsFile, "utf8")) as unknown;
if (!isRecord(parsed) || !isRecord(parsed.env)) {
return {};
}
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(parsed.env)) {
if (isEnvName(key) && typeof value === "string") {
env[key] = value;
}
}
return env;
} catch {
return {};
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isEnvName(value: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
}
function ensureClaudeBotWorkerRuntime(runtimeFile: string): void {
const content = codexCliMiddlewareRuntimeScript();
const existing = existsSync(runtimeFile) ? readFileSync(runtimeFile, "utf8") : "";
if (existing !== content) {
mkdirSync(path.dirname(runtimeFile), { recursive: true });
writeFileSync(runtimeFile, content);
if (process.platform !== "win32") {
chmodSync(runtimeFile, 0o755);
}
}
if (!content.includes("CCR_CLAUDE_CODE_BOT_WORKER") || !content.includes("claude-bot-worker")) {
throw new Error("Claude bot worker runtime does not contain the bot worker entrypoint.");
}
}
function stopClaudeAppBotWorker(profileId?: string): void {
if (profileId && claudeAppBotWorkerProfileId && claudeAppBotWorkerProfileId !== profileId) {
return;
}
const child = claudeAppBotWorker;
claudeAppBotWorker = undefined;
claudeAppBotWorkerProfileId = undefined;
if (!child || child.killed) {
return;
}
try {
child.kill("SIGTERM");
} catch {
// The worker may have already exited.
}
}
function nodeRuntimeLaunch(): { command: string; electronRunAsNode: boolean } {
const configured = process.env.CCR_NODE_BIN?.trim();
if (configured) {
return { command: configured, electronRunAsNode: false };
}
return {
command: process.execPath,
electronRunAsNode: Boolean(process.versions.electron)
};
}
function commandProfileRef(config: AppConfig, profile: ReturnType<typeof findProfileForOpen>): string {
const name = profile.name?.trim();
if (!name) {
return profile.id;
}
const normalizedName = name.toLowerCase();
const duplicateName = config.profile.profiles.some((item) =>
item.enabled &&
item.id !== profile.id &&
item.name.trim().toLowerCase() === normalizedName
);
return duplicateName ? profile.id : name;
}
export function ensureCcrCliLauncher(): string {
const binDir = path.join(CONFIGDIR, "bin");
mkdirSync(binDir, { recursive: true });
const runtimeFile = path.join(binDir, "ccr-cli.js");
const runtimeSource = findBundledCcrCliSource();
writeFileIfChanged(runtimeFile, readFileSync(runtimeSource, "utf8"));
chmodSafe(runtimeFile);
const launcherFile = path.join(binDir, process.platform === "win32" ? "ccr.cmd" : "ccr");
const launcherContent = process.platform === "win32"
? windowsCcrLauncher(runtimeFile)
: posixCcrLauncher(runtimeFile);
writeFileIfChanged(launcherFile, launcherContent);
chmodSafe(launcherFile);
ensureCcrBinOnPath(binDir);
return launcherFile;
}
function findBundledCcrCliSource(): string {
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath;
const candidates = [
path.join(__dirname, "cli.js"),
...(resourcesPath
? [
path.join(resourcesPath, "app.asar", "dist", "main", "cli.js"),
path.join(resourcesPath, "app", "dist", "main", "cli.js")
]
: []),
path.join(process.cwd(), "dist", "main", "cli.js")
];
const source = candidates.find((candidate) => existsSync(candidate));
if (!source) {
throw new Error(`CCR CLI runtime was not found. Rebuild or reinstall CCR and try again. Checked: ${candidates.join(", ")}`);
}
return source;
}
function posixCcrLauncher(runtimeFile: string): string {
const nodePath = bundledNodePath();
return [
"#!/bin/sh",
`CCR_CLI_NODE_PATH=${shQuote(nodePath)}`,
'if [ -n "$NODE_PATH" ]; then',
' export NODE_PATH="$CCR_CLI_NODE_PATH:$NODE_PATH"',
"else",
' export NODE_PATH="$CCR_CLI_NODE_PATH"',
"fi",
'if [ -n "$CCR_NODE_BIN" ]; then',
` exec "$CCR_NODE_BIN" ${shQuote(runtimeFile)} "$@"`,
"fi",
`ELECTRON_RUN_AS_NODE=1 exec ${shQuote(process.execPath)} ${shQuote(runtimeFile)} "$@"`
].join("\n") + "\n";
}
function windowsCcrLauncher(runtimeFile: string): string {
const nodePath = bundledNodePath();
return [
"@echo off",
"setlocal",
`set "CCR_CLI_RUNTIME=${cmdEnvValue(runtimeFile)}"`,
`set "CCR_CLI_NODE_PATH=${cmdEnvValue(nodePath)}"`,
"if defined NODE_PATH (",
" set \"NODE_PATH=%CCR_CLI_NODE_PATH%;%NODE_PATH%\"",
") else (",
" set \"NODE_PATH=%CCR_CLI_NODE_PATH%\"",
")",
"if defined CCR_NODE_BIN (",
' "%CCR_NODE_BIN%" "%CCR_CLI_RUNTIME%" %*',
" exit /b %ERRORLEVEL%",
")",
"set \"ELECTRON_RUN_AS_NODE=1\"",
`${cmdQuote(process.execPath)} "%CCR_CLI_RUNTIME%" %*`,
"exit /b %ERRORLEVEL%"
].join("\r\n") + "\r\n";
}
function bundledNodePath(): string {
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath;
const candidates = [
path.join(__dirname, "..", "..", "node_modules"),
...(resourcesPath
? [
path.join(resourcesPath, "app.asar", "node_modules"),
path.join(resourcesPath, "app.asar.unpacked", "node_modules"),
path.join(resourcesPath, "app", "node_modules")
]
: []),
path.join(process.cwd(), "node_modules")
];
return uniqueStrings(candidates).join(path.delimiter);
}
function uniqueStrings(values: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
if (!value || seen.has(value)) {
continue;
}
seen.add(value);
result.push(value);
}
return result;
}
function writeFileIfChanged(file: string, content: string): void {
if (existsSync(file) && readFileSync(file, "utf8") === content) {
return;
}
writeFileSync(file, content, "utf8");
}
function ensureCcrBinOnPath(binDir: string): void {
prependProcessPath(binDir);
try {
if (process.platform === "win32") {
ensureWindowsUserPath(binDir);
return;
}
ensurePosixShellPath(binDir);
} catch (error) {
console.warn(`[profile] Failed to persist ccr PATH: ${formatError(error)}`);
}
}
function prependProcessPath(binDir: string): void {
const pathKey = process.platform === "win32"
? Object.keys(process.env).find((key) => key.toLowerCase() === "path") || "Path"
: "PATH";
const delimiter = path.delimiter;
const currentPath = process.env[pathKey] || "";
const segments = currentPath.split(delimiter).filter(Boolean);
if (pathSegmentsInclude(segments, binDir)) {
return;
}
process.env[pathKey] = [binDir, ...segments].join(delimiter);
}
function ensureWindowsUserPath(binDir: string): void {
const script = [
"$ErrorActionPreference = 'Stop'",
`$bin = ${powershellString(binDir)}`,
"$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')",
"$segments = @()",
"if (-not [string]::IsNullOrWhiteSpace($userPath)) {",
" $segments = $userPath -split ';' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }",
"}",
"$expandedBin = [Environment]::ExpandEnvironmentVariables($bin).TrimEnd('\\\\')",
"$expandedSegments = $segments | ForEach-Object { [Environment]::ExpandEnvironmentVariables($_).TrimEnd('\\\\') }",
"if ($expandedSegments -notcontains $expandedBin) {",
" [Environment]::SetEnvironmentVariable('Path', ((@($bin) + $segments) -join ';'), 'User')",
"}"
].join("\n");
const result = spawnSync(windowsSystemCommand("powershell.exe"), [
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
script
], {
encoding: "utf8",
windowsHide: true
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error((result.stderr || result.stdout || `powershell.exe exited with ${result.status}`).trim());
}
broadcastWindowsEnvironmentChanged();
}
function ensurePosixShellPath(binDir: string): void {
const shellName = path.basename(process.env.SHELL || "").toLowerCase();
if (shellName.includes("fish")) {
ensureFishPathBlock(path.join(os.homedir(), ".config", "fish", "conf.d", "ccr.fish"), binDir);
return;
}
ensureShellRcPathBlock(preferredShellRcFile(shellName), binDir);
}
function preferredShellRcFile(shellName = path.basename(process.env.SHELL || "").toLowerCase()): string {
const home = os.homedir();
if (shellName.includes("zsh")) {
return path.join(home, ".zshrc");
}
if (shellName.includes("bash")) {
if (process.platform === "darwin") {
const bashProfile = path.join(home, ".bash_profile");
return existsSync(bashProfile) ? bashProfile : path.join(home, ".bashrc");
}
return path.join(home, ".bashrc");
}
return path.join(home, ".profile");
}
function pathSegmentsInclude(segments: string[], target: string): boolean {
if (process.platform === "win32") {
const normalizedTarget = normalizeWindowsPathSegment(target);
return segments.some((segment) => normalizeWindowsPathSegment(segment) === normalizedTarget);
}
return segments.includes(target);
}
function normalizeWindowsPathSegment(value: string): string {
return value.trim().replace(/[\\/]+$/g, "").toLowerCase();
}
function ensureShellRcPathBlock(rcFile: string, binDir: string): void {
mkdirSync(path.dirname(rcFile), { recursive: true });
const source = existsSync(rcFile) ? readFileSync(rcFile, "utf8") : "";
const block = shellRcPathBlock();
const managedPattern = new RegExp(
`\\n?${escapeRegExp(ccrPathBlockStart)}[\\s\\S]*?${escapeRegExp(ccrPathBlockEnd)}\\n?`,
"m"
);
if (managedPattern.test(source)) {
const next = ensureTrailingNewline(source.replace(managedPattern, `\n${block}\n`)).replace(/^\n+/, "");
writeFileIfChanged(rcFile, next);
return;
}
if (shellRcAlreadyAddsCcrBin(source, binDir)) {
return;
}
const separator = source.trim() ? (source.endsWith("\n") ? "\n" : "\n\n") : "";
writeFileIfChanged(rcFile, `${source}${separator}${block}\n`);
}
function shellRcPathBlock(): string {
const binDir = "$HOME/.claude-code-router/bin";
return [
ccrPathBlockStart,
"# Added by Claude Code Router. Enables the ccr command in new shells.",
'case ":$PATH:" in',
` *":${binDir}:"*) ;;`,
` *) export PATH="${binDir}:$PATH" ;;`,
"esac",
ccrPathBlockEnd
].join("\n");
}
function ensureFishPathBlock(file: string, binDir: string): void {
mkdirSync(path.dirname(file), { recursive: true });
const source = existsSync(file) ? readFileSync(file, "utf8") : "";
const block = fishPathBlock();
const managedPattern = new RegExp(
`\\n?${escapeRegExp(ccrPathBlockStart)}[\\s\\S]*?${escapeRegExp(ccrPathBlockEnd)}\\n?`,
"m"
);
if (managedPattern.test(source)) {
const next = ensureTrailingNewline(source.replace(managedPattern, `\n${block}\n`)).replace(/^\n+/, "");
writeFileIfChanged(file, next);
return;
}
if (shellRcAlreadyAddsCcrBin(source, binDir)) {
return;
}
const separator = source.trim() ? (source.endsWith("\n") ? "\n" : "\n\n") : "";
writeFileIfChanged(file, `${source}${separator}${block}\n`);
}
function fishPathBlock(): string {
return [
ccrPathBlockStart,
"# Added by Claude Code Router. Enables the ccr command in new shells.",
'set -l ccr_bin "$HOME/.claude-code-router/bin"',
"if not contains $ccr_bin $PATH",
" set -gx PATH $ccr_bin $PATH",
"end",
ccrPathBlockEnd
].join("\n");
}
function shellRcAlreadyAddsCcrBin(source: string, binDir: string): boolean {
return source.includes("$HOME/.claude-code-router/bin") ||
source.includes("~/.claude-code-router/bin") ||
source.includes(binDir);
}
function ensureTrailingNewline(value: string): string {
return value.endsWith("\n") ? value : `${value}\n`;
}
function chmodSafe(file: string): void {
if (process.platform === "win32") {
return;
}
try {
chmodSync(file, 0o755);
} catch {
// The launcher can still be shown; execution will surface the filesystem error.
}
}
function shQuote(value: string): string {
return `'${value.replace(/'/g, "'\\''")}'`;
}
function cmdQuote(value: string): string {
return `"${cmdValue(value)}"`;
}
function cmdEnvValue(value: string): string {
return cmdValue(value);
}
function cmdValue(value: string): string {
return value
.replace(/\r?\n/g, " ")
.replace(/\^/g, "^^")
.replace(/%/g, "%%")
.replace(/"/g, '^"')
.replace(/[&|<>()]/g, "^$&");
}
function powershellString(value: string): string {
return `'${value.replace(/'/g, "''")}'`;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function formatError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function stringRecord(value: Record<string, string> | undefined): Record<string, string> {
if (!value || typeof value !== "object") {
return {};
}
return Object.fromEntries(Object.entries(value).filter(([, item]) => typeof item === "string"));
}
function findProfileApiKey(config: AppConfig, profile: ReturnType<typeof findProfileForOpen>): string {
const keyId = profileApiKeyId(profile);
const key = config.APIKEYS.find((apiKey) => apiKey.id === keyId)?.key.trim();
return key || config.APIKEYS.find((apiKey) => apiKey.key.trim())?.key.trim() || config.APIKEY.trim();
}
function profileApiKeyId(profile: ReturnType<typeof findProfileForOpen>): string {
return `profile:${sanitizeProfilePathSegment(profile.id || profile.name || profile.agent) || "profile"}`;
}
function sanitizeProfilePathSegment(value: string): string {
return value.trim().replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
}