From 4092dbae583811aec6b6f7f9a0ff9d2d44577028 Mon Sep 17 00:00:00 2001 From: Geerdan1995 <206380229+Geerdan1995@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:09:55 +0800 Subject: [PATCH] feat(agents): add ZCode (Z.ai) agent adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - detect.ts: zcode entry (protocol argv, ZCODE_BIN override) - argv.ts: --json -p flags + single-JSON-document parser (response/usage) - invoke.ts: deliver the prompt via --attach temp file — multi-line prompts cannot survive Windows cmd.exe argv (npm .cmd shims re-parse at newlines), and zcode -p takes no stdin sentinel. Skips the per-line stdout loop like openclaw (whole-document parse on close). Verified end-to-end on Windows: zcode 0.16.9 via GLM-5.3 coding plan generates a full magazine-article HTML through the app. --- next/src/lib/agents/argv.ts | 25 +++++++++++++++++++ next/src/lib/agents/detect.ts | 14 +++++++++++ next/src/lib/agents/invoke.ts | 45 +++++++++++++++++++++++++++++++---- 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/next/src/lib/agents/argv.ts b/next/src/lib/agents/argv.ts index f92b6b59..4de6f093 100644 --- a/next/src/lib/agents/argv.ts +++ b/next/src/lib/agents/argv.ts @@ -120,6 +120,15 @@ export function buildArgv(agent: string, _opts: AgentArgvOpts = {}): string[] { "--yolo", ...(model ? ["--model", model] : []), ]; + case "zcode": + // ZCode (Z.ai's agent CLI, https://github.com/zai-org/ZCode): + // `zcode --json -p ""` runs one headless turn with yolo + // permissions (the CLI's own default for --prompt) and prints a single + // JSON document ({response, usage, sessionId}) on close. No --model + // flag exists yet — the CLI's configured default model wins. The + // prompt is appended as the positional after "-p" by invoke.ts + // (protocol "argv"). + return ["--json", "-p"]; case "codewhale": case "deepseek-tui": // DeepSeek's `exec --auto` requires the prompt as a positional arg; @@ -259,6 +268,22 @@ function parseLineWithState(agent: string, line: string, state: ParseState): Age const obj = parsed as Record; const out: AgentParse[] = []; + // ZCode — `--json -p` prints ONE pretty-printed JSON document after the + // turn closes. Per-line fragments during streaming fail JSON.parse above + // and surface as noise; the definitive parse happens when invoke.ts feeds + // the complete stdout buffer to this parser on close. + if (agent === "zcode") { + if (typeof obj.response === "string" && obj.response) { + out.push({ kind: "delta", text: obj.response }); + if (obj.sessionId) out.push({ kind: "meta", key: "session", value: obj.sessionId }); + const usage = obj.usage as Record | undefined; + if (usage && typeof usage.totalTokens === "number") { + out.push({ kind: "meta", key: "tokens", value: usage.totalTokens }); + } + } + return out; + } + if (agent === "claude") { // Init / system metadata if (obj.type === "system" && obj.subtype === "init") { diff --git a/next/src/lib/agents/detect.ts b/next/src/lib/agents/detect.ts index f98123e5..17d66582 100644 --- a/next/src/lib/agents/detect.ts +++ b/next/src/lib/agents/detect.ts @@ -103,6 +103,20 @@ export const AGENTS: AgentDef[] = [ { id: "o4-mini", label: "o4-mini" }, ], }, + { + id: "zcode", + label: "ZCode", + bin: "zcode", + envOverride: "ZCODE_BIN", + vendor: "Z.ai", + // Headless shape: `zcode --json -p ""` — one JSON document with + // {response, usage, sessionId} printed after the turn closes (see the + // "argv" protocol handling in invoke.ts / argv.ts). The CLI has no + // --model flag yet; it runs its configured default model, so only the + // synthetic DEFAULT entry is offered. + protocol: "argv", + fallbackModels: [DEFAULT_MODEL], + }, { id: "cursor-agent", label: "Cursor Agent", diff --git a/next/src/lib/agents/invoke.ts b/next/src/lib/agents/invoke.ts index 22d11d91..1aea445a 100644 --- a/next/src/lib/agents/invoke.ts +++ b/next/src/lib/agents/invoke.ts @@ -1,5 +1,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { existsSync } from "node:fs"; +import { existsSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { resolveOnPath, resolveOpenclawAgentId, AGENTS } from "./detect"; import { buildArgv, envFor, makeParser, UnsupportedAgentProtocolError } from "./argv"; @@ -150,9 +152,37 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream { safeClose(); return; } - // `protocol: "argv"` adapters (deepseek-tui today) take the prompt as a - // trailing positional arg rather than reading from stdin. - if (promptViaArgv) argv = [...argv, opts.prompt]; + // ZCode: multi-line prompts cannot survive Windows argv — npm's .cmd + // shims (and this file's shell:true spawn) re-parse the command line at + // newlines, so `-p "<4KB skill prompt>"` arrives truncated. The CLI + // natively accepts file attachments, so the prompt goes to a temp file + // and `-p` carries a short ASCII-only pointer instead. + let promptFile: string | null = null; + const cleanupPromptFile = () => { + if (!promptFile) return; + const f = promptFile; + promptFile = null; + try { + rmSync(f, { force: true }); + } catch {} + }; + if (opts.agent === "zcode") { + promptFile = path.join( + tmpdir(), + `html-anything-zcode-prompt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.md`, + ); + writeFileSync(promptFile, opts.prompt, "utf8"); + argv = [ + ...argv, + "Your complete task instructions are in the attached file. Read it in full and follow it exactly; output the result directly.", + "--attach", + promptFile, + ]; + } else if (promptViaArgv) { + // `protocol: "argv"` adapters (deepseek-tui today) take the prompt as a + // trailing positional arg rather than reading from stdin. + argv = [...argv, opts.prompt]; + } // `protocol: "argv-message"` (openclaw today) wants the prompt under // an explicit `--message ` flag. if (promptViaMessageFlag) argv = [...argv, "--message", opts.prompt]; @@ -208,7 +238,10 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream { stdoutBuf += chunk; // OpenClaw emits one big multi-line JSON document — accumulate and // parse it once on close instead of trying to parse each line. - if (opts.agent === "openclaw") return; + // OpenClaw and ZCode both emit ONE multi-line JSON document that is + // parsed on close — skip the per-line loop so stdoutBuf keeps the + // whole thing. + if (opts.agent === "openclaw" || opts.agent === "zcode") return; let nl: number; while ((nl = stdoutBuf.indexOf("\n")) !== -1) { const line = stdoutBuf.slice(0, nl); @@ -303,6 +336,7 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream { } } safeEnqueue({ type: "done", code }); + cleanupPromptFile(); safeClose(); }); @@ -310,6 +344,7 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream { try { child?.kill("SIGTERM"); } catch {} + cleanupPromptFile(); safeClose(); }; opts.signal?.addEventListener("abort", onAbort, { once: true });