diff --git a/README.md b/README.md index 238cc19..f4213f4 100644 --- a/README.md +++ b/README.md @@ -98,16 +98,34 @@ tasks: ## Configuration -Create `.ralpi/config.yaml`: +Create config files. Both are optional: + +| Scope | Path | +|-------|------| +| **Global** | `~/.pi/ralpi/config.yaml` | +| **Project** | `./.ralpi/config.yaml` | ```yaml -maxRetries: 3 -retryDelayMs: 5000 -timeoutMs: 1800000 -maxParallel: 3 -projectContext: "Additional context for all tasks" +execution: + maxParallel: 3 # ralpi-level concurrency only +prompts: + projectContext: "Additional context for all tasks" ``` +> ralpi deliberately does **not** set timeouts or retries — those are inherited +> from Pi's own settings. Tasks run until they complete or Pi's own flow stops them. + +The keys mirror the nested structure of `RalpiConfig` in `src/types.ts`. + +### Precedence (highest wins) + +| Priority | Source | +|----------|--------| +| **1st** | In-memory overrides (`model`, `thinkingLevel` from parent Pi session) | +| **2nd** | `./.ralpi/config.yaml` — project-level | +| **3rd** | `~/.pi/ralpi/config.yaml` — global, shared across projects | +| **4th** | `DEFAULT_CONFIG` in `src/types.ts` | + ### Task-Level Timeout You can set a timeout for individual tasks using a meta block in the task file: diff --git a/index.ts b/index.ts index 614b25c..809eb53 100644 --- a/index.ts +++ b/index.ts @@ -1,27 +1,27 @@ import * as fs from "node:fs"; import * as path from "node:path"; import type { - ExtensionAPI, - ExtensionContext, + ExtensionAPI, + ExtensionContext, } from "@earendil-works/pi-coding-agent"; import { Box, Text } from "@earendil-works/pi-tui"; import { parseTaskFile, updateTaskInFile } from "./src/parser"; import { - buildExecutionPlan, - buildSequentialPlan, - formatExecutionPlan, - getReadyTasks, + buildExecutionPlan, + buildSequentialPlan, + formatExecutionPlan, + getReadyTasks, } from "./src/dag"; import { ProgressTracker } from "./src/progress"; import { buildPlanPrompt } from "./src/prompts"; import { formatReflections } from "./src/reflection"; import { executeBatch, type SendChatMessage } from "./src/executor"; import { - loadConfig, - resolveTaskArg, - formatProgressStatus, - formatAllPRDsStatus, - findProgressFile, + loadConfig, + resolveTaskArg, + formatProgressStatus, + formatAllPRDsStatus, + findProgressFile, } from "./src/utils"; const COMMANDS = ["status", "resume", "next", "reset"] as const; @@ -35,583 +35,622 @@ type ExecutionMode = "parallel" | "sequential"; * Matches: @path, /path, ./path, ../path, path/to/file, path.md, path.yaml */ function looksLikePath(token: string): boolean { - return ( - token.startsWith("@") || - token.startsWith("/") || - token.startsWith("./") || - token.startsWith("../") || - token.includes("/") || - token.endsWith(".md") || - token.endsWith(".yaml") || - token.endsWith(".yml") - ); + return ( + token.startsWith("@") || + token.startsWith("/") || + token.startsWith("./") || + token.startsWith("../") || + token.includes("/") || + token.endsWith(".md") || + token.endsWith(".yaml") || + token.endsWith(".yml") + ); } /** Build the set of completed tasks from progress tracker and PRD checkboxes. */ function buildCompletedSet( - progress: ProgressTracker, - project: import("./src/types").Project, + progress: ProgressTracker, + project: import("./src/types").Project, ): Set { - const completed = new Set(progress.getCompletedTaskIds()); - for (const task of project.tasks) { - if (task.status === "completed") { - completed.add(task.id); - } - } - return completed; + const completed = new Set(progress.getCompletedTaskIds()); + for (const task of project.tasks) { + if (task.status === "completed") { + completed.add(task.id); + } + } + return completed; } /** Prompt user to select an execution mode with dependency validation. */ async function selectExecutionMode( - ctx: ExtensionContext, - project: import("./src/types").Project, - taskFile: string, + ctx: ExtensionContext, + project: import("./src/types").Project, + taskFile: string, ): Promise { - const mode = await ctx.ui.select("Execution mode for this run?", [ - "Parallel (where dependencies allow)", - "Sequential (one at a time)", - ]); - const isParallel = mode?.startsWith("Parallel") ?? false; + const mode = await ctx.ui.select("Execution mode for this run?", [ + "Parallel (where dependencies allow)", + "Sequential (one at a time)", + ]); + const isParallel = mode?.startsWith("Parallel") ?? false; - if (!isParallel) return "sequential"; + if (!isParallel) return "sequential"; - // Validate dependency graph for parallel mode - if (Object.keys(project.dependencies).length === 0) { - const hasDepsSection = await fs.promises - .readFile(taskFile, "utf-8") - .then((content) => /^##\s+Dependencies\s*$/m.test(content)) - .catch(() => false); + // Validate dependency graph for parallel mode + if (Object.keys(project.dependencies).length === 0) { + const hasDepsSection = await fs.promises + .readFile(taskFile, "utf-8") + .then((content) => /^##\s+Dependencies\s*$/m.test(content)) + .catch(() => false); - if (hasDepsSection) { - const choice = await ctx.ui.select( - "Found ## Dependencies section but no valid dependencies were parsed.\n\n" + - "This may be due to unsupported format. Parallel mode requires explicit dependencies.\n\n" + - "See README.md for supported dependency formats:\n" + - "- Arrow notation: `1 -> 2,3,4`\n" + - "- Natural language: `13 depends on 17, 18, 19, 20`\n\n" + - "Fall back to sequential mode?", - ["Yes, use sequential", "No, continue with parallel"], - ); - if (choice?.startsWith("Yes")) { - return "sequential"; - } - } - } + if (hasDepsSection) { + const choice = await ctx.ui.select( + "Found ## Dependencies section but no valid dependencies were parsed.\n\n" + + "This may be due to unsupported format. Parallel mode requires explicit dependencies.\n\n" + + "See README.md for supported dependency formats:\n" + + "- Arrow notation: `1 -> 2,3,4`\n" + + "- Natural language: `13 depends on 17, 18, 19, 20`\n\n" + + "Fall back to sequential mode?", + ["Yes, use sequential", "No, continue with parallel"], + ); + if (choice?.startsWith("Yes")) { + return "sequential"; + } + } + } - return "parallel"; + return "parallel"; } /** Build an execution plan based on the selected mode. */ function buildPlanByMode( - mode: ExecutionMode, - project: Parameters[0], - completed: Set, + mode: ExecutionMode, + project: Parameters[0], + completed: Set, ) { - return mode === "parallel" - ? buildExecutionPlan(project, completed) - : buildSequentialPlan(project, completed); + return mode === "parallel" + ? buildExecutionPlan(project, completed) + : buildSequentialPlan(project, completed); } /** Run all batches in a plan, updating the task file after each batch. */ async function executePlanBatches( - plan: ReturnType, - project: Parameters[0], - taskFile: string, - config: import("./src/types").ralpiConfig, - progress: ProgressTracker, - ctx: ExtensionContext, - mode: ExecutionMode, - sendChatMessage?: SendChatMessage, - projectDir?: string, + plan: ReturnType, + project: Parameters[0], + taskFile: string, + config: import("./src/types").RalpiConfig, + progress: ProgressTracker, + ctx: ExtensionContext, + mode: ExecutionMode, + sendChatMessage?: SendChatMessage, + projectDir?: string, ): Promise { - for (const batch of plan.batches) { - if (progress.getState().paused) { - ctx.ui.notify( - "Execution paused. Use /ralpi resume to continue.", - "warning", - ); - return; - } + for (const batch of plan.batches) { + if (progress.getState().paused) { + ctx.ui.notify( + "Execution paused. Use /ralpi resume to continue.", + "warning", + ); + return; + } - if (!Array.isArray(batch.tasks)) { - throw new Error( - `Batch ${ - batch.batchIndex - } has invalid tasks: expected array, got ${typeof batch.tasks}`, - ); - } + if (!Array.isArray(batch.tasks)) { + throw new Error( + `Batch ${ + batch.batchIndex + } has invalid tasks: expected array, got ${typeof batch.tasks}`, + ); + } - await executeBatch( - batch.tasks, - project, - config, - progress, - ctx, - { parallel: mode === "parallel" }, - sendChatMessage, - projectDir, - ); + await executeBatch( + batch.tasks, + project, + config, + progress, + ctx, + { parallel: mode === "parallel" }, + sendChatMessage, + projectDir, + ); - for (const task of batch.tasks) { - const status = progress.getTaskStatus(task.id); - updateTaskInFile(taskFile, task.id, status); - } - } + for (const task of batch.tasks) { + const status = progress.getTaskStatus(task.id); + updateTaskInFile(taskFile, task.id, status); + } + } } // ─── Extension Entry ──────────────────────────────────────────────────────── export default function ralpiLoopExtension(pi: ExtensionAPI): void { - // Register custom message renderer for ralpi progress messages. - // Renders an expandable tool-call tree: collapsed shows last 3 + "N more", - // expanded (Ctrl+O) shows every tool call. - pi.registerMessageRenderer( - "ralpi-progress", - (message, { expanded }, theme) => { - const details = message.details as - | { - phase?: string; - toolCalls?: Array<{ name: string; label: string }>; - } - | undefined; + // Register custom message renderer for ralpi progress messages. + // Renders an expandable tool-call tree: collapsed shows last 3 + "N more", + // expanded (Ctrl+O) shows every tool call. + pi.registerMessageRenderer( + "ralpi-progress", + (message, { expanded }, theme) => { + const details = message.details as + | { + phase?: string; + toolCalls?: Array<{ name: string; label: string }>; + } + | undefined; - const MAX_COLLAPSED = 3; - const lines: string[] = []; + const MAX_COLLAPSED = 3; + const lines: string[] = []; - // Header line — e.g. "✓ 05 · billing-subscriptions-trials (2m 14s)" - lines.push(String(message.content)); + // Header line — e.g. "✓ 05 · billing-subscriptions-trials (2m 14s)" + lines.push(String(message.content)); - // Build tool-call tree - if (details?.toolCalls && details.toolCalls.length > 0) { - const all = details.toolCalls; + // Build tool-call tree + if (details?.toolCalls && details.toolCalls.length > 0) { + const all = details.toolCalls; - if (expanded) { - // Expanded: show ALL tool calls - for (let i = 0; i < all.length; i++) { - const entry = all[i]; - const isLast = i === all.length - 1; - const branch = isLast ? " └── " : " ├── "; - const tag = theme.fg("accent", `[${entry.name}]`); - lines.push(`${branch}${tag} ${entry.label}`); - } - } else { - // Collapsed: last N + "X more" - const shown = all.slice(-MAX_COLLAPSED); - const remaining = all.length - shown.length; + if (expanded) { + // Expanded: show ALL tool calls + for (let i = 0; i < all.length; i++) { + const entry = all[i]; + const isLast = i === all.length - 1; + const branch = isLast ? " └── " : " ├── "; + const tag = theme.fg("accent", `[${entry.name}]`); + lines.push(`${branch}${tag} ${entry.label}`); + } + } else { + // Collapsed: last N + "X more" + const shown = all.slice(-MAX_COLLAPSED); + const remaining = all.length - shown.length; - if (remaining > 0) { - lines.push(theme.fg("dim", ` ├── ${remaining} more`)); - } + if (remaining > 0) { + lines.push(theme.fg("dim", ` ├── ${remaining} more`)); + } - for (let i = 0; i < shown.length; i++) { - const entry = shown[i]; - const isLast = i === shown.length - 1; - const branch = isLast ? " └── " : " ├── "; - const tag = theme.fg("accent", `[${entry.name}]`); - lines.push(`${branch}${tag} ${entry.label}`); - } - } - } + for (let i = 0; i < shown.length; i++) { + const entry = shown[i]; + const isLast = i === shown.length - 1; + const branch = isLast ? " └── " : " ├── "; + const tag = theme.fg("accent", `[${entry.name}]`); + lines.push(`${branch}${tag} ${entry.label}`); + } + } + } - const text = lines.join("\n"); - const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t)); - box.addChild(new Text(text, 0, 0)); - return box; - }, - ); + const text = lines.join("\n"); + const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t)); + box.addChild(new Text(text, 0, 0)); + return box; + }, + ); - pi.registerCommand("ralpi", { - description: - "Execute tasks from a task file using DAG-based dependency resolution", - handler: async (args: string, ctx: ExtensionContext) => { - const parts = (args || "").trim().split(/\s+/).filter(Boolean); + pi.registerCommand("ralpi", { + description: + "Execute tasks from a task file using DAG-based dependency resolution", + handler: async (args: string, ctx: ExtensionContext) => { + const parts = (args || "").trim().split(/\s+/).filter(Boolean); - // Wraps pi.sendMessage() for posting status to the chat history. - // Uses "ralpi-progress" customType with a "progress" phase so the - // renderer omits the label prefix entirely (no [INFO] etc.). - // Accepts an optional meta object with toolCalls for the expandable view. - const sendProgress: SendChatMessage = ( - content: string, - meta?: { toolCalls?: Array<{ name: string; label: string }> }, - ) => { - pi.sendMessage({ - customType: "ralpi-progress", - content, - display: true, - details: { phase: "progress", toolCalls: meta?.toolCalls }, - }); - }; + // Wraps pi.sendMessage() for posting status to the chat history. + // Uses "ralpi-progress" customType with a "progress" phase so the + // renderer omits the label prefix entirely (no [INFO] etc.). + // Accepts an optional meta object with toolCalls for the expandable view. + const sendProgress: SendChatMessage = ( + content: string, + meta?: { toolCalls?: Array<{ name: string; label: string }> }, + ) => { + pi.sendMessage({ + customType: "ralpi-progress", + content, + display: true, + details: { phase: "progress", toolCalls: meta?.toolCalls }, + }); + }; - // If no args, show plan. If first token looks like a path (@path, /path, ./path), - // route to run so the execution mode prompt fires. - if (parts.length === 0) { - return handlePlan(ctx, parts); - } - if (looksLikePath(parts[0])) { - return handleRun(ctx, parts, sendProgress); - } + // If no args, show plan. If first token looks like a path (@path, /path, ./path), + // route to run so the execution mode prompt fires. + if (parts.length === 0) { + return handlePlan(ctx, parts); + } + if (looksLikePath(parts[0])) { + return handleRun( + ctx, + parts, + sendProgress, + ctx.model, + pi.getThinkingLevel(), + ); + } - const command = parts[0]; - switch (command) { - case "run": - return handleRun(ctx, parts.slice(1), sendProgress); - case "plan": - return handlePlan(ctx, parts.slice(1)); - case "status": - return handleStatus(ctx, parts.slice(1)); - case "resume": - return handleResume(ctx, parts.slice(1), sendProgress); - case "next": - return handleNext(ctx, parts.slice(1), sendProgress); - case "reset": - return handleReset(ctx, parts.slice(1)); - default: { - // Auto-discover progress and offer resume - const found = findProgressFile(process.cwd()); - if (found) { - ctx.ui.notify( - `Unknown command: ${command}\n\nFound existing progress in ${ - found.path - }\nUse /ralpi resume to continue.\n\nAvailable: ${COMMANDS.join( - ", ", - )}`, - "warning", - ); - } else { - ctx.ui.notify( - `Unknown command: ${command}\nAvailable: ${COMMANDS.join(", ")}`, - "error", - ); - } - } - } - }, - }); + const command = parts[0]; + switch (command) { + case "run": + return handleRun( + ctx, + parts.slice(1), + sendProgress, + ctx.model, + pi.getThinkingLevel(), + ); + case "plan": + return handlePlan(ctx, parts.slice(1)); + case "status": + return handleStatus(ctx, parts.slice(1)); + case "resume": + return handleResume( + ctx, + parts.slice(1), + sendProgress, + ctx.model, + pi.getThinkingLevel(), + ); + case "next": + return handleNext( + ctx, + parts.slice(1), + sendProgress, + ctx.model, + pi.getThinkingLevel(), + ); + case "reset": + return handleReset(ctx, parts.slice(1)); + default: { + // Auto-discover progress and offer resume + const found = findProgressFile(process.cwd()); + if (found) { + ctx.ui.notify( + `Unknown command: ${command}\n\nFound existing progress in ${ + found.path + }\nUse /ralpi resume to continue.\n\nAvailable: ${COMMANDS.join( + ", ", + )}`, + "warning", + ); + } else { + ctx.ui.notify( + `Unknown command: ${command}\nAvailable: ${COMMANDS.join(", ")}`, + "error", + ); + } + } + } + }, + }); } // ─── /ralpi plan ───────────────────────────────────────────────────────────── async function handlePlan( - ctx: ExtensionContext, - args: string[], + ctx: ExtensionContext, + args: string[], ): Promise { - const taskFile = resolveTaskArg(args[0] || "README.md", process.cwd()); - const project = parseTaskFile(taskFile); - if (!Array.isArray(project.tasks)) { - throw new Error( - `Parsed project from ${taskFile} has invalid tasks: expected array, got ${typeof project.tasks}`, - ); - } + const taskFile = resolveTaskArg(args[0] || "README.md", process.cwd()); + const project = parseTaskFile(taskFile); + if (!Array.isArray(project.tasks)) { + throw new Error( + `Parsed project from ${taskFile} has invalid tasks: expected array, got ${typeof project.tasks}`, + ); + } - const planPrompt = buildPlanPrompt(project); - const plan = buildExecutionPlan(project, new Set()); - const formatted = formatExecutionPlan(plan); + const planPrompt = buildPlanPrompt(project); + const plan = buildExecutionPlan(project, new Set()); + const formatted = formatExecutionPlan(plan); - ctx.ui.notify(`${planPrompt}\n\n${formatted}`, "info"); + ctx.ui.notify(`${planPrompt}\n\n${formatted}`, "info"); } // ─── /ralpi run ────────────────────────────────────────────────────────────── async function handleRun( - ctx: ExtensionContext, - args: string[], - sendChatMessage?: SendChatMessage, + ctx: ExtensionContext, + args: string[], + sendChatMessage?: SendChatMessage, + parentModel?: unknown, + parentThinkingLevel?: unknown, ): Promise { - const taskFile = resolveTaskArg(args[0] || "README.md", process.cwd()); + const taskFile = resolveTaskArg(args[0] || "README.md", process.cwd()); - // If targeting a specific task file and there's existing progress for it, - // auto-resume instead of starting fresh - const existingProgress = findProgressFile(process.cwd(), taskFile); - if (existingProgress) { - return handleResume(ctx, args.slice(0, 1), sendChatMessage); - } + // If targeting a specific task file and there's existing progress for it, + // auto-resume instead of starting fresh + const existingProgress = findProgressFile(process.cwd(), taskFile); + if (existingProgress) { + return handleResume( + ctx, + args.slice(0, 1), + sendChatMessage, + parentModel, + parentThinkingLevel, + ); + } - // No existing progress for this task — check for any progress at all - const found = findProgressFile(process.cwd()); - if (found && !args[0]) { - // Offer to resume instead of starting fresh - const shouldResume = await ctx.ui.select( - "Found existing ralpi progress. Resume?", - ["Yes, resume", "No, start fresh"], - ); + // No existing progress for this task — check for any progress at all + const found = findProgressFile(process.cwd()); + if (found && !args[0]) { + // Offer to resume instead of starting fresh + const shouldResume = await ctx.ui.select( + "Found existing ralpi progress. Resume?", + ["Yes, resume", "No, start fresh"], + ); - if (shouldResume?.startsWith("Yes")) { - return handleResume(ctx, [], sendChatMessage); - } - } + if (shouldResume?.startsWith("Yes")) { + return handleResume( + ctx, + [], + sendChatMessage, + parentModel, + parentThinkingLevel, + ); + } + } - const projectDir = found - ? path.dirname(path.dirname(found.path)) - : process.cwd(); + const projectDir = found + ? path.dirname(path.dirname(found.path)) + : process.cwd(); - const project = parseTaskFile(taskFile); - const config = loadConfig(projectDir); - const progress = new ProgressTracker(projectDir, taskFile); + const project = parseTaskFile(taskFile); + const config = loadConfig(projectDir); + config.model = parentModel ?? ctx.model; + config.thinkingLevel = parentThinkingLevel; + const progress = new ProgressTracker(projectDir, taskFile); - // Set initial status - ctx.ui.setStatus( - "ralpi", - `Starting ${project.tasks.length} tasks from ${path.basename(taskFile)}`, - ); + const completed = buildCompletedSet(progress, project); + const mode = await selectExecutionMode(ctx, project, taskFile); + const plan = buildPlanByMode(mode, project, completed); - const completed = buildCompletedSet(progress, project); - const mode = await selectExecutionMode(ctx, project, taskFile); - const plan = buildPlanByMode(mode, project, completed); + // Show execution plan before starting so user can see batch breakdown + const formattedPlan = formatExecutionPlan(plan); + ctx.ui.notify(`${formattedPlan}\n\nStarting ${mode} execution...`, "info"); - // Show execution plan before starting so user can see batch breakdown - const formattedPlan = formatExecutionPlan(plan); - ctx.ui.notify(`${formattedPlan}\n\nStarting ${mode} execution...`, "info"); + await executePlanBatches( + plan, + project, + taskFile, + config, + progress, + ctx, + mode, + sendChatMessage, + projectDir, + ); - await executePlanBatches( - plan, - project, - taskFile, - config, - progress, - ctx, - mode, - sendChatMessage, - projectDir, - ); + const state = progress.getState(); + const output = formatProgressStatus(state); - const state = progress.getState(); - const output = formatProgressStatus(state); + const reflections = progress.getAllReflections(); + if (reflections.length > 0) { + ctx.ui.notify(`${output}\n\n${formatReflections(reflections)}`, "info"); + return; + } - const reflections = progress.getAllReflections(); - if (reflections.length > 0) { - ctx.ui.notify(`${output}\n\n${formatReflections(reflections)}`, "info"); - return; - } - - ctx.ui.notify(output, "info"); + ctx.ui.notify(output, "info"); } // ─── /ralpi status ─────────────────────────────────────────────────────────── async function handleStatus( - ctx: ExtensionContext, - args: string[], + ctx: ExtensionContext, + args: string[], ): Promise { - if (args[0]) { - const taskFile = resolveTaskArg(args[0], process.cwd()); - const existingProgress = findProgressFile(process.cwd(), taskFile); - if (existingProgress) { - const projectDir = path.dirname(path.dirname(existingProgress.path)); - const progress = new ProgressTracker( - projectDir, - taskFile, - existingProgress.prdKey, - ); - ctx.ui.notify(formatProgressStatus(progress.getState()), "info"); - return; - } - // No progress yet for this task — parse and show plan instead - const project = parseTaskFile(taskFile); - ctx.ui.notify( - `No progress for ${path.basename(taskFile)}. ${ - project.tasks.length - } tasks found.\nUse /ralpi run ${args[0]} to start.`, - "info", - ); - return; - } + if (args[0]) { + const taskFile = resolveTaskArg(args[0], process.cwd()); + const existingProgress = findProgressFile(process.cwd(), taskFile); + if (existingProgress) { + const projectDir = path.dirname(path.dirname(existingProgress.path)); + const progress = new ProgressTracker( + projectDir, + taskFile, + existingProgress.prdKey, + ); + ctx.ui.notify(formatProgressStatus(progress.getState()), "info"); + return; + } + // No progress yet for this task — parse and show plan instead + const project = parseTaskFile(taskFile); + ctx.ui.notify( + `No progress for ${path.basename(taskFile)}. ${ + project.tasks.length + } tasks found.\nUse /ralpi run ${args[0]} to start.`, + "info", + ); + return; + } - const found = findProgressFile(process.cwd()); - if (!found) { - ctx.ui.notify( - "No .ralpi/progress.json found. Start with /ralpi run [task-file]", - "warning", - ); - return; - } + const found = findProgressFile(process.cwd()); + if (!found) { + ctx.ui.notify( + "No .ralpi/progress.json found. Start with /ralpi run [task-file]", + "warning", + ); + return; + } - ctx.ui.notify(formatAllPRDsStatus(found.state), "info"); + ctx.ui.notify(formatAllPRDsStatus(found.state), "info"); } // ─── /ralpi resume ─────────────────────────────────────────────────────────── async function handleResume( - ctx: ExtensionContext, - args: string[], - sendChatMessage?: SendChatMessage, + ctx: ExtensionContext, + args: string[], + sendChatMessage?: SendChatMessage, + parentModel?: unknown, + parentThinkingLevel?: unknown, ): Promise { - let taskFile: string; - let projectDir: string; - let found: ReturnType; + let taskFile: string; + let projectDir: string; + let found: ReturnType; - if (args[0]) { - taskFile = resolveTaskArg(args[0], process.cwd()); - found = findProgressFile(process.cwd(), taskFile); - if (!found) { - ctx.ui.notify( - `No existing progress for ${args[0]}. Start with /ralpi run ${args[0]}`, - "warning", - ); - return; - } - projectDir = path.dirname(path.dirname(found.path)); - } else { - found = findProgressFile(process.cwd()); - if (!found) { - ctx.ui.notify( - "No .ralpi/progress.json found. Start with /ralpi run [task-file]", - "warning", - ); - return; - } - projectDir = path.dirname(path.dirname(found.path)); - // For no-arg resume, use the first PRD's source path or legacy sourcePath - taskFile = found.state.prds - ? Object.values(found.state.prds)[0].sourcePath - : found.state.sourcePath; - } + if (args[0]) { + taskFile = resolveTaskArg(args[0], process.cwd()); + found = findProgressFile(process.cwd(), taskFile); + if (!found) { + ctx.ui.notify( + `No existing progress for ${args[0]}. Start with /ralpi run ${args[0]}`, + "warning", + ); + return; + } + projectDir = path.dirname(path.dirname(found.path)); + } else { + found = findProgressFile(process.cwd()); + if (!found) { + ctx.ui.notify( + "No .ralpi/progress.json found. Start with /ralpi run [task-file]", + "warning", + ); + return; + } + projectDir = path.dirname(path.dirname(found.path)); + // For no-arg resume, use the first PRD's source path or legacy sourcePath + taskFile = found.state.prds + ? Object.values(found.state.prds)[0].sourcePath + : found.state.sourcePath; + } - const project = parseTaskFile(taskFile); - if (!Array.isArray(project.tasks)) { - throw new Error( - `Parsed project from ${taskFile} has invalid tasks: expected array, got ${typeof project.tasks}`, - ); - } - const config = loadConfig(projectDir); - const progress = new ProgressTracker(projectDir, taskFile, found.prdKey); + const project = parseTaskFile(taskFile); + if (!Array.isArray(project.tasks)) { + throw new Error( + `Parsed project from ${taskFile} has invalid tasks: expected array, got ${typeof project.tasks}`, + ); + } + const config = loadConfig(projectDir); + config.model = parentModel ?? ctx.model; + config.thinkingLevel = parentThinkingLevel; + const progress = new ProgressTracker(projectDir, taskFile, found.prdKey); - progress.setPaused(false); + progress.setPaused(false); - // Set resume status - ctx.ui.setStatus("ralpi", `Resuming from ${path.basename(taskFile)}`); + const completed = buildCompletedSet(progress, project); + const mode = await selectExecutionMode(ctx, project, taskFile); + const plan = buildPlanByMode(mode, project, completed); - const completed = buildCompletedSet(progress, project); - const mode = await selectExecutionMode(ctx, project, taskFile); - const plan = buildPlanByMode(mode, project, completed); + await executePlanBatches( + plan, + project, + taskFile, + config, + progress, + ctx, + mode, + sendChatMessage, + projectDir, + ); - await executePlanBatches( - plan, - project, - taskFile, - config, - progress, - ctx, - mode, - sendChatMessage, - projectDir, - ); - - ctx.ui.notify(formatProgressStatus(progress.getState()), "info"); + ctx.ui.notify(formatProgressStatus(progress.getState()), "info"); } // ─── /ralpi next ───────────────────────────────────────────────────────────── async function handleNext( - ctx: ExtensionContext, - args: string[], - sendChatMessage?: SendChatMessage, + ctx: ExtensionContext, + args: string[], + sendChatMessage?: SendChatMessage, + parentModel?: unknown, + parentThinkingLevel?: unknown, ): Promise { - let taskFile: string; - let projectDir: string; - let found: ReturnType; + let taskFile: string; + let projectDir: string; + let found: ReturnType; - if (args[0]) { - taskFile = resolveTaskArg(args[0], process.cwd()); - found = findProgressFile(process.cwd(), taskFile); - if (found) { - projectDir = path.dirname(path.dirname(found.path)); - } else { - projectDir = process.cwd(); - } - } else { - found = findProgressFile(process.cwd()); - if (!found) { - ctx.ui.notify( - "No .ralpi/progress.json found. Start with /ralpi run [task-file]", - "warning", - ); - return; - } - taskFile = found.state.prds - ? Object.values(found.state.prds)[0].sourcePath - : found.state.sourcePath; - projectDir = path.dirname(path.dirname(found.path)); - } + if (args[0]) { + taskFile = resolveTaskArg(args[0], process.cwd()); + found = findProgressFile(process.cwd(), taskFile); + if (found) { + projectDir = path.dirname(path.dirname(found.path)); + } else { + projectDir = process.cwd(); + } + } else { + found = findProgressFile(process.cwd()); + if (!found) { + ctx.ui.notify( + "No .ralpi/progress.json found. Start with /ralpi run [task-file]", + "warning", + ); + return; + } + taskFile = found.state.prds + ? Object.values(found.state.prds)[0].sourcePath + : found.state.sourcePath; + projectDir = path.dirname(path.dirname(found.path)); + } - const project = parseTaskFile(taskFile); - if (!Array.isArray(project.tasks)) { - throw new Error( - `Parsed project from ${taskFile} has invalid tasks: expected array, got ${typeof project.tasks}`, - ); - } - const config = loadConfig(projectDir); - const progress = new ProgressTracker(projectDir, taskFile, found?.prdKey); + const project = parseTaskFile(taskFile); + if (!Array.isArray(project.tasks)) { + throw new Error( + `Parsed project from ${taskFile} has invalid tasks: expected array, got ${typeof project.tasks}`, + ); + } + const config = loadConfig(projectDir); + config.model = parentModel ?? ctx.model; + config.thinkingLevel = parentThinkingLevel; + const progress = new ProgressTracker(projectDir, taskFile, found?.prdKey); - const completed = buildCompletedSet(progress, project); - const ready = getReadyTasks(project, completed); + const completed = buildCompletedSet(progress, project); + const ready = getReadyTasks(project, completed); - if (ready.length === 0) { - ctx.ui.notify( - "No tasks ready to execute. All tasks completed or blocked.", - "info", - ); - return; - } + if (ready.length === 0) { + ctx.ui.notify( + "No tasks ready to execute. All tasks completed or blocked.", + "info", + ); + return; + } - const nextBatch = ready.slice( - 0, - config.execution.maxParallel || ready.length, - ); + const nextBatch = ready.slice( + 0, + config.execution.maxParallel || ready.length, + ); - for (const task of nextBatch) { - await executeBatch( - [task], - project, - config, - progress, - ctx, - { parallel: false }, - sendChatMessage, - projectDir, - ); - updateTaskInFile(taskFile, task.id, progress.getTaskStatus(task.id)); - } + for (const task of nextBatch) { + await executeBatch( + [task], + project, + config, + progress, + ctx, + { parallel: false }, + sendChatMessage, + projectDir, + ); + updateTaskInFile(taskFile, task.id, progress.getTaskStatus(task.id)); + } - ctx.ui.notify( - `Executed: ${nextBatch - .map((t) => t.id) - .join(", ")}\n\n${formatProgressStatus(progress.getState())}`, - "info", - ); + ctx.ui.notify( + `Executed: ${nextBatch + .map((t) => t.id) + .join(", ")}\n\n${formatProgressStatus(progress.getState())}`, + "info", + ); } // ─── /ralpi reset ──────────────────────────────────────────────────────────── async function handleReset( - ctx: ExtensionContext, - args: string[], + ctx: ExtensionContext, + args: string[], ): Promise { - if (args[0]) { - const taskFile = resolveTaskArg(args[0], process.cwd()); - const found = findProgressFile(process.cwd(), taskFile); - const projectDir = found - ? path.dirname(path.dirname(found.path)) - : process.cwd(); - const progress = new ProgressTracker(projectDir, taskFile, found?.prdKey); - progress.reset(); - } else { - const found = findProgressFile(process.cwd()); - if (!found) { - ctx.ui.notify( - "No .ralpi/progress.json found. Start with /ralpi run [task-file]", - "warning", - ); - return; - } - const projectDir = path.dirname(path.dirname(found.path)); - const progress = new ProgressTracker( - projectDir, - found.state.prds - ? Object.values(found.state.prds)[0].sourcePath - : found.state.sourcePath, - ); - progress.reset(); - } + if (args[0]) { + const taskFile = resolveTaskArg(args[0], process.cwd()); + const found = findProgressFile(process.cwd(), taskFile); + const projectDir = found + ? path.dirname(path.dirname(found.path)) + : process.cwd(); + const progress = new ProgressTracker(projectDir, taskFile, found?.prdKey); + progress.reset(); + } else { + const found = findProgressFile(process.cwd()); + if (!found) { + ctx.ui.notify( + "No .ralpi/progress.json found. Start with /ralpi run [task-file]", + "warning", + ); + return; + } + const projectDir = path.dirname(path.dirname(found.path)); + const progress = new ProgressTracker( + projectDir, + found.state.prds + ? Object.values(found.state.prds)[0].sourcePath + : found.state.sourcePath, + ); + progress.reset(); + } - ctx.ui.notify("Progress reset. All task statuses cleared.", "info"); + ctx.ui.notify("Progress reset. All task statuses cleared.", "info"); } diff --git a/package.json b/package.json index 634e0f9..ecbd460 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "ralpi-loop", - "version": "1.0.0", - "description": "Execute tasks from task files using DAG-based dependency resolution with persistent progress tracking", + "name": "ralpi", + "version": "0.1.0", + "description": "Execute tasks from task files/PRD's using DAG-based dependency resolution with persistent progress tracking", "main": "dist/index.js", "keywords": [ "pi-package", diff --git a/src/executor.ts b/src/executor.ts index 0e31f72..ff86dee 100644 --- a/src/executor.ts +++ b/src/executor.ts @@ -5,27 +5,47 @@ import type { ProgressTracker } from "./progress"; import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { buildTaskPrompt } from "./prompts"; import { extractReflection } from "./reflection"; -import { WidgetBatcher } from "./widget-batcher"; import { - runAgentSession, - writeFileSafe, - ensureDir, - captureGitCommits, - formatDuration, + runAgentSession, + writeFileSafe, + ensureDir, + captureGitCommits, + formatDuration, } from "./utils"; /** Optional callback to post a progress message into the chat history. */ export type SendChatMessage = ( - content: string, - /** Extra data passed to the message renderer for the expanded view. */ - meta?: { toolCalls?: ToolCallEntry[] }, + content: string, + /** Extra data passed to the message renderer for the expanded view. */ + meta?: { toolCalls?: ToolCallEntry[] }, ) => void; export interface ToolCallEntry { - name: string; - label: string; + name: string; + label: string; } +// ─── Widget Expand/Collapse ─────────────────────────────────────────────── + +/** Max tool calls shown in a live widget before truncating. Widgets don't + * support message-style Ctrl+O expansion (that's only for chat-history + * messages rendered by registerMessageRenderer). */ +const MAX_COLLAPSED = 3; + +const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +/** Shared state for parallel-batch widget. Each running task writes its + * tool calls and spinner frame; the batch widget reads them in task-ID order. */ +interface ParallelWidgetEntry { + taskHeader: string; + frameIndex: number; + done: boolean; + success: boolean; + toolCalls: ToolCallEntry[]; +} + +type ParallelWidgetState = Map; + // ─── Run Single Task ──────────────────────────────────────────────────────── /** @@ -33,176 +53,204 @@ export interface ToolCallEntry { * Non-blocking — the TUI remains responsive throughout. */ export async function runTask( - task: Task, - project: Project, - config: RalpiConfig, - depReflections: Reflection[], - ctx: ExtensionContext, - sendChatMessage?: SendChatMessage, - projectDir: string = project.sourceDir, - batcher?: WidgetBatcher, + task: Task, + project: Project, + config: RalpiConfig, + depReflections: Reflection[], + ctx: ExtensionContext, + sendChatMessage?: SendChatMessage, + projectDir: string = project.sourceDir, + parallelState?: ParallelWidgetState, ): Promise<{ - success: boolean; - reflection?: Reflection; - error?: string; - durationMs: number; - toolUsage?: ToolUsage; - outputPreview?: string; - sessionFile?: string; - commitMessages?: string[]; - commitSummary?: string; + success: boolean; + reflection?: Reflection; + error?: string; + durationMs: number; + toolUsage?: ToolUsage; + outputPreview?: string; + sessionFile?: string; + commitMessages?: string[]; + commitSummary?: string; }> { - const startMs = Date.now(); + const startMs = Date.now(); - // Build prompt - const prompt = buildTaskPrompt( - task, - project, - depReflections, - config.prompts.projectContext, - ); + // Build prompt + const prompt = buildTaskPrompt( + task, + project, + depReflections, + config.prompts.projectContext, + ); - // Write prompt to .ralpi/ with timestamp (for debugging) - const ralpiDir = path.join(projectDir, ".ralpi"); - ensureDir(ralpiDir); - const promptFile = path.join(ralpiDir, `prompt-${startMs}.md`); - writeFileSafe(promptFile, prompt); + // Write prompt to .ralpi/ with timestamp (for debugging) + const ralpiDir = path.join(projectDir, ".ralpi"); + ensureDir(ralpiDir); + const promptFile = path.join(ralpiDir, `prompt-${startMs}.md`); + writeFileSafe(promptFile, prompt); - // Footer shows just the task title (no batch prefix) - ctx.ui.setStatus("ralpi", task.title); + const taskHeader = `${task.id} · ${task.title}`; - const taskHeader = `${task.id} · ${task.title}`; + // When running in parallel, all tasks share a single widget so ordering + // is deterministic (sorted by task ID). In sequential mode each task gets + // its own widget. + const isParallel = !!parallelState; + const widgetKey = `ralpi-task-${task.id}`; + let frameIndex = 0; + const toolCalls: ToolCallEntry[] = []; + let widgetTui: { requestRender(): void } | null = null; - // Live progress widget above the editor — animated spinner + tool call tree - // Using setWidget instead of setWorkingMessage because the working message area - // is only visible during parent agent streaming, not during extension command execution. - // Widget key is unique per task so parallel tasks each get their own widget. - const widgetKey = `ralpi-task-${task.id}`; - const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - let frameIndex = 0; - const theme = ctx.ui.theme; - const MAX_COLLAPSED = 3; + if (isParallel) { + parallelState!.set(task.id, { + taskHeader, + frameIndex: 0, + done: false, + success: false, + toolCalls: [], + }); + } else { + // Build widget lines from current state. Live widgets can't expand/collapse + // like chat messages, so we always truncate to MAX_COLLAPSED recent calls. + const buildLines = (t: typeof ctx.ui.theme): string[] => { + const frame = t.fg("accent", SPINNER_FRAMES[frameIndex]); + const lines = [`${frame} ${taskHeader}`]; - const toolCalls: ToolCallEntry[] = []; + if (toolCalls.length > 0) { + if (toolCalls.length <= MAX_COLLAPSED) { + for (let i = 0; i < toolCalls.length; i++) { + const entry = toolCalls[i]; + const isLast = i === toolCalls.length - 1; + const branch = isLast ? " └── " : " ├── "; + const tag = t.fg("accent", `[${entry.name}]`); + lines.push(`${branch}${tag} ${entry.label}`); + } + } else { + const shown = toolCalls.slice(-MAX_COLLAPSED); + const remaining = toolCalls.length - shown.length; + lines.push(t.fg("dim", ` ├── …${remaining} earlier`)); + for (let i = 0; i < shown.length; i++) { + const entry = shown[i]; + const isLast = i === shown.length - 1; + const branch = isLast ? " └── " : " ├── "; + const tag = t.fg("accent", `[${entry.name}]`); + lines.push(`${branch}${tag} ${entry.label}`); + } + } + } + return lines; + }; - const updateWidget = () => { - const frame = theme.fg("accent", SPINNER_FRAMES[frameIndex]); - const lines = [`${frame} ${taskHeader}`]; + ctx.ui.setWidget(widgetKey, (tui, t) => { + widgetTui = tui; + return { + render: () => buildLines(t), + invalidate: () => widgetTui?.requestRender(), + }; + }); + } - if (toolCalls.length > 0) { - const shown = toolCalls.slice(-MAX_COLLAPSED); - const remaining = toolCalls.length - shown.length; + const requestRender = () => widgetTui?.requestRender(); - if (remaining > 0) { - lines.push(theme.fg("dim", ` ├── ${remaining} more`)); - } + // Spinner animation (sequential only — parallel uses a single batch timer) + let spinnerTimer: NodeJS.Timeout | undefined; + if (!isParallel) { + spinnerTimer = setInterval(() => { + frameIndex = (frameIndex + 1) % SPINNER_FRAMES.length; + requestRender(); + }, 100); + } - for (let i = 0; i < shown.length; i++) { - const entry = shown[i]; - const isLast = i === shown.length - 1; - const branch = isLast ? " └── " : " ├── "; - const tag = theme.fg("accent", `[${entry.name}]`); - lines.push(`${branch}${tag} ${entry.label}`); - } - } + // Use task-level timeout if set, otherwise fall back to config + const timeoutMs = task.timeoutMs ?? config.execution.timeoutMs; - if (batcher) { - batcher.schedule(widgetKey, lines); - } else { - ctx.ui.setWidget(widgetKey, lines); - } - }; + // Pre-create session file path so events stream to disk (avoids 300+ MB in-memory accumulation) + const sessionsDir = path.join(ralpiDir, "sessions"); + ensureDir(sessionsDir); + const sessionFilePath = path.join(sessionsDir, `${task.id}-${startMs}.txt`); - // Smooth spinner animation at 100ms intervals - const spinnerTimer = setInterval(() => { - frameIndex = (frameIndex + 1) % SPINNER_FRAMES.length; - updateWidget(); - }, 100); + // Run task asynchronously via Pi SDK — event loop stays responsive + const output = await runAgentSession( + prompt, + projectDir, + timeoutMs, + (event) => { + if (event.type === "tool_execution_start") { + const label = formatToolArg(event.toolName, event.args); + toolCalls.push({ + name: event.toolName, + label, + }); + if (isParallel) { + const entry = parallelState!.get(task.id); + if (entry) { + entry.toolCalls.push({ name: event.toolName, label }); + } + } + requestRender(); + } + }, + undefined, // no abort signal + sessionFilePath, // stream events to file + config.model, + config.thinkingLevel, + ); - // Initial display - updateWidget(); + const durationMs = Date.now() - startMs; - // Use task-level timeout if set, otherwise fall back to config - const timeoutMs = task.timeoutMs ?? config.execution.timeoutMs; + // Clear progress widget and status after task finishes + if (spinnerTimer) clearInterval(spinnerTimer); + if (isParallel) { + const entry = parallelState!.get(task.id); + if (entry) { + entry.done = true; + entry.success = output.success; + } + } else { + ctx.ui.setWidget(widgetKey, undefined); + } - // Pre-create session file path so events stream to disk (avoids 300+ MB in-memory accumulation) - const sessionsDir = path.join(ralpiDir, "sessions"); - ensureDir(sessionsDir); - const sessionFilePath = path.join(sessionsDir, `${task.id}-${startMs}.txt`); + if (!output.success) { + sendChatMessage?.(`✗ ${taskHeader} — ${output.error}`); + ctx.ui.notify(`Task ${task.id} failed: ${output.error}`, "error"); + return { + success: false, + error: output.error, + durationMs, + sessionFile: sessionFilePath, // events streamed to file for debugging + }; + } - // Run task asynchronously via Pi SDK — event loop stays responsive - const output = await runAgentSession( - prompt, - projectDir, - timeoutMs, - (event) => { - if (event.type === "tool_execution_start") { - const label = formatToolArg(event.toolName, event.args); - toolCalls.push({ - name: event.toolName, - label, - }); - updateWidget(); - } - }, - undefined, // no abort signal - sessionFilePath, // stream events to file - ); + const agentText = output.text; + const toolUsage = output.toolUsage; - const durationMs = Date.now() - startMs; + // Capture git commits made during this task + const { commitMessages, commitSummary } = captureGitCommits(projectDir); - // Clear progress widget and status after task finishes - clearInterval(spinnerTimer); - if (batcher) { - batcher.scheduleRemove(widgetKey); - } else { - ctx.ui.setWidget(widgetKey, undefined); - } - ctx.ui.setStatus("ralpi", undefined); + // Session file already written by runAgentSession (events streamed to disk) + const sessionFile = sessionFilePath; - if (!output.success) { - sendChatMessage?.(`✗ ${taskHeader} — ${output.error}`); - ctx.ui.notify(`Task ${task.id} failed: ${output.error}`, "error"); - return { - success: false, - error: output.error, - durationMs, - sessionFile: sessionFilePath, // events streamed to file for debugging - }; - } + // Build output preview (first 500 chars of agent text) + const outputPreview = + agentText.length > 500 + ? agentText.slice(0, 500) + "\n... (truncated, see session file)" + : agentText; - const agentText = output.text; - const toolUsage = output.toolUsage; + // Extract reflection from agent output + const reflection = extractReflection(agentText, task.id, task.title); - // Capture git commits made during this task - const { commitMessages, commitSummary } = captureGitCommits(projectDir); + // Post completion chat message — header only, renderer builds the expandable tree + const dur = formatDuration(durationMs); + sendChatMessage?.(`✓ ${taskHeader} (${dur})`, { toolCalls }); - // Session file already written by runAgentSession (events streamed to disk) - const sessionFile = sessionFilePath; - - // Build output preview (first 500 chars of agent text) - const outputPreview = - agentText.length > 500 - ? agentText.slice(0, 500) + "\n... (truncated, see session file)" - : agentText; - - // Extract reflection from agent output - const reflection = extractReflection(agentText, task.id, task.title); - - // Post completion chat message — header only, renderer builds the expandable tree - const dur = formatDuration(durationMs); - sendChatMessage?.(`✓ ${taskHeader} (${dur})`, { toolCalls }); - - return { - success: true, - reflection: reflection ?? undefined, - durationMs, - toolUsage, - outputPreview, - sessionFile, - commitMessages, - commitSummary, - }; + return { + success: true, + reflection: reflection ?? undefined, + durationMs, + toolUsage, + outputPreview, + sessionFile, + commitMessages, + commitSummary, + }; } // ─── Execute Batch ─────────────────────────────────────────────────────────── @@ -211,198 +259,260 @@ export async function runTask( * Execute a batch of tasks (sequentially or in parallel) */ export async function executeBatch( - tasks: Task[], - project: Project, - config: RalpiConfig, - progress: ProgressTracker, - ctx: ExtensionContext, - options?: { parallel?: boolean }, - sendChatMessage?: SendChatMessage, - projectDir?: string, + tasks: Task[], + project: Project, + config: RalpiConfig, + progress: ProgressTracker, + ctx: ExtensionContext, + options?: { parallel?: boolean }, + sendChatMessage?: SendChatMessage, + projectDir?: string, ): Promise { - // Defensive: ensure tasks is an iterable array - if (!Array.isArray(tasks)) { - throw new Error( - `executeBatch received invalid tasks: expected array, got ${typeof tasks}`, - ); - } + // Defensive: ensure tasks is an iterable array + if (!Array.isArray(tasks)) { + throw new Error( + `executeBatch received invalid tasks: expected array, got ${typeof tasks}`, + ); + } - // Check if we should run parallel - const shouldParallel = - options?.parallel && tasks.length > 1 && config.execution.maxParallel > 0; + // Check if we should run parallel + const shouldParallel = + options?.parallel && tasks.length > 1 && config.execution.maxParallel > 0; - if (shouldParallel) { - await executeBatchParallel( - tasks, - project, - config, - progress, - ctx, - sendChatMessage, - projectDir, - ); - return; - } + if (shouldParallel) { + await executeBatchParallel( + tasks, + project, + config, + progress, + ctx, + sendChatMessage, + projectDir, + ); + return; + } - // Execute sequentially - for (const task of tasks) { - await executeTask( - task, - project, - config, - progress, - ctx, - sendChatMessage, - projectDir, - ); - } + // Execute sequentially + for (const task of tasks) { + await executeTask( + task, + project, + config, + progress, + ctx, + sendChatMessage, + projectDir, + ); + } } /** * Execute tasks in parallel using child processes */ async function executeBatchParallel( - tasks: Task[], - project: Project, - config: RalpiConfig, - progress: ProgressTracker, - ctx: ExtensionContext, - sendChatMessage?: SendChatMessage, - projectDir?: string, + tasks: Task[], + project: Project, + config: RalpiConfig, + progress: ProgressTracker, + ctx: ExtensionContext, + sendChatMessage?: SendChatMessage, + projectDir?: string, ): Promise { - const maxParallel = config.execution.maxParallel; - const batcher = new WidgetBatcher(ctx); - const results: Array<{ task: Task; result: Promise }> = []; + const maxParallel = config.execution.maxParallel; + const sharedState: ParallelWidgetState = new Map(); - for (const task of tasks) { - results.push({ - task, - result: executeTask( - task, - project, - config, - progress, - ctx, - sendChatMessage, - projectDir, - batcher, - ), - }); + // Register a single batch widget that renders ALL parallel tasks in ID order. + const widgetKey = `ralpi-parallel-${Date.now()}`; + let widgetTui: { requestRender(): void } | null = null; - // Limit concurrency - if (results.length >= maxParallel) { - const first = results.shift(); - if (first) await first.result; - } - } + const buildBatchLines = (t: typeof ctx.ui.theme): string[] => { + const lines: string[] = []; + const sortedIds = Array.from(sharedState.keys()).sort(); - // Wait for remaining tasks - for (const { result } of results) { - await result; - } + for (const id of sortedIds) { + const entry = sharedState.get(id)!; + const frame = entry.done + ? entry.success + ? "✓" + : "✗" + : t.fg("accent", SPINNER_FRAMES[entry.frameIndex]); + lines.push(`${frame} ${entry.taskHeader}`); - // Flush and stop the batcher after all tasks complete - batcher.stop(); + if (entry.toolCalls.length > 0) { + if (entry.toolCalls.length <= MAX_COLLAPSED) { + for (let i = 0; i < entry.toolCalls.length; i++) { + const tc = entry.toolCalls[i]; + const isLast = i === entry.toolCalls.length - 1; + const branch = isLast ? " └── " : " ├── "; + const tag = t.fg("accent", `[${tc.name}]`); + lines.push(`${branch}${tag} ${tc.label}`); + } + } else { + const shown = entry.toolCalls.slice(-MAX_COLLAPSED); + const remaining = entry.toolCalls.length - shown.length; + lines.push(t.fg("dim", ` ├── …${remaining} earlier`)); + for (let i = 0; i < shown.length; i++) { + const tc = shown[i]; + const isLast = i === shown.length - 1; + const branch = isLast ? " └── " : " ├── "; + const tag = t.fg("accent", `[${tc.name}]`); + lines.push(`${branch}${tag} ${tc.label}`); + } + } + } + } + return lines; + }; + + ctx.ui.setWidget(widgetKey, (tui, t) => { + widgetTui = tui; + return { + render: () => buildBatchLines(t), + invalidate: () => widgetTui?.requestRender(), + }; + }); + + // Single spinner timer drives all tasks in the batch + const spinnerTimer = setInterval(() => { + for (const entry of sharedState.values()) { + if (!entry.done) { + entry.frameIndex = (entry.frameIndex + 1) % SPINNER_FRAMES.length; + } + } + widgetTui?.requestRender(); + }, 100); + + const results: Array<{ task: Task; result: Promise }> = []; + + for (const task of tasks) { + results.push({ + task, + result: executeTask( + task, + project, + config, + progress, + ctx, + sendChatMessage, + projectDir, + sharedState, + ), + }); + + // Limit concurrency + if (results.length >= maxParallel) { + const first = results.shift(); + if (first) await first.result; + } + } + + // Wait for remaining tasks + for (const { result } of results) { + await result; + } + + clearInterval(spinnerTimer); + ctx.ui.setWidget(widgetKey, undefined); } // ─── Execute Single Task with Retry ────────────────────────────────────────── async function executeTask( - task: Task, - project: Project, - config: RalpiConfig, - progress: ProgressTracker, - ctx: ExtensionContext, - sendChatMessage?: SendChatMessage, - projectDir: string = project.sourceDir, - batcher?: WidgetBatcher, + task: Task, + project: Project, + config: RalpiConfig, + progress: ProgressTracker, + ctx: ExtensionContext, + sendChatMessage?: SendChatMessage, + projectDir: string = project.sourceDir, + parallelState?: ParallelWidgetState, ): Promise { - const maxRetries = config.execution.maxRetries; - let retries = 0; + const maxRetries = config.execution.maxRetries; + let retries = 0; - while (retries <= maxRetries) { - try { - // Mark as in progress - progress.markInProgress(task.id); + while (retries <= maxRetries) { + try { + // Mark as in progress + progress.markInProgress(task.id); - // Get dependency reflections - const depReflections = progress.getDependencyReflections( - task.dependencies || [], - ); + // Get dependency reflections + const depReflections = progress.getDependencyReflections( + task.dependencies || [], + ); - // Run the task - const result = await runTask( - task, - project, - config, - depReflections, - ctx, - sendChatMessage, - projectDir, - batcher, - ); + // Run the task + const result = await runTask( + task, + project, + config, + depReflections, + ctx, + sendChatMessage, + projectDir, + parallelState, + ); - if (result.success) { - // Save reflection - if (result.reflection) { - saveReflectionToFile(projectDir, config, result.reflection); - } + if (result.success) { + // Save reflection + if (result.reflection) { + saveReflectionToFile(projectDir, config, result.reflection); + } - // Mark completed with all metadata - progress.markCompleted( - task.id, - result.durationMs, - result.reflection, - result.toolUsage, - result.sessionFile, - result.outputPreview, - result.commitMessages, - result.commitSummary, - ); - return; - } + // Mark completed with all metadata + progress.markCompleted( + task.id, + result.durationMs, + result.reflection, + result.toolUsage, + result.sessionFile, + result.outputPreview, + result.commitMessages, + result.commitSummary, + ); + return; + } - // Task failed, check if we should retry - if (retries < maxRetries) { - retries = progress.incrementRetry(task.id); - ctx.ui.notify( - `Retrying task ${task.id} (${retries}/${maxRetries}): ${result.error}`, - "warning", - ); + // Task failed, check if we should retry + if (retries < maxRetries) { + retries = progress.incrementRetry(task.id); + ctx.ui.notify( + `Retrying task ${task.id} (${retries}/${maxRetries}): ${result.error}`, + "warning", + ); - // Exponential backoff - const delay = config.execution.retryDelayMs * 2 ** (retries - 1); - await sleep(delay); - } else { - // Max retries exceeded - progress.markFailed(task.id, result.error || "Unknown error"); - throw new Error(`Task ${task.id} failed: ${result.error}`); - } - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - progress.markFailed(task.id, errorMsg); - throw error; - } - } + // Exponential backoff + const delay = config.execution.retryDelayMs * 2 ** (retries - 1); + await sleep(delay); + } else { + // Max retries exceeded + progress.markFailed(task.id, result.error || "Unknown error"); + throw new Error(`Task ${task.id} failed: ${result.error}`); + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + progress.markFailed(task.id, errorMsg); + throw error; + } + } } // ─── Save Reflection to File ──────────────────────────────────────────────── function saveReflectionToFile( - sourceDir: string, - config: RalpiConfig, - reflection: Reflection, + sourceDir: string, + config: RalpiConfig, + reflection: Reflection, ): void { - const reflectionsDir = path.join(sourceDir, config.paths.reflectionsDir); - ensureDir(reflectionsDir); - const filePath = path.join(reflectionsDir, `${reflection.taskId}.json`); - writeFileSafe(filePath, JSON.stringify(reflection, null, 2)); + const reflectionsDir = path.join(sourceDir, config.paths.reflectionsDir); + ensureDir(reflectionsDir); + const filePath = path.join(reflectionsDir, `${reflection.taskId}.json`); + writeFileSafe(filePath, JSON.stringify(reflection, null, 2)); } // ─── Helpers ───────────────────────────────────────────────────────────────── function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); } // ─── Tool Call Formatting ──────────────────────────────────────────────── @@ -411,34 +521,34 @@ function sleep(ms: number): Promise { * Format a tool call argument into a short label. */ function formatToolArg(name: string, args: unknown): string { - const a = args as Record; - switch (name) { - case "bash": - return truncateMiddle(String(a.command ?? ""), 70); - case "write": - case "read": - return truncateMiddle(String(a.path ?? ""), 60); - case "edit": - return truncateMiddle(String(a.path ?? ""), 60); - case "grep": - return `${a.pattern ?? "?"} — ${truncateMiddle( - String(a.path ?? ""), - 40, - )}`; - case "find": - return `${a.path ?? "."} — ${a.glob ?? "*"}`; - case "ls": - return truncateMiddle(String(a.path ?? "."), 60); - default: - return name; - } + const a = args as Record; + switch (name) { + case "bash": + return truncateMiddle(String(a.command ?? ""), 70); + case "write": + case "read": + return truncateMiddle(String(a.path ?? ""), 60); + case "edit": + return truncateMiddle(String(a.path ?? ""), 60); + case "grep": + return `${a.pattern ?? "?"} — ${truncateMiddle( + String(a.path ?? ""), + 40, + )}`; + case "find": + return `${a.path ?? "."} — ${a.glob ?? "*"}`; + case "ls": + return truncateMiddle(String(a.path ?? "."), 60); + default: + return name; + } } /** * Truncate a long string in the middle, keeping start and end visible. */ function truncateMiddle(s: string, maxLen: number): string { - if (s.length <= maxLen) return s; - const half = Math.floor((maxLen - 3) / 2); - return s.slice(0, half) + "…" + s.slice(s.length - half); + if (s.length <= maxLen) return s; + const half = Math.floor((maxLen - 3) / 2); + return s.slice(0, half) + "…" + s.slice(s.length - half); } diff --git a/src/types.ts b/src/types.ts index 003f35e..e083593 100644 --- a/src/types.ts +++ b/src/types.ts @@ -160,6 +160,10 @@ export interface RalpiConfig { /** Custom prompt suffix for reflection extraction */ reflectionPrompt: string; }; + /** Parent session model to inherit in child agent sessions */ + model?: unknown; + /** Parent session thinking level to inherit in child agent sessions */ + thinkingLevel?: unknown; } export const DEFAULT_CONFIG: RalpiConfig = { @@ -168,9 +172,9 @@ export const DEFAULT_CONFIG: RalpiConfig = { reflectionsDir: ".ralpi/reflections", }, execution: { - maxRetries: 3, - retryDelayMs: 5000, - timeoutMs: 30 * 60 * 1000, // 30 minutes + maxRetries: 0, + retryDelayMs: 0, + timeoutMs: 0, // 0 = inherit Pi's own defaults (no ralpi-level timeout) maxParallel: 3, }, prompts: { diff --git a/src/utils.ts b/src/utils.ts index da45d0d..1acb51d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -82,32 +82,35 @@ export function findProgressFile( // ─── Config ────────────────────────────────────────────────────────────────── -function parseSimpleYaml(content: string): Record { - const result: Record = {}; - const lines = content.split("\n"); - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - - const match = trimmed.match(/^([^:]+):\s*(.+)$/); - if (match) { - const key = match[1].trim(); - let value: string | boolean | number = match[2].trim(); - - // Parse booleans - if (value === "true") value = true; - else if (value === "false") value = false; - // Parse numbers - else if (/^\d+$/.test(value)) value = parseInt(value, 10); - else if (/^\d+\.\d+$/.test(value)) value = parseFloat(value); - - result[key] = value; - } +/** Try to use the `yaml` package (real dependency in package.json). + * Falls back to a flat key:value parser when unavailable. */ +const parseSimpleYaml: (content: string) => Record = (() => { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { parse } = require("yaml"); + return (content: string) => parse(content) ?? {}; + } catch { + return (content: string) => { + const result: Record = {}; + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const match = trimmed.match(/^([^:]+):\s*(.*)$/); + if (match) { + const value = match[2].trim(); + if (value === "true") result[match[1].trim()] = true; + else if (value === "false") result[match[1].trim()] = false; + else if (/^\d+$/.test(value)) + result[match[1].trim()] = parseInt(value, 10); + else if (/^\d+\.\d+$/.test(value)) + result[match[1].trim()] = parseFloat(value); + else result[match[1].trim()] = value; + } + } + return result; + }; } - - return result; -} +})(); /** * Deep merge configuration objects @@ -129,25 +132,44 @@ function mergeConfig( return result as RalpiConfig; } +/** Path to the global ralpi config under the user's Pi home directory. */ +const GLOBAL_CONFIG_PATH = path.join( + process.env.HOME || "/tmp", + ".pi", + "ralpi", + "config.yaml", +); + /** - * Load configuration from .ralpi/config.yaml or return defaults + * Load and merge config from global and project sources. + * + * Precedence (highest wins): + * 1. Project-level: `/.ralpi/config.yaml` + * 2. Global: `~/.pi/ralpi/config.yaml` + * 3. `DEFAULT_CONFIG` in `src/types.ts` */ export function loadConfig(projectDir: string): RalpiConfig { - const configPath = path.join(projectDir, ".ralpi", "config.yaml"); + // Start with defaults + const merged: RalpiConfig = { ...DEFAULT_CONFIG }; - // Return defaults silently when config file does not exist - if (!fs.existsSync(configPath)) { - return { ...DEFAULT_CONFIG }; - } + // Layer 1: global config (~/.pi/ralpi/config.yaml) + tryLoadConfigFile(GLOBAL_CONFIG_PATH, merged); - try { - const content = fs.readFileSync(configPath, "utf-8"); - // Simple YAML parsing (key: value format) - const config = parseSimpleYaml(content); - return mergeConfig(DEFAULT_CONFIG, config); - } catch { - // Malformed config — fall back to defaults silently - return { ...DEFAULT_CONFIG }; + // Layer 2: project config (.ralpi/config.yaml) — overrides global + tryLoadConfigFile(path.join(projectDir, ".ralpi", "config.yaml"), merged); + + return merged; + + /** Attempt to load a single config file and merge into `acc` in place. */ + function tryLoadConfigFile(filePath: string, acc: RalpiConfig): void { + if (!fs.existsSync(filePath)) return; + try { + const content = fs.readFileSync(filePath, "utf-8"); + const parsed = parseSimpleYaml(content); + Object.assign(acc, mergeConfig(acc, parsed)); + } catch { + // Malformed config — skip silently + } } } @@ -339,6 +361,8 @@ export async function runAgentSession( onEvent?: (event: AgentSessionEvent) => void, signal?: AbortSignal, sessionFile?: string, + model?: unknown, + thinkingLevel?: unknown, ): Promise<{ success: boolean; text: string; @@ -361,10 +385,13 @@ export async function runAgentSession( ? fs.createWriteStream(sessionFile, { flags: "a" }) : null; - // Wire timeout via abort signal - const timeoutHandle = setTimeout(() => { - if (sessionRef?.session) sessionRef.session.agent.abort(); - }, timeoutMs); + // Wire timeout via abort signal (only when set; 0 means inherit Pi's defaults) + let timeoutHandle: NodeJS.Timeout | null = null; + if (timeoutMs > 0) { + timeoutHandle = setTimeout(() => { + if (sessionRef?.session) sessionRef.session.agent.abort(); + }, timeoutMs); + } const sessionRef: { session?: Awaited>["session"]; @@ -387,6 +414,8 @@ export async function runAgentSession( sessionManager: SessionManager.inMemory(), resourceLoader: loader, tools: ["read", "bash", "edit", "write", "grep", "find", "ls"], + model: model as any, + thinkingLevel: thinkingLevel as any, }); sessionRef.session = result.session; @@ -437,7 +466,7 @@ export async function runAgentSession( unsubscribe(); result.session.dispose(); signal?.removeEventListener("abort", abortHandler); - clearTimeout(timeoutHandle); + if (timeoutHandle) clearTimeout(timeoutHandle); // Flush and close the event stream before returning if (eventStream) { @@ -463,7 +492,7 @@ export async function runAgentSession( events: [], // streamed to file }; } catch (error) { - clearTimeout(timeoutHandle); + if (timeoutHandle) clearTimeout(timeoutHandle); if (eventStream && !eventStream.destroyed) { eventStream.end(); }