/** * Coding Agent Extension for OpenClaw * * Integrates Claude Code (backed by Zhipu GLM via z.ai) as a sub-agent * for autonomous coding on HuggingFace Spaces. * * Tools: * - claude_code: Spawn Claude Code CLI to autonomously complete coding tasks * - hf_space_status: Check Space health/stage * - hf_restart_space: Restart a Space */ import { execSync } from "node:child_process"; import { existsSync } from "node:fs"; import { WebSocket } from "ws"; // ── Types ──────────────────────────────────────────────────────────────────── interface PluginApi { pluginConfig: Record; logger: { info: (...a: unknown[]) => void; warn: (...a: unknown[]) => void; error: (...a: unknown[]) => void }; registerTool?: (def: ToolDef) => void; } interface ToolDef { name: string; description: string; label?: string; parameters: Record; execute: (toolCallId: string, params: Record) => Promise; } interface ToolResult { content: Array<{ type: "text"; text: string }>; } // ── Helpers ────────────────────────────────────────────────────────────────── const WORK_DIR = "/tmp/claude-workspace"; const CLAUDE_TIMEOUT = 300_000; // 5 minutes function text(t: string): ToolResult { return { content: [{ type: "text", text: t }] }; } function asStr(v: unknown, fallback = ""): string { return typeof v === "string" ? v : fallback; } // ── WebSocket Client for Agent Status ───────────────────────────────────────── interface AgentStatus { current_state: string; last_updated: string; agent: string; error?: string; } let wsClient: WebSocket | null = null; let statusListeners: Array<(status: AgentStatus) => void> = []; let apiLogger: PluginApi["logger"] | null = null; function connectAgentStatusWebSocket(apiBaseUrl: string = "http://127.0.0.1:7860"): void { // Determine WebSocket URL from API base URL const wsUrl = apiBaseUrl.replace("http://", "ws://").replace("https://", "wss://") + "/ws/agent-status"; if (wsClient?.readyState === WebSocket.OPEN) { apiLogger?.info("coding-agent: WebSocket already connected"); return; } try { wsClient = new WebSocket(wsUrl); wsClient.on("open", () => { apiLogger?.info("coding-agent: Agent Status Connected"); }); wsClient.on("message", (data: Buffer) => { try { const status: AgentStatus = JSON.parse(data.toString()); // Notify all listeners statusListeners.forEach(listener => { try { listener(status); } catch (e) { apiLogger?.error(`coding-agent: Listener error: ${e}`); } }); } catch (e) { apiLogger?.error(`coding-agent: Failed to parse status: ${e}`); } }); wsClient.on("error", (error) => { apiLogger?.error(`coding-agent: WebSocket error: ${error}`); }); wsClient.on("close", () => { apiLogger?.warn("coding-agent: WebSocket disconnected, reconnecting in 5s"); wsClient = null; setTimeout(() => connectAgentStatusWebSocket(apiBaseUrl), 5000); }); } catch (e) { apiLogger?.error(`coding-agent: Failed to create WebSocket: ${e}`); } } function disconnectAgentStatusWebSocket(): void { if (wsClient) { wsClient.close(); wsClient = null; } statusListeners = []; } // ── Git helpers ────────────────────────────────────────────────────────────── function ensureRepo(targetSpace: string, hfToken: string): void { const repoUrl = `https://user:${hfToken}@huggingface.co/spaces/${targetSpace}`; if (existsSync(`${WORK_DIR}/.git`)) { // Reset to latest remote state (clean slate for each task) try { execSync("git fetch origin && git reset --hard origin/main", { cwd: WORK_DIR, timeout: 30_000, stdio: "pipe", }); return; } catch { // If fetch/reset fails, re-clone execSync(`rm -rf ${WORK_DIR}`, { stdio: "pipe" }); } } // Fresh clone if (existsSync(WORK_DIR)) { execSync(`rm -rf ${WORK_DIR}`, { stdio: "pipe" }); } execSync(`git clone --depth 20 ${repoUrl} ${WORK_DIR}`, { timeout: 60_000, stdio: "pipe", }); execSync('git config user.name "Claude Code"', { cwd: WORK_DIR, stdio: "pipe" }); execSync('git config user.email "claude-code@huggingclaw"', { cwd: WORK_DIR, stdio: "pipe" }); } function pushChanges(summary: string): string { const status = execSync("git status --porcelain", { cwd: WORK_DIR, encoding: "utf-8", }).trim(); if (!status) return "No files changed."; execSync("git add -A", { cwd: WORK_DIR, stdio: "pipe" }); // Use a safe commit message const msg = summary.slice(0, 72).replace(/"/g, '\\"'); execSync(`git commit -m "Claude Code: ${msg}"`, { cwd: WORK_DIR, stdio: "pipe" }); execSync("git push", { cwd: WORK_DIR, timeout: 60_000, stdio: "pipe" }); return `Pushed changes:\n${status}`; } // ── Plugin ─────────────────────────────────────────────────────────────────── const plugin = { id: "coding-agent", name: "Coding Agent", description: "Claude Code sub-agent for autonomous coding on HF Spaces (Zhipu GLM backend via z.ai)", register(api: PluginApi) { // Store logger reference for WebSocket client apiLogger = api.logger; const cfg = (api.pluginConfig as Record) || {}; const targetSpace = asStr(cfg.targetSpace) || process.env.CODING_AGENT_TARGET_SPACE || ""; const hfToken = asStr(cfg.hfToken) || process.env.HF_TOKEN || ""; const zaiApiKey = asStr(cfg.zaiApiKey) || process.env.ZAI_API_KEY || process.env.ZHIPU_API_KEY || ""; const apiBaseUrl = asStr(cfg.apiBaseUrl) || process.env.CAIN_API_URL || "http://127.0.0.1:7860"; api.logger.info(`coding-agent: targetSpace=${targetSpace}, zaiKey=${zaiApiKey ? "set" : "missing"}`); // Auto-connect to agent status WebSocket try { connectAgentStatusWebSocket(apiBaseUrl); } catch (e) { api.logger.warn(`coding-agent: Could not connect to WebSocket: ${e}`); } if (!api.registerTool) { api.logger.warn("coding-agent: registerTool unavailable — no tools registered"); return; } // ── Tool: claude_code ─────────────────────────────────────────────────── api.registerTool({ name: "claude_code", label: "Run Claude Code", description: "Run Claude Code to autonomously complete a coding task on the target HF Space. " + "Claude Code clones the Space repo, analyzes code, makes changes, and pushes them back. " + "Powered by Zhipu GLM via z.ai. Use for: debugging, fixing errors, adding features, refactoring.", parameters: { type: "object", required: ["task"], properties: { task: { type: "string", description: "Detailed coding task description. Be specific about what to fix/change and why.", }, auto_push: { type: "boolean", description: "Automatically push changes after Claude Code finishes (default: true)", }, }, }, async execute(_id, params) { const task = asStr(params.task); const autoPush = params.auto_push !== false; if (!targetSpace) return text("Error: no targetSpace configured"); if (!hfToken) return text("Error: no HF token configured"); if (!zaiApiKey) return text("Error: no ZAI_API_KEY or ZHIPU_API_KEY configured for Claude Code backend"); try { // 1. Clone / reset to latest api.logger.info(`coding-agent: Syncing repo ${targetSpace}...`); ensureRepo(targetSpace, hfToken); // 2. Run Claude Code with z.ai backend api.logger.info(`coding-agent: Running Claude Code: ${task.slice(0, 100)}...`); const claudeEnv: Record = { ...(process.env as Record), ANTHROPIC_BASE_URL: "https://api.z.ai/api/anthropic", ANTHROPIC_AUTH_TOKEN: zaiApiKey, ANTHROPIC_DEFAULT_OPUS_MODEL: "GLM-4.7", ANTHROPIC_DEFAULT_SONNET_MODEL: "GLM-4.7", ANTHROPIC_DEFAULT_HAIKU_MODEL: "GLM-4.5-Air", // Avoid interactive prompts CI: "true", }; const output = execSync( `claude -p ${JSON.stringify(task)} --output-format text`, { cwd: WORK_DIR, env: claudeEnv, timeout: CLAUDE_TIMEOUT, encoding: "utf-8", maxBuffer: 10 * 1024 * 1024, // 10MB }, ); // 3. Push changes if requested let pushResult = "Auto-push disabled."; if (autoPush) { try { pushResult = pushChanges(task); } catch (e: unknown) { pushResult = `Push failed: ${e instanceof Error ? e.message : e}`; } } return text( `=== Claude Code Output ===\n${output}\n\n=== Changes ===\n${pushResult}`, ); } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); return text(`Claude Code failed:\n${msg.slice(0, 3000)}`); } }, }); // ── Tool: hf_space_status ─────────────────────────────────────────────── api.registerTool({ name: "hf_space_status", label: "Check Space Health", description: "Check the current status of the target HuggingFace Space. " + "Returns: stage (BUILDING, APP_STARTING, RUNNING, RUNTIME_ERROR, BUILD_ERROR, NO_APP_FILE).", parameters: { type: "object", properties: {}, }, async execute() { if (!targetSpace) return text("Error: no target space configured"); try { const resp = await fetch(`https://huggingface.co/api/spaces/${targetSpace}`, { headers: { Authorization: `Bearer ${hfToken}` }, }); if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`); const data = (await resp.json()) as Record; const runtime = (data.runtime as Record) || {}; const stage = runtime.stage || "unknown"; const hardware = runtime.hardware || "unknown"; // Try hitting the Space URL let apiStatus = "not checked"; try { const spaceUrl = `https://${targetSpace.replace("/", "-").toLowerCase()}.hf.space`; const probe = await fetch(spaceUrl, { signal: AbortSignal.timeout(8000) }); apiStatus = probe.ok ? `reachable (${probe.status})` : `error (${probe.status})`; } catch { apiStatus = "unreachable"; } return text( `Space: ${targetSpace}\nStage: ${stage}\nHardware: ${hardware}\nAPI: ${apiStatus}`, ); } catch (e: unknown) { return text(`Error checking space: ${e instanceof Error ? e.message : e}`); } }, }); // ── Tool: hf_restart_space ────────────────────────────────────────────── api.registerTool({ name: "hf_restart_space", label: "Restart Space", description: "Restart the target HuggingFace Space. Use when the Space is stuck or after deploying fixes.", parameters: { type: "object", properties: {}, }, async execute() { if (!targetSpace) return text("Error: no target space configured"); try { const resp = await fetch(`https://huggingface.co/api/spaces/${targetSpace}/restart`, { method: "POST", headers: { Authorization: `Bearer ${hfToken}` }, }); if (!resp.ok) { const body = await resp.text().catch(() => ""); throw new Error(`${resp.status}: ${body}`); } return text(`Space ${targetSpace} is restarting`); } catch (e: unknown) { return text(`Error restarting space: ${e instanceof Error ? e.message : e}`); } }, }); // ── Tool: agent_status_ws ─────────────────────────────────────────────────── api.registerTool({ name: "agent_status_ws", label: "Agent Status WebSocket", description: "Get the current agent status from the WebSocket connection. " + "Returns the latest status received from the agent status WebSocket including state, last updated time, and any errors.", parameters: { type: "object", properties: {}, }, async execute() { // Check if WebSocket is connected if (!wsClient || wsClient.readyState !== WebSocket.OPEN) { return text("WebSocket not connected. Ensure the API server is running and WebSocket is available."); } // Return current connection status const status = `Agent Status WebSocket:\n- Connected: ${wsClient.readyState === WebSocket.OPEN}\n- URL: ${wsClient.url}\n- Active listeners: ${statusListeners.length}`; // Note: The latest status is received asynchronously via the WebSocket // Users can check the logs for real-time status updates return text(`${status}\n\nLatest status updates are logged in real-time.`); }, }); api.logger.info("coding-agent: Registered 4 tools (claude_code, hf_space_status, hf_restart_space, agent_status_ws)"); }, }; export default plugin;