import * as p from "@clack/prompts"; import { getCommandDef } from "@/cli"; import { getAgentDisplayInfo } from "@/lib/agents/display"; import { parseCommand } from "@/lib/cli-parser"; import { loadEffectiveConfig } from "@/lib/config"; import { collectIssueItems, runDiagnostics } from "@/lib/diagnostics"; import { getTmuxInstallHint } from "@/lib/diagnostics/tmux-install"; import type { DiagnosticItem, DiagnosticsReport } from "@/lib/diagnostics/types"; import { type CycleResult, runReviewCycle } from "@/lib/engine"; import { formatReviewType } from "@/lib/format"; import { formatHandoffNote } from "@/lib/handoff-note"; import { createLogSession, getGitBranch } from "@/lib/logger"; import { playCompletionSound, resolveSoundEnabled, type SoundOverride } from "@/lib/notify/sound"; import { CLI_PATH } from "@/lib/paths"; import { formatPriorityList, getRepeatedPriorityFlagError, parsePriorityList, } from "@/lib/priority-list"; import { runFixSession } from "@/lib/review-workflow/remediation/run-fix-session"; import type { FixSessionResult } from "@/lib/review-workflow/remediation/types"; import { mapSessionStatusToFinalStatus } from "@/lib/review-workflow/session-status"; import { createSessionId, createSessionState, HEARTBEAT_INTERVAL_MS, readSessionState, removeSessionState, touchSessionHeartbeat, updateSessionState, } from "@/lib/session-state"; import { createSession, generateSessionName, isInsideTmux, isTmuxInstalled } from "@/lib/tmux"; import type { AgentType, Config, Priority, ReviewOptions } from "@/lib/types"; type IntervalHandle = ReturnType; export interface RunOptions { max?: number; force?: boolean; auto?: boolean; priority?: string; base?: string; uncommitted?: boolean; commit?: string; sound?: boolean; "no-sound"?: boolean; } function hasRepeatedPriorityFlag(args: string[]): boolean { let count = 0; for (const arg of args) { if (arg === "--priority" || arg.startsWith("--priority=")) { count += 1; } } return count > 1; } export function classifyRunCompletion(result: CycleResult): "success" | "warning" | "error" { if (result.success) { return "success"; } if (result.finalStatus === "completed" || result.finalStatus === "interrupted") { return "warning"; } return "error"; } function shellEscape(str: string): string { return `'${str.replace(/'/g, "'\\''")}'`; } export function parseSoundOverride(value: string | undefined): SoundOverride | undefined { if (value === "on" || value === "off") { return value; } return undefined; } export function resolveRunSoundOverride(options: RunOptions): SoundOverride | undefined { if (options.sound && options["no-sound"]) { throw new Error("Cannot use --sound and --no-sound together"); } if (options.sound) { return "on"; } if (options["no-sound"]) { return "off"; } return undefined; } export function formatRunAgentsNote(config: Config, reviewOptions: ReviewOptions): string { const reviewer = getAgentDisplayInfo(config.reviewer); const fixer = getAgentDisplayInfo(config.fixer); const lines = [ `Reviewer: ${reviewer.agentName} (${reviewer.modelName}, reasoning: ${reviewer.reasoning})`, `Fixer: ${fixer.agentName} (${fixer.modelName}, reasoning: ${fixer.reasoning}) (used by rr fix)`, ]; lines.push(`Review: ${formatReviewType(reviewOptions)}`); return lines.join("\n"); } function hasAutoFixPriorityMatches(result: CycleResult, priorities: Priority[]): boolean { return ( result.artifact?.findings.some((finding) => priorities.includes(finding.priority)) ?? false ); } function mapFixSessionResult(baseResult: CycleResult, fixResult: FixSessionResult): CycleResult { return { ...baseResult, success: fixResult.reviewOutcome === "fixed-selected", finalStatus: mapSessionStatusToFinalStatus(fixResult.sessionStatus), reason: fixResult.reason, phase: fixResult.phase, sessionStatus: fixResult.sessionStatus, reviewOutcome: fixResult.reviewOutcome, retainedWorktree: fixResult.retainedWorktree, handoffStatus: fixResult.handoffStatus, handoffId: fixResult.handoffId, handoffUpdatedAt: fixResult.handoffUpdatedAt, commitSha: fixResult.commitSha, artifact: fixResult.artifact ?? baseResult.artifact, }; } async function pushRunSessionStateUpdate( runtime: RunRuntime, projectPath: string, sessionId: string, updates: Parameters[3] ): Promise { await runtime.sessionState .updateSessionState(undefined, projectPath, sessionId, updates, { expectedSessionId: sessionId, }) .catch(() => {}); } export function getDynamicProbeAgents(config: Config | null): AgentType[] { if (!config) { return []; } const probeAgents = new Set(); const settings: (Config["reviewer"] | Config["fixer"] | undefined)[] = [ config.reviewer, config.fixer, ]; for (const entry of settings) { const agent = entry?.agent; if (agent === "droid" || agent === "opencode" || agent === "pi") { probeAgents.add(agent); } } return probeAgents.size > 0 ? [...probeAgents] : []; } export interface RunRuntime { prompt: { log: { error: (message: string) => void; warn: (message: string) => void; success: (message: string) => void; message: (message: string) => void; }; note: (message: string, title: string) => void; spinner: () => { start: (message: string) => void; stop: (message: string) => void; }; }; getCommandDef: typeof getCommandDef; parseCommand: typeof parseCommand; loadConfig: typeof loadEffectiveConfig; runDiagnostics: typeof runDiagnostics; collectIssueItems: typeof collectIssueItems; getTmuxInstallHint: typeof getTmuxInstallHint; runReviewCycle: typeof runReviewCycle; runFixSession: typeof runFixSession; createLogSession: typeof createLogSession; sessionState: { createSessionState: typeof createSessionState; createSessionId: typeof createSessionId; readSessionState: typeof readSessionState; removeSessionState: typeof removeSessionState; touchSessionHeartbeat: typeof touchSessionHeartbeat; updateSessionState: typeof updateSessionState; }; getGitBranch: typeof getGitBranch; sound: { playCompletionSound: typeof playCompletionSound; resolveSoundEnabled: typeof resolveSoundEnabled; }; tmux: { createSession: typeof createSession; generateSessionName: typeof generateSessionName; isInsideTmux: typeof isInsideTmux; isTmuxInstalled: typeof isTmuxInstalled; }; process: { cwd: () => string; env: Record; pid: number; execPath: string; stdoutIsTTY: boolean; exit: (code: number) => void; }; timer: { now: () => number; setInterval: (handler: () => void, ms: number) => IntervalHandle; clearInterval: (handle: IntervalHandle) => void; }; openSessionPanel: (projectPath: string, branch?: string) => Promise; consoleLog: (...args: unknown[]) => void; } interface RunPromptOverrides { log?: Partial; note?: RunRuntime["prompt"]["note"]; spinner?: RunRuntime["prompt"]["spinner"]; } export interface RunRuntimeOverrides extends Partial< Omit > { prompt?: RunPromptOverrides; sessionState?: Partial; sound?: Partial; tmux?: Partial; process?: Partial; timer?: Partial; } function createRunCommandContext(overrides: RunRuntimeOverrides): { runtime: RunRuntime; projectPath: string; } { const runtime = createRunRuntime(overrides); return { runtime, projectPath: runtime.process.env.RR_PROJECT_PATH || runtime.process.cwd(), }; } function logPreflightItems( runtime: RunRuntime, items: DiagnosticItem[], heading: string, severity: "error" | "warn" ): void { runtime.prompt.log[severity](heading); for (const item of items) { runtime.prompt.log.message(` ${item.summary}`); item.remediation.forEach((remediation) => { runtime.prompt.log.message(` -> ${remediation}`); }); } } export function createRunRuntime(overrides: RunRuntimeOverrides = {}): RunRuntime { const defaults: RunRuntime = { prompt: { log: { error: p.log.error, warn: p.log.warn, success: p.log.success, message: p.log.message, }, note: p.note, spinner: p.spinner, }, getCommandDef, parseCommand, loadConfig: loadEffectiveConfig, runDiagnostics, collectIssueItems, getTmuxInstallHint, runReviewCycle, runFixSession, createLogSession, sessionState: { createSessionState, createSessionId, readSessionState, removeSessionState, touchSessionHeartbeat, updateSessionState, }, getGitBranch, sound: { playCompletionSound, resolveSoundEnabled, }, tmux: { createSession, generateSessionName, isInsideTmux, isTmuxInstalled, }, process: { cwd: () => process.cwd(), env: process.env, pid: process.pid, execPath: process.execPath, stdoutIsTTY: process.stdout.isTTY === true, exit: (code: number) => { process.exit(code); }, }, timer: { now: () => Date.now(), setInterval: (handler, ms) => setInterval(handler, ms), clearInterval: (handle) => clearInterval(handle), }, openSessionPanel: async (projectPath, branch) => { const { renderDashboard } = await import("@/lib/tui/index"); await renderDashboard({ projectPath, branch }); }, consoleLog: (...args: unknown[]) => console.log(...args), }; return { ...defaults, ...overrides, prompt: { log: { ...defaults.prompt.log, ...(overrides.prompt?.log ?? {}), }, note: overrides.prompt?.note ?? defaults.prompt.note, spinner: overrides.prompt?.spinner ?? defaults.prompt.spinner, }, sessionState: { ...defaults.sessionState, ...(overrides.sessionState ?? {}), }, sound: { ...defaults.sound, ...(overrides.sound ?? {}), }, tmux: { ...defaults.tmux, ...(overrides.tmux ?? {}), }, process: { ...defaults.process, ...(overrides.process ?? {}), }, timer: { ...defaults.timer, ...(overrides.timer ?? {}), }, }; } async function runInBackground( runtime: RunRuntime, projectPath: string, config: Config, maxIterations?: number, auto?: boolean, priorities?: Priority[], baseBranch?: string, commitSha?: string, customInstructions?: string, force?: boolean, soundOverride?: SoundOverride ): Promise { // Check tmux is installed if (!runtime.tmux.isTmuxInstalled()) { runtime.prompt.log.error( `tmux is not installed. Install with: ${runtime.getTmuxInstallHint()}` ); runtime.process.exit(1); return; } const branch = await runtime.getGitBranch(projectPath); const sessionName = runtime.tmux.generateSessionName(); const sessionId = runtime.sessionState.createSessionId(); const sessionPath = await runtime.createLogSession(undefined, projectPath, branch ?? undefined); await runtime.sessionState.createSessionState(undefined, projectPath, sessionName, { branch, sessionId, state: "pending", mode: "background", lastHeartbeat: runtime.timer.now(), sessionPath, }); const envParts = [ `RR_PROJECT_PATH=${shellEscape(projectPath)}`, `RR_GIT_BRANCH=${shellEscape(branch ?? "")}`, `RR_SESSION_ID=${shellEscape(sessionId)}`, `RR_SESSION_PATH=${shellEscape(sessionPath)}`, ]; if (baseBranch) { envParts.push(`RR_BASE_BRANCH=${shellEscape(baseBranch)}`); } if (commitSha) { envParts.push(`RR_COMMIT_SHA=${shellEscape(commitSha)}`); } if (customInstructions) { envParts.push(`RR_CUSTOM_PROMPT=${shellEscape(customInstructions)}`); } if (soundOverride) { envParts.push(`RR_SOUND_OVERRIDE=${shellEscape(soundOverride)}`); } const commandArgs: string[] = ["_run-foreground"]; if (maxIterations) { commandArgs.push("--max", String(maxIterations)); } if (force) { commandArgs.push("--force"); } if (auto) { commandArgs.push("--auto"); } if (priorities && priorities.length > 0) { commandArgs.push("--priority", formatPriorityList(priorities)); } const envVars = envParts.join(" "); const command = `${envVars} ${runtime.process.execPath} ${CLI_PATH} ${commandArgs.join(" ")}`; try { await runtime.tmux.createSession(sessionName, command); runtime.prompt.log.success(`Review started in background session: ${sessionName}`); const reviewOptions: ReviewOptions = { baseBranch, commitSha, customInstructions }; runtime.prompt.note(formatRunAgentsNote(config, reviewOptions), "Agents"); runtime.prompt.note( "rr - Open Interactive Mode\n" + "rr stop - Stop the review\n" + `rr fix --session ${sessionId} - Fix selected findings after review`, "Commands" ); } catch (error) { await runtime.sessionState.removeSessionState(undefined, projectPath, sessionId, { expectedSessionId: sessionId, }); runtime.prompt.log.error(`Failed to start background session: ${error}`); runtime.process.exit(1); } } export async function runForeground( args: string[] = [], overrides: RunRuntimeOverrides = {} ): Promise { const { runtime, projectPath } = createRunCommandContext(overrides); const config = await runtime.loadConfig(projectPath); if (!config) { runtime.prompt.log.error("Failed to load config"); runtime.process.exit(1); return; } const baseBranch = runtime.process.env.RR_BASE_BRANCH || undefined; const commitSha = runtime.process.env.RR_COMMIT_SHA || undefined; const customInstructions = runtime.process.env.RR_CUSTOM_PROMPT || undefined; const expectedSessionId = runtime.process.env.RR_SESSION_ID || undefined; const soundOverride = parseSoundOverride(runtime.process.env.RR_SOUND_OVERRIDE); let forceMaxIterations = false; let autoFixRequested = false; let autoFixPriorities: Priority[] | undefined; let completionState: "success" | "warning" | "error" = "error"; const soundEnabled = runtime.sound.resolveSoundEnabled(config, soundOverride); let cycleResult: CycleResult | undefined; let sessionId = expectedSessionId; // Parse --max option using the _run-foreground command def const foregroundDef = runtime.getCommandDef("_run-foreground"); if (foregroundDef) { try { if (hasRepeatedPriorityFlag(args)) { throw new Error(getRepeatedPriorityFlagError()); } const { values } = runtime.parseCommand<{ max?: number; force?: boolean; auto?: boolean; priority?: string; }>(foregroundDef, args); if (values.max !== undefined) { config.maxIterations = values.max; } forceMaxIterations = values.force === true; autoFixRequested = values.auto === true; autoFixPriorities = values.priority ? parsePriorityList(values.priority) : undefined; } catch { // Ignore parse errors for internal command } } const branch = await runtime.getGitBranch(projectPath); let sessionState = sessionId ? await runtime.sessionState.readSessionState(undefined, projectPath, sessionId) : null; const sessionPath = sessionState?.sessionPath || runtime.process.env.RR_SESSION_PATH || (await runtime.createLogSession(undefined, projectPath, branch ?? sessionState?.branch)); if (!sessionId) { sessionId = runtime.sessionState.createSessionId(); const sessionName = runtime.tmux.generateSessionName(); await runtime.sessionState.createSessionState(undefined, projectPath, sessionName, { branch, sessionId, state: "running", mode: "foreground", pid: runtime.process.pid, lastHeartbeat: runtime.timer.now(), sessionPath, }); sessionState = await runtime.sessionState.readSessionState(undefined, projectPath, sessionId); } await runtime.sessionState.updateSessionState( undefined, projectPath, sessionId, { pid: runtime.process.pid, state: "running", mode: "foreground", lastHeartbeat: runtime.timer.now(), currentPhase: "review", phase: "review", sessionStatus: "running", currentAgent: null, branch: branch ?? sessionState?.branch, sessionPath, }, { expectedSessionId: sessionId, } ); const heartbeatTimer = runtime.timer.setInterval(() => { void runtime.sessionState.touchSessionHeartbeat(undefined, projectPath, sessionId).catch(() => { // Ignore heartbeat failures if the session state was removed mid-shutdown. }); }, HEARTBEAT_INTERVAL_MS); try { cycleResult = await runtime.runReviewCycle( config, undefined, { baseBranch, commitSha, customInstructions, forceMaxIterations, }, { projectPath, sessionId, sessionPath, } ); if ( autoFixRequested && cycleResult.reviewOutcome === "findings-pending" && cycleResult.finalStatus === "completed" && sessionId ) { if (autoFixPriorities && !hasAutoFixPriorityMatches(cycleResult, autoFixPriorities)) { runtime.prompt.note( `No persisted findings matched priorities ${formatPriorityList(autoFixPriorities)}. Findings remain pending.`, "Auto Fix" ); } else { const fixResult = await runtime.runFixSession(config, { sessionId, selector: autoFixPriorities ? { priorities: autoFixPriorities } : { all: true }, isTTY: false, onProgress: async (updates) => { await pushRunSessionStateUpdate(runtime, projectPath, sessionId, updates); }, }); cycleResult = mapFixSessionResult(cycleResult, fixResult); } } completionState = classifyRunCompletion(cycleResult); runtime.consoleLog(`\n${"=".repeat(50)}`); if (completionState === "success") { if (cycleResult.reviewOutcome === "findings-pending") { runtime.prompt.log.success( `Review complete! Findings are ready for selection (${cycleResult.iterations} iterations)` ); } else if (cycleResult.reviewOutcome === "fixed-selected") { runtime.prompt.log.success(`${cycleResult.reason} (${cycleResult.iterations} iterations)`); } else if (cycleResult.reviewOutcome === "clean") { runtime.prompt.log.success( `Review complete! No actionable findings (${cycleResult.iterations} iterations)` ); } else { runtime.prompt.log.success(`Review cycle complete! (${cycleResult.iterations} iterations)`); } } else if (completionState === "warning") { runtime.prompt.log.warn( `Review cycle complete with warnings: ${cycleResult.reason} (${cycleResult.iterations} iterations)` ); } else { runtime.prompt.log.error( `Review stopped: ${cycleResult.reason} (${cycleResult.iterations} iterations)` ); } if (cycleResult.reviewOutcome === "findings-pending" && sessionId) { runtime.prompt.note( `Fix selected findings with:\nrr fix --session ${sessionId}`, "Next Step" ); } const handoffNote = formatHandoffNote({ handoffStatus: cycleResult.handoffStatus, commitSha: cycleResult.commitSha, applyCommand: cycleResult.handoffId ? `Apply: rr apply --session ${cycleResult.handoffId}` : undefined, discardCommand: cycleResult.handoffId ? `Discard: rr prune --discard --session ${cycleResult.handoffId}` : undefined, }); if (handoffNote) { runtime.prompt.note(handoffNote, "Handoff"); } else if (cycleResult.retainedWorktree) { runtime.prompt.note( `Retained worktree for review:\n` + `Path: ${cycleResult.retainedWorktree.worktreeProjectPath}\n` + `Branch: ${cycleResult.retainedWorktree.worktreeBranch}`, "Worktree" ); } runtime.consoleLog(`${"=".repeat(50)}\n`); } finally { runtime.timer.clearInterval(heartbeatTimer); if (cycleResult) { await runtime.sessionState.updateSessionState( undefined, projectPath, sessionId, { state: cycleResult.finalStatus, endTime: runtime.timer.now(), reason: cycleResult.reason, currentPhase: cycleResult.phase, phase: cycleResult.phase, sessionStatus: cycleResult.sessionStatus, currentAgent: null, lastHeartbeat: runtime.timer.now(), worktreeProjectPath: cycleResult.retainedWorktree?.worktreeProjectPath, worktreeBranch: cycleResult.retainedWorktree?.worktreeBranch, worktreeMergeReady: cycleResult.retainedWorktree?.mergeReady, worktreeCommitSha: cycleResult.retainedWorktree?.commitSha, artifactPath: cycleResult.artifactPath, reviewOutcome: cycleResult.reviewOutcome, handoffStatus: cycleResult.handoffStatus, handoffId: cycleResult.handoffId, handoffUpdatedAt: cycleResult.handoffUpdatedAt, commitSha: cycleResult.commitSha, }, { expectedSessionId: sessionId, } ); } else { await pushRunSessionStateUpdate(runtime, projectPath, sessionId, { state: "failed", endTime: runtime.timer.now(), reason: "Review exited unexpectedly", currentPhase: undefined, phase: undefined, sessionStatus: undefined, currentAgent: null, lastHeartbeat: runtime.timer.now(), worktreeProjectPath: undefined, worktreeBranch: undefined, worktreeMergeReady: undefined, worktreeCommitSha: undefined, artifactPath: undefined, reviewOutcome: undefined, handoffStatus: undefined, handoffId: undefined, handoffUpdatedAt: undefined, commitSha: undefined, }); } if (soundEnabled) { const soundResult = await runtime.sound.playCompletionSound(completionState); if (!soundResult.played && soundResult.reason) { runtime.prompt.log.warn(`Could not play completion sound: ${soundResult.reason}`); } } await runtime.sessionState.removeSessionState(undefined, projectPath, sessionId, { expectedSessionId: sessionId, }); } } export async function startReview( args: string[], overrides: RunRuntimeOverrides = {} ): Promise { const { runtime, projectPath } = createRunCommandContext(overrides); // Parse options using command definition const runDef = runtime.getCommandDef("run"); if (!runDef) { runtime.prompt.log.error("Internal error: run command definition not found"); runtime.process.exit(1); return; } let options: RunOptions; let customInstructions: string | undefined; try { if (hasRepeatedPriorityFlag(args)) { throw new Error(getRepeatedPriorityFlagError()); } const { values, positional } = runtime.parseCommand(runDef, args); options = values; customInstructions = positional[0]; } catch (error) { runtime.prompt.log.error(`${error}`); runtime.process.exit(1); return; } // Validate max iterations if provided if (options.max !== undefined && options.max <= 0) { runtime.prompt.log.error("--max must be a positive number"); runtime.process.exit(1); return; } let soundOverride: SoundOverride | undefined; try { soundOverride = resolveRunSoundOverride(options); } catch (error) { runtime.prompt.log.error(`${error}`); runtime.process.exit(1); return; } if (customInstructions !== undefined) { customInstructions = customInstructions.trim(); if (customInstructions.length === 0) { runtime.prompt.log.error("Custom review instructions cannot be empty"); runtime.process.exit(1); return; } } const loadedConfig = await runtime.loadConfig(projectPath); let priorities: Priority[] | undefined; if (options.priority !== undefined) { try { priorities = parsePriorityList(options.priority); } catch (error) { runtime.prompt.log.error(`${error}`); runtime.process.exit(1); return; } } if (priorities && !options.auto) { runtime.prompt.log.error("--priority requires --auto"); runtime.process.exit(1); return; } if (options.commit !== undefined) { options.commit = options.commit.trim(); if (options.commit.length === 0) { runtime.prompt.log.error("--commit cannot be empty"); runtime.process.exit(1); return; } } const hasExplicitMode = options.base !== undefined || options.uncommitted === true || options.commit !== undefined; if (!hasExplicitMode) { if (loadedConfig?.defaultReview) { if (loadedConfig.defaultReview.type === "base") { options.base = loadedConfig.defaultReview.branch; } } else if (customInstructions !== undefined) { runtime.prompt.log.error( "Custom review instructions require --base, --commit, or --uncommitted when no defaultReview is configured" ); runtime.process.exit(1); return; } // else: defaults to uncommitted behavior (no base flag) } if (options.base !== undefined) { options.base = options.base.trim(); if (options.base.length === 0) { runtime.prompt.log.error("--base cannot be empty"); runtime.process.exit(1); return; } } if (options.base !== undefined && options.commit !== undefined) { runtime.prompt.log.error("Cannot use --base and --commit together"); runtime.process.exit(1); return; } if (options.uncommitted && options.base !== undefined) { runtime.prompt.log.error("Cannot use --uncommitted and --base together"); runtime.process.exit(1); return; } if (options.uncommitted && options.commit !== undefined) { runtime.prompt.log.error("Cannot use --uncommitted and --commit together"); runtime.process.exit(1); return; } const preflightSpinner = runtime.prompt.spinner(); preflightSpinner.start("Running preflight checks..."); let diagnostics: DiagnosticsReport; try { diagnostics = await runtime.runDiagnostics("run", { projectPath, baseBranch: options.base, commitSha: options.commit, customInstructions, capabilityReviewOptions: { probeAgents: getDynamicProbeAgents(loadedConfig), }, }); } finally { preflightSpinner.stop("Preflight checks complete."); } const issues = runtime.collectIssueItems(diagnostics); const errors = issues.filter((item) => item.severity === "error"); const warnings = issues.filter((item) => item.severity === "warning"); if (errors.length > 0) { logPreflightItems(runtime, errors, "Cannot run review:", "error"); runtime.process.exit(1); return; } if (warnings.length > 0) { logPreflightItems(runtime, warnings, "Preflight warnings:", "warn"); } const config = diagnostics.config ?? (await runtime.loadConfig(projectPath)); if (!config) { runtime.prompt.log.error("Failed to load configuration"); runtime.process.exit(1); return; } // Check if inside tmux - warn about nesting if (runtime.tmux.isInsideTmux()) { runtime.prompt.log.warn("Running inside tmux session. Review will start in a nested session."); } await runInBackground( runtime, projectPath, config, options.max, options.auto, priorities, options.base, options.commit, customInstructions, options.force, soundOverride ); }