120 lines
3.8 KiB
TypeScript
120 lines
3.8 KiB
TypeScript
// Runs Claude Code for one profile and returns its final result text.
|
|
import { createInterface } from "node:readline";
|
|
import type { Readable } from "node:stream";
|
|
|
|
import { isClaudeAuthFailure } from "./auth.ts";
|
|
import type { ClaudeFailureFields } from "./auth.ts";
|
|
import { chunks } from "./command.ts";
|
|
import { agentCommand } from "./isolation.ts";
|
|
import { isObject } from "./json.ts";
|
|
import type { JsonObject } from "./json.ts";
|
|
import { AgentAuthError, agentEnvironment } from "./profiles.ts";
|
|
import { PROCESS_ENV, WORKSPACE } from "./run-context.ts";
|
|
import type { RuntimeProfile } from "./runtime.ts";
|
|
|
|
// Claude Code's stream-json `result` event, in its own field names.
|
|
type ClaudeResult = ClaudeFailureFields &
|
|
Partial<Record<"is_error", boolean>> & {
|
|
subtype: string;
|
|
};
|
|
|
|
// Echoes stderr as it arrives and resolves with all of it.
|
|
async function copyError(stream: Readable): Promise<string> {
|
|
const decoder = new TextDecoder();
|
|
let error = "";
|
|
for await (const chunk of chunks(stream)) {
|
|
const text = decoder.decode(chunk, { stream: true });
|
|
error += text;
|
|
console.error(text);
|
|
}
|
|
return error + decoder.decode();
|
|
}
|
|
|
|
// Prints the visible text of each streamed message part.
|
|
function printParts(event: JsonObject): void {
|
|
const content = isObject(event.message) ? event.message.content : undefined;
|
|
for (const part of Array.isArray(content) ? content : []) {
|
|
if (isObject(part)) {
|
|
const text = part.thinking ?? part.text ?? part.name;
|
|
if (typeof text === "string" && text !== "") {
|
|
console.log(text);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function toResult(event: JsonObject): ClaudeResult {
|
|
const { is_error: isError, result, subtype, api_error_status: apiErrorStatus } = event;
|
|
return {
|
|
...(typeof isError === "boolean" && { is_error: isError }),
|
|
...(typeof result === "string" && { result }),
|
|
subtype: String(subtype),
|
|
...((typeof apiErrorStatus === "number" || apiErrorStatus === null) && {
|
|
api_error_status: apiErrorStatus,
|
|
}),
|
|
};
|
|
}
|
|
|
|
// Prints each streamed message part and returns the final `result` event.
|
|
async function readEvents(stream: Readable): Promise<ClaudeResult | undefined> {
|
|
let result: ClaudeResult | undefined;
|
|
for await (const line of createInterface({ input: stream, crlfDelay: Infinity })) {
|
|
if (line !== "") {
|
|
const event: unknown = JSON.parse(line);
|
|
if (isObject(event)) {
|
|
printParts(event);
|
|
if (event.type === "result") {
|
|
result = toResult(event);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function checkResult(result: ClaudeResult | undefined, error: string): string {
|
|
if (result?.is_error === true) {
|
|
const failure = new Error(result.result ?? `claude ended with ${result.subtype}`);
|
|
if (isClaudeAuthFailure(result)) {
|
|
throw new AgentAuthError("claude sign-in failed", { cause: failure });
|
|
}
|
|
throw failure;
|
|
}
|
|
if (result?.result === undefined) {
|
|
throw new Error(error.trim() || `claude ended with ${result?.subtype ?? "no result"}`);
|
|
}
|
|
return result.result;
|
|
}
|
|
|
|
export async function runClaude(
|
|
profile: RuntimeProfile,
|
|
token: string,
|
|
prompt: string,
|
|
): Promise<string> {
|
|
const args = [
|
|
"--print",
|
|
"--dangerously-skip-permissions",
|
|
"--model",
|
|
profile.model,
|
|
"--output-format",
|
|
"stream-json",
|
|
"--verbose",
|
|
prompt,
|
|
];
|
|
const claude = agentCommand(profile, "claude", args, {
|
|
cwd: WORKSPACE,
|
|
env: { ...agentEnvironment(PROCESS_ENV, profile, token), IS_SANDBOX: "1" },
|
|
clearEnv: true,
|
|
stdout: "piped",
|
|
stderr: "piped",
|
|
}).spawn();
|
|
const errorText = copyError(claude.stderr);
|
|
const result = await readEvents(claude.stdout);
|
|
const [status, error] = await Promise.all([claude.status, errorText]);
|
|
const body = checkResult(result, error);
|
|
if (!status.success) {
|
|
throw new Error(error.trim() || `claude exited with ${status.code}`);
|
|
}
|
|
return body;
|
|
}
|