///
/**
* Ralph Loop Tool - Run subagent tasks in a loop.
*
* Executes subagent tasks (single or chain) repeatedly while
* a condition command returns "true".
*/
import { spawn, spawnSync } from "node:child_process";
import * as crypto from "node:crypto";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
import type { ImageContent, Message, TextContent } from "@earendil-works/pi-ai";
import { StringEnum, Type } from "@earendil-works/pi-ai";
import {
type ExtensionAPI,
AssistantMessageComponent,
DynamicBorder,
ToolExecutionComponent,
UserMessageComponent,
formatSize,
truncateTail,
} from "@earendil-works/pi-coding-agent";
import { Box, Container, Spacer, Text } from "@earendil-works/pi-tui";
import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.js";
/**
* Look up the provider for a model using `pi --list-models`.
* Returns the first matching provider, or null if not found.
*/
function lookupProviderForModel(modelName: string): string | null {
try {
const result = spawnSync("pi", ["--list-models", modelName], {
encoding: "utf-8",
timeout: 5000,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0 || !result.stdout) return null;
const lines = result.stdout.split("\n");
// Skip header line, find exact match for model
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
// Parse columns: provider, model, context, max-out, thinking, images
const parts = line.split(/\s+/);
if (parts.length >= 2) {
const provider = parts[0];
const model = parts[1];
// Return first provider where model matches exactly
if (model === modelName) {
return provider;
}
}
}
// If no exact match, return first result's provider (fuzzy match)
if (lines.length > 1) {
const firstResult = lines[1].trim().split(/\s+/);
if (firstResult.length >= 2) {
return firstResult[0];
}
}
return null;
} catch {
return null;
}
}
interface UsageStats {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
cost: number;
contextTokens: number;
turns: number;
}
interface SingleResult {
agent: string;
agentSource: "user" | "project" | "builtin" | "unknown";
task: string;
exitCode: number;
messages: Message[];
stderr: string;
usage: UsageStats;
model?: string;
stopReason?: string;
errorMessage?: string;
step?: number;
sessionFile?: string; // Path to subagent's session file
}
interface SubagentDetails {
mode: "single" | "chain";
agentScope: AgentScope;
projectAgentsDir: string | null;
results: SingleResult[];
}
interface LoopIterationResult {
index: number;
details: SubagentDetails;
output: string;
isError?: boolean;
}
interface LoopPromptItem {
agent: string;
task: string;
model?: string;
thinking?: string;
}
interface LoopPromptInfo {
mode: "single" | "chain";
items: LoopPromptItem[];
}
type LoopRunStatus = "idle" | "running" | "paused" | "stopping";
interface RalphLoopDetails {
iterations: LoopIterationResult[];
stopReason: string;
conditionCommand: string;
conditionSource: "provided" | "inferred" | "default";
maxIterations: number | null;
sleepMs: number;
lastCondition: { stdout: string; stderr: string; exitCode: number };
prompt: LoopPromptInfo;
steering: string[];
followUps: string[];
steeringSent: string[];
followUpsSent: string[];
status: LoopRunStatus;
}
interface LoopControlState {
status: LoopRunStatus;
runId: string | null;
iterations: number;
steering: string[];
steeringOnce: string[];
followUps: string[];
steeringSent: string[];
followUpsSent: string[];
paused: boolean;
abortController: AbortController | null;
lastDetails: RalphLoopDetails | null;
}
interface ActiveRun {
process: any;
sendFollowUp: (message: string) => Promise;
sendSteer: (message: string) => Promise;
}
type ActiveRunRegistration = (run: ActiveRun) => () => void;
function getFinalOutput(messages: Message[]): string {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role === "assistant") {
for (const part of msg.content) {
if (part.type === "text") return part.text;
}
}
}
return "";
}
type LoopViewerEntry =
| { type: "section"; text: string }
| { type: "meta"; text: string }
| { type: "note"; text: string }
| { type: "user"; text: string }
| { type: "assistant"; message: Message }
| {
type: "toolExecution";
toolName: string;
args: Record;
result: { content: (TextContent | ImageContent)[]; details?: any; isError: boolean; isPartial?: boolean };
};
function buildEntryComponent(entry: LoopViewerEntry, theme: any, ui: any, cwd: string, expanded: boolean) {
switch (entry.type) {
case "section":
return new Text(theme.fg("accent", entry.text), 1, 0);
case "meta":
return new Text(theme.fg("dim", entry.text), 1, 0);
case "note":
return new Text(theme.fg("muted", entry.text), 1, 0);
case "user":
return new UserMessageComponent(entry.text);
case "assistant":
return new AssistantMessageComponent(entry.message as any, false);
case "toolExecution": {
const toolComp = new ToolExecutionComponent(
entry.toolName,
entry.args,
{ showImages: false },
undefined,
ui,
cwd,
);
toolComp.updateResult(entry.result, Boolean(entry.result.isPartial));
toolComp.setExpanded(expanded);
return toolComp;
}
}
}
function renderLoopEntries(entries: LoopViewerEntry[], theme: any, tui: any, cwd: string, expanded: boolean) {
const container = new Container();
const toolUi = tui ?? { requestRender: () => {} };
for (const entry of entries) {
const component = buildEntryComponent(entry, theme, toolUi, cwd, expanded);
if (component) {
container.addChild(component);
container.addChild(new Spacer(1));
}
}
return container;
}
function buildLoopEntries(loopDetails: RalphLoopDetails): LoopViewerEntry[] {
const entries: LoopViewerEntry[] = [];
entries.push({ type: "meta", text: `Status: ${loopDetails.status}` });
entries.push({ type: "meta", text: `Stop: ${loopDetails.stopReason}` });
entries.push({ type: "meta", text: `Condition: ${loopDetails.conditionCommand} (${loopDetails.conditionSource})` });
entries.push({ type: "meta", text: `Iterations: ${loopDetails.iterations.length}` });
const appendQueuedEntries = () => {
const hasQueued =
loopDetails.steering.length > 0 ||
loopDetails.followUps.length > 0 ||
loopDetails.steeringSent.length > 0 ||
loopDetails.followUpsSent.length > 0;
if (!hasQueued) return;
entries.push({ type: "section", text: "Queued Messages" });
if (loopDetails.steering.length > 0) {
entries.push({ type: "note", text: `Steering queued: ${loopDetails.steering.join(" | ")}` });
}
if (loopDetails.followUps.length > 0) {
entries.push({ type: "note", text: `Follow-ups queued: ${loopDetails.followUps.join(" | ")}` });
}
if (loopDetails.steeringSent.length > 0) {
entries.push({ type: "note", text: `Steering sent: ${loopDetails.steeringSent.join(" | ")}` });
}
if (loopDetails.followUpsSent.length > 0) {
entries.push({ type: "note", text: `Follow-ups sent: ${loopDetails.followUpsSent.join(" | ")}` });
}
};
if (loopDetails.iterations.length === 0) {
entries.push({ type: "note", text: "(no iterations yet)" });
appendQueuedEntries();
return entries;
}
for (const iteration of loopDetails.iterations) {
entries.push({ type: "section", text: `Iteration ${iteration.index} (${iteration.details.mode})` });
for (const result of iteration.details.results) {
const statusIcon = result.exitCode === 0 ? "✓" : "✗";
const agentLine = `${statusIcon} ${result.agent} (${result.agentSource})`;
entries.push({ type: "note", text: agentLine });
if (result.task) entries.push({ type: "note", text: `Task: ${result.task}` });
if (result.errorMessage) entries.push({ type: "note", text: `Error: ${result.errorMessage}` });
const toolCalls = new Map }>();
const toolResults = new Map<
string,
{
toolName: string;
result: {
content: (TextContent | ImageContent)[];
details?: any;
isError: boolean;
isPartial?: boolean;
};
}
>();
for (const msg of result.messages) {
if (msg.role === "assistant") {
for (const part of msg.content) {
if (part.type === "toolCall") {
toolCalls.set(part.id, { name: part.name, args: part.arguments });
}
}
} else if (msg.role === "toolResult" && msg.toolCallId) {
toolResults.set(msg.toolCallId, {
toolName: msg.toolName,
result: {
content: msg.content,
details: msg.details,
isError: msg.isError,
isPartial: msg.isPartial,
},
});
}
}
for (const msg of result.messages) {
if (msg.role === "assistant") {
entries.push({ type: "assistant", message: msg });
for (const part of msg.content) {
if (part.type !== "toolCall") continue;
const toolResult = toolResults.get(part.id);
entries.push({
type: "toolExecution",
toolName: part.name || toolResult?.toolName || "",
args: part.arguments ?? {},
result:
toolResult?.result ??
{
content: [],
details: undefined,
isError: false,
isPartial: true,
},
});
}
} else if (msg.role === "user") {
const text = extractTextFromContent(msg.content).trim();
entries.push({ type: "user", text: text || "(user message)" });
} else if (msg.role === "toolResult") {
// Render orphan tool results (e.g., when toolCall is missing)
if (msg.toolCallId && toolCalls.has(msg.toolCallId)) continue;
entries.push({
type: "toolExecution",
toolName: msg.toolName,
args: {},
result: {
content: msg.content,
details: msg.details,
isError: msg.isError,
isPartial: msg.isPartial,
},
});
}
}
if (result.messages.length === 0) {
entries.push({ type: "note", text: "(no messages)" });
}
}
}
appendQueuedEntries();
return entries;
}
function formatSteeringText(messages: string[]): string | null {
const cleaned = messages.map((msg) => msg.trim()).filter(Boolean);
if (cleaned.length === 0) return null;
const lines = cleaned.map((msg, index) => `${index + 1}. ${msg}`);
return `Steering updates:\n${lines.join("\n")}`;
}
function appendSteeringToTask(task: string, steering: string | null): string {
if (!steering) return task;
return `${task.trim()}\n\n${steering}`;
}
function cloneLoopParams(params: any): any {
return {
...params,
chain: Array.isArray(params.chain) ? params.chain.map((step: any) => ({ ...step })) : undefined,
};
}
function applySteeringToParams(params: any, steering: string | null): any {
const nextParams = cloneLoopParams(params);
if (!steering) return nextParams;
if (typeof nextParams.task === "string") {
nextParams.task = appendSteeringToTask(nextParams.task, steering);
}
if (Array.isArray(nextParams.chain)) {
nextParams.chain = nextParams.chain.map((step: any) => ({
...step,
task: appendSteeringToTask(step.task, steering),
}));
}
return nextParams;
}
function mergeAbortSignals(primary?: AbortSignal, secondary?: AbortSignal): AbortSignal | undefined {
if (!primary) return secondary;
if (!secondary) return primary;
const controller = new AbortController();
const abort = () => controller.abort();
if (primary.aborted || secondary.aborted) {
controller.abort();
} else {
primary.addEventListener("abort", abort, { once: true });
secondary.addEventListener("abort", abort, { once: true });
}
return controller.signal;
}
function isProcessActive(proc: any): boolean {
return Boolean(proc && proc.exitCode === null);
}
function pauseActiveRuns(control: LoopControlState, runs: Set): boolean {
if (control.paused) return runs.size > 0;
let paused = false;
for (const run of runs) {
try {
if (isProcessActive(run.process)) {
const didStop = run.process.kill("SIGSTOP");
if (didStop) paused = true;
}
} catch {
// ignore
}
}
if (paused) {
control.paused = true;
if (control.status !== "stopping") {
control.status = "paused";
}
}
return paused;
}
function clearPausedState(control: LoopControlState): void {
if (!control.paused) return;
control.paused = false;
if (control.status === "paused") {
control.status = "running";
}
}
function resumeActiveRuns(control: LoopControlState, runs: Set): boolean {
if (!control.paused) return runs.size > 0;
if (runs.size === 0) {
clearPausedState(control);
return true;
}
let resumed = false;
for (const run of runs) {
try {
if (isProcessActive(run.process)) {
const didResume = run.process.kill("SIGCONT");
if (didResume) resumed = true;
}
} catch {
// ignore
}
}
if (resumed) {
clearPausedState(control);
}
return resumed;
}
function writePromptToTempFile(agentName: string, prompt: string): { dir: string; filePath: string } {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-"));
const safeName = agentName.replace(/[^\w.-]+/g, "_");
const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
fs.writeFileSync(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
return { dir: tmpDir, filePath };
}
type OnUpdateCallback = (partial: AgentToolResult) => void;
async function runSingleAgent(
defaultCwd: string,
agents: AgentConfig[],
agentName: string,
task: string,
cwd: string | undefined,
step: number | undefined,
signal: AbortSignal | undefined,
onUpdate: OnUpdateCallback | undefined,
makeDetails: (results: SingleResult[]) => SubagentDetails,
modelOverride?: string,
thinkingLevel?: string,
taskIndex?: number,
registerActiveRun?: ActiveRunRegistration,
initialFollowUps?: string[],
): Promise {
const agent = agents.find((a) => a.name === agentName);
if (!agent) {
return {
agent: agentName,
agentSource: "unknown",
task,
exitCode: 1,
messages: [],
stderr: `Unknown agent: ${agentName}`,
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
step,
};
}
// Use model override if provided, otherwise fall back to agent's model
const effectiveModel = modelOverride || agent.model;
// Create session file for this subagent invocation
const subagentSessionDir = path.join(os.homedir(), ".pi", "agent", "sessions", "subagents");
if (!fs.existsSync(subagentSessionDir)) {
fs.mkdirSync(subagentSessionDir, { recursive: true });
}
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const safeName = agentName.replace(/[^\w.-]+/g, "_");
// Use UUID for guaranteed uniqueness
const uuid = crypto.randomUUID().substring(0, 8);
const indexSuffix = taskIndex !== undefined ? `_idx${taskIndex}` : "";
const stepSuffix = step !== undefined ? `_step${step}` : "";
const sessionFile = path.join(subagentSessionDir, `${timestamp}_${safeName}${stepSuffix}${indexSuffix}_${uuid}.jsonl`);
const args: string[] = ["--mode", "rpc", "--session", sessionFile];
// Parse provider:model format and pass both --provider and --model flags
// This is needed because pi's --model flag alone doesn't override defaultProvider from settings.json
if (effectiveModel) {
if (effectiveModel.includes(":")) {
const colonIndex = effectiveModel.indexOf(":");
const provider = effectiveModel.slice(0, colonIndex);
const model = effectiveModel.slice(colonIndex + 1);
args.push("--provider", provider, "--model", model);
} else {
// Look up the provider for this model
const provider = lookupProviderForModel(effectiveModel);
if (provider) {
args.push("--provider", provider, "--model", effectiveModel);
} else {
args.push("--model", effectiveModel);
}
}
}
if (thinkingLevel) args.push("--thinking", thinkingLevel);
if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
// Add custom tools from agent configuration (e.g., question tool for planners)
if (agent.customTools && agent.customTools.length > 0) {
const optionalToolsDir = path.join(os.homedir(), ".pi", "agent", "tools-optional");
for (const toolRef of agent.customTools) {
// Support both tool names (looked up in tools-optional) and full paths
let toolPath = toolRef;
if (!path.isAbsolute(toolRef) && !toolRef.startsWith("~")) {
toolPath = path.join(optionalToolsDir, toolRef, "index.ts");
} else if (toolRef.startsWith("~")) {
toolPath = path.join(os.homedir(), toolRef.slice(1));
}
if (fs.existsSync(toolPath)) {
args.push("--extension", toolPath);
}
}
}
let tmpPromptDir: string | null = null;
let tmpPromptPath: string | null = null;
const currentResult: SingleResult = {
agent: agentName,
agentSource: agent.source,
task,
exitCode: 0,
messages: [],
stderr: "",
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
model: effectiveModel,
step,
sessionFile,
};
const emitUpdate = () => {
if (onUpdate) {
onUpdate({
content: [{ type: "text", text: getFinalOutput(currentResult.messages) || "(running...)" }],
details: makeDetails([currentResult]),
});
}
};
try {
if (agent.systemPrompt.trim()) {
const tmp = writePromptToTempFile(agent.name, agent.systemPrompt);
tmpPromptDir = tmp.dir;
tmpPromptPath = tmp.filePath;
args.push("--append-system-prompt", tmpPromptPath);
}
let wasAborted = false;
const exitCode = await new Promise((resolve) => {
const env = agent.permissionLevel
? { ...process.env, PI_PERMISSION_LEVEL: agent.permissionLevel }
: process.env;
const proc = spawn("pi", args, { cwd: cwd ?? defaultCwd, shell: false, stdio: ["pipe", "pipe", "pipe"], env });
let buffer = "";
let resolved = false;
let unregisterActive: (() => void) | null = null;
let requestId = 0;
const pending = new Map void; reject: (error: Error) => void }>();
let stdinClosed = false;
const rejectPending = (error: Error) => {
for (const pendingReq of pending.values()) {
try {
pendingReq.reject(error);
} catch {
// ignore
}
}
pending.clear();
};
const markStdinClosed = (error: Error) => {
if (stdinClosed) return;
stdinClosed = true;
rejectPending(error);
};
proc.stdin?.on("error", (error) => {
markStdinClosed(error instanceof Error ? error : new Error("stdin error"));
});
proc.stdin?.on("close", () => {
markStdinClosed(new Error("stdin closed"));
});
const resolveOnce = (code: number) => {
if (resolved) return;
resolved = true;
if (unregisterActive) unregisterActive();
resolve(code);
};
const sendCommand = (command: any) =>
new Promise((resolveCommand, rejectCommand) => {
if (stdinClosed || proc.exitCode !== null || proc.stdin?.destroyed) {
rejectCommand(new Error("RPC process is not available"));
return;
}
const id = `req_${++requestId}`;
const payload = { ...command, id };
const timeout = setTimeout(() => {
if (pending.has(id)) {
pending.delete(id);
rejectCommand(new Error(`Timeout waiting for ${command.type}`));
}
}, 30000);
pending.set(id, {
resolve: (response) => {
clearTimeout(timeout);
resolveCommand(response);
},
reject: (error) => {
clearTimeout(timeout);
rejectCommand(error);
},
});
try {
proc.stdin?.write(`${JSON.stringify(payload)}\n`);
} catch (error: any) {
pending.delete(id);
const err = error instanceof Error ? error : new Error(String(error));
markStdinClosed(err);
rejectCommand(err);
}
});
const sendFollowUp = (message: string) => sendCommand({ type: "follow_up", message });
const sendSteer = (message: string) => sendCommand({ type: "steer", message });
unregisterActive = registerActiveRun
? registerActiveRun({ process: proc, sendFollowUp, sendSteer })
: null;
const handleResponse = (event: any) => {
const id = event?.id as string | undefined;
if (!id) return false;
const pendingReq = pending.get(id);
if (!pendingReq) return false;
pending.delete(id);
if (event.success === false) {
pendingReq.reject(new Error(event.error || "RPC command failed"));
} else {
pendingReq.resolve(event);
}
return true;
};
let finalizing = false;
const finalizeRun = async () => {
if (resolved || finalizing) return;
finalizing = true;
try {
const response = await sendCommand({ type: "get_state" });
const state = response?.data;
if (state?.pendingMessageCount > 0 || state?.isStreaming) {
finalizing = false;
return;
}
} catch {
// ignore
}
if (resolved) return;
resolveOnce(0);
proc.kill("SIGTERM");
setTimeout(() => {
if (!proc.killed) proc.kill("SIGKILL");
}, 2000);
};
const upsertToolResult = (
toolCallId: string,
toolName: string,
result: any,
isError: boolean,
isPartial: boolean,
) => {
const existingIndex = currentResult.messages.findIndex(
(msg: any) => msg.role === "toolResult" && msg.toolCallId === toolCallId,
);
const existing = existingIndex >= 0 ? (currentResult.messages[existingIndex] as any) : null;
const toolMessage = {
role: "toolResult",
toolCallId,
toolName: toolName || existing?.toolName || "",
content: result?.content || [],
details: result?.details,
isError,
isPartial,
timestamp: Date.now(),
};
if (existingIndex >= 0) {
currentResult.messages[existingIndex] = { ...existing, ...toolMessage } as Message;
} else {
currentResult.messages.push(toolMessage as Message);
}
emitUpdate();
};
const processEvent = (event: any) => {
if (event.type === "message_end" && event.message) {
const msg = event.message as Message;
currentResult.messages.push(msg);
if (msg.role === "assistant") {
currentResult.usage.turns++;
const usage = msg.usage;
if (usage) {
currentResult.usage.input += usage.input || 0;
currentResult.usage.output += usage.output || 0;
currentResult.usage.cacheRead += usage.cacheRead || 0;
currentResult.usage.cacheWrite += usage.cacheWrite || 0;
currentResult.usage.cost += usage.cost?.total || 0;
currentResult.usage.contextTokens = usage.totalTokens || 0;
}
if (!currentResult.model && msg.model) currentResult.model = msg.model;
if (msg.stopReason) currentResult.stopReason = msg.stopReason;
if (msg.errorMessage) currentResult.errorMessage = msg.errorMessage;
}
emitUpdate();
return;
}
if (event.type === "tool_execution_start" && event.toolCallId) {
upsertToolResult(event.toolCallId, event.toolName || "", { content: [], details: undefined }, false, true);
return;
}
if (event.type === "tool_execution_update" && event.toolCallId) {
const partial = event.partialResult ?? { content: [], details: undefined };
upsertToolResult(event.toolCallId, event.toolName || "", partial, false, true);
return;
}
if (event.type === "tool_execution_end" && event.toolCallId) {
const result = event.result ?? { content: [], details: undefined };
upsertToolResult(event.toolCallId, event.toolName || "", result, Boolean(event.isError), false);
return;
}
if (event.type === "agent_end") {
if (Array.isArray(event.messages)) {
currentResult.messages = event.messages as Message[];
}
emitUpdate();
void finalizeRun();
}
};
const processLine = (line: string) => {
if (!line.trim()) return;
let event: any;
try {
event = JSON.parse(line);
} catch {
return;
}
if (event.type === "response" && handleResponse(event)) {
return;
}
processEvent(event);
};
proc.stdout.on("data", (data) => {
buffer += data.toString();
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) processLine(line);
});
proc.stderr.on("data", (data) => {
currentResult.stderr += data.toString();
});
proc.on("close", (code) => {
if (buffer.trim()) processLine(buffer);
markStdinClosed(new Error("process closed"));
resolveOnce(code ?? 0);
});
proc.on("error", () => {
markStdinClosed(new Error("process error"));
resolveOnce(1);
});
if (signal) {
const abortRpc = () => {
wasAborted = true;
sendCommand({ type: "abort" }).catch(() => undefined);
proc.kill("SIGTERM");
setTimeout(() => {
if (!proc.killed) proc.kill("SIGKILL");
}, 5000);
};
if (signal.aborted) abortRpc();
else signal.addEventListener("abort", abortRpc, { once: true });
}
sendCommand({ type: "prompt", message: `Task: ${task}` })
.then(() => {
const followUps = (initialFollowUps ?? []).filter((msg) => msg.trim().length > 0);
if (followUps.length === 0) return;
const queueFollowUps = async () => {
for (const message of followUps) {
try {
await sendCommand({ type: "follow_up", message });
} catch (error: any) {
const errorMessage = error?.message ? `\n${error.message}` : `\n${String(error)}`;
currentResult.stderr += errorMessage;
}
}
};
void queueFollowUps();
})
.catch((error) => {
currentResult.stderr += error?.message ? `\n${error.message}` : String(error);
resolveOnce(1);
proc.kill("SIGTERM");
});
});
currentResult.exitCode = exitCode;
if (wasAborted) throw new Error("Subagent was aborted");
return currentResult;
} finally {
if (tmpPromptPath)
try {
fs.unlinkSync(tmpPromptPath);
} catch {
/* ignore */
}
if (tmpPromptDir)
try {
fs.rmdirSync(tmpPromptDir);
} catch {
/* ignore */
}
}
}
const ThinkingLevel = StringEnum(["off", "minimal", "low", "medium", "high", "xhigh"] as const, {
description: "Thinking/reasoning level for the model",
});
const ChainItem = Type.Object({
agent: Type.String({ description: "Name of the agent to invoke" }),
task: Type.String({ description: "Task with optional {previous} placeholder for prior output" }),
cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
model: Type.Optional(Type.String({ description: "Override the agent's model (e.g., 'claude-opus-4-5', 'gpt-5.2-codex')" })),
thinking: Type.Optional(ThinkingLevel),
});
const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
description: 'Which agent directories to use. Default: "user". Use "both" to include project-local agents.',
default: "user",
});
const DEFAULT_LOOP_MAX_ITERATIONS = Number.MAX_SAFE_INTEGER;
const DEFAULT_LOOP_SLEEP_MS = 1000;
const LoopParams = Type.Object({
conditionCommand: Type.Optional(
Type.String({
description:
"Bash command used for looping; continue while stdout is 'true' (case-insensitive). If omitted, inferred from task or defaults to 'echo true'.",
}),
),
maxIterations: Type.Optional(Type.Number({ description: "Max iterations (optional)." })),
sleepMs: Type.Optional(Type.Number({ description: `Sleep between iterations in ms (default ${DEFAULT_LOOP_SLEEP_MS}).` })),
agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (for single mode)" })),
task: Type.Optional(Type.String({ description: "Task to delegate (for single mode)" })),
chain: Type.Optional(Type.Array(ChainItem, { description: "Array of {agent, task} for sequential execution" })),
agentScope: Type.Optional(AgentScopeSchema),
confirmProjectAgents: Type.Optional(
Type.Boolean({ description: "Prompt before running project-local agents. Default: true.", default: true }),
),
cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
model: Type.Optional(Type.String({ description: "Override the agent's model for single mode (e.g., 'claude-opus-4-5', 'gpt-5.2-codex')" })),
thinking: Type.Optional(ThinkingLevel),
});
interface LoopExecutionResult {
output: string;
details: SubagentDetails;
isError?: boolean;
}
async function executeSubagentOnce(
params: any,
ctx: any,
signal?: AbortSignal,
onUpdate?: OnUpdateCallback,
registerActiveRun?: ActiveRunRegistration,
initialFollowUps?: string[],
): Promise {
const agentScope: AgentScope = params.agentScope ?? "user";
const discovery = discoverAgents(ctx.cwd, agentScope);
const agents = discovery.agents;
const confirmProjectAgents = params.confirmProjectAgents ?? true;
const makeDetails =
(mode: "single" | "chain") =>
(results: SingleResult[]): SubagentDetails => ({
mode,
agentScope,
projectAgentsDir: discovery.projectAgentsDir,
results,
});
const hasChain = (params.chain?.length ?? 0) > 0;
const hasTasks = (params.tasks?.length ?? 0) > 0;
const hasSingle = Boolean(params.agent && params.task);
if (hasTasks) {
return {
output: "Parallel mode is not supported. Use chain instead.",
details: makeDetails("single")([]),
isError: true,
};
}
const modeCount = Number(hasChain) + Number(hasSingle);
if (modeCount !== 1) {
const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
return {
output: `Invalid parameters. Provide exactly one mode (single or chain).\nAvailable agents: ${available}`,
details: makeDetails("single")([]),
isError: true,
};
}
if ((agentScope === "project" || agentScope === "both") && confirmProjectAgents && ctx.hasUI) {
const requestedAgentNames = new Set();
if (params.chain) for (const step of params.chain) requestedAgentNames.add(step.agent);
if (params.agent) requestedAgentNames.add(params.agent);
const projectAgentsRequested = Array.from(requestedAgentNames)
.map((name) => agents.find((a) => a.name === name))
.filter((a: AgentConfig | undefined): a is AgentConfig => a?.source === "project");
if (projectAgentsRequested.length > 0) {
const names = projectAgentsRequested.map((a) => a.name).join(", ");
const dir = discovery.projectAgentsDir ?? "(unknown)";
const ok = await ctx.ui.confirm(
"Run project-local agents?",
`Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
);
if (!ok)
return {
output: "Canceled: project-local agents not approved.",
details: makeDetails(hasChain ? "chain" : "single")([]),
isError: true,
};
}
}
if (params.chain && params.chain.length > 0) {
const results: SingleResult[] = [];
let previousOutput = "";
for (let i = 0; i < params.chain.length; i++) {
const step = params.chain[i];
const taskWithContext = step.task.replace(/\{previous\}/g, previousOutput);
const chainUpdate: OnUpdateCallback | undefined = onUpdate
? (partial) => {
const currentResult = partial.details?.results[0];
if (currentResult) {
const allResults = [...results, currentResult];
onUpdate({
content: partial.content,
details: makeDetails("chain")(allResults),
});
}
}
: undefined;
const result = await runSingleAgent(
ctx.cwd,
agents,
step.agent,
taskWithContext,
step.cwd,
i + 1,
signal,
chainUpdate,
makeDetails("chain"),
step.model,
step.thinking ?? params.thinking,
undefined,
registerActiveRun,
initialFollowUps,
);
results.push(result);
const isError =
result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
if (isError) {
const errorMsg = result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
return {
output: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}`,
details: makeDetails("chain")(results),
isError: true,
};
}
previousOutput = getFinalOutput(result.messages);
}
const stepOutputs = results.map((r, idx) => {
const output = getFinalOutput(r.messages);
const status = r.exitCode === 0 ? "✓" : "✗";
return `Step ${idx + 1} [${r.agent}] ${status}:\n${output || "(no output)"}`;
});
const finalResult = `Chain completed (${results.length} steps)\n\n${"=".repeat(80)}\n\n${stepOutputs.join(
`\n\n${"=".repeat(80)}\n\n`,
)}\n\n${"=".repeat(80)}\n\nFinal output:\n${previousOutput}`;
return {
output: finalResult,
details: makeDetails("chain")(results),
};
}
if (params.agent && params.task) {
const result = await runSingleAgent(
ctx.cwd,
agents,
params.agent,
params.task,
params.cwd,
undefined,
signal,
onUpdate,
makeDetails("single"),
params.model,
params.thinking,
undefined,
registerActiveRun,
initialFollowUps,
);
const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
if (isError) {
const errorMsg = result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
return {
output: `Agent ${result.stopReason || "failed"}: ${errorMsg}`,
details: makeDetails("single")([result]),
isError: true,
};
}
return {
output: getFinalOutput(result.messages) || "(no output)",
details: makeDetails("single")([result]),
};
}
const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
return {
output: `Invalid parameters. Available agents: ${available}`,
details: makeDetails("single")([]),
isError: true,
};
}
function parseLoopNumber(value: string | null, fallback: number, allowZero = false): number | null {
const trimmed = value?.trim();
if (!trimmed) return fallback;
const parsed = Number.parseInt(trimmed, 10);
if (!Number.isFinite(parsed)) return null;
if (parsed < 0) return null;
if (!allowZero && parsed === 0) return null;
return parsed;
}
async function sleep(delayMs: number, signal?: AbortSignal): Promise {
if (delayMs <= 0) return;
await new Promise((resolve) => {
const timer = setTimeout(resolve, delayMs);
if (signal) {
const cancel = () => {
clearTimeout(timer);
resolve();
};
if (signal.aborted) cancel();
else signal.addEventListener("abort", cancel, { once: true });
}
});
}
async function checkLoopCondition(
pi: ExtensionAPI,
command: string,
cwd: string,
signal?: AbortSignal,
): Promise<{ shouldContinue: boolean; stdout: string; stderr: string; exitCode: number }> {
const result = await pi.exec("bash", ["-lc", command], { cwd, signal });
const stdout = (result.stdout || "").trim();
const shouldContinue = stdout.toLowerCase() === "true";
return {
shouldContinue,
stdout,
stderr: result.stderr || "",
exitCode: result.code ?? 0,
};
}
async function confirmProjectAgentsOnce(params: any, ctx: any): Promise {
const agentScope: AgentScope = params.agentScope ?? "user";
if (agentScope === "user" || !ctx.hasUI) return true;
const discovery = discoverAgents(ctx.cwd, agentScope);
const agents = discovery.agents;
const requestedAgentNames = new Set();
if (params.chain) for (const step of params.chain) requestedAgentNames.add(step.agent);
if (params.agent) requestedAgentNames.add(params.agent);
const projectAgentsRequested = Array.from(requestedAgentNames)
.map((name) => agents.find((a) => a.name === name))
.filter((a: AgentConfig | undefined): a is AgentConfig => a?.source === "project");
if (projectAgentsRequested.length === 0) return true;
const names = projectAgentsRequested.map((a) => a.name).join(", ");
const dir = discovery.projectAgentsDir ?? "(unknown)";
return ctx.ui.confirm(
"Run project-local agents?",
`Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
);
}
function extractTextFromContent(content: any): string {
if (!content) return "";
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.filter((part) => part && typeof part.text === "string")
.map((part) => part.text)
.join("\n");
}
return "";
}
function writeLargeOutputToTempFile(prefix: string, output: string): string | null {
try {
const id = crypto.randomBytes(8).toString("hex");
const filePath = path.join(os.tmpdir(), `pi-${prefix}-${id}.log`);
fs.writeFileSync(filePath, output, { encoding: "utf-8", mode: 0o600 });
return filePath;
} catch {
return null;
}
}
function formatTailTruncationNotice(truncation: any, fullOutputPath: string | null, fullOutput: string): string {
if (!truncation?.truncated) return "";
const startLine = truncation.totalLines - truncation.outputLines + 1;
const endLine = truncation.totalLines;
const fullOutputLabel = fullOutputPath ? `Full output: ${fullOutputPath}` : "Full output: (failed to save)";
if (truncation.lastLinePartial) {
const lastLine = fullOutput.split("\n").pop() || "";
let lastLineBytes = 0;
try {
lastLineBytes = new TextEncoder().encode(lastLine).length;
} catch {
lastLineBytes = lastLine.length;
}
const lastLineSize = formatSize(lastLineBytes);
return `[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). ${fullOutputLabel}]`;
}
if (truncation.truncatedBy === "lines") {
return `[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. ${fullOutputLabel}]`;
}
return `[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(truncation.maxBytes)} limit). ${fullOutputLabel}]`;
}
function formatLastOutputForSummary(lastOutput: string): { text: string; fullOutputPath: string | null } {
const truncation = truncateTail(lastOutput);
let outputText = truncation.content || "(no output)";
if (!truncation.truncated) return { text: outputText, fullOutputPath: null };
const fullOutputPath = writeLargeOutputToTempFile("ralph-loop", lastOutput);
const notice = formatTailTruncationNotice(truncation, fullOutputPath, lastOutput);
if (notice) outputText += `\n\n${notice}`;
return { text: outputText, fullOutputPath };
}
function getLastUserText(ctx: any): string | null {
const entries = ctx.sessionManager?.getEntries?.() ?? [];
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
if (entry?.type !== "message") continue;
const msg = entry.message;
if (msg?.role !== "user") continue;
const text = extractTextFromContent(msg.content);
if (text.trim()) return text.trim();
}
return null;
}
function pickDefaultAgent(agents: AgentConfig[]): string | null {
if (agents.length === 0) return null;
const worker = agents.find((a) => a.name === "worker");
return worker?.name ?? agents[0].name;
}
function inferConditionCommandFromText(text: string | undefined): string | null {
if (!text) return null;
const lines = text.split("\n");
for (const line of lines) {
const match = line.match(/^(?:\s*)(?:condition|exit condition|loop condition)\s*:\s*(.+)$/i);
if (match) return match[1].trim();
}
const backtick = text.match(/\b(?:until|while)\s+`([^`]+)`/i);
if (backtick) return backtick[1].trim();
const inline = text.match(/\b(?:until|while)\s+([^\n.]+)$/i);
if (inline) return inline[1].replace(/[.?!]$/, "").trim();
return null;
}
function buildLoopPromptInfo(params: any): LoopPromptInfo {
if (Array.isArray(params.chain) && params.chain.length > 0) {
return {
mode: "chain",
items: params.chain.map((step: any) => ({
agent: step.agent,
task: step.task,
model: step.model,
thinking: step.thinking,
})),
};
}
return {
mode: "single",
items: [
{
agent: params.agent || "(auto)",
task: params.task || "",
model: params.model,
thinking: params.thinking,
},
],
};
}
function formatLoopPromptItem(item: LoopPromptItem, maxTaskLength: number): string {
const overrides: string[] = [];
if (item.model) overrides.push(item.model);
if (item.thinking) overrides.push(`thinking:${item.thinking}`);
const overrideText = overrides.length > 0 ? ` (${overrides.join(", ")})` : "";
const task = item.task || "";
const preview = task.length > maxTaskLength ? `${task.slice(0, maxTaskLength)}...` : task;
return `${item.agent}${overrideText}${preview ? ` ${preview}` : ""}`;
}
export default function (pi: ExtensionAPI) {
const loopControl: LoopControlState = {
status: "idle",
runId: null,
iterations: 0,
steering: [],
steeringOnce: [],
followUps: [],
steeringSent: [],
followUpsSent: [],
paused: false,
abortController: null,
lastDetails: null,
};
const activeRuns = new Set();
const registerActiveRun: ActiveRunRegistration = (run) => {
activeRuns.add(run);
return () => activeRuns.delete(run);
};
const sendFollowUpToActive = async (message: string) => {
const runs = Array.from(activeRuns);
if (runs.length === 0) return false;
let delivered = false;
await Promise.all(
runs.map(async (run) => {
try {
await run.sendFollowUp(message);
delivered = true;
} catch {
// ignore
}
}),
);
return delivered;
};
const sendSteerToActive = async (message: string) => {
const runs = Array.from(activeRuns);
if (runs.length === 0) return false;
let delivered = false;
await Promise.all(
runs.map(async (run) => {
try {
await run.sendSteer(message);
delivered = true;
} catch {
// ignore
}
}),
);
return delivered;
};
const getLoopStatusLine = () => {
if (loopControl.status === "idle") return undefined;
const details = loopControl.lastDetails;
const iterations = details?.iterations.length ?? loopControl.iterations;
const maxIterations = details?.maxIterations;
const maxLabel =
typeof maxIterations === "number" && maxIterations !== Number.MAX_SAFE_INTEGER ? `/${maxIterations}` : "";
return `ralph-loop ${loopControl.status}: ${iterations}${maxLabel}`;
};
const ensureActiveLoop = (ctx: any) => {
if (loopControl.status === "idle") {
ctx.ui.notify("No active ralph_loop run.", "warning");
return false;
}
return true;
};
pi.registerCommand("ralph-steer", {
description: "Queue a steering message for the active ralph_loop run",
handler: async (args, ctx) => {
if (!ctx.hasUI) {
ctx.ui.notify("Interactive mode required.", "error");
return;
}
if (!ensureActiveLoop(ctx)) return;
let message = args.trim();
let once = false;
if (message.startsWith("--once ")) {
once = true;
message = message.slice("--once ".length).trim();
}
if (!message) {
const input = await ctx.ui.input("Steer ralph_loop:", "Add guidance for next iterations");
if (!input) return;
message = input.trim();
}
if (!message) return;
const sentToActive = await sendSteerToActive(message);
if (sentToActive) {
loopControl.steeringSent.push(message);
} else {
if (once) {
loopControl.steeringOnce.push(message);
} else {
loopControl.steering.push(message);
}
}
if (sentToActive) {
ctx.ui.notify("Queued for current iteration.", "info");
} else {
ctx.ui.notify(once ? "One-off steering queued for next iteration." : "Steering queued for next iteration.", "info");
}
ctx.ui.setStatus("ralph-loop", getLoopStatusLine());
},
});
pi.registerCommand("ralph-follow", {
description: "Queue a follow-up message for the active ralph_loop run",
handler: async (args, ctx) => {
if (!ctx.hasUI) {
ctx.ui.notify("Interactive mode required.", "error");
return;
}
if (!ensureActiveLoop(ctx)) return;
let message = args.trim();
if (!message) {
const input = await ctx.ui.input("Follow up ralph_loop:", "Queue a follow-up message");
if (!input) return;
message = input.trim();
}
if (!message) return;
const sentToActive = await sendFollowUpToActive(message);
if (sentToActive) {
loopControl.followUpsSent.push(message);
ctx.ui.notify("Queued for current iteration.", "info");
} else {
loopControl.followUps.push(message);
ctx.ui.notify("Follow-up queued for next iteration.", "info");
}
ctx.ui.setStatus("ralph-loop", getLoopStatusLine());
},
});
pi.registerCommand("ralph-clear", {
description: "Clear queued steering for ralph_loop",
handler: async (_args, ctx) => {
loopControl.steering = [];
loopControl.steeringOnce = [];
ctx.ui.notify("Cleared steering queue.", "info");
ctx.ui.setStatus("ralph-loop", getLoopStatusLine());
},
});
pi.registerCommand("ralph-pause", {
description: "Pause the currently running ralph_loop iteration",
handler: async (_args, ctx) => {
if (!ctx.hasUI) {
ctx.ui.notify("Interactive mode required.", "error");
return;
}
if (!ensureActiveLoop(ctx)) return;
if (loopControl.paused) {
ctx.ui.notify("ralph_loop is already paused.", "info");
return;
}
const paused = pauseActiveRuns(loopControl, activeRuns);
if (!paused) {
ctx.ui.notify("No running iteration to pause.", "warning");
return;
}
ctx.ui.notify("Paused current iteration.", "info");
ctx.ui.setStatus("ralph-loop", getLoopStatusLine());
},
});
pi.registerCommand("ralph-resume", {
description: "Resume a paused ralph_loop",
handler: async (_args, ctx) => {
if (!ctx.hasUI) {
ctx.ui.notify("Interactive mode required.", "error");
return;
}
if (!ensureActiveLoop(ctx)) return;
if (!loopControl.paused) {
ctx.ui.notify("ralph_loop is not paused.", "info");
return;
}
const hadRuns = activeRuns.size > 0;
const resumed = resumeActiveRuns(loopControl, activeRuns);
if (!resumed) {
ctx.ui.notify("No paused iteration to resume.", "warning");
return;
}
ctx.ui.notify(hadRuns ? "Resumed current iteration." : "Cleared paused state.", "info");
ctx.ui.setStatus("ralph-loop", getLoopStatusLine());
},
});
pi.registerCommand("ralph-stop", {
description: "Stop the active ralph_loop run",
handler: async (_args, ctx) => {
if (!ctx.hasUI) {
ctx.ui.notify("Interactive mode required.", "error");
return;
}
if (!ensureActiveLoop(ctx)) return;
loopControl.status = "stopping";
loopControl.abortController?.abort();
resumeActiveRuns(loopControl, activeRuns);
ctx.ui.notify("Stop requested for ralph_loop.", "warning");
ctx.ui.setStatus("ralph-loop", getLoopStatusLine());
},
});
pi.registerCommand("ralph-status", {
description: "Show ralph_loop status",
handler: async (_args, ctx) => {
const details = loopControl.lastDetails;
if (loopControl.status === "idle" && !details) {
ctx.ui.notify("No ralph_loop activity yet.", "info");
return;
}
const iterations = details?.iterations.length ?? loopControl.iterations;
const maxIterations = details?.maxIterations;
const maxLabel =
typeof maxIterations === "number" && maxIterations !== Number.MAX_SAFE_INTEGER ? `/${maxIterations}` : "";
const steeringCount = loopControl.steering.length + loopControl.steeringOnce.length;
const followUpCount = loopControl.followUps.length;
const parts = [`Status: ${loopControl.status}`];
if (loopControl.runId) parts.push(`Run: ${loopControl.runId}`);
parts.push(`Iterations: ${iterations}${maxLabel}`);
if (loopControl.status === "idle" && details?.stopReason) parts.push(`Last stop: ${details.stopReason}`);
if (steeringCount > 0) parts.push(`Steering queued: ${steeringCount}`);
if (followUpCount > 0) parts.push(`Follow-ups queued: ${followUpCount}`);
ctx.ui.notify(parts.join(" | "), "info");
},
});
pi.registerTool({
name: "ralph_loop",
label: "Ralph Loop",
description: [
"Run subagent tasks in a loop while a condition command prints 'true' to continue (anything else stops).",
"Supports single and chain modes.",
"Supports model/thinking overrides like subagent.",
"Defaults to agent 'worker' and the latest user message when agent/task are omitted.",
"If conditionCommand is omitted, it is inferred from the task text or defaults to 'echo true'.",
].join(" "),
parameters: LoopParams,
async execute(_toolCallId, params, signal, onUpdate, ctx) {
const agentScope: AgentScope = params.agentScope ?? "user";
const discovery = discoverAgents(ctx.cwd, agentScope);
const agents = discovery.agents;
const emptyPrompt: LoopPromptInfo = { mode: "single", items: [] };
const buildDetails = (overrides: Partial): RalphLoopDetails => ({
iterations: [],
stopReason: "invalid-params",
conditionCommand: "",
conditionSource: "default",
maxIterations: DEFAULT_LOOP_MAX_ITERATIONS,
sleepMs: DEFAULT_LOOP_SLEEP_MS,
lastCondition: { stdout: "", stderr: "", exitCode: 0 },
prompt: emptyPrompt,
steering: [...loopControl.steering, ...loopControl.steeringOnce],
followUps: [...loopControl.followUps],
steeringSent: [...loopControl.steeringSent],
followUpsSent: [...loopControl.followUpsSent],
status: loopControl.status,
...overrides,
});
if (signal?.aborted) {
return {
content: [{ type: "text", text: "ralph_loop aborted." }],
details: buildDetails({ stopReason: "aborted" }),
isError: true,
};
}
if (agents.length === 0) {
return {
content: [{ type: "text", text: "No agents available for the selected scope." }],
details: buildDetails({ stopReason: "no-agents" }),
isError: true,
};
}
const loopParams: any = { ...params, agentScope };
const hasChain = (params.chain?.length ?? 0) > 0;
const hasTasks = (params.tasks?.length ?? 0) > 0;
const hasSingle = Boolean(params.agent || params.task);
if (hasTasks) {
return {
content: [{ type: "text", text: "Parallel mode is not supported. Use chain instead." }],
details: buildDetails({ stopReason: "invalid-params" }),
isError: true,
};
}
const modeCount = Number(hasChain) + Number(hasSingle);
if (modeCount > 1) {
return {
content: [{ type: "text", text: "Provide exactly one mode (single or chain)." }],
details: buildDetails({ stopReason: "invalid-params" }),
isError: true,
};
}
if (!hasChain && !hasSingle) {
const inferredTask = getLastUserText(ctx);
const defaultAgent = pickDefaultAgent(agents);
if (!inferredTask || !defaultAgent) {
return {
content: [
{
type: "text",
text: "Unable to infer task or agent. Provide agent/task or chain.",
},
],
details: buildDetails({ stopReason: "missing-task" }),
isError: true,
};
}
loopParams.agent = defaultAgent;
loopParams.task = inferredTask;
}
if (hasSingle) {
if (!loopParams.agent) {
const defaultAgent = pickDefaultAgent(agents);
if (!defaultAgent) {
return {
content: [{ type: "text", text: "No agents available for the selected scope." }],
details: buildDetails({ stopReason: "no-agents" }),
isError: true,
};
}
loopParams.agent = defaultAgent;
}
if (!loopParams.task) {
const inferredTask = getLastUserText(ctx);
if (!inferredTask) {
return {
content: [{ type: "text", text: "Unable to infer task. Provide a task." }],
details: buildDetails({ stopReason: "missing-task" }),
isError: true,
};
}
loopParams.task = inferredTask;
}
}
const sessionThinking = pi.getThinkingLevel();
if (loopParams.thinking === undefined) {
loopParams.thinking = sessionThinking;
}
if (Array.isArray(loopParams.chain)) {
loopParams.chain = loopParams.chain.map((step: any) => ({
...step,
thinking: step.thinking ?? loopParams.thinking,
}));
}
const promptInfo = buildLoopPromptInfo(loopParams);
const conditionCandidate =
typeof params.conditionCommand === "string" && params.conditionCommand.trim()
? params.conditionCommand.trim()
: null;
let conditionSource: "provided" | "inferred" | "default" = "default";
let conditionCommand = conditionCandidate || "";
if (conditionCandidate) {
conditionSource = "provided";
} else {
let textForInference: string | undefined;
if (typeof loopParams.task === "string") textForInference = loopParams.task;
else if (Array.isArray(loopParams.chain) && loopParams.chain.length > 0) textForInference = loopParams.chain[0].task;
const inferred = inferConditionCommandFromText(textForInference);
if (inferred) {
conditionCommand = inferred;
conditionSource = "inferred";
} else {
conditionCommand = "echo true";
conditionSource = "default";
}
}
const maxIterations = parseLoopNumber(
params.maxIterations === undefined ? null : String(params.maxIterations),
DEFAULT_LOOP_MAX_ITERATIONS,
);
if (maxIterations === null || maxIterations <= 0) {
return {
content: [{ type: "text", text: "maxIterations must be a positive number." }],
details: buildDetails({
stopReason: "invalid-params",
conditionCommand,
conditionSource,
prompt: promptInfo,
}),
isError: true,
};
}
const sleepMs = parseLoopNumber(
params.sleepMs === undefined ? null : String(params.sleepMs),
DEFAULT_LOOP_SLEEP_MS,
true,
);
if (sleepMs === null || sleepMs < 0) {
return {
content: [{ type: "text", text: "sleepMs must be zero or a positive number." }],
details: buildDetails({
stopReason: "invalid-params",
conditionCommand,
conditionSource,
maxIterations,
prompt: promptInfo,
}),
isError: true,
};
}
const confirmProjectAgents = params.confirmProjectAgents ?? true;
const approved = confirmProjectAgents ? await confirmProjectAgentsOnce(loopParams, ctx) : true;
if (!approved) {
return {
content: [{ type: "text", text: "Canceled: project-local agents not approved." }],
details: buildDetails({
stopReason: "canceled",
conditionCommand,
conditionSource,
maxIterations,
sleepMs,
prompt: promptInfo,
}),
isError: true,
};
}
loopParams.confirmProjectAgents = false;
const runAbortController = new AbortController();
const mergedSignal = mergeAbortSignals(signal, runAbortController.signal);
const baseLoopParams = cloneLoopParams(loopParams);
loopControl.status = "running";
loopControl.runId = crypto.randomUUID().slice(0, 8);
loopControl.iterations = 0;
loopControl.steering = [];
loopControl.steeringOnce = [];
loopControl.followUps = [];
loopControl.steeringSent = [];
loopControl.followUpsSent = [];
loopControl.paused = false;
loopControl.abortController = runAbortController;
loopControl.lastDetails = null;
if (ctx.hasUI) {
ctx.ui.setStatus("ralph-loop", getLoopStatusLine());
}
const iterations: LoopIterationResult[] = [];
let stopReason = "running";
let errorMessage = "";
let lastCondition = { stdout: "", stderr: "", exitCode: 0 };
const buildLoopDetails = (currentIterations: LoopIterationResult[]): RalphLoopDetails => {
const details: RalphLoopDetails = {
iterations: [...currentIterations],
stopReason,
conditionCommand,
conditionSource,
maxIterations,
sleepMs,
lastCondition,
prompt: promptInfo,
steering: [...loopControl.steering, ...loopControl.steeringOnce],
followUps: [...loopControl.followUps],
steeringSent: [...loopControl.steeringSent],
followUpsSent: [...loopControl.followUpsSent],
status: loopControl.status,
};
loopControl.iterations = currentIterations.length;
loopControl.lastDetails = details;
return details;
};
const emitUpdate = () => {
const details = buildLoopDetails(iterations);
if (ctx.hasUI) {
ctx.ui.setStatus("ralph-loop", getLoopStatusLine());
}
if (!onUpdate) return;
onUpdate({
content: [
{
type: "text",
text: `ralph-loop: ${iterations.length}/${maxIterations} iterations complete`,
},
],
details,
});
};
for (let i = 0; i < maxIterations; i++) {
if (mergedSignal?.aborted) {
stopReason = "aborted";
break;
}
if (mergedSignal?.aborted) {
stopReason = "aborted";
break;
}
const condition = await checkLoopCondition(pi, conditionCommand, ctx.cwd, mergedSignal);
lastCondition = { stdout: condition.stdout, stderr: condition.stderr, exitCode: condition.exitCode };
if (!condition.shouldContinue) {
stopReason = "condition-false";
break;
}
const iterationIndex = i + 1;
const iterationUpdate: OnUpdateCallback | undefined = onUpdate
? (partial) => {
if (!partial.details) return;
const partialOutput = extractTextFromContent(partial.content);
const streamingIterations = [
...iterations,
{
index: iterationIndex,
details: partial.details,
output: partialOutput || "(running...)",
isError: partial.isError,
},
];
const details = buildLoopDetails(streamingIterations);
if (ctx.hasUI) {
ctx.ui.setStatus("ralph-loop", getLoopStatusLine());
}
onUpdate({
content: partial.content ?? [
{ type: "text", text: `ralph-loop iteration ${iterationIndex} running...` },
],
details,
});
}
: undefined;
let runResult: LoopExecutionResult | null = null;
const steeringOnceCount = loopControl.steeringOnce.length;
const steeringText = formatSteeringText([...loopControl.steering, ...loopControl.steeringOnce]);
const iterationParams = applySteeringToParams(baseLoopParams, steeringText);
const queuedFollowUps = loopControl.followUps;
if (queuedFollowUps.length > 0) {
loopControl.followUps = [];
loopControl.followUpsSent.push(...queuedFollowUps);
}
try {
runResult = await executeSubagentOnce(
iterationParams,
ctx,
mergedSignal,
iterationUpdate,
registerActiveRun,
queuedFollowUps,
);
} catch (error: any) {
stopReason = mergedSignal?.aborted ? "aborted" : "error";
errorMessage = error?.message || String(error);
break;
}
loopControl.steeringOnce = loopControl.steeringOnce.slice(steeringOnceCount);
loopControl.steeringSent = [];
loopControl.followUpsSent = [];
if (loopControl.paused && activeRuns.size === 0) {
clearPausedState(loopControl);
}
iterations.push({
index: iterationIndex,
details: runResult.details,
output: runResult.output,
isError: runResult.isError,
});
emitUpdate();
if (runResult.isError) {
stopReason = "error";
errorMessage = runResult.output;
break;
}
if (sleepMs > 0 && i < maxIterations - 1) {
await sleep(sleepMs, mergedSignal);
}
}
if (stopReason === "running") {
stopReason = "max-iterations";
}
const lastOutput = iterations.length > 0 ? iterations[iterations.length - 1].output : "";
const summaryLines = [
`ralph-loop finished after ${iterations.length} iteration${iterations.length === 1 ? "" : "s"}.`,
`Stop reason: ${stopReason}.`,
`Condition: ${conditionCommand} (${conditionSource}).`,
`Max iterations: ${maxIterations}.`,
`Sleep: ${sleepMs}ms.`,
];
if (lastCondition.stdout) summaryLines.push(`Condition stdout: ${lastCondition.stdout}`);
if (lastCondition.stderr) summaryLines.push(`Condition stderr: ${lastCondition.stderr}`);
if (lastCondition.exitCode !== 0) summaryLines.push(`Condition exit code: ${lastCondition.exitCode}`);
if (errorMessage && errorMessage !== lastOutput) summaryLines.push(`Error: ${errorMessage}`);
let lastOutputFullPath: string | null = null;
if (lastOutput) {
const formatted = formatLastOutputForSummary(lastOutput);
lastOutputFullPath = formatted.fullOutputPath;
summaryLines.push(`Last output:\n${formatted.text}`);
}
loopControl.status = "idle";
loopControl.abortController = null;
loopControl.paused = false;
loopControl.steering = [];
loopControl.steeringOnce = [];
loopControl.followUps = [];
loopControl.steeringSent = [];
loopControl.followUpsSent = [];
if (ctx.hasUI) {
ctx.ui.setStatus("ralph-loop", undefined);
}
const finalDetails = buildLoopDetails(iterations);
if (lastOutputFullPath) {
(finalDetails as any).lastOutputPath = lastOutputFullPath;
}
const summaryText = summaryLines.join("\n\n");
const isError = stopReason === "error" || stopReason === "aborted";
return {
content: [{ type: "text", text: summaryText }],
details: finalDetails,
isError,
};
},
renderCall(args, theme) {
const scope: AgentScope = args.agentScope ?? "user";
const hasChain = Array.isArray(args.chain) && args.chain.length > 0;
const hasSingle = Boolean(args.agent || args.task);
const mode = hasChain ? `chain (${args.chain.length} steps)` : hasSingle ? `single ${args.agent || "(auto)"}` : "auto";
const condition = args.conditionCommand ? `cond: ${args.conditionCommand}` : "cond: (auto)";
const maxIterations = args.maxIterations ?? DEFAULT_LOOP_MAX_ITERATIONS;
const sleepMs = args.sleepMs ?? DEFAULT_LOOP_SLEEP_MS;
const promptInfo = buildLoopPromptInfo(args);
let text =
theme.fg("toolTitle", theme.bold("ralph_loop ")) +
theme.fg("accent", mode) +
theme.fg("muted", ` [${scope}]`);
text += `\n ${theme.fg("dim", condition)}`;
if (promptInfo.items.length > 0) {
const preview = formatLoopPromptItem(promptInfo.items[0], 40);
const more = promptInfo.items.length > 1 ? ` +${promptInfo.items.length - 1} more` : "";
text += `\n ${theme.fg("dim", `prompt: ${preview}${more}`)}`;
}
text += `\n ${theme.fg("dim", `max:${maxIterations} sleep:${sleepMs}ms`)}`;
return new Text(text, 0, 0);
},
renderResult(result, { expanded }, theme) {
const details = result.details as RalphLoopDetails | undefined;
if (!details) {
const text = result.content[0];
return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
}
const iterations = details.iterations || [];
const isError = details.stopReason === "error" || details.stopReason === "aborted";
const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
const header =
icon +
" " +
theme.fg("toolTitle", theme.bold("ralph_loop ")) +
theme.fg("accent", `${iterations.length} iteration${iterations.length === 1 ? "" : "s"}`);
const wrapper = new Container();
const mainBox = new Box(1, 0, (text: string) => theme.bg("toolPendingBg", text));
const container = new Container();
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
container.addChild(new Spacer(1));
container.addChild(new Text(header, 1, 0));
container.addChild(new Spacer(1));
container.addChild(new DynamicBorder((s: string) => theme.fg("muted", s)));
const entriesComponent = renderLoopEntries(buildLoopEntries(details), theme, undefined, process.cwd(), expanded);
container.addChild(entriesComponent);
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
mainBox.addChild(container);
wrapper.addChild(mainBox);
return wrapper;
},
});
}