File size: 14,436 Bytes
8907cf8
 
 
 
 
 
 
 
 
 
 
 
 
 
19f8a70
8907cf8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19f8a70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8907cf8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19f8a70
 
 
8907cf8
 
 
 
19f8a70
8907cf8
 
 
19f8a70
 
 
 
 
 
 
8907cf8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19f8a70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8907cf8
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
/**
 * 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<string, unknown>;
  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<string, unknown>;
  execute: (toolCallId: string, params: Record<string, unknown>) => Promise<ToolResult>;
}

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<string, unknown>) || {};
    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<string, string> = {
            ...(process.env as Record<string, string>),
            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<string, unknown>;
          const runtime = (data.runtime as Record<string, unknown>) || {};
          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;