Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions next/src/lib/agents/argv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<prompt>"` 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;
Expand Down Expand Up @@ -259,6 +268,22 @@ function parseLineWithState(agent: string, line: string, state: ParseState): Age
const obj = parsed as Record<string, unknown>;
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<string, unknown> | 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") {
Expand Down
14 changes: 14 additions & 0 deletions next/src/lib/agents/detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<prompt>"` — 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",
Expand Down
45 changes: 40 additions & 5 deletions next/src/lib/agents/invoke.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -150,9 +152,37 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream<InvokeEvent> {
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 <text>` flag.
if (promptViaMessageFlag) argv = [...argv, "--message", opts.prompt];
Expand Down Expand Up @@ -208,7 +238,10 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream<InvokeEvent> {
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);
Expand Down Expand Up @@ -303,13 +336,15 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream<InvokeEvent> {
}
}
safeEnqueue({ type: "done", code });
cleanupPromptFile();
safeClose();
});

const onAbort = () => {
try {
child?.kill("SIGTERM");
} catch {}
cleanupPromptFile();
safeClose();
};
opts.signal?.addEventListener("abort", onAbort, { once: true });
Expand Down