diff --git a/index.ts b/index.ts index 95cf86f..7c09c0a 100644 --- a/index.ts +++ b/index.ts @@ -1,5 +1,6 @@ import * as fs from "node:fs"; import * as path from "node:path"; +import { fileURLToPath } from "node:url"; import type { ExtensionAPI, ExtensionContext, @@ -14,6 +15,7 @@ import { } from "./src/dag"; import { ProgressTracker } from "./src/progress"; import { buildPlanPrompt } from "./src/prompts"; +import { loadTaskManagerPrompt } from "./src/task-manager-prompt"; import { formatReflections } from "./src/reflection"; import { verdictGlyph, verdictSummary, formatFindings } from "./src/review"; import type { ReviewResult } from "./src/types"; @@ -255,6 +257,23 @@ async function executePlanBatches( projectDir?: string, isResume?: boolean, ): Promise { + // Refresh the model registry so the host reloads models.json before we + // resolve the round-robin model pool. The registry snapshot is captured at + // host startup and only reloaded here; a long-running host would otherwise + // skip providers added to models.json after it booted (e.g. "strix"). + // Best-effort: a failed refresh shouldn't block execution — the pool just + // resolves against the existing snapshot. + try { + await ctx.modelRegistry?.refresh(); + } catch (error) { + ctx.ui.notify( + `ralpi: model registry refresh failed — continuing with existing snapshot: ${ + error instanceof Error ? error.message : String(error) + }`, + "warning", + ); + } + // Write loop-active marker so a session reload can detect an interrupted // loop and resume it (in-process agent sessions die on reload — the marker // + progress.json in_progress tasks are the signal to re-run them). @@ -692,12 +711,18 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void { }, }); + const extensionDir = path.dirname(fileURLToPath(import.meta.url)); + pi.registerCommand("ralpi-plan", { description: "Open the Task Manager to plan a ralpi run", handler: async (args: string, ctx: ExtensionContext) => { - const prompt = (args || "").trim(); - const message = prompt ? `@task-manager\n\n${prompt}` : "@task-manager"; - pi.sendUserMessage(message); + // pi.sendUserMessage() sends with expandPromptTemplates: false, so it + // would NOT expand `/task-manager` — and `@task-manager` is an + // @-mention, not a template invocation. Load the bundled template, + // strip frontmatter, substitute $@ args ourselves, and send the + // expanded body directly. + const body = loadTaskManagerPrompt(extensionDir, args ?? ""); + pi.sendUserMessage(body); ctx.ui.notify("Opening Task Manager...", "info"); }, }); diff --git a/src/task-manager-prompt.ts b/src/task-manager-prompt.ts new file mode 100644 index 0000000..c006acd --- /dev/null +++ b/src/task-manager-prompt.ts @@ -0,0 +1,104 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { stripFrontmatter } from "@earendil-works/pi-coding-agent"; + +const TEMPLATE_REL = path.join("prompts", "task-manager.md"); + +/** + * Parse command arguments respecting quoted strings (bash-style). + * Ported from pi's core/prompt-templates.js so the task-manager template + * receives the same arg-splitting a real `/task-manager` invocation would. + */ +function parseCommandArgs(argsString: string): string[] { + const args: string[] = []; + let current = ""; + let inQuote: string | null = null; + for (let i = 0; i < argsString.length; i++) { + const char = argsString[i]; + if (inQuote) { + if (char === inQuote) { + inQuote = null; + } else { + current += char; + } + } else if (char === '"' || char === "'") { + inQuote = char; + } else if (/\s/.test(char)) { + if (current) { + args.push(current); + current = ""; + } + } else { + current += char; + } + } + if (current) args.push(current); + return args; +} + +/** + * Substitute argument placeholders in template content. + * Faithful port of pi's substituteArgs (core/prompt-templates.js): + * - $1, $2, ... positional args + * - $@ / $ARGUMENTS all args joined + * - ${N:-default} positional N with default when missing/empty + * - ${@:-default} all args with default when empty + * - ${@:N} / ${@:N:L} bash-style slicing + * + * Replacement runs once over the template only; argument/default values + * containing patterns like $1 or $@ are NOT recursively substituted. + */ +function substituteArgs(content: string, args: string[]): string { + const allArgs = args.join(" "); + return content.replace( + /\$\{(\d+|ARGUMENTS|@):-([^}]*)\}|\$\{@:(\d+)(?::(\d+))?\}|\$(ARGUMENTS|@|\d+)/g, + (_match, defaultTarget, defaultValue, sliceStart, sliceLength, simple) => { + if (defaultTarget) { + const value = + defaultTarget === "@" || defaultTarget === "ARGUMENTS" + ? allArgs + : args[parseInt(defaultTarget, 10) - 1]; + return value ? value : defaultValue; + } + if (sliceStart) { + let start = parseInt(sliceStart, 10) - 1; // 1-indexed → 0-indexed + if (start < 0) start = 0; + if (sliceLength) { + const length = parseInt(sliceLength, 10); + return args.slice(start, start + length).join(" "); + } + return args.slice(start).join(" "); + } + if (simple === "ARGUMENTS" || simple === "@") { + return allArgs; + } + const index = parseInt(simple, 10) - 1; + return args[index] ?? ""; + }, + ); +} + +/** + * Load and expand the task-manager prompt template bundled with the extension. + * + * `pi.sendUserMessage()` sends with `expandPromptTemplates: false`, so it will + * NOT expand a `/task-manager` invocation — and `@task-manager` is an + * @-mention, not a template invocation anyway. We therefore read the + * template ourselves, strip its frontmatter, substitute args ($@ etc.), and + * return the fully-expanded prompt body ready to send as a user message. + * + * @param extensionDir Absolute path to the extension root (where index.ts + * lives), used to locate `prompts/task-manager.md`. + * @param argsString Raw argument string from the slash command (may be ""). + * @throws if the template file is missing or unreadable. + */ +export function loadTaskManagerPrompt( + extensionDir: string, + argsString: string, +): string { + const templatePath = path.join(extensionDir, TEMPLATE_REL); + const raw = fs.readFileSync(templatePath, "utf-8"); + const body = stripFrontmatter(raw); + const args = parseCommandArgs(argsString); + return substituteArgs(body, args).trim(); +} diff --git a/src/worktree.ts b/src/worktree.ts index 4264885..3b45b46 100644 --- a/src/worktree.ts +++ b/src/worktree.ts @@ -172,6 +172,23 @@ export function createWorktree( if (existing && existing.includes(`worktree ${wtDir}`)) { // The worktree is registered — sanity-check it's a valid checkout. if (getGitHead(wtDir)) { + // Return the branch the worktree is ACTUALLY checked out on, NOT the + // slug recomputed from the (possibly changed) task title. Mismatch + // happens routinely on resume: the task agent may have created its + // own feature branch (e.g. `proctored-exam-delivery-mode-10-exam-...`) + // once it saw the convention in the git log, the title may have been + // edited between runs, or an older ralpi version used a different + // naming scheme. Returning the slug here makes `git merge ` + // fail with "not something we can merge" because no such ref exists — + // exactly the spurious merge-conflict we see on resumes. + const actual = getCurrentBranch(wtDir); + if (actual && actual !== "HEAD" && actual !== "detached") { + return { dir: wtDir, branch: actual, mainDir }; + } + // Detached-HEAD worktree (e.g. left by a prior `--detach` fallback). + // The slug ref doesn't exist as a branch — create one matching the + // slug from the worktree's current HEAD so the merge step resolves. + git(`branch "${branch}" HEAD`, mainDir); return { dir: wtDir, branch, mainDir }; } // Registered but broken (dir gone / checkout corrupt) — drop its