From 9a22372e281596ae1eeabee8734609e8a9b8f95f Mon Sep 17 00:00:00 2001 From: dtzp555 Date: Fri, 20 Mar 2026 22:35:18 +1000 Subject: [PATCH] feat: auto-detect claude binary to prevent ENOENT in launchd (v1.8.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proxy previously defaulted to bare "claude" command which fails in macOS LaunchAgent where PATH is minimal. Now resolves the binary at startup via: CLAUDE_BIN env → well-known paths → which fallback. Fails fast with clear error if binary cannot be found. Also enhances /health to report claudeBinary and claudeBinaryOk status. Co-Authored-By: Claude Opus 4.6 --- openclaw-claude-proxy/package.json | 28 ++ openclaw-claude-proxy/server.mjs | 521 +++++++++++++++++++++++++++++ package.json | 2 +- server.mjs | 49 ++- 4 files changed, 595 insertions(+), 5 deletions(-) create mode 100644 openclaw-claude-proxy/package.json create mode 100644 openclaw-claude-proxy/server.mjs diff --git a/openclaw-claude-proxy/package.json b/openclaw-claude-proxy/package.json new file mode 100644 index 0000000..4540520 --- /dev/null +++ b/openclaw-claude-proxy/package.json @@ -0,0 +1,28 @@ +{ + "name": "openclaw-claude-proxy", + "version": "1.8.0", + "description": "OpenAI-compatible proxy that routes requests through Claude CLI \u2014 use your Claude Pro/Max subscription as an OpenClaw model provider", + "type": "module", + "bin": { + "openclaw-claude-proxy": "./server.mjs" + }, + "scripts": { + "start": "node server.mjs", + "setup": "node setup.mjs" + }, + "keywords": [ + "openclaw", + "claude", + "proxy", + "openai", + "anthropic" + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "repository": { + "type": "git", + "url": "https://github.com/dtzp555-max/openclaw-claude-proxy" + } +} diff --git a/openclaw-claude-proxy/server.mjs b/openclaw-claude-proxy/server.mjs new file mode 100644 index 0000000..e375c58 --- /dev/null +++ b/openclaw-claude-proxy/server.mjs @@ -0,0 +1,521 @@ +#!/usr/bin/env node +/** + * openclaw-claude-proxy — OpenAI-compatible proxy for Claude CLI + * + * Translates OpenAI chat/completions requests into `claude -p` CLI calls, + * letting you use your Claude Pro/Max subscription as an OpenClaw model provider. + * + * Features: + * - Process pool: pre-spawns CLI processes to eliminate cold start latency + * - SSE streaming + non-streaming responses + * - Concurrent request support + * + * Env vars: + * CLAUDE_PROXY_PORT — listen port (default: 3456) + * CLAUDE_BIN — path to claude binary (default: "claude") + * CLAUDE_TIMEOUT — per-request timeout in ms (default: 120000) + * CLAUDE_POOL_SIZE — warm process pool size per model (default: 1) + * PROXY_API_KEY — Bearer token for API authentication (optional, if unset auth is disabled) + */ +import { createServer } from "node:http"; +import { spawn, execFileSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { readFileSync, accessSync, constants } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8")); + +// ── Resolve claude binary ─────────────────────────────────────────────── +// Priority: CLAUDE_BIN env > well-known paths > which lookup +// Fail-fast if not found — never start with an unresolvable binary. +function resolveClaude() { + if (process.env.CLAUDE_BIN) { + try { + accessSync(process.env.CLAUDE_BIN, constants.X_OK); + return process.env.CLAUDE_BIN; + } catch { + console.error(`FATAL: CLAUDE_BIN="${process.env.CLAUDE_BIN}" is set but not executable.`); + process.exit(1); + } + } + + const candidates = [ + "/opt/homebrew/bin/claude", + "/usr/local/bin/claude", + "/usr/bin/claude", + join(process.env.HOME || "", ".local/bin/claude"), + ]; + for (const p of candidates) { + try { accessSync(p, constants.X_OK); console.warn(`[init] CLAUDE_BIN not set, resolved to ${p}`); return p; } catch {} + } + + try { + const resolved = execFileSync("which", ["claude"], { encoding: "utf8", timeout: 5000 }).trim(); + if (resolved) { console.warn(`[init] CLAUDE_BIN not set, resolved via which: ${resolved}`); return resolved; } + } catch {} + + console.error( + "FATAL: claude binary not found.\n" + + " Set CLAUDE_BIN=/path/to/claude or ensure claude is in PATH.\n" + + " Checked: " + candidates.join(", ") + ); + process.exit(1); +} + +const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10); +const CLAUDE = resolveClaude(); +const TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "300000", 10); +const POOL_SIZE = parseInt(process.env.CLAUDE_POOL_SIZE || "1", 10); +const POOL_MAX_IDLE = parseInt(process.env.CLAUDE_POOL_MAX_IDLE || "60000", 10); // max idle time before recycle +const PROXY_API_KEY = process.env.PROXY_API_KEY || ""; + +const VERSION = _pkg.version; +const START_TIME = Date.now(); + +// Model alias mapping: request model → claude CLI --model arg +// Maps both shorthand aliases AND full model IDs to the canonical full model ID +// that the claude CLI accepts. Using short names like "sonnet"/"opus"/"haiku" +// causes the CLI to reject the --model arg and crash immediately. +const MODEL_MAP = { + // Full canonical IDs (pass through as-is) + "claude-opus-4-6": "claude-opus-4-6", + "claude-sonnet-4-6": "claude-sonnet-4-6", + "claude-haiku-4-5-20251001": "claude-haiku-4-5-20251001", + // Short aliases → full canonical IDs + "claude-opus-4": "claude-opus-4-6", + "claude-haiku-4": "claude-haiku-4-5-20251001", + "claude-haiku-4-5": "claude-haiku-4-5-20251001", + "opus": "claude-opus-4-6", + "sonnet": "claude-sonnet-4-6", + "haiku": "claude-haiku-4-5-20251001", +}; + +const MODELS = [ + { id: "claude-opus-4-6", name: "Claude Opus 4.6" }, + { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + { id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5" }, +]; + +// ── Process Pool ────────────────────────────────────────────────────────── +// Pre-spawns `claude -p` processes that read prompts from stdin. +// When a request arrives, we grab a warm process and pipe the prompt in. +// After the process finishes, a new one is spawned to replace it. + +const pool = new Map(); // model → [{ proc, ready }] + +// Exponential backoff state per model: tracks consecutive fast failures +// to prevent a tight spawn/die loop when workers crash on startup. +// Delays: 2s base, doubled each failure, capped at 60s. +// After 5 consecutive fast crashes (each lived < 10s, all within 60s), +// the model is marked "degraded" and respawning stops entirely. +const poolBackoff = new Map(); // model → { failures: number, timer: TimeoutId|null, degraded: boolean, windowStart: number } + +function spawnWarm(cliModel) { + const env = { ...process.env }; + delete env.CLAUDECODE; + delete env.ANTHROPIC_API_KEY; + delete env.ANTHROPIC_BASE_URL; + delete env.ANTHROPIC_AUTH_TOKEN; + + const proc = spawn(CLAUDE, [ + "-p", "--model", cliModel, + "--output-format", "text", + "--no-session-persistence", + "--allowedTools", "Bash", "Read", "Write", "Edit", "Glob", "Grep", + ], { env, stdio: ["pipe", "pipe", "pipe"] }); + + const entry = { proc, cliModel, ready: true, spawnedAt: Date.now() }; + + // Capture stderr from pool workers so crash reasons are visible in logs + let stderrBuf = ""; + proc.stderr.on("data", (d) => { + stderrBuf += d; + if (stderrBuf.length > 500) stderrBuf = stderrBuf.slice(-500); + }); + + proc.on("error", (err) => { + console.error(`[pool] spawn error model=${cliModel}: ${err.message}`); + entry.ready = false; + }); + + proc.on("exit", (code) => { + const livedMs = Date.now() - entry.spawnedAt; + entry.ready = false; + + // Log stderr from crashed pool worker (first 500 chars) to aid debugging + if (stderrBuf.trim()) { + console.error(`[pool] worker stderr model=${cliModel} exit=${code} lived=${livedMs}ms: ${stderrBuf.slice(0, 500)}`); + } + + // If the process survived > 10s, it was healthy — reset the backoff counter and window + if (livedMs > 10000) { + const state = poolBackoff.get(cliModel); + if (state && (state.failures > 0 || state.degraded)) { + console.log(`[pool] resetting backoff for model=${cliModel} (lived ${livedMs}ms)`); + state.failures = 0; + state.degraded = false; + state.windowStart = Date.now(); + } + } + + // Remove from pool + const arr = pool.get(cliModel); + if (arr) { + const idx = arr.indexOf(entry); + if (idx !== -1) arr.splice(idx, 1); + } + // Replenish: treat as crash (apply backoff) only if it died fast (< 10s) + const isCrash = livedMs <= 10000; + replenishPool(cliModel, isCrash); + }); + + return entry; +} + +// Recycle idle processes to prevent stale connections +function recycleStaleProcesses() { + const now = Date.now(); + for (const [cliModel, arr] of pool) { + for (const entry of arr) { + if (entry.ready && (now - entry.spawnedAt) > POOL_MAX_IDLE) { + console.log(`[pool] recycling stale process model=${cliModel} (idle ${Math.round((now - entry.spawnedAt) / 1000)}s)`); + entry.ready = false; + entry.proc.kill(); + // exit handler will replenish + } + } + } +} + +setInterval(recycleStaleProcesses, 15000); // check every 15s + +const BACKOFF_BASE_MS = 2000; // 2s starting delay +const BACKOFF_MAX_MS = 60000; // 60s ceiling +const CRASH_LIMIT = 5; // max consecutive fast crashes before degraded +const CRASH_WINDOW_MS = 60000; // window for counting consecutive fast crashes (60s) + +// replenishPool(cliModel, isCrash) +// isCrash=false → initial or manual fill, no backoff applied +// isCrash=true → called from exit handler after a fast crash +function replenishPool(cliModel, isCrash = false) { + if (!pool.has(cliModel)) pool.set(cliModel, []); + if (!poolBackoff.has(cliModel)) poolBackoff.set(cliModel, { failures: 0, timer: null, degraded: false, windowStart: Date.now() }); + + const arr = pool.get(cliModel); + const state = poolBackoff.get(cliModel); + + // If this model is degraded (too many consecutive fast crashes), stop respawning + if (state.degraded) { + console.error(`[pool] DEGRADED: model=${cliModel} will not be respawned. Restart the proxy to retry.`); + return; + } + + const alive = arr.filter((e) => e.ready).length; + const needed = POOL_SIZE - alive; + if (needed <= 0) return; + + // Cancel any pending backoff timer for this model before scheduling a new one + if (state.timer) { + clearTimeout(state.timer); + state.timer = null; + } + + // Only track failures and apply backoff when this is a crash respawn + if (!isCrash) { + // Immediate spawn — no backoff on initial fill or manual replenish + const currentAlive = arr.filter((e) => e.ready).length; + const currentNeeded = POOL_SIZE - currentAlive; + for (let i = 0; i < currentNeeded; i++) { + const entry = spawnWarm(cliModel); + arr.push(entry); + console.log(`[pool] pre-spawned model=${cliModel} (pool size: ${arr.filter(e => e.ready).length})`); + } + return; + } + + // --- Crash path: apply exponential backoff and degraded-state logic --- + + const now = Date.now(); + + // Reset window if the last crash was outside the rolling window + if ((now - state.windowStart) > CRASH_WINDOW_MS) { + state.windowStart = now; + state.failures = 0; + } + + state.failures += 1; + + // Check if we've hit the crash limit within the rolling window + if (state.failures >= CRASH_LIMIT) { + state.degraded = true; + console.error( + `[pool] DEGRADED: model=${cliModel} crashed ${state.failures} times in ` + + `${Math.round((now - state.windowStart) / 1000)}s. ` + + `Stopping respawn to prevent CPU spin. Restart the proxy to retry.` + ); + return; + } + + // Exponential backoff: 2s, 4s, 8s, 16s, 32s … capped at 60s + const delayMs = Math.min(BACKOFF_BASE_MS * Math.pow(2, state.failures - 1), BACKOFF_MAX_MS); + console.warn(`[pool] backoff model=${cliModel} delay=${delayMs}ms (failures=${state.failures}/${CRASH_LIMIT})`); + + state.timer = setTimeout(() => { + state.timer = null; + const currentAlive = arr.filter((e) => e.ready).length; + const currentNeeded = POOL_SIZE - currentAlive; + for (let i = 0; i < currentNeeded; i++) { + const entry = spawnWarm(cliModel); + arr.push(entry); + console.log(`[pool] re-spawned model=${cliModel} (pool size: ${arr.filter(e => e.ready).length}, failures=${state.failures}/${CRASH_LIMIT})`); + } + }, delayMs); +} + +function getWarmProcess(cliModel) { + const arr = pool.get(cliModel) || []; + const entry = arr.find((e) => e.ready); + if (entry) { + entry.ready = false; // mark as in-use + const warmMs = Date.now() - entry.spawnedAt; + console.log(`[pool] using warm process model=${cliModel} (warm for ${warmMs}ms)`); + return entry.proc; + } + return null; +} + +// Initialize pool for all models +function initPool() { + for (const cliModel of new Set(Object.values(MODEL_MAP))) { + replenishPool(cliModel); + } +} + +// ── Call claude CLI ───────────────────────────────────────────────────── +function callClaude(model, messages) { + return new Promise((resolve, reject) => { + const prompt = messages + .map((m) => { + const text = typeof m.content === "string" ? m.content : JSON.stringify(m.content); + if (m.role === "system") return `[System] ${text}`; + if (m.role === "assistant") return `[Assistant] ${text}`; + return text; + }) + .join("\n\n"); + + const cliModel = MODEL_MAP[model] || model; + + // Try to use a warm process from the pool + let proc = getWarmProcess(cliModel); + let usedPool = !!proc; + + if (!proc) { + // Cold start fallback: spawn fresh + console.log(`[pool] no warm process for model=${cliModel}, cold starting...`); + const env = { ...process.env }; + delete env.CLAUDECODE; + delete env.ANTHROPIC_API_KEY; + delete env.ANTHROPIC_BASE_URL; + delete env.ANTHROPIC_AUTH_TOKEN; + proc = spawn(CLAUDE, [ + "-p", "--model", cliModel, + "--output-format", "text", + "--no-session-persistence", + "--allowedTools", "Bash", "Read", "Write", "Edit", "Glob", "Grep", + "--", prompt, + ], { env, stdio: ["ignore", "pipe", "pipe"] }); + } + + let stdout = ""; + let stderr = ""; + const t0 = Date.now(); + + proc.stdout.on("data", (d) => (stdout += d)); + proc.stderr.on("data", (d) => (stderr += d)); + proc.on("close", (code) => { + const elapsed = Date.now() - t0; + if (code !== 0) { + console.error(`[claude] exit=${code} model=${cliModel} elapsed=${elapsed}ms stderr=${stderr.slice(0, 300)}`); + reject(new Error(stderr || stdout || `exit ${code}`)); + } else { + console.log(`[claude] ok model=${cliModel} chars=${stdout.length} elapsed=${elapsed}ms pool=${usedPool}`); + resolve(stdout.trim()); + } + }); + proc.on("error", reject); + + // Log prompt size for debugging + console.log(`[claude] request model=${cliModel} prompt_chars=${prompt.length} pool=${usedPool}`); + + // If using pool process, send prompt via stdin + if (usedPool) { + proc.stdin.write(prompt); + proc.stdin.end(); + } + + const timer = setTimeout(() => { proc.kill(); reject(new Error("timeout")); }, TIMEOUT); + proc.on("close", () => clearTimeout(timer)); + }); +} + +// ── Response helpers ──────────────────────────────────────────────────── +function jsonResponse(res, status, data) { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(data)); +} + +function sendSSE(res, data) { + res.write(`data: ${JSON.stringify(data)}\n\n`); +} + +function streamResponse(res, id, model, content) { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }); + const created = Math.floor(Date.now() / 1000); + // Role chunk + sendSSE(res, { + id, object: "chat.completion.chunk", created, model, + choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], + }); + // Content chunks (~500 chars each) + for (let i = 0; i < content.length; i += 500) { + sendSSE(res, { + id, object: "chat.completion.chunk", created, model, + choices: [{ index: 0, delta: { content: content.slice(i, i + 500) }, finish_reason: null }], + }); + } + // Finish + sendSSE(res, { + id, object: "chat.completion.chunk", created, model, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }); + res.write("data: [DONE]\n\n"); + res.end(); +} + +function completionResponse(res, id, model, content) { + jsonResponse(res, 200, { + id, object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model, + choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }], + usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, + }); +} + +// ── Handle chat completions ───────────────────────────────────────────── +async function handleChatCompletions(req, res) { + let body = ""; + for await (const chunk of req) body += chunk; + + let parsed; + try { parsed = JSON.parse(body); } catch { return jsonResponse(res, 400, { error: "Invalid JSON" }); } + + const messages = parsed.messages || parsed.input || [{ role: "user", content: parsed.prompt || "" }]; + const model = parsed.model || "claude-sonnet-4-6"; + const stream = parsed.stream; + + if (!messages?.length) return jsonResponse(res, 400, { error: "messages required" }); + + try { + const content = await callClaude(model, messages); + const id = `chatcmpl-${randomUUID()}`; + + if (stream) { + streamResponse(res, id, model, content); + } else { + completionResponse(res, id, model, content); + } + } catch (err) { + console.error(`[proxy] error: ${err.message}`); + if (res.headersSent || res.writableEnded || res.destroyed) { + try { res.end(); } catch {} + return; + } + jsonResponse(res, 500, { error: { message: err.message, type: "proxy_error" } }); + } +} + +// ── HTTP server ───────────────────────────────────────────────────────── +const server = createServer(async (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); + if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; } + + // Bearer token auth (skip for /health and when PROXY_API_KEY is not set) + if (PROXY_API_KEY && req.url !== "/health") { + const auth = req.headers["authorization"] || ""; + const token = auth.startsWith("Bearer ") ? auth.slice(7) : ""; + if (token !== PROXY_API_KEY) { + return jsonResponse(res, 401, { error: { message: "Unauthorized: invalid or missing Bearer token", type: "auth_error" } }); + } + } + + // GET /v1/models + if (req.url === "/v1/models" && req.method === "GET") { + return jsonResponse(res, 200, { + object: "list", + data: MODELS.map((m) => ({ + id: m.id, object: "model", owned_by: "anthropic", + created: Math.floor(Date.now() / 1000), + })), + }); + } + + // POST /v1/chat/completions + if (req.url === "/v1/chat/completions" && req.method === "POST") { + return handleChatCompletions(req, res); + } + + // GET /health — includes pool status, version, uptime + if (req.url === "/health") { + const poolStatus = {}; + for (const [model, arr] of pool) { + const readyCount = arr.filter(e => e.ready).length; + const errorCount = arr.filter(e => !e.ready).length; + poolStatus[model] = { + total: arr.length, + ready: readyCount, + error: errorCount, + status: readyCount > 0 ? "ready" : "error", + }; + } + const uptimeMs = Date.now() - START_TIME; + let binaryOk = false; + try { accessSync(CLAUDE, constants.X_OK); binaryOk = true; } catch {} + return jsonResponse(res, 200, { + status: binaryOk ? "ok" : "degraded", + version: VERSION, + uptime: uptimeMs, + uptimeHuman: `${Math.floor(uptimeMs / 3600000)}h ${Math.floor((uptimeMs % 3600000) / 60000)}m ${Math.floor((uptimeMs % 60000) / 1000)}s`, + claudeBinary: CLAUDE, + claudeBinaryOk: binaryOk, + pool: poolStatus, + }); + } + + // Catch-all: try to handle any POST with messages + if (req.method === "POST") { + return handleChatCompletions(req, res); + } + + jsonResponse(res, 404, { error: "Not found. Endpoints: GET /v1/models, POST /v1/chat/completions, GET /health" }); +}); + +// ── Start ────────────────────────────────────────────────────────────── +initPool(); + +server.listen(PORT, "0.0.0.0", () => { + console.log(`openclaw-claude-proxy v${VERSION} listening on http://0.0.0.0:${PORT}`); + console.log(`Models: ${MODELS.map((m) => m.id).join(", ")}`); + console.log(`Claude binary: ${CLAUDE}`); + console.log(`Timeout: ${TIMEOUT}ms`); + console.log(`Pool size: ${POOL_SIZE} per model, max idle: ${POOL_MAX_IDLE / 1000}s`); + console.log(`Auth: ${PROXY_API_KEY ? "enabled (PROXY_API_KEY set)" : "disabled (no PROXY_API_KEY)"}`); +}); diff --git a/package.json b/package.json index 8110e5e..4540520 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openclaw-claude-proxy", - "version": "1.7.1", + "version": "1.8.0", "description": "OpenAI-compatible proxy that routes requests through Claude CLI \u2014 use your Claude Pro/Max subscription as an OpenClaw model provider", "type": "module", "bin": { diff --git a/server.mjs b/server.mjs index 6506785..e375c58 100644 --- a/server.mjs +++ b/server.mjs @@ -18,17 +18,54 @@ * PROXY_API_KEY — Bearer token for API authentication (optional, if unset auth is disabled) */ import { createServer } from "node:http"; -import { spawn } from "node:child_process"; +import { spawn, execFileSync } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { readFileSync } from "node:fs"; +import { readFileSync, accessSync, constants } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; const __dirname = dirname(fileURLToPath(import.meta.url)); const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8")); +// ── Resolve claude binary ─────────────────────────────────────────────── +// Priority: CLAUDE_BIN env > well-known paths > which lookup +// Fail-fast if not found — never start with an unresolvable binary. +function resolveClaude() { + if (process.env.CLAUDE_BIN) { + try { + accessSync(process.env.CLAUDE_BIN, constants.X_OK); + return process.env.CLAUDE_BIN; + } catch { + console.error(`FATAL: CLAUDE_BIN="${process.env.CLAUDE_BIN}" is set but not executable.`); + process.exit(1); + } + } + + const candidates = [ + "/opt/homebrew/bin/claude", + "/usr/local/bin/claude", + "/usr/bin/claude", + join(process.env.HOME || "", ".local/bin/claude"), + ]; + for (const p of candidates) { + try { accessSync(p, constants.X_OK); console.warn(`[init] CLAUDE_BIN not set, resolved to ${p}`); return p; } catch {} + } + + try { + const resolved = execFileSync("which", ["claude"], { encoding: "utf8", timeout: 5000 }).trim(); + if (resolved) { console.warn(`[init] CLAUDE_BIN not set, resolved via which: ${resolved}`); return resolved; } + } catch {} + + console.error( + "FATAL: claude binary not found.\n" + + " Set CLAUDE_BIN=/path/to/claude or ensure claude is in PATH.\n" + + " Checked: " + candidates.join(", ") + ); + process.exit(1); +} + const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10); -const CLAUDE = process.env.CLAUDE_BIN || "claude"; +const CLAUDE = resolveClaude(); const TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "300000", 10); const POOL_SIZE = parseInt(process.env.CLAUDE_POOL_SIZE || "1", 10); const POOL_MAX_IDLE = parseInt(process.env.CLAUDE_POOL_MAX_IDLE || "60000", 10); // max idle time before recycle @@ -450,11 +487,15 @@ const server = createServer(async (req, res) => { }; } const uptimeMs = Date.now() - START_TIME; + let binaryOk = false; + try { accessSync(CLAUDE, constants.X_OK); binaryOk = true; } catch {} return jsonResponse(res, 200, { - status: "ok", + status: binaryOk ? "ok" : "degraded", version: VERSION, uptime: uptimeMs, uptimeHuman: `${Math.floor(uptimeMs / 3600000)}h ${Math.floor((uptimeMs % 3600000) / 60000)}m ${Math.floor((uptimeMs % 60000) / 1000)}s`, + claudeBinary: CLAUDE, + claudeBinaryOk: binaryOk, pool: poolStatus, }); }