import { createInteractiveCommandDeps, createPromptDeps, type InteractiveCommandDeps, type PromptDeps, } from "@/commands/interactive-deps"; import { parseCommand } from "@/lib/cli-parser"; import { listProjectPendingHandoffs } from "@/lib/handoff"; import { formatHandoffNote } from "@/lib/handoff-note"; import { computeSessionStats, getProjectName, type LogSession } from "@/lib/logger"; import { type ActiveSession, listAllActiveSessions, listProjectActiveSessions, readSessionState, removeAllSessionStates, removeSessionState, updateSessionState, } from "@/lib/session-state"; import { stopActiveSession } from "@/lib/stop-session"; import { killSession, listRalphSessions, sendInterrupt, sessionExists } from "@/lib/tmux"; import type { HandoffStatus } from "@/lib/types"; interface StopOptions { all: boolean; session?: string; } type StopDeps = InteractiveCommandDeps & PromptDeps & { cwd: () => string; computeSessionStats: typeof computeSessionStats; listProjectPendingHandoffs: typeof listProjectPendingHandoffs; listAllActiveSessions: typeof listAllActiveSessions; listProjectActiveSessions: typeof listProjectActiveSessions; removeAllSessionStates: typeof removeAllSessionStates; stopActiveSession: typeof stopActiveSession; updateSessionState: typeof updateSessionState; sendInterrupt: typeof sendInterrupt; readSessionState: typeof readSessionState; sessionExists: typeof sessionExists; killSession: typeof killSession; removeSessionState: typeof removeSessionState; listRalphSessions: typeof listRalphSessions; sleep: (ms: number) => Promise; }; const DEFAULT_STOP_DEPS: StopDeps = { ...createInteractiveCommandDeps(), ...createPromptDeps(), cwd: () => process.cwd(), computeSessionStats, listProjectPendingHandoffs, listAllActiveSessions, listProjectActiveSessions, removeAllSessionStates, stopActiveSession, updateSessionState, sendInterrupt, readSessionState, sessionExists, killSession, removeSessionState, listRalphSessions, sleep: (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }), }; type ResolvedStopHandoff = { handoffStatus: Extract; handoffId?: string; commitSha?: string; }; function getCurrentProjectSessions( sessions: ActiveSession[], projectPath: string ): ActiveSession[] { return sessions .filter((session) => session.projectPath === projectPath) .sort((left, right) => { if (left.startTime !== right.startTime) { return right.startTime - left.startTime; } return right.sessionId.localeCompare(left.sessionId); }); } function formatSessionSelectorLabel(session: ActiveSession): string { return `${session.sessionName} (${session.sessionId.slice(0, 8)})`; } function formatSessionSelectorHint(session: ActiveSession): string { return session.worktreeProjectPath ?? branchOrProjectHint(session); } function branchOrProjectHint(session: ActiveSession): string { return session.worktreeBranch ?? session.branch ?? session.projectPath; } function shellEscape(str: string): string { return `'${str.replace(/'/g, "'\\''")}'`; } function formatShellPath(path: string): string { return /[^A-Za-z0-9_./-]/u.test(path) ? shellEscape(path) : path; } function isReportedStopHandoffStatus( status: HandoffStatus | undefined ): status is Extract { return status === "applied-auto" || status === "pending-apply" || status === "apply-conflicted"; } function createLogSessionFromPath(session: ActiveSession): LogSession | null { if (!session.sessionPath) { return null; } return { path: session.sessionPath, name: session.sessionPath.split("/").at(-1) ?? session.sessionId, projectName: getProjectName(session.projectPath), timestamp: Bun.file(session.sessionPath).lastModified, }; } async function resolveStoppedSessionHandoff( session: ActiveSession, deps: StopDeps ): Promise { const logSession = createLogSessionFromPath(session); if (logSession) { try { const stats = await deps.computeSessionStats(logSession); if ( (!stats.sessionId || stats.sessionId === session.sessionId) && isReportedStopHandoffStatus(stats.handoffStatus) ) { return { handoffStatus: stats.handoffStatus, handoffId: stats.handoffId, commitSha: stats.commitSha, }; } } catch { // Fall back to pending handoff metadata when the session log is unavailable. } } try { const pendingHandoffs = await deps.listProjectPendingHandoffs(undefined, session.projectPath); const matchingHandoffs = pendingHandoffs.filter( (handoff) => handoff.sessionId === session.sessionId ); if (matchingHandoffs.length !== 1) { return null; } const pendingHandoff = matchingHandoffs[0]; if (!pendingHandoff) { return null; } return { handoffStatus: pendingHandoff.state, handoffId: pendingHandoff.handoffId, commitSha: pendingHandoff.commitSha, }; } catch { return null; } } function formatProjectScopedCommand( currentProjectPath: string, session: ActiveSession, handoffId: string, action: "apply" | "discard" ): string { const baseCommand = action === "discard" ? `rr prune --discard --session ${handoffId}` : `rr apply --session ${handoffId}`; if (session.projectPath === currentProjectPath) { return baseCommand; } return `cd ${formatShellPath(session.projectPath)} && ${baseCommand}`; } async function resolveStoppedSessionHandoffNote( session: ActiveSession, currentProjectPath: string, deps: StopDeps ): Promise { const handoff = await resolveStoppedSessionHandoff(session, deps); if (!handoff) { return null; } return formatHandoffNote({ handoffStatus: handoff.handoffStatus, commitSha: handoff.commitSha, applyCommand: handoff.handoffStatus === "pending-apply" && handoff.handoffId ? `Apply: ${formatProjectScopedCommand(currentProjectPath, session, handoff.handoffId, "apply")}` : undefined, discardCommand: handoff.handoffStatus === "pending-apply" && handoff.handoffId ? `Discard: ${formatProjectScopedCommand( currentProjectPath, session, handoff.handoffId, "discard" )}` : undefined, }); } async function stopSessionWithHandoff( session: ActiveSession, currentProjectPath: string, deps: StopDeps ): Promise { await deps.stopActiveSession(session, { updateSessionState: deps.updateSessionState, sendInterrupt: deps.sendInterrupt, readSessionState: deps.readSessionState, sessionExists: deps.sessionExists, killSession: deps.killSession, removeSessionState: deps.removeSessionState, sleep: deps.sleep, }); return await resolveStoppedSessionHandoffNote(session, currentProjectPath, deps); } function findSessionBySelector( sessions: ActiveSession[], selector: string ): { session: ActiveSession | null; error?: string } { const normalizedSelector = selector.trim(); if (normalizedSelector.length === 0) { return { session: null, error: "Session selector cannot be empty." }; } const exactMatches = sessions.filter( (session) => session.sessionId === normalizedSelector || session.sessionName === normalizedSelector ); if (exactMatches.length === 1) { return { session: exactMatches[0] ?? null }; } const prefixMatches = sessions.filter((session) => session.sessionId.startsWith(normalizedSelector) ); if (prefixMatches.length === 1) { return { session: prefixMatches[0] ?? null }; } if (prefixMatches.length > 1) { return { session: null, error: `Session selector "${normalizedSelector}" is ambiguous for the current project.`, }; } return { session: null, error: `No active review session matches "${normalizedSelector}" in the current project.`, }; } async function chooseProjectSession( projectSessions: ActiveSession[], deps: StopDeps ): Promise { const selection = await deps.select({ message: "Choose a review session to stop", options: projectSessions.map((session) => ({ value: session.sessionId, label: formatSessionSelectorLabel(session), hint: formatSessionSelectorHint(session), })), }); if (deps.isCancel(selection)) { return null; } return projectSessions.find((session) => session.sessionId === selection) ?? null; } async function stopSession(session: ActiveSession, deps: StopDeps): Promise { deps.logStep(`Stopping session: ${session.sessionName}`); const handoffNote = await stopSessionWithHandoff(session, deps.cwd(), deps); deps.logSuccess("Review stopped."); if (handoffNote) { deps.logMessage(`Handoff:\n${handoffNote}`); } } async function stopAllSessions(deps: StopDeps): Promise { const orphanStopGracePeriod = 1_000; const currentProjectPath = deps.cwd(); const activeSessions = await deps.listAllActiveSessions(); const tmuxSessions = await deps.listRalphSessions(); const sessionNames = [ ...new Set([...tmuxSessions, ...activeSessions.map((session) => session.sessionName)]), ]; if (sessionNames.length === 0) { deps.logInfo("No active review sessions."); await deps.removeAllSessionStates(); return; } deps.logStep(`Stopping ${sessionNames.length} session(s)...`); const activeSessionsByName = new Map( activeSessions.map((session) => [session.sessionName, session] as const) ); const orphanSessionNames = sessionNames.filter( (sessionName) => !activeSessionsByName.has(sessionName) ); const activeStopPromise = Promise.all( activeSessions.map(async (session) => ({ handoffNote: await stopSessionWithHandoff(session, currentProjectPath, deps), })) ); for (const sessionName of orphanSessionNames) { await deps.sendInterrupt(sessionName); } const stoppedActiveSessions = await activeStopPromise; if (orphanSessionNames.length > 0) { await deps.sleep(orphanStopGracePeriod); } for (const sessionName of orphanSessionNames) { await deps.killSession(sessionName); } for (const sessionName of sessionNames) { deps.logMessage(` Stopped: ${sessionName}`); } for (const stoppedSession of stoppedActiveSessions) { if (stoppedSession.handoffNote) { deps.logMessage(`Handoff:\n${stoppedSession.handoffNote}`); } } await deps.removeAllSessionStates(); deps.logSuccess(`Stopped ${sessionNames.length} session(s).`); } async function stopCurrentProjectSession( projectPath: string, selector: string | undefined, deps: StopDeps ): Promise { const projectSessions = getCurrentProjectSessions( await deps.listProjectActiveSessions(undefined, projectPath), projectPath ); if (selector) { const match = findSessionBySelector(projectSessions, selector); if (!match.session) { deps.logError(match.error ?? "No matching session found."); deps.exit(1); return; } await stopSession(match.session, deps); return; } if (projectSessions.length === 0) { deps.logInfo("No active review session for current working directory."); const allSessions = await deps.listAllActiveSessions(); if (allSessions.length > 0) { deps.logMessage(`\nThere are ${allSessions.length} other session(s) running.`); deps.logMessage( 'Use "rr stop --all" to stop all running review sessions, or "rr" to see details.' ); } return; } if (projectSessions.length === 1) { const onlySession = projectSessions[0]; if (onlySession) { await stopSession(onlySession, deps); } return; } if (!deps.isTTY()) { deps.logError( "Multiple review sessions are running for this project. Re-run with --session ." ); deps.exit(1); return; } const selectedSession = await chooseProjectSession(projectSessions, deps); if (!selectedSession) { return; } await stopSession(selectedSession, deps); } export async function runStop(args: string[], deps: Partial = {}): Promise { const stopDeps = { ...DEFAULT_STOP_DEPS, ...deps }; const stopDef = stopDeps.getCommandDef("stop"); if (!stopDef) { stopDeps.logError("Internal error: stop command definition not found"); stopDeps.exit(1); return; } let options: StopOptions; try { const { values } = parseCommand(stopDef, args); options = values; } catch (error) { stopDeps.logError(`${error}`); stopDeps.exit(1); return; } if (options.all) { await stopAllSessions(stopDeps); return; } await stopCurrentProjectSession(stopDeps.cwd(), options.session, stopDeps); } export type { StopDeps };