/** * Executor adapters for Reasonix, Claude Code, and Agy. * * All adapters: * - Spawn processes with argument arrays (no shell interpolation) * - Include fixed safety constraints in every prompt (no commit/push/reset) * - Return ExecutorResult with session handle for future resume */ import { randomUUID } from "node:crypto"; import { execFileSync, spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import os from "node:os"; import { spawnStreamingChild } from "./child-process.js"; import type { AgySessionHandle, ClaudeSessionHandle, ExecutorAdapter, ExecutorConfig, ExecutorKind, ExecutorResumeOpts, ExecutorResult, ExecutorSessionHandle, ExecutorStartOpts, ProgressSidecar, ReasonixSessionHandle, VcsKind, } from "./workflow-types.js"; import { nowIso, writeProgressSidecar, readProgressSidecar, attemptLogPath, } from "./workflow-state.js"; // --------------------------------------------------------------------------- // Safety prompt footer (appended to every executor instruction) // --------------------------------------------------------------------------- function getSafetyConstraints(vcsKind?: VcsKind): string { let vcsInspectCmd = ""; if (vcsKind === "svn") { vcsInspectCmd = "Before exiting, inspect svn status and leave no artifacts outside the allowed scope."; } else if (vcsKind === "none") { vcsInspectCmd = "Before exiting, confirm that no temporary files or paths outside the allowed scope remain."; } else { vcsInspectCmd = "Before exiting, inspect git status --porcelain --untracked-files=all and leave no artifacts outside the allowed scope."; } return ` ## CRITICAL CONSTRAINTS — DO NOT VIOLATE - DO NOT run git commit, git push, or git reset under any circumstances. - DO NOT run svn commit, svn switch, svn update, svn relocate, or svn revert under any circumstances. - DO NOT modify .svn metadata directories or files. - DO NOT rewrite git history or delete branches. - DO NOT create or modify files outside the scope listed above. - Exception: a Scope Remediation prompt may authorize removing or restoring only the exact out-of-scope paths it lists. - Put all temporary files in an OS temporary directory outside the repository (for example, one created with mktemp). - If you accidentally create a temporary repository file, remove only that artifact before exiting. - SQLite cleanup must include the database and its -wal, -shm, and -journal sidecars. - ${vcsInspectCmd} - You may read, edit, and test code, but never commit or push changes. - If you encounter an error you cannot resolve, stop and report it clearly. `.trimStart(); } // --------------------------------------------------------------------------- // Progress sidecar helpers // --------------------------------------------------------------------------- function startProgressSidecar( dir: string, executor: "reasonix" | "claude" | "agy", cycleIndex: number, attemptIndex: number, logFilePath: string | undefined, intervalMs = 5000, ): NodeJS.Timeout { const sidecar: ProgressSidecar = { executor, cycleIndex, attemptIndex, phase: "executing", startedAt: nowIso(), lastActivityAt: nowIso(), logFilePath: logFilePath ? path.relative(dir, logFilePath) : undefined, logBytes: 0, activitySummary: "Starting...", updatedAt: nowIso(), }; writeProgressSidecar(dir, sidecar); return setInterval(() => { const current = readProgressSidecar(dir); if (!current) return; const now = nowIso(); current.updatedAt = now; if (current.logFilePath) { try { current.logBytes = fs.statSync(path.join(dir, current.logFilePath)).size; } catch { // file may not exist yet } } writeProgressSidecar(dir, current); }, intervalMs); } function updateSidecar(dir: string, updates: Partial): void { const current = readProgressSidecar(dir); if (!current) return; Object.assign(current, updates, { updatedAt: nowIso() }); writeProgressSidecar(dir, current); } function stopProgressSidecar(dir: string, timer: NodeJS.Timeout, failed?: boolean): void { clearInterval(timer); const current = readProgressSidecar(dir); if (!current) return; current.phase = failed ? "failed" : "completed"; const elapsed = Date.now() - new Date(current.startedAt).getTime(); current.executionDurationMs = elapsed; current.updatedAt = nowIso(); if (current.logFilePath) { try { current.logBytes = fs.statSync(path.join(dir, current.logFilePath)).size; } catch { // ignore } } writeProgressSidecar(dir, current); } // -------------------------------------------------------------------------- // Claude stream-json activity derivation // --------------------------------------------------------------------------- function deriveClaudeActivity(line: string): string | undefined { if (!line.trimStart().startsWith("{")) return undefined; try { const parsed = JSON.parse(line) as Record; const type = String(parsed.type ?? ""); // Real Claude Code stream-json envelope: // type=system, subtype=init, session_id // type=assistant, message:{content:[{type:"tool_use",...}]} // type=result, is_error?, result, session_id if (type === "assistant") { const message = parsed.message as Record | undefined; const content = message?.content as unknown[] | undefined; if (!Array.isArray(content) || content.length === 0) return undefined; for (const item of content) { if (typeof item !== "object" || item === null) continue; const block = item as Record; const blockType = String(block.type ?? ""); if (blockType === "thinking") continue; // DO NOT surface thinking if (blockType === "tool_use") { return deriveToolUse(block); } } return undefined; } // Direct tool_use at top level (legacy) if (type === "tool_use") { return deriveToolUse(parsed); } // System init / result / error at top level switch (type) { case "system": { if (parsed.subtype === "init") return "Creating session"; return undefined; } case "result": { // is_error result → error if (parsed.is_error === true) { const err = String(parsed.result ?? "").slice(0, 80); return err ? `Error: ${err}` : "Error occurred"; } const result = String(parsed.result ?? "").slice(0, 60); return result ? `Result: ${result}` : "Processing"; } case "error": { const err = String(parsed.error ?? "").slice(0, 80); return err ? `Error: ${err}` : "Error occurred"; } default: return undefined; } } catch { return undefined; } } /** Derive a concise activity string from a tool_use content block. */ function deriveToolUse(block: Record): string | undefined { const name = String(block.name ?? ""); const input = block.input as Record | undefined; const target = (input?.file_path ?? input?.path ?? input?.file ?? input?.filePath ?? "") as string; const lowerName = name.toLowerCase(); switch (lowerName) { case "read": case "read_file": return target ? `Reading ${target}` : "Reading file"; case "write": case "create": return target ? `Writing ${target}` : "Writing file"; case "edit": case "edit_file": case "str_replace_editor": return target ? `Editing ${target}` : "Editing file"; case "bash": case "command": { const cmd = String(input?.command ?? "").slice(0, 80); return cmd ? `Running: ${cmd}` : "Running command"; } case "glob": return "Searching files"; case "grep": case "search": return "Searching code"; case "web_fetch": case "web": return "Fetching URL"; case "subagent": case "agent": case "dispatch_agent": return "Running sub-agent"; default: return `${name}...`; } } // --------------------------------------------------------------------------- // Prompt builders // --------------------------------------------------------------------------- function buildStartPrompt(opts: ExecutorStartOpts): string { const { plan } = opts; const scopeList = plan.scope.map((s) => ` - ${s}`).join("\n"); const criteriaList = plan.acceptanceCriteria.map((c, i) => ` ${i + 1}. ${c}`).join("\n"); const cmdsText = plan.verificationCommands .map((cmd) => ` ${cmd.join(" ")}`) .join("\n"); return `# Implementation Task: ${plan.title} ## Plan ${plan.planMarkdown} ## Allowed Scope Only modify files within these paths (relative to repository root): ${scopeList} ## Acceptance Criteria ${criteriaList} ## Verification Commands After implementation, ensure these commands pass: ${cmdsText} ${getSafetyConstraints(opts.vcsKind)}`; } function buildFixPrompt(opts: ExecutorResumeOpts): string { const { plan, acceptedFindings } = opts; if (opts.purpose === "task_plan") { const scopeList = plan.scope.map((s) => ` - ${s}`).join("\n"); const criteriaList = plan.acceptanceCriteria.map((c, i) => ` ${i + 1}. ${c}`).join("\n"); const cmdsText = plan.verificationCommands.map((cmd) => ` ${cmd.join(" ")}`).join("\n"); return `# Continued Task Plan: ${plan.title} This is a new frozen plan for the same task. Use the repository's current state and your existing task context. ## Plan ${plan.planMarkdown} ## Allowed Scope Only modify files within these paths (relative to repository root): ${scopeList} ## Acceptance Criteria ${criteriaList} ## Verification Commands After implementation, ensure these commands pass: ${cmdsText} ${getSafetyConstraints(opts.vcsKind)}`; } if (acceptedFindings.startsWith("# Scope Remediation")) { return `# Scope Fix Request: ${plan.title} ${acceptedFindings} Do not modify any other out-of-scope path. If a listed path is not clearly an executor-created artifact, stop and report it instead of deleting it. ## Frozen Scope The implementation itself must remain within: ${plan.scope.map((s) => ` - ${s}`).join("\n")} ## Verification After scope cleanup, run: ${plan.verificationCommands.map((cmd) => ` ${cmd.join(" ")}`).join("\n")} ${getSafetyConstraints(opts.vcsKind)}`; } const hasDetailedGuidance = acceptedFindings.includes("## Detailed Implementation Guidance"); const planContext = hasDetailedGuidance ? `\n## Plan\n\n${plan.planMarkdown}\n\n## Acceptance Criteria\n\n${plan.acceptanceCriteria .map((c, i) => ` ${i + 1}. ${c}`) .join("\n")}\n` : ""; return `# Fix Request: ${plan.title} ${planContext} The code review found the following issues that must be fixed: ${acceptedFindings} ## Scope Reminder Only modify files within: ${plan.scope.map((s) => ` - ${s}`).join("\n")} ## Verification After applying fixes, run: ${plan.verificationCommands.map((cmd) => ` ${cmd.join(" ")}`).join("\n")} ${getSafetyConstraints(opts.vcsKind)}`; } // --------------------------------------------------------------------------- // Child env (inherit + extra PATH entries for executors) // --------------------------------------------------------------------------- function executorEnv(): NodeJS.ProcessEnv { const homeDir = process.env.HOME || os.homedir(); const extras = [ path.join(homeDir, ".local", "bin"), path.join(homeDir, ".bun", "bin"), path.join(homeDir, ".n", "bin"), "/usr/local/bin", "/usr/bin", "/bin", process.env.PATH || "", ]; return { ...process.env, PATH: [...new Set(extras.join(":").split(":").filter(Boolean))].join(":"), }; } function resolveExecutable(config: ExecutorConfig, defaultBin: string): string { return config.binary || defaultBin; } function deriveAttemptIndex(logFilePath: string | undefined): number { if (!logFilePath) return 0; const base = path.basename(logFilePath); const match = base.match(/retry-(\d+)\.log$/); return match ? parseInt(match[1], 10) : 0; } // --------------------------------------------------------------------------- // Reasonix adapter // --------------------------------------------------------------------------- function readFirstLine(filePath: string): string | undefined { const CHUNK_SIZE = 65536; const fd = fs.openSync(filePath, "r"); let buffer = Buffer.alloc(CHUNK_SIZE); let totalData = ""; let newlineIndex = -1; let position = 0; try { while (true) { const bytesRead = fs.readSync(fd, buffer, 0, CHUNK_SIZE, position); if (bytesRead === 0) break; totalData += buffer.toString("utf8", 0, bytesRead); newlineIndex = totalData.indexOf("\n"); if (newlineIndex !== -1) { return totalData.slice(0, newlineIndex); } position += bytesRead; if (position > 1024 * 1024) break; // 1MB safety limit } return totalData; } finally { fs.closeSync(fd); } } function getWorkspaceFromJsonl(jsonlPath: string): string | undefined { try { const firstLine = readFirstLine(jsonlPath); if (!firstLine) return undefined; const obj = JSON.parse(firstLine); if (obj && obj.role === "system" && typeof obj.content === "string") { let match = obj.content.match(/Current workspace:\s*"([^"\n\r]+)"/i); if (!match) { match = obj.content.match(/Current workspace:\s*([^"\n\r]+)/i); } if (match) { return match[1].trim(); } } } catch { // ignore } return undefined; } function reasonixProjectDir(projectsDir: string, repoRoot: string): string { const projectKey = path.resolve(repoRoot).replaceAll(path.sep, "-"); return path.join(projectsDir, projectKey); } function isSessionInReasonixProject(jsonlPath: string, projectsDir: string, repoRoot: string): boolean { const sessionsDir = path.join(reasonixProjectDir(projectsDir, repoRoot), "sessions"); const relative = path.relative(sessionsDir, jsonlPath); return relative !== "" && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); } export function validateReasonixSessionForRepository(sessionFilePath: string, repoRoot: string): string { const projectsDir = path.join(process.env.HOME || os.homedir(), ".reasonix", "projects"); const resolved = path.resolve(sessionFilePath); if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile() || !resolved.endsWith(".jsonl")) { throw new Error(`Reasonix session file not found: ${resolved}`); } if (!fs.existsSync(`${resolved}.meta`)) { throw new Error(`Reasonix session metadata file not found: ${resolved}.meta`); } const workspaceMatches = getWorkspaceFromJsonl(resolved) === repoRoot; if (!workspaceMatches && !isSessionInReasonixProject(resolved, projectsDir, repoRoot)) { throw new Error(`Reasonix session does not belong to repository ${repoRoot}: ${resolved}`); } return resolved; } function listJsonlFiles(dir: string, repoRoot?: string): string[] { if (!fs.existsSync(dir)) return []; const result: string[] = []; function recurse(current: string) { if (!fs.existsSync(current)) return; const entries = fs.readdirSync(current, { withFileTypes: true }); for (const entry of entries) { const full = path.join(current, entry.name); if (entry.isDirectory()) { const lowerDir = entry.name.toLowerCase(); if (lowerDir !== "archive" && lowerDir !== "subagents") { recurse(full); } } else if (entry.isFile() && entry.name.endsWith(".jsonl")) { const lowerName = entry.name.toLowerCase(); if ( !lowerName.includes("events") && !lowerName.includes("archive") && !lowerName.includes("conflict") && !lowerName.includes("subagents") ) { // Check companion file name: .jsonl.meta const metaFile = full + ".meta"; if (fs.existsSync(metaFile)) { if (repoRoot) { const ws = getWorkspaceFromJsonl(full); if (ws === repoRoot || isSessionInReasonixProject(full, dir, repoRoot)) { result.push(full); } } else { result.push(full); } } } } } } recurse(dir); return result; } function sessionContainsPlanTitle(jsonlPath: string, planTitle: string): boolean { try { const content = fs.readFileSync(jsonlPath, "utf8"); return content.includes(`# Implementation Task: ${planTitle}`); } catch { return false; } } export function findReasonixSessionForWorkflow( repoRoot: string, planTitle: string, startedAt?: string, completedAt?: string, ): string | undefined { const homeDir = process.env.HOME || os.homedir(); const projectsDir = path.join(homeDir, ".reasonix", "projects"); const startMs = startedAt ? Date.parse(startedAt) : Number.NEGATIVE_INFINITY; const endMs = completedAt ? Date.parse(completedAt) : Number.POSITIVE_INFINITY; const toleranceMs = 5000; const candidates = listJsonlFiles(projectsDir, repoRoot).filter((file) => { if (!sessionContainsPlanTitle(file, planTitle)) return false; try { const mtimeMs = fs.statSync(file).mtimeMs; return mtimeMs >= startMs - toleranceMs && mtimeMs <= endMs + toleranceMs; } catch { return false; } }); candidates.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs); return candidates[0]; } function detectNewSessionFile(beforeSnapshot: Set, searchDirs: string[], repoRoot: string): string | undefined { const candidates: string[] = []; for (const dir of searchDirs) { for (const file of listJsonlFiles(dir, repoRoot)) { if (!beforeSnapshot.has(file)) { candidates.push(file); } } } if (candidates.length === 0) return undefined; if (candidates.length === 1) return candidates[0]; candidates.sort((a, b) => { try { return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs; } catch { return 0; } }); return candidates[0]; } class ReasonixAdapter implements ExecutorAdapter { readonly kind: ExecutorKind = "reasonix"; async probe(config: ExecutorConfig = {}): Promise { try { const res = spawnSync(resolveExecutable(config, "reasonix"), ["--version"], { stdio: "ignore", env: executorEnv(), }); return res.status === 0 && !res.error; } catch { return false; } } async start(opts: ExecutorStartOpts): Promise { const bin = resolveExecutable(opts.config, "reasonix"); const prompt = buildStartPrompt(opts); const promptFile = path.join(opts.runDir, `cycle-${opts.cycleIndex}-prompt.txt`); fs.writeFileSync(promptFile, prompt, "utf8"); // Snapshot JSONL files before run to detect the session file created by reasonix const homeDir = process.env.HOME || os.homedir(); const projectsDir = path.join(homeDir, ".reasonix", "projects"); fs.mkdirSync(projectsDir, { recursive: true }); const snapshotDirs = [projectsDir]; const beforeSnapshot = new Set(listJsonlFiles(projectsDir, opts.repoRoot)); const args: string[] = [ "run", "--dir", opts.repoRoot, ...(opts.config.model ? ["--model", opts.config.model] : []), prompt, ]; const startMs = Date.now(); const logFile = opts.logFilePath; const attemptIndex = deriveAttemptIndex(logFile); const sidecarTimer = startProgressSidecar(opts.runDir, "reasonix", opts.cycleIndex, attemptIndex, logFile); const child = await spawnStreamingChild(bin, args, { cwd: opts.repoRoot, env: executorEnv(), signal: opts.signal, processGroup: true, logFilePath: logFile, onProgress: ({ pid, bytesLogged }) => { opts.onActivity?.(); updateSidecar(opts.runDir, { pid, logBytes: bytesLogged, lastActivityAt: nowIso() }); }, }); const reasonixFailed = child.status !== 0 || !!child.error; stopProgressSidecar(opts.runDir, sidecarTimer, reasonixFailed); const durationMs = Date.now() - startMs; const newSession = detectNewSessionFile(beforeSnapshot, snapshotDirs, opts.repoRoot); const handle: ReasonixSessionHandle | undefined = newSession ? { kind: "reasonix", sessionFilePath: newSession } : undefined; const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : ""); const failed = reasonixFailed; return { exitCode: child.status, report, durationMs, sessionHandle: handle, failed, ...(child.error ? { failureReason: child.error.message } : {}), }; } async resume(opts: ExecutorResumeOpts): Promise { const handle = opts.handle as ReasonixSessionHandle; const bin = resolveExecutable(opts.config, "reasonix"); const prompt = buildFixPrompt(opts); const promptFile = path.join(opts.runDir, `cycle-${opts.cycleIndex}-prompt.txt`); fs.writeFileSync(promptFile, prompt, "utf8"); const args: string[] = [ "run", "--dir", opts.repoRoot, "--resume", handle.sessionFilePath, ...(opts.config.model ? ["--model", opts.config.model] : []), prompt, ]; const startMs = Date.now(); const logFile = opts.logFilePath; const attemptIndex = deriveAttemptIndex(logFile); const sidecarTimer = startProgressSidecar(opts.runDir, "reasonix", opts.cycleIndex, attemptIndex, logFile); const child = await spawnStreamingChild(bin, args, { cwd: opts.repoRoot, env: executorEnv(), signal: opts.signal, processGroup: true, logFilePath: logFile, onProgress: ({ pid, bytesLogged }) => { opts.onActivity?.(); updateSidecar(opts.runDir, { pid, logBytes: bytesLogged, lastActivityAt: nowIso() }); }, }); const reasonixFailed = child.status !== 0 || !!child.error; stopProgressSidecar(opts.runDir, sidecarTimer, reasonixFailed); const durationMs = Date.now() - startMs; const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : ""); return { exitCode: child.status, report, durationMs, sessionHandle: handle, // Reasonix handle stays the same file failed: reasonixFailed, ...(child.error ? { failureReason: child.error.message } : {}), }; } async cancel(_handle: ExecutorSessionHandle): Promise { // Reasonix does not expose a cancel API in v1; no-op. } } // --------------------------------------------------------------------------- // Claude Code adapter // --------------------------------------------------------------------------- function extractClaudeSessionId(output: string): string | undefined { // Claude Code outputs JSON with session_id field when using --output-format json try { for (const line of output.split("\n")) { const trimmed = line.trim(); if (!trimmed.startsWith("{")) continue; const parsed = JSON.parse(trimmed) as Record; if (typeof parsed.session_id === "string") return parsed.session_id; } } catch { // fall through } // Also try a simple regex const m = output.match(/"session_id"\s*:\s*"([^"]+)"/); return m?.[1]; } export function validateClaudeSessionForWorkflow( sessionId: string, repoRoot: string, planTitle: string, ): string { const { sessionFile, content } = readClaudeSessionForRepository(sessionId, repoRoot); if (!content.includes(planTitle)) { throw new Error(`Claude session ${sessionId} does not contain frozen plan title: ${planTitle}`); } return sessionFile; } export function validateClaudeSessionForRepository(sessionId: string, repoRoot: string): string { return readClaudeSessionForRepository(sessionId, repoRoot).sessionFile; } function readClaudeSessionForRepository( sessionId: string, repoRoot: string, ): { sessionFile: string; content: string } { if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sessionId)) { throw new Error(`Invalid Claude session ID: ${sessionId}`); } const projectsDir = path.join(process.env.HOME || os.homedir(), ".claude", "projects"); if (!fs.existsSync(projectsDir)) { throw new Error(`Claude projects directory not found: ${projectsDir}`); } const candidates = fs.readdirSync(projectsDir, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => path.join(projectsDir, entry.name, `${sessionId}.jsonl`)) .filter((candidate) => fs.existsSync(candidate)); if (candidates.length !== 1) { throw new Error( candidates.length === 0 ? `Claude session file not found for ID: ${sessionId}` : `Multiple Claude session files found for ID: ${sessionId}`, ); } const sessionFile = candidates[0]; const fd = fs.openSync(sessionFile, "r"); const maxBytes = 4 * 1024 * 1024; const buffer = Buffer.alloc(maxBytes); let content: string; try { const bytesRead = fs.readSync(fd, buffer, 0, maxBytes, 0); content = buffer.toString("utf8", 0, bytesRead); } finally { fs.closeSync(fd); } let repoMatches = false; let sessionMatches = false; for (const line of content.split("\n")) { if (!line.trim()) continue; try { const entry = JSON.parse(line) as Record; if (entry.sessionId === sessionId) sessionMatches = true; if (entry.cwd === repoRoot) repoMatches = true; } catch { // Ignore a trailing partial line at the read boundary. } } if (!sessionMatches || !repoMatches) { throw new Error(`Claude session ${sessionId} does not belong to repository ${repoRoot}`); } return { sessionFile, content }; } function extractClaudeFailureReason(output: string): string | undefined { for (const line of output.split("\n")) { const trimmed = line.trim(); if (!trimmed.startsWith("{")) continue; try { const parsed = JSON.parse(trimmed) as Record; if (Array.isArray(parsed.errors)) { const errors = parsed.errors.filter((error): error is string => typeof error === "string" && error.trim().length > 0); if (errors.length > 0) return errors.join("; "); } if (parsed.is_error === true && typeof parsed.result === "string" && parsed.result.trim()) { return parsed.result.trim(); } // Handle stream-json error format: {"type":"error","error":"Rate limit exceeded"} if (parsed.type === "error" && typeof parsed.error === "string" && parsed.error.trim()) { return parsed.error.trim(); } } catch { // Ignore non-JSON output lines. } } return undefined; } class ClaudeAdapter implements ExecutorAdapter { readonly kind: ExecutorKind = "claude"; async probe(config: ExecutorConfig = {}): Promise { try { const res = spawnSync(resolveExecutable(config, "claude"), ["--version"], { stdio: "ignore", env: executorEnv(), }); return res.status === 0 && !res.error; } catch { return false; } } async start(opts: ExecutorStartOpts): Promise { const bin = resolveExecutable(opts.config, "claude"); const sessionId = randomUUID(); const prompt = buildStartPrompt(opts); const args: string[] = [ "-p", "--verbose", "--safe-mode", "--permission-mode", "bypassPermissions", "--output-format", "stream-json", "--session-id", sessionId, ...(opts.config.model ? ["--model", opts.config.model] : []), prompt, ]; const startMs = Date.now(); const logFile = opts.logFilePath; const attemptIndex = deriveAttemptIndex(logFile); const sidecarTimer = startProgressSidecar(opts.runDir, "claude", opts.cycleIndex, attemptIndex, logFile); let lineBuffer = ""; const child = await spawnStreamingChild(bin, args, { cwd: opts.repoRoot, env: executorEnv(), signal: opts.signal, processGroup: true, logFilePath: logFile, onProgress: ({ pid, bytesLogged }) => { opts.onActivity?.(); updateSidecar(opts.runDir, { pid, logBytes: bytesLogged, lastActivityAt: nowIso() }); }, onStdoutData: (chunk: string) => { lineBuffer += chunk; while (true) { const nl = lineBuffer.indexOf("\n"); if (nl < 0) break; const line = lineBuffer.slice(0, nl); lineBuffer = lineBuffer.slice(nl + 1); const activity = deriveClaudeActivity(line); if (activity) { // Only surface tool actions in activitySummary, not result/error const isToolActivity = !activity.startsWith("Result:") && !activity.startsWith("Error:"); if (isToolActivity) { updateSidecar(opts.runDir, { activitySummary: activity, lastActivityAt: nowIso() }); } else { updateSidecar(opts.runDir, { lastActivityAt: nowIso() }); } } } }, }); const claudeFailed = child.status !== 0 || !!child.error; stopProgressSidecar(opts.runDir, sidecarTimer, claudeFailed); const durationMs = Date.now() - startMs; const capturedId = extractClaudeSessionId(child.stdout) ?? sessionId; const handle: ClaudeSessionHandle = { kind: "claude", sessionId: capturedId }; const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : ""); const failureReason = child.error?.message ?? extractClaudeFailureReason(child.stdout) ?? extractClaudeFailureReason(child.stderr); return { exitCode: child.status, report, durationMs, sessionHandle: handle, failed: child.status !== 0 || !!child.error, ...(failureReason ? { failureReason } : {}), }; } async resume(opts: ExecutorResumeOpts): Promise { const handle = opts.handle as ClaudeSessionHandle; const bin = resolveExecutable(opts.config, "claude"); const prompt = buildFixPrompt(opts); const args: string[] = [ "-p", "--verbose", "--safe-mode", "--permission-mode", "bypassPermissions", "--output-format", "stream-json", "--resume", handle.sessionId, ...(opts.config.model ? ["--model", opts.config.model] : []), prompt, ]; const startMs = Date.now(); const logFile = opts.logFilePath; const attemptIndex = deriveAttemptIndex(logFile); const sidecarTimer = startProgressSidecar(opts.runDir, "claude", opts.cycleIndex, attemptIndex, logFile); let lineBuffer = ""; const child = await spawnStreamingChild(bin, args, { cwd: opts.repoRoot, env: executorEnv(), signal: opts.signal, processGroup: true, logFilePath: logFile, onProgress: ({ pid, bytesLogged }) => { opts.onActivity?.(); updateSidecar(opts.runDir, { pid, logBytes: bytesLogged, lastActivityAt: nowIso() }); }, onStdoutData: (chunk: string) => { lineBuffer += chunk; while (true) { const nl = lineBuffer.indexOf("\n"); if (nl < 0) break; const line = lineBuffer.slice(0, nl); lineBuffer = lineBuffer.slice(nl + 1); const activity = deriveClaudeActivity(line); if (activity) { // Only surface tool actions in activitySummary, not result/error const isToolActivity = !activity.startsWith("Result:") && !activity.startsWith("Error:"); if (isToolActivity) { updateSidecar(opts.runDir, { activitySummary: activity, lastActivityAt: nowIso() }); } else { updateSidecar(opts.runDir, { lastActivityAt: nowIso() }); } } } }, }); const claudeFailed = child.status !== 0 || !!child.error; stopProgressSidecar(opts.runDir, sidecarTimer, claudeFailed); const durationMs = Date.now() - startMs; const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : ""); const failureReason = child.error?.message ?? extractClaudeFailureReason(child.stdout) ?? extractClaudeFailureReason(child.stderr); return { exitCode: child.status, report, durationMs, sessionHandle: handle, failed: child.status !== 0 || !!child.error, ...(failureReason ? { failureReason } : {}), }; } async cancel(_handle: ExecutorSessionHandle): Promise { // Claude Code: no explicit cancel API in v1. } } // --------------------------------------------------------------------------- // Agy adapter // --------------------------------------------------------------------------- function extractAgyConversationId(output: string): string | undefined { // Try to parse conversation_id from JSON output lines for (const line of output.split("\n")) { const trimmed = line.trim(); if (!trimmed.startsWith("{")) continue; try { const parsed = JSON.parse(trimmed) as Record; if (typeof parsed.conversation_id === "string") return parsed.conversation_id; if (typeof parsed.conversationId === "string") return parsed.conversationId; } catch { /* skip */ } } return undefined; } function extractAgyConversationIdFromLog(logFile: string): string | undefined { try { const log = fs.readFileSync(logFile, "utf8"); const matches = [ ...log.matchAll(/Print mode: conversation=([0-9a-f-]{36})\b/gi), ...log.matchAll(/Created conversation ([0-9a-f-]{36})\b/gi), ]; return matches.at(-1)?.[1]; } catch { return undefined; } } function agyConversationCacheFile(): string { const homeDir = process.env.HOME || os.homedir(); return path.join(homeDir, ".gemini", "antigravity-cli", "cache", "last_conversations.json"); } function getAgyWorkspaceConversationId(repoRoot: string): string | undefined { try { const parsed = JSON.parse(fs.readFileSync(agyConversationCacheFile(), "utf8")) as Record; const id = parsed[repoRoot]; return typeof id === "string" && /^[0-9a-f-]{36}$/i.test(id) ? id : undefined; } catch { return undefined; } } function extractAgyFailureReason(stdout: string, stderr: string): string | undefined { const output = `${stdout}\n${stderr}`.trim(); if (!output) return "Agy exited without producing output."; const diagnostic = output .split("\n") .map((line) => line.trim()) .find((line) => /no output produced/i.test(line) || /headless mode cannot prompt/i.test(line) || /permission .*auto-denied/i.test(line) || /tool required .* permission/i.test(line)); return diagnostic; } function agyCommonArgs(config: ExecutorConfig, timeoutSeconds?: number): string[] { return [ "--dangerously-skip-permissions", "--mode", "accept-edits", ...(timeoutSeconds ? ["--print-timeout", "24h"] : []), ...(config.agent ? ["--agent", config.agent] : []), ...(config.model ? ["--model", config.model] : []), ]; } class AgyAdapter implements ExecutorAdapter { readonly kind: ExecutorKind = "agy"; async probe(config: ExecutorConfig = {}): Promise { try { const res = spawnSync(resolveExecutable(config, "agy"), ["--version"], { stdio: "ignore", env: executorEnv(), }); return res.status === 0 && !res.error; } catch { return false; } } async start(opts: ExecutorStartOpts): Promise { const bin = resolveExecutable(opts.config, "agy"); const prompt = buildStartPrompt(opts); const agyLogFile = path.join(opts.runDir, `cycle-${opts.cycleIndex}-agy.log`); const previousWorkspaceConversationId = getAgyWorkspaceConversationId(opts.repoRoot); const args: string[] = [ ...agyCommonArgs(opts.config, opts.timeoutSeconds), "--log-file", agyLogFile, "--print", prompt, ]; const startMs = Date.now(); const logFile = opts.logFilePath; const attemptIndex = deriveAttemptIndex(logFile); const sidecarTimer = startProgressSidecar(opts.runDir, "agy", opts.cycleIndex, attemptIndex, logFile); const child = await spawnStreamingChild(bin, args, { cwd: opts.repoRoot, env: executorEnv(), signal: opts.signal, processGroup: true, logFilePath: logFile, onProgress: ({ pid, bytesLogged }) => { opts.onActivity?.(); updateSidecar(opts.runDir, { pid, logBytes: bytesLogged, lastActivityAt: nowIso() }); }, }); const agyFailed = child.status !== 0 || !!child.error || !!extractAgyFailureReason(child.stdout, child.stderr); stopProgressSidecar(opts.runDir, sidecarTimer, agyFailed); const durationMs = Date.now() - startMs; const conversationId = extractAgyConversationId(child.stdout) ?? extractAgyConversationIdFromLog(agyLogFile) ?? (() => { const currentId = getAgyWorkspaceConversationId(opts.repoRoot); return currentId !== previousWorkspaceConversationId ? currentId : undefined; })(); const handle: AgySessionHandle = { kind: "agy", ...(conversationId ? { conversationId } : {}), degraded: !conversationId, }; const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : ""); const failureReason = child.error?.message ?? extractAgyFailureReason(child.stdout, child.stderr); return { exitCode: child.status, report, durationMs, sessionHandle: handle, failed: child.status !== 0 || !!failureReason, ...(failureReason ? { failureReason } : {}), }; } async resume(opts: ExecutorResumeOpts): Promise { const handle = opts.handle as AgySessionHandle; const bin = resolveExecutable(opts.config, "agy"); const prompt = buildFixPrompt(opts); const agyLogFile = path.join(opts.runDir, `cycle-${opts.cycleIndex}-agy.log`); const conversationId = handle.conversationId ?? getAgyWorkspaceConversationId(opts.repoRoot); let args: string[]; if (conversationId) { args = [ ...agyCommonArgs(opts.config, opts.timeoutSeconds), "--conversation", conversationId, "--log-file", agyLogFile, "--print", prompt, ]; } else { // Degraded mode: use --continue (best effort, may pick wrong session in concurrent setups) args = [ ...agyCommonArgs(opts.config, opts.timeoutSeconds), "--continue", "--log-file", agyLogFile, "--print", prompt, ]; } const startMs = Date.now(); const logFile = opts.logFilePath; const attemptIndex = deriveAttemptIndex(logFile); const sidecarTimer = startProgressSidecar(opts.runDir, "agy", opts.cycleIndex, attemptIndex, logFile); const child = await spawnStreamingChild(bin, args, { cwd: opts.repoRoot, env: executorEnv(), signal: opts.signal, processGroup: true, logFilePath: logFile, onProgress: ({ pid, bytesLogged }) => { opts.onActivity?.(); updateSidecar(opts.runDir, { pid, logBytes: bytesLogged, lastActivityAt: nowIso() }); }, }); const agyFailed = child.status !== 0 || !!child.error || !!extractAgyFailureReason(child.stdout, child.stderr); stopProgressSidecar(opts.runDir, sidecarTimer, agyFailed); const durationMs = Date.now() - startMs; const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : ""); const capturedConversationId = conversationId ?? extractAgyConversationId(child.stdout) ?? extractAgyConversationIdFromLog(agyLogFile); const updatedHandle: AgySessionHandle = { kind: "agy", ...(capturedConversationId ? { conversationId: capturedConversationId } : {}), degraded: !capturedConversationId, }; const failureReason = child.error?.message ?? extractAgyFailureReason(child.stdout, child.stderr); return { exitCode: child.status, report, durationMs, sessionHandle: updatedHandle, failed: child.status !== 0 || !!failureReason, ...(failureReason ? { failureReason } : {}), }; } async cancel(_handle: ExecutorSessionHandle): Promise { // Agy: no explicit cancel API in v1. } } // --------------------------------------------------------------------------- // Factory // --------------------------------------------------------------------------- export function createExecutorAdapter(kind: ExecutorKind): ExecutorAdapter { switch (kind) { case "reasonix": return new ReasonixAdapter(); case "claude": return new ClaudeAdapter(); case "agy": return new AgyAdapter(); default: { const _exhaustive: never = kind; throw new Error(`Unknown executor kind: ${_exhaustive}`); } } } /** Check that the executor binary is available and throw a descriptive error if not. */ export async function probeExecutor(kind: ExecutorKind, config: ExecutorConfig = {}): Promise { const adapter = createExecutorAdapter(kind); const available = await adapter.probe(config); if (!available) { const bin = config.binary || kind; throw new Error( `Executor '${kind}' is not available. ` + `Make sure '${bin}' is installed and on PATH. ` + `You can override the binary path with the 'binary' field in agent-workflow.json.`, ); } } /** * Derive a concise human-readable activity summary from a Claude Code stream-json line. * Exported for testing. Returns undefined when the line does not describe observable activity. */ export function parseClaudeActivityLine(line: string): string | undefined { return deriveClaudeActivity(line); }