From c6fc183ad6a797940b1582376c07ec9342969658 Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Sat, 17 Oct 2026 18:46:52 +0900 Subject: [PATCH] Restore run.ts from main --- src/agent-claude.ts | 119 ------------ src/agent-codex.ts | 174 ----------------- src/agent-opencode.ts | 46 ----- src/agents.ts | 30 --- src/run-context.ts | 79 -------- src/run.ts | 440 +++++++++++++++++++++++++++++++++++++----- 6 files changed, 391 insertions(+), 497 deletions(-) delete mode 100644 src/agent-claude.ts delete mode 100644 src/agent-codex.ts delete mode 100644 src/agent-opencode.ts delete mode 100644 src/agents.ts delete mode 100644 src/run-context.ts diff --git a/src/agent-claude.ts b/src/agent-claude.ts deleted file mode 100644 index b56acb1..0000000 --- a/src/agent-claude.ts +++ /dev/null @@ -1,119 +0,0 @@ -// 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> & { - subtype: string; - }; - -// Echoes stderr as it arrives and resolves with all of it. -async function copyError(stream: Readable): Promise { - 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 { - 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 { - 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; -} diff --git a/src/agent-codex.ts b/src/agent-codex.ts deleted file mode 100644 index 495d741..0000000 --- a/src/agent-codex.ts +++ /dev/null @@ -1,174 +0,0 @@ -// Signs Codex in for one profile, runs it, and returns its result file path. -import process from "node:process"; -import type { Readable } from "node:stream"; -import { text } from "node:stream/consumers"; -import { setTimeout as sleep } from "node:timers/promises"; - -import { isCodexAuthFailure, retryInvalidToken } from "./auth.ts"; -import type { CodexAuthentication } from "./auth.ts"; -import { chunks, endWith, writeChunk } from "./command.ts"; -import type { CommandStatus } from "./command.ts"; -import { agentCommand } from "./isolation.ts"; -import { AgentAuthError, agentEnvironment, withProfile } from "./profiles.ts"; -import { - makeProfileFile, - postAuthorComment, - PROCESS_ENV, - runAgentCommand, - WORKSPACE, -} from "./run-context.ts"; -import type { RuntimeProfile } from "./runtime.ts"; - -interface CodexAttempt { - status: CommandStatus; - error: string; -} - -async function collectText(stream: Readable, append: (chunk: string) => void): Promise { - const decoder = new TextDecoder(); - for await (const chunk of chunks(stream)) { - append(decoder.decode(chunk, { stream: true })); - } - append(decoder.decode()); -} - -// Polls every 100 ms until the login prints something, failing if it exits first. -async function pollForInstructions( - shownText: () => string, - status: Promise, -): Promise { - if (shownText().trim() !== "") { - return; - } - const exited = await Promise.race([status.then(() => true), sleep(100, false)]); - if (exited) { - throw new AgentAuthError("AI bot login produced no instructions"); - } - await pollForInstructions(shownText, status); -} - -async function codexDeviceLogin( - profile: RuntimeProfile, - environment: Record, -): Promise { - const login = agentCommand(profile, "codex", ["login", "--device-auth"], { - cwd: WORKSPACE, - env: environment, - clearEnv: true, - stdout: "piped", - stderr: "piped", - }).spawn(); - let shown = ""; - const append = (chunk: string): void => { - shown += chunk; - }; - const drained = Promise.all([ - collectText(login.stdout, append), - collectText(login.stderr, append), - ]); - await sleep(3000); - await pollForInstructions(() => shown, login.status); - await postAuthorComment(withProfile(shown, profile)); - const [result] = await Promise.all([login.status, drained]); - if (!result.success) { - throw new AgentAuthError(`codex login exited with ${result.code}`); - } -} - -async function codexApiLogin( - profile: RuntimeProfile, - environment: Record, - token: string, -): Promise { - const login = agentCommand(profile, "codex", ["login", "--with-api-key"], { - cwd: WORKSPACE, - env: environment, - clearEnv: true, - stdin: "piped", - stderr: "piped", - }).spawn(); - await endWith(login.stdin, `${token}\n`); - const [status, error] = await Promise.all([login.status, text(login.stderr)]); - if (!status.success) { - throw new Error(error.trim() || `codex login exited with ${status.code}`); - } -} - -async function signIn( - profile: RuntimeProfile, - environment: Record, - authentication: CodexAuthentication, -): Promise { - if (authentication.kind === "api-key") { - await codexApiLogin(profile, environment, authentication.token); - return; - } - const loggedIn = await agentCommand(profile, "codex", ["login", "status"], { - cwd: WORKSPACE, - env: environment, - clearEnv: true, - }).output(); - if (!loggedIn.success) { - await codexDeviceLogin(profile, environment); - } -} - -async function execCodex( - profile: RuntimeProfile, - environment: Record, - prompt: string, - outputPath: string, -): Promise { - const args = [ - "exec", - "--model", - profile.model, - "--dangerously-bypass-approvals-and-sandbox", - "--output-last-message", - outputPath, - prompt, - ]; - const codex = agentCommand(profile, "codex", args, { - cwd: WORKSPACE, - env: environment, - clearEnv: true, - stdout: "inherit", - stderr: "piped", - }).spawn(); - const decoder = new TextDecoder(); - let error = ""; - for await (const chunk of chunks(codex.stderr)) { - await writeChunk(process.stderr, chunk); - error += decoder.decode(chunk, { stream: true }); - } - error += decoder.decode(); - // Codex echoes the prompt, whose comments may quote auth errors. - return { status: await codex.status, error: error.replaceAll(prompt, "") }; -} - -export async function runCodex( - profile: RuntimeProfile, - prompt: string, - authentication: CodexAuthentication, -): Promise { - const token = authentication.kind === "api-key" ? authentication.token : undefined; - const environment = agentEnvironment(PROCESS_ENV, profile, token); - await signIn(profile, environment, authentication); - const outputPath = await makeProfileFile(profile, "result.md"); - const attempt = (): Promise => execCodex(profile, environment, prompt, outputPath); - const result = - authentication.kind === "api-key" - ? await attempt() - : await retryInvalidToken(attempt, async () => { - await runAgentCommand(profile, "codex", ["logout"]); - await codexDeviceLogin(profile, environment); - }); - if (!result.status.success) { - const failure = new Error(result.error.trim() || `codex exited with ${result.status.code}`); - if (isCodexAuthFailure(result.error)) { - throw new AgentAuthError("codex sign-in failed", { cause: failure }); - } - throw failure; - } - return outputPath; -} diff --git a/src/agent-opencode.ts b/src/agent-opencode.ts deleted file mode 100644 index c21ff90..0000000 --- a/src/agent-opencode.ts +++ /dev/null @@ -1,46 +0,0 @@ -// Runs OpenCode for one profile and returns its final visible text. -import process from "node:process"; - -import { chunks, writeChunk } from "./command.ts"; -import { agentCommand } from "./isolation.ts"; -import { - collectOpenCodeOutput, - openCodeArgs, - openCodeError, - parseOpenCodeEvents, -} from "./opencode.ts"; -import { agentEnvironment } from "./profiles.ts"; -import { PROCESS_ENV, WORKSPACE } from "./run-context.ts"; -import type { RuntimeProfile } from "./runtime.ts"; - -export async function runOpenCode( - profile: RuntimeProfile, - token: string, - prompt: string, -): Promise { - const child = agentCommand(profile, "opencode", openCodeArgs(profile.model, prompt), { - cwd: WORKSPACE, - env: agentEnvironment(PROCESS_ENV, profile, token), - clearEnv: true, - stdout: "piped", - stderr: "piped", - }).spawn(); - const collected = collectOpenCodeOutput( - chunks(child.stdout), - chunks(child.stderr), - async (chunk) => { - await writeChunk(process.stdout, chunk); - }, - async (chunk) => { - await writeChunk(process.stderr, chunk); - }, - ); - const [status, streams] = await Promise.all([child.status, collected]); - const error = streams.stderr.trim(); - if (!status.success) { - throw ( - openCodeError(streams.stdout) ?? new Error(error || `opencode exited with ${status.code}`) - ); - } - return parseOpenCodeEvents(streams.stdout); -} diff --git a/src/agents.ts b/src/agents.ts deleted file mode 100644 index 44f1101..0000000 --- a/src/agents.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Dispatches one attempt to the agent CLI its profile names. -import { runClaude } from "./agent-claude.ts"; -import { runCodex } from "./agent-codex.ts"; -import { runOpenCode } from "./agent-opencode.ts"; -import { codexAuthentication } from "./auth.ts"; -import type { CodexAuthentication } from "./auth.ts"; -import type { RuntimeProfile } from "./runtime.ts"; - -export interface AgentAnswer { - body?: string; - resultPath?: string; -} - -// Runs the selected agent; Codex leaves its answer in a file, the others return it. -export async function runAgent( - profile: RuntimeProfile, - token: string | undefined, - prompt: string, - authentication: CodexAuthentication | null, -): Promise { - if (profile.type === "claude") { - return { body: await runClaude(profile, token ?? "", prompt) }; - } - if (profile.type === "codex") { - return { - resultPath: await runCodex(profile, prompt, authentication ?? codexAuthentication(token)), - }; - } - return { body: await runOpenCode(profile, token ?? "", prompt) }; -} diff --git a/src/run-context.ts b/src/run-context.ts deleted file mode 100644 index 33ae659..0000000 --- a/src/run-context.ts +++ /dev/null @@ -1,79 +0,0 @@ -// The agent phase's environment and the helpers every agent runner shares. It -// is read once, when the runner starts, as the single-file runner did. -import { chmod, chown, writeFile } from "node:fs/promises"; -import process from "node:process"; - -import { agentCommand } from "./isolation.ts"; -import { agentEnvironment, sanitizedEnvironment } from "./profiles.ts"; -import { readRuntimeManifest } from "./runtime.ts"; -import type { RuntimeProfile } from "./runtime.ts"; - -export const env = (name: string): string => { - const value = process.env[name]; - if (value === undefined) { - throw new Error(`${name} is not set`); - } - return value; -}; - -function definedEnvironment(): Record { - const result: Record = {}; - for (const [name, value] of Object.entries(process.env)) { - if (value !== undefined) { - result[name] = value; - } - } - return result; -} - -export const API = env("GITEA_API_URL"); -export const REPO = env("GITEA_REPOSITORY"); -export const INDEX = env("ISSUE_INDEX"); -export const EVENT = env("EVENT_NAME"); -const AUTHOR_TOKEN = env("GITEA_TOKEN"); -export const WORKSPACE = env("GITHUB_WORKSPACE"); -export const MANIFEST = await readRuntimeManifest(env("BOT_AGENTS_MANIFEST")); -export const PROCESS_ENV = sanitizedEnvironment(definedEnvironment()); - -export async function gitea(path: string, body?: unknown): Promise { - const response = await fetch(`${API}/${path}`, { - method: body === undefined ? "GET" : "POST", - headers: { - Authorization: `token ${AUTHOR_TOKEN}`, - "Content-Type": "application/json", - }, - body: body === undefined ? null : JSON.stringify(body), - }); - if (!response.ok) { - throw new Error(`${path}: ${response.status} ${await response.text()}`); - } - return response; -} - -export async function postAuthorComment(body: string): Promise { - await gitea(`repos/${REPO}/issues/${INDEX}/comments`, { body }); -} - -export async function runAgentCommand( - profile: RuntimeProfile, - command: string, - args: string[], - token?: string, -): Promise { - const { success, code } = await agentCommand(profile, command, args, { - cwd: WORKSPACE, - env: agentEnvironment(PROCESS_ENV, profile, token), - clearEnv: true, - }).output(); - if (!success) { - throw new Error(`${command} ${args[0]} exited with ${code}`); - } -} - -export async function makeProfileFile(profile: RuntimeProfile, name: string): Promise { - const path = `${profile.home}/${name}`; - await writeFile(path, "", { mode: 0o600 }); - await chmod(path, 0o600); - await chown(path, profile.uid, profile.gid); - return path; -} diff --git a/src/run.ts b/src/run.ts index b559d11..34cdfe6 100644 --- a/src/run.ts +++ b/src/run.ts @@ -1,28 +1,40 @@ // Runs one ordered agent profile. Credential decryption and reviewer posting // live in separate processes so neither privileged secret reaches this runner. -import { chmod, readFile, writeFile } from "node:fs/promises"; +import { chmod, chown, readFile, writeFile } from "node:fs/promises"; +import process from "node:process"; +import { createInterface } from "node:readline"; +import type { Readable } from "node:stream"; +import { text as readText } from "node:stream/consumers"; +import { setTimeout as sleep } from "node:timers/promises"; -import { codexAuthentication, persistCodexAuth, stageCodexAuth } from "./auth.ts"; -import { runAgent } from "./agents.ts"; -import type { AgentAnswer } from "./agents.ts"; -import { output } from "./command.ts"; -import { killProfileProcesses, readProfileFile } from "./isolation.ts"; -import { isObject } from "./json.ts"; -import { runProfiles } from "./profiles.ts"; -import type { ProfileFailure } from "./profiles.ts"; import { - API, - env, - EVENT, - gitea, - INDEX, - makeProfileFile, - MANIFEST, - PROCESS_ENV, - REPO, - runAgentCommand, - WORKSPACE, -} from "./run-context.ts"; + codexAuthentication, + isClaudeAuthFailure, + isCodexAuthFailure, + persistCodexAuth, + retryInvalidToken, + stageCodexAuth, +} from "./auth.ts"; +import type { ClaudeFailureFields, CodexAuthentication } from "./auth.ts"; +import { chunks, endWith, output, writeChunk } from "./command.ts"; +import type { CommandStatus } from "./command.ts"; +import { agentCommand, killProfileProcesses, readProfileFile } from "./isolation.ts"; +import { isObject } from "./json.ts"; +import { + collectOpenCodeOutput, + openCodeArgs, + openCodeError, + parseOpenCodeEvents, +} from "./opencode.ts"; +import { + AgentAuthError, + agentEnvironment, + runProfiles, + sanitizedEnvironment, + withProfile, +} from "./profiles.ts"; +import type { ProfileFailure } from "./profiles.ts"; +import { readRuntimeManifest } from "./runtime.ts"; import type { RuntimeOutcome, RuntimeProfile } from "./runtime.ts"; interface AttemptResult { @@ -32,16 +44,61 @@ interface AttemptResult { profile: Pick; } -interface RenderedPrompt { - prompt: string; - reviewPath: string; - anchorsPath: string; +// Claude Code's stream-json `result` event, in its own field names. +type ClaudeResult = ClaudeFailureFields & + Partial> & { + subtype: string; + }; + +const env = (name: string): string => { + const value = process.env[name]; + if (value === undefined) { + throw new Error(`${name} is not set`); + } + return value; +}; + +function definedEnvironment(): Record { + const result: Record = {}; + for (const [name, value] of Object.entries(process.env)) { + if (value !== undefined) { + result[name] = value; + } + } + return result; } +const API = env("GITEA_API_URL"); +const REPO = env("GITEA_REPOSITORY"); +const INDEX = env("ISSUE_INDEX"); +const EVENT = env("EVENT_NAME"); +const AUTHOR_TOKEN = env("GITEA_TOKEN"); +const WORKSPACE = env("GITHUB_WORKSPACE"); +const MANIFEST = await readRuntimeManifest(env("BOT_AGENTS_MANIFEST")); +const PROCESS_ENV = sanitizedEnvironment(definedEnvironment()); const RULES_PATH = "repos/commons/code-rules/raw/README.md"; const CODEX_AUTH = "/root/.codex/auth.json"; const SKILLS_INSTALL = "skills add obra/superpowers"; +async function gitea(path: string, body?: unknown): Promise { + const response = await fetch(`${API}/${path}`, { + method: body === undefined ? "GET" : "POST", + headers: { + Authorization: `token ${AUTHOR_TOKEN}`, + "Content-Type": "application/json", + }, + body: body === undefined ? null : JSON.stringify(body), + }); + if (!response.ok) { + throw new Error(`${path}: ${response.status} ${await response.text()}`); + } + return response; +} + +async function postAuthorComment(body: string): Promise { + await gitea(`repos/${REPO}/issues/${INDEX}/comments`, { body }); +} + async function runRoot(command: string, args: string[]): Promise { const { success, code } = await output(command, { args, env: PROCESS_ENV, clearEnv: true }); if (!success) { @@ -63,6 +120,22 @@ async function readProfileToken(profile: RuntimeProfile): Promise { + const { success, code } = await agentCommand(profile, command, args, { + cwd: WORKSPACE, + env: agentEnvironment(PROCESS_ENV, profile, token), + clearEnv: true, + }).output(); + if (!success) { + throw new Error(`${command} ${args[0]} exited with ${code}`); + } +} + async function prepareProfile(profile: RuntimeProfile): Promise { await runAgentCommand(profile, "git", [ "config", @@ -88,17 +161,18 @@ function formatComment(comment: unknown): string { return `## ${String(login)} at ${String(createdAt)}\n\n${String(body)}\n`; } -async function promptValues( - reviewPath: string, - anchorsPath: string, -): Promise> { +async function renderPrompt( + profile: RuntimeProfile, +): Promise<{ prompt: string; reviewPath: string; anchorsPath: string }> { + const reviewPath = await makeProfileFile(profile, "review.md"); + const anchorsPath = await makeProfileFile(profile, "anchors.json"); const comments: unknown = await ( await gitea(`repos/${REPO}/issues/${INDEX}/comments?limit=100`) ).json(); if (!Array.isArray(comments)) { throw new TypeError("comments: unexpected Gitea response"); } - return { + const values: Record = { COMMENT: env("COMMENT"), ISSUE_COMMENTS: comments.map((comment: unknown) => formatComment(comment)).join("\n"), CODE_RULES: await (await gitea(RULES_PATH)).text(), @@ -109,12 +183,6 @@ async function promptValues( REVIEW_PATH: reviewPath, ANCHORS_PATH: anchorsPath, }; -} - -async function renderPrompt(profile: RuntimeProfile): Promise { - const reviewPath = await makeProfileFile(profile, "review.md"); - const anchorsPath = await makeProfileFile(profile, "anchors.json"); - const values = await promptValues(reviewPath, anchorsPath); const template = await readFile(new URL("../prompt.md", import.meta.url), "utf8"); return { prompt: template.replaceAll( @@ -126,45 +194,319 @@ async function renderPrompt(profile: RuntimeProfile): Promise { }; } +async function runClaude(profile: RuntimeProfile, token: string, prompt: string): Promise { + const claude = agentCommand( + profile, + "claude", + [ + "--print", + "--dangerously-skip-permissions", + "--model", + profile.model, + "--output-format", + "stream-json", + "--verbose", + prompt, + ], + { + cwd: WORKSPACE, + env: { + ...agentEnvironment(PROCESS_ENV, profile, token), + IS_SANDBOX: "1", + }, + clearEnv: true, + stdout: "piped", + stderr: "piped", + }, + ).spawn(); + let result: ClaudeResult | undefined; + let error = ""; + const copyError = (async (): Promise => { + const decoder = new TextDecoder(); + for await (const chunk of chunks(claude.stderr)) { + const part = decoder.decode(chunk, { stream: true }); + error += part; + console.error(part); + } + error += decoder.decode(); + })(); + for await (const line of createInterface({ input: claude.stdout, crlfDelay: Infinity })) { + if (line !== "") { + const event: unknown = JSON.parse(line); + if (isObject(event)) { + 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); + } + } + } + if (event.type === "result") { + const { is_error: isError, result: answer, subtype, api_error_status: apiStatus } = event; + result = { + ...(typeof isError === "boolean" && { is_error: isError }), + ...(typeof answer === "string" && { result: answer }), + subtype: String(subtype), + ...((typeof apiStatus === "number" || apiStatus === null) && { + api_error_status: apiStatus, + }), + }; + } + } + } + } + const [status] = await Promise.all([claude.status, copyError]); + 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"}`); + } + if (!status.success) { + throw new Error(error.trim() || `claude exited with ${status.code}`); + } + return result.result; +} + +// Polls every 100 ms until the login prints something, failing if it exits first. +async function pollForInstructions( + shownText: () => string, + status: Promise, +): Promise { + if (shownText().trim() !== "") { + return; + } + const exited = await Promise.race([status.then(() => true), sleep(100, false)]); + if (exited) { + throw new AgentAuthError("AI bot login produced no instructions"); + } + await pollForInstructions(shownText, status); +} + +async function codexDeviceLogin( + profile: RuntimeProfile, + environment: Record, +): Promise { + const login = agentCommand(profile, "codex", ["login", "--device-auth"], { + cwd: WORKSPACE, + env: environment, + clearEnv: true, + stdout: "piped", + stderr: "piped", + }).spawn(); + let shown = ""; + const collect = async (stream: Readable): Promise => { + const decoder = new TextDecoder(); + for await (const chunk of chunks(stream)) { + shown += decoder.decode(chunk, { stream: true }); + } + shown += decoder.decode(); + }; + const drained = Promise.all([collect(login.stdout), collect(login.stderr)]); + const status = login.status; + await sleep(3000); + await pollForInstructions(() => shown, status); + await postAuthorComment(withProfile(shown, profile)); + const [result] = await Promise.all([status, drained]); + if (!result.success) { + throw new AgentAuthError(`codex login exited with ${result.code}`); + } +} + +async function codexApiLogin( + profile: RuntimeProfile, + environment: Record, + token: string, +): Promise { + const login = agentCommand(profile, "codex", ["login", "--with-api-key"], { + cwd: WORKSPACE, + env: environment, + clearEnv: true, + stdin: "piped", + stderr: "piped", + }).spawn(); + await endWith(login.stdin, `${token}\n`); + const [status, error] = await Promise.all([login.status, readText(login.stderr)]); + if (!status.success) { + throw new Error(error.trim() || `codex login exited with ${status.code}`); + } +} + +async function makeProfileFile(profile: RuntimeProfile, name: string): Promise { + const path = `${profile.home}/${name}`; + await writeFile(path, "", { mode: 0o600 }); + await chmod(path, 0o600); + await chown(path, profile.uid, profile.gid); + return path; +} + +async function runCodex( + profile: RuntimeProfile, + prompt: string, + authentication: CodexAuthentication, +): Promise { + const token = authentication.kind === "api-key" ? authentication.token : undefined; + const environment = agentEnvironment(PROCESS_ENV, profile, token); + if (authentication.kind === "api-key") { + await codexApiLogin(profile, environment, authentication.token); + } else { + const loggedIn = await agentCommand(profile, "codex", ["login", "status"], { + cwd: WORKSPACE, + env: environment, + clearEnv: true, + }).output(); + if (!loggedIn.success) { + await codexDeviceLogin(profile, environment); + } + } + const outputPath = await makeProfileFile(profile, "result.md"); + const attempt = async (): Promise<{ status: CommandStatus; error: string }> => { + const codex = agentCommand( + profile, + "codex", + [ + "exec", + "--model", + profile.model, + "--dangerously-bypass-approvals-and-sandbox", + "--output-last-message", + outputPath, + prompt, + ], + { + cwd: WORKSPACE, + env: environment, + clearEnv: true, + stdout: "inherit", + stderr: "piped", + }, + ).spawn(); + const decoder = new TextDecoder(); + let error = ""; + for await (const chunk of chunks(codex.stderr)) { + await writeChunk(process.stderr, chunk); + error += decoder.decode(chunk, { stream: true }); + } + error += decoder.decode(); + // Codex echoes the prompt, whose comments may quote auth errors. + return { status: await codex.status, error: error.replaceAll(prompt, "") }; + }; + const result = + authentication.kind === "api-key" + ? await attempt() + : await retryInvalidToken(attempt, async () => { + await runAgentCommand(profile, "codex", ["logout"]); + await codexDeviceLogin(profile, environment); + }); + if (!result.status.success) { + const failure = new Error(result.error.trim() || `codex exited with ${result.status.code}`); + if (isCodexAuthFailure(result.error)) { + throw new AgentAuthError("codex sign-in failed", { cause: failure }); + } + throw failure; + } + return outputPath; +} + +async function runOpenCode( + profile: RuntimeProfile, + token: string, + prompt: string, +): Promise { + const child = agentCommand(profile, "opencode", openCodeArgs(profile.model, prompt), { + cwd: WORKSPACE, + env: agentEnvironment(PROCESS_ENV, profile, token), + clearEnv: true, + stdout: "piped", + stderr: "piped", + }).spawn(); + const collected = collectOpenCodeOutput( + chunks(child.stdout), + chunks(child.stderr), + async (chunk) => { + await writeChunk(process.stdout, chunk); + }, + async (chunk) => { + await writeChunk(process.stderr, chunk); + }, + ); + const [status, streams] = await Promise.all([child.status, collected]); + const error = streams.stderr.trim(); + if (!status.success) { + throw ( + openCodeError(streams.stdout) ?? new Error(error || `opencode exited with ${status.code}`) + ); + } + return parseOpenCodeEvents(streams.stdout); +} + async function runProfile(profile: RuntimeProfile): Promise { const token = await readProfileToken(profile); const authentication = profile.type === "codex" ? codexAuthentication(token) : null; let stagedCodexAuth: string | null = null; - let answer: AgentAnswer = {}; - let rendered: RenderedPrompt | undefined; + let body: string | undefined; + let resultPath: string | null = null; + let reviewPath: string | undefined; + let anchorsPath: string | undefined; try { if (authentication?.kind === "persistent") { stagedCodexAuth = await stageCodexAuth(CODEX_AUTH, profile.home, profile.uid, profile.gid); } await prepareProfile(profile); - rendered = await renderPrompt(profile); - answer = await runAgent(profile, token, rendered.prompt, authentication); + const rendered = await renderPrompt(profile); + reviewPath = rendered.reviewPath; + anchorsPath = rendered.anchorsPath; + if (profile.type === "claude") { + body = await runClaude(profile, token ?? "", rendered.prompt); + } else if (profile.type === "codex") { + resultPath = await runCodex( + profile, + rendered.prompt, + authentication ?? codexAuthentication(token), + ); + } else { + body = await runOpenCode(profile, token ?? "", rendered.prompt); + } } finally { await killProfileProcesses(profile.uid); if (stagedCodexAuth !== null) { await persistCodexAuth(stagedCodexAuth, CODEX_AUTH); } } - const body = - answer.resultPath === undefined - ? answer.body - : await readProfileFile(profile, answer.resultPath); - if (body === undefined || rendered === undefined) { + if (resultPath !== null) { + body = await readProfileFile(profile, resultPath); + } + if (body === undefined || reviewPath === undefined || anchorsPath === undefined) { throw new Error("agent attempt produced no result"); } - const { reviewPath, anchorsPath } = rendered; - return { body, reviewPath, anchorsPath, profile: { type: profile.type, model: profile.model } }; + return { + body, + reviewPath, + anchorsPath, + profile: { type: profile.type, model: profile.model }, + }; } async function writeOutcome(outcome: RuntimeOutcome): Promise { - await writeFile(MANIFEST.outcomeFile, JSON.stringify(outcome), { mode: 0o600 }); + await writeFile(MANIFEST.outcomeFile, JSON.stringify(outcome), { + mode: 0o600, + }); await chmod(MANIFEST.outcomeFile, 0o600); } const failures: ProfileFailure[] = []; try { await configureGitAuthor(); - await writeOutcome({ ok: true, ...(await runProfiles(MANIFEST.profiles, runProfile, failures)) }); + await writeOutcome({ + ok: true, + ...(await runProfiles(MANIFEST.profiles, runProfile, failures)), + }); } catch (error) { console.error(error); await writeOutcome({ ok: false, failures });