feat: add Ralph Wiggum mode (#484)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-01-06 21:13:50 -08:00
committed by GitHub
parent 807bd362f2
commit c96a5204eb
4 changed files with 597 additions and 4 deletions

View File

@@ -34,6 +34,11 @@ import { getModelDisplayName, getModelInfo } from "../agent/model";
import { SessionStats } from "../agent/stats";
import type { ApprovalContext } from "../permissions/analyzer";
import { type PermissionMode, permissionMode } from "../permissions/mode";
import {
DEFAULT_COMPLETION_PROMISE,
type RalphState,
ralphMode,
} from "../ralph/mode";
import { updateProjectSettings } from "../settings";
import { settingsManager } from "../settings-manager";
import { telemetry } from "../telemetry";
@@ -361,6 +366,100 @@ function getSkillUnloadReminder(): string {
return "";
}
// Parse /ralph or /yolo-ralph command arguments
function parseRalphArgs(input: string): {
prompt: string | null;
completionPromise: string | null | undefined; // undefined = use default, null = no promise
maxIterations: number;
} {
let rest = input.replace(/^\/(yolo-)?ralph\s*/, "");
// Extract --completion-promise "value" or --completion-promise 'value'
// Also handles --completion-promise "" or none for opt-out
let completionPromise: string | null | undefined;
const promiseMatch = rest.match(/--completion-promise\s+["']([^"']*)["']/);
if (promiseMatch) {
const val = promiseMatch[1] ?? "";
completionPromise = val === "" || val.toLowerCase() === "none" ? null : val;
rest = rest.replace(/--completion-promise\s+["'][^"']*["']\s*/, "");
}
// Extract --max-iterations N
const maxMatch = rest.match(/--max-iterations\s+(\d+)/);
const maxIterations = maxMatch?.[1] ? parseInt(maxMatch[1], 10) : 0;
rest = rest.replace(/--max-iterations\s+\d+\s*/, "");
// Remaining text is the inline prompt (may be quoted)
const prompt = rest.trim().replace(/^["']|["']$/g, "") || null;
return { prompt, completionPromise, maxIterations };
}
// Build Ralph first-turn reminder (when activating)
// Uses exact wording from claude-code/plugins/ralph-wiggum/scripts/setup-ralph-loop.sh
function buildRalphFirstTurnReminder(state: RalphState): string {
const iterInfo =
state.maxIterations > 0
? `${state.currentIteration}/${state.maxIterations}`
: `${state.currentIteration}`;
let reminder = `<system-reminder>
🔄 Ralph Wiggum mode activated (iteration ${iterInfo})
`;
if (state.completionPromise) {
reminder += `
═══════════════════════════════════════════════════════════
RALPH LOOP COMPLETION PROMISE
═══════════════════════════════════════════════════════════
To complete this loop, output this EXACT text:
<promise>${state.completionPromise}</promise>
STRICT REQUIREMENTS (DO NOT VIOLATE):
✓ Use <promise> XML tags EXACTLY as shown above
✓ The statement MUST be completely and unequivocally TRUE
✓ Do NOT output false statements to exit the loop
✓ Do NOT lie even if you think you should exit
IMPORTANT - Do not circumvent the loop:
Even if you believe you're stuck, the task is impossible,
or you've been running too long - you MUST NOT output a
false promise statement. The loop is designed to continue
until the promise is GENUINELY TRUE. Trust the process.
If the loop should stop, the promise statement will become
true naturally. Do not force it by lying.
═══════════════════════════════════════════════════════════
`;
} else {
reminder += `
No completion promise set - loop runs until --max-iterations or ESC/Shift+Tab to exit.
`;
}
reminder += `</system-reminder>`;
return reminder;
}
// Build Ralph continuation reminder (on subsequent iterations)
// Exact format from claude-code/plugins/ralph-wiggum/hooks/stop-hook.sh line 160
function buildRalphContinuationReminder(state: RalphState): string {
const iterInfo =
state.maxIterations > 0
? `${state.currentIteration}/${state.maxIterations}`
: `${state.currentIteration}`;
if (state.completionPromise) {
return `<system-reminder>
🔄 Ralph iteration ${iterInfo} | To stop: output <promise>${state.completionPromise}</promise> (ONLY when statement is TRUE - do not lie to exit!)
</system-reminder>`;
} else {
return `<system-reminder>
🔄 Ralph iteration ${iterInfo} | No completion promise set - loop runs infinitely
</system-reminder>`;
}
}
// Items that have finished rendering and no longer change
type StaticItem =
| {
@@ -544,6 +643,18 @@ export default function App({
[],
);
// Ralph Wiggum mode: config waiting for next message to capture as prompt
const [pendingRalphConfig, setPendingRalphConfig] = useState<{
completionPromise: string | null | undefined;
maxIterations: number;
isYolo: boolean;
} | null>(null);
// Track ralph mode for UI updates (singleton state doesn't trigger re-renders)
const [uiRalphActive, setUiRalphActive] = useState(
ralphMode.getState().isActive,
);
// Derive current approval from pending approvals and results
// This is the approval currently being shown to the user
const currentApproval = pendingApprovals[approvalResults.length];
@@ -1216,6 +1327,91 @@ export default function App({
initialInput: Array<MessageCreate | ApprovalCreate>,
options?: { allowReentry?: boolean; submissionGeneration?: number },
): Promise<void> => {
// Helper function for Ralph Wiggum mode continuation
// Defined here to have access to buffersRef, processConversation via closure
const handleRalphContinuation = () => {
const ralphState = ralphMode.getState();
// Extract LAST assistant message from buffers to check for promise
// (We only want to check the most recent response, not the entire transcript)
const lines = toLines(buffersRef.current);
const assistantLines = lines.filter(
(l): l is Line & { kind: "assistant" } => l.kind === "assistant",
);
const lastAssistantText =
assistantLines.length > 0
? (assistantLines[assistantLines.length - 1]?.text ?? "")
: "";
// Check for completion promise
if (ralphMode.checkForPromise(lastAssistantText)) {
// Promise matched - exit ralph mode
const wasYolo = ralphState.isYolo;
ralphMode.deactivate();
setUiRalphActive(false);
if (wasYolo) {
permissionMode.setMode("default");
}
// Add completion status to transcript
const statusId = uid("status");
buffersRef.current.byId.set(statusId, {
kind: "status",
id: statusId,
lines: [
`✅ Ralph loop complete: promise detected after ${ralphState.currentIteration} iteration(s)`,
],
});
buffersRef.current.order.push(statusId);
refreshDerived();
return;
}
// Check iteration limit
if (!ralphMode.shouldContinue()) {
// Max iterations reached - exit ralph mode
const wasYolo = ralphState.isYolo;
ralphMode.deactivate();
setUiRalphActive(false);
if (wasYolo) {
permissionMode.setMode("default");
}
// Add status to transcript
const statusId = uid("status");
buffersRef.current.byId.set(statusId, {
kind: "status",
id: statusId,
lines: [
`🛑 Ralph loop: Max iterations (${ralphState.maxIterations}) reached`,
],
});
buffersRef.current.order.push(statusId);
refreshDerived();
return;
}
// Continue loop - increment iteration and re-send prompt
ralphMode.incrementIteration();
const newState = ralphMode.getState();
const systemMsg = buildRalphContinuationReminder(newState);
// Re-inject original prompt with ralph reminder prepended
// Use setTimeout to avoid blocking the current render cycle
setTimeout(() => {
processConversation(
[
{
type: "message",
role: "user",
content: `${systemMsg}\n\n${newState.originalPrompt}`,
},
],
{ allowReentry: true },
);
}, 0);
};
// Copy so we can safely mutate for retry recovery flows
const currentInput = [...initialInput];
const allowReentry = options?.allowReentry ?? false;
@@ -1444,6 +1640,14 @@ export default function App({
queueSnapshotRef.current = [];
}
// === RALPH WIGGUM CONTINUATION CHECK ===
// Check if ralph mode is active and should auto-continue
// This happens at the very end, right before we'd release input
if (ralphMode.getState().isActive) {
handleRalphContinuation();
return;
}
return;
}
@@ -1478,6 +1682,23 @@ export default function App({
if (!EAGER_CANCEL) {
appendError(INTERRUPT_MESSAGE, true);
}
// In ralph mode, ESC interrupts but does NOT exit ralph
// User can type additional instructions, which will get ralph prefix prepended
// (Similar to how plan mode works)
if (ralphMode.getState().isActive) {
// Add status to transcript showing ralph is paused
const statusId = uid("status");
buffersRef.current.byId.set(statusId, {
kind: "status",
id: statusId,
lines: [
`⏸️ Ralph loop paused - type to continue or shift+tab to exit`,
],
});
buffersRef.current.order.push(statusId);
refreshDerived();
}
}
return;
@@ -2902,6 +3123,44 @@ export default function App({
// Note: userCancelledRef.current was already reset above before the queue check
// to ensure the dequeue effect isn't blocked by a stale cancellation flag.
// Handle pending Ralph config - activate ralph mode but let message flow through normal path
// This ensures session context and other reminders are included
// Track if we just activated so we can use first turn reminder vs continuation
let justActivatedRalph = false;
if (pendingRalphConfig && !msg.startsWith("/")) {
const { completionPromise, maxIterations, isYolo } = pendingRalphConfig;
ralphMode.activate(msg, completionPromise, maxIterations, isYolo);
setUiRalphActive(true);
setPendingRalphConfig(null);
justActivatedRalph = true;
if (isYolo) {
permissionMode.setMode("bypassPermissions");
}
const ralphState = ralphMode.getState();
// Add status to transcript
const statusId = uid("status");
const promiseDisplay = ralphState.completionPromise
? `"${ralphState.completionPromise.slice(0, 50)}${ralphState.completionPromise.length > 50 ? "..." : ""}"`
: "(none)";
buffersRef.current.byId.set(statusId, {
kind: "status",
id: statusId,
lines: [
`🔄 ${isYolo ? "yolo-ralph" : "ralph"} mode started (iter 1/${maxIterations || "∞"})`,
`Promise: ${promiseDisplay}`,
],
});
buffersRef.current.order.push(statusId);
refreshDerived();
// Don't return - let message flow through normal path which will:
// 1. Add session context reminder (if first message)
// 2. Add ralph mode reminder (since ralph is now active)
// 3. Add other reminders (skill unload, memory, etc.)
}
let aliasedMsg = msg;
if (msg === "exit" || msg === "quit") {
aliasedMsg = "/exit";
@@ -3227,6 +3486,75 @@ export default function App({
return { submitted: true };
}
// Special handling for /ralph and /yolo-ralph commands - Ralph Wiggum mode
if (trimmed.startsWith("/yolo-ralph") || trimmed.startsWith("/ralph")) {
const isYolo = trimmed.startsWith("/yolo-ralph");
const { prompt, completionPromise, maxIterations } =
parseRalphArgs(trimmed);
const cmdId = uid("cmd");
if (prompt) {
// Inline prompt - activate immediately and send
ralphMode.activate(
prompt,
completionPromise,
maxIterations,
isYolo,
);
setUiRalphActive(true);
if (isYolo) {
permissionMode.setMode("bypassPermissions");
}
const ralphState = ralphMode.getState();
const promiseDisplay = ralphState.completionPromise
? `"${ralphState.completionPromise.slice(0, 50)}${ralphState.completionPromise.length > 50 ? "..." : ""}"`
: "(none)";
buffersRef.current.byId.set(cmdId, {
kind: "command",
id: cmdId,
input: trimmed,
output: `🔄 ${isYolo ? "yolo-ralph" : "ralph"} mode activated (iter 1/${maxIterations || "∞"})\nPromise: ${promiseDisplay}`,
phase: "finished",
success: true,
});
buffersRef.current.order.push(cmdId);
refreshDerived();
// Send the prompt with ralph reminder prepended
const systemMsg = buildRalphFirstTurnReminder(ralphState);
processConversation([
{
type: "message",
role: "user",
content: `${systemMsg}\n\n${prompt}`,
},
]);
} else {
// No inline prompt - wait for next message
setPendingRalphConfig({ completionPromise, maxIterations, isYolo });
const defaultPromisePreview = DEFAULT_COMPLETION_PROMISE.slice(
0,
40,
);
buffersRef.current.byId.set(cmdId, {
kind: "command",
id: cmdId,
input: trimmed,
output: `🔄 ${isYolo ? "yolo-ralph" : "ralph"} mode ready (waiting for task)\nMax iterations: ${maxIterations || "unlimited"}\nPromise: ${completionPromise === null ? "(none)" : (completionPromise ?? `"${defaultPromisePreview}..." (default)`)}\n\nType your task to begin the loop.`,
phase: "finished",
success: true,
});
buffersRef.current.order.push(cmdId);
refreshDerived();
}
return { submitted: true };
}
// Special handling for /stream command - toggle and save
if (msg.trim() === "/stream") {
const newValue = !tokenStreamingEnabled;
@@ -4248,6 +4576,21 @@ ${gitContext}
// Prepend plan mode reminder if in plan mode
const planModeReminder = getPlanModeReminder();
// Prepend ralph mode reminder if in ralph mode
let ralphModeReminder = "";
if (ralphMode.getState().isActive) {
if (justActivatedRalph) {
// First turn - use full first turn reminder, don't increment (already at 1)
const ralphState = ralphMode.getState();
ralphModeReminder = `${buildRalphFirstTurnReminder(ralphState)}\n\n`;
} else {
// Continuation after ESC - increment iteration and use shorter reminder
ralphMode.incrementIteration();
const ralphState = ralphMode.getState();
ralphModeReminder = `${buildRalphContinuationReminder(ralphState)}\n\n`;
}
}
// Prepend skill unload reminder if skills are loaded (using cached flag)
const skillUnloadReminder = getSkillUnloadReminder();
@@ -4294,10 +4637,11 @@ DO NOT respond to these messages or otherwise consider them in your response unl
// Increment turn count for next iteration
turnCountRef.current += 1;
// Combine reminders with content (session context first, then plan mode, then skill unload, then bash commands, then memory reminder)
// Combine reminders with content (session context first, then plan mode, then ralph mode, then skill unload, then bash commands, then memory reminder)
const allReminders =
sessionContextReminder +
planModeReminder +
ralphModeReminder +
skillUnloadReminder +
bashCommandPrefix +
memoryReminderContent;
@@ -4747,6 +5091,7 @@ DO NOT respond to these messages or otherwise consider them in your response unl
isAgentBusy,
setStreaming,
setCommandRunning,
pendingRalphConfig,
],
);
@@ -5636,6 +5981,20 @@ DO NOT respond to these messages or otherwise consider them in your response unl
permissionMode.getMode(),
);
// Handle ralph mode exit from Input component (shift+tab)
const handleRalphExit = useCallback(() => {
const ralph = ralphMode.getState();
if (ralph.isActive) {
const wasYolo = ralph.isYolo;
ralphMode.deactivate();
setUiRalphActive(false);
if (wasYolo) {
permissionMode.setMode("default");
setUiPermissionMode("default");
}
}
}, []);
// Handle permission mode changes from the Input component (e.g., shift+tab cycling)
const handlePermissionModeChange = useCallback((mode: PermissionMode) => {
// When entering plan mode via tab cycling, generate and set the plan file path
@@ -6446,6 +6805,10 @@ Plan file path: ${planFilePath}`;
onEscapeCancel={
profileConfirmPending ? handleProfileEscapeCancel : undefined
}
ralphActive={uiRalphActive}
ralphPending={pendingRalphConfig !== null}
ralphPendingYolo={pendingRalphConfig?.isYolo ?? false}
onRalphExit={handleRalphExit}
/>
</Box>

View File

@@ -287,6 +287,24 @@ export const commands: Record<string, Command> = {
},
},
// === Ralph Wiggum mode (order 45-46) ===
"/ralph": {
desc: 'Start Ralph Wiggum loop (/ralph [prompt] [--completion-promise "X"] [--max-iterations N])',
order: 45,
handler: () => {
// Handled specially in App.tsx
return "Activating ralph mode...";
},
},
"/yolo-ralph": {
desc: "Start Ralph loop with bypass permissions (yolo + ralph)",
order: 46,
handler: () => {
// Handled specially in App.tsx
return "Activating yolo-ralph mode...";
},
},
// === Hidden commands (not shown in autocomplete) ===
"/stream": {
desc: "Toggle token streaming on/off",

View File

@@ -14,6 +14,7 @@ import {
import type { PermissionMode } from "../../permissions/mode";
import { permissionMode } from "../../permissions/mode";
import { ANTHROPIC_PROVIDER_NAME } from "../../providers/anthropic-provider";
import { ralphMode } from "../../ralph/mode";
import { settingsManager } from "../../settings-manager";
import { charsToTokens, formatCompact } from "../helpers/format";
import { useTerminalWidth } from "../hooks/useTerminalWidth";
@@ -56,6 +57,10 @@ export function Input({
messageQueue,
onEnterQueueEditMode,
onEscapeCancel,
ralphActive = false,
ralphPending = false,
ralphPendingYolo = false,
onRalphExit,
}: {
visible?: boolean;
streaming: boolean;
@@ -75,6 +80,10 @@ export function Input({
messageQueue?: string[];
onEnterQueueEditMode?: () => void;
onEscapeCancel?: () => void;
ralphActive?: boolean;
ralphPending?: boolean;
ralphPendingYolo?: boolean;
onRalphExit?: () => void;
}) {
const [value, setValue] = useState("");
const [escapePressed, setEscapePressed] = useState(false);
@@ -236,7 +245,7 @@ export function Input({
// Note: bash mode entry/exit is implemented inside PasteAwareTextInput so we can
// consume the keystroke before it renders (no flicker).
// Handle Shift+Tab for permission mode cycling
// Handle Shift+Tab for permission mode cycling (or ralph mode exit)
useInput((_input, key) => {
if (!visible) return;
// Debug logging for shift+tab detection
@@ -247,6 +256,12 @@ export function Input({
);
}
if (key.shift && key.tab) {
// If ralph mode is active, exit it first (goes to default mode)
if (ralphActive && onRalphExit) {
onRalphExit();
return;
}
// Cycle through permission modes
const modes: PermissionMode[] = [
"default",
@@ -570,8 +585,43 @@ export function Input({
setCursorPos(selectedCommand.length);
};
// Get display name and color for permission mode
// Get display name and color for permission mode (ralph modes take precedence)
const getModeInfo = () => {
// Check ralph pending first (waiting for task input)
if (ralphPending) {
if (ralphPendingYolo) {
return {
name: "yolo-ralph (waiting)",
color: "#FF8C00", // dark orange
};
}
return {
name: "ralph (waiting)",
color: "#FEE19C", // yellow (brandColors.statusWarning)
};
}
// Check ralph mode active (using prop for reactivity)
if (ralphActive) {
const ralph = ralphMode.getState();
const iterDisplay =
ralph.maxIterations > 0
? `${ralph.currentIteration}/${ralph.maxIterations}`
: `${ralph.currentIteration}`;
if (ralph.isYolo) {
return {
name: `yolo-ralph (iter ${iterDisplay})`,
color: "#FF8C00", // dark orange
};
}
return {
name: `ralph (iter ${iterDisplay})`,
color: "#FEE19C", // yellow (brandColors.statusWarning)
};
}
// Fall through to permission modes
switch (currentMode) {
case "acceptEdits":
return { name: "accept edits", color: colors.status.processing };
@@ -597,6 +647,7 @@ export function Input({
const elapsedMinutes = Math.floor(elapsedMs / 60000);
// Build the status hint text (esc to interrupt · 2m · 1.2k ↑)
// In ralph mode, also show "shift+tab to exit"
const statusHintText = (() => {
const hintColor = chalk.hex(colors.subagent.hint);
const hintBold = hintColor.bold;
@@ -716,7 +767,7 @@ export function Input({
<Text color={modeInfo.color}> {modeInfo.name}</Text>
<Text color={modeInfo.color} dimColor>
{" "}
(shift+tab to cycle)
(shift+tab to {ralphActive || ralphPending ? "exit" : "cycle"})
</Text>
</Text>
) : (