fix(plan-mode): use context event to filter stale plan mode messages

- Filter out old [PLAN MODE ACTIVE] and [EXECUTING PLAN] messages
- Fresh context injected via before_agent_start with current state
- Agent now correctly sees tools are enabled when executing
- Reverted to ID-based tracking with [DONE:id] tags
- Simplified execution message (no need to override old context)
This commit is contained in:
Helmut Januschka 2026-01-03 21:57:01 +01:00 committed by Mario Zechner
parent f1792d04c4
commit 650d8f2615

View file

@ -11,7 +11,7 @@
* - After each agent response, prompts to execute the plan or continue planning * - After each agent response, prompts to execute the plan or continue planning
* - Shows "plan" indicator in footer when active * - Shows "plan" indicator in footer when active
* - Extracts todo list from plan and tracks progress during execution * - Extracts todo list from plan and tracks progress during execution
* - Uses smart matching to track progress (no ugly IDs shown to user) * - Uses ID-based tracking: agent outputs [DONE:id] to mark steps complete
* *
* Usage: * Usage:
* 1. Copy this file to ~/.pi/agent/hooks/ or your project's .pi/hooks/ * 1. Copy this file to ~/.pi/agent/hooks/ or your project's .pi/hooks/
@ -130,64 +130,20 @@ function isSafeCommand(command: string): boolean {
return true; return true;
} }
// Todo item // Todo item with unique ID
interface TodoItem { interface TodoItem {
id: string;
text: string; text: string;
completed: boolean; completed: boolean;
// Keywords extracted for matching }
keywords: string[];
// Generate a short unique ID
function generateId(): string {
return Math.random().toString(36).substring(2, 8);
} }
/** /**
* Extract significant keywords from text for matching. * Extract todo items from assistant message and assign IDs.
*/
function extractKeywords(text: string): string[] {
// Remove common words and extract significant terms
const stopWords = new Set([
"the",
"a",
"an",
"to",
"for",
"of",
"in",
"on",
"at",
"by",
"with",
"using",
"and",
"or",
"use",
"run",
"execute",
"create",
"make",
"do",
"then",
"next",
"step",
"first",
"second",
"third",
"finally",
"it",
"its",
"this",
"that",
"from",
"into",
]);
return text
.toLowerCase()
.replace(/[^a-z0-9/._-]/g, " ")
.split(/\s+/)
.filter((w) => w.length > 2 && !stopWords.has(w));
}
/**
* Extract todo items from assistant message.
*/ */
function extractTodoItems(message: string): TodoItem[] { function extractTodoItems(message: string): TodoItem[] {
const items: TodoItem[] = []; const items: TodoItem[] = [];
@ -198,11 +154,7 @@ function extractTodoItems(message: string): TodoItem[] {
let text = match[2].trim(); let text = match[2].trim();
text = text.replace(/\*{1,2}$/, "").trim(); text = text.replace(/\*{1,2}$/, "").trim();
if (text.length > 5 && !text.startsWith("`") && !text.startsWith("/") && !text.startsWith("-")) { if (text.length > 5 && !text.startsWith("`") && !text.startsWith("/") && !text.startsWith("-")) {
items.push({ items.push({ id: generateId(), text, completed: false });
text,
completed: false,
keywords: extractKeywords(text),
});
} }
} }
@ -213,11 +165,7 @@ function extractTodoItems(message: string): TodoItem[] {
let text = match[1].trim(); let text = match[1].trim();
text = text.replace(/\*{1,2}$/, "").trim(); text = text.replace(/\*{1,2}$/, "").trim();
if (text.length > 10 && !text.startsWith("`")) { if (text.length > 10 && !text.startsWith("`")) {
items.push({ items.push({ id: generateId(), text, completed: false });
text,
completed: false,
keywords: extractKeywords(text),
});
} }
} }
} }
@ -226,54 +174,15 @@ function extractTodoItems(message: string): TodoItem[] {
} }
/** /**
* Calculate similarity between tool action and todo item. * Find [DONE:id] tags in text and return the IDs.
* Returns a score from 0 to 1.
*/ */
function matchScore(todoKeywords: string[], actionText: string): number { function findDoneTags(text: string): string[] {
if (todoKeywords.length === 0) return 0; const pattern = /\[DONE:([a-z0-9]+)\]/gi;
const ids: string[] = [];
const actionLower = actionText.toLowerCase(); for (const match of text.matchAll(pattern)) {
let matches = 0; ids.push(match[1].toLowerCase());
for (const keyword of todoKeywords) {
if (actionLower.includes(keyword)) {
matches++;
} }
} return ids;
return matches / todoKeywords.length;
}
/**
* Find the best matching uncompleted todo for a tool action.
* Uses keyword matching with a preference for sequential order.
*/
function findBestMatch(todos: TodoItem[], toolName: string, input: Record<string, unknown>): number {
// Build action text from tool name and input
let actionText = toolName;
if (input.path) actionText += ` ${input.path}`;
if (input.command) actionText += ` ${input.command}`;
if (input.content) actionText += ` ${String(input.content).slice(0, 100)}`;
let bestIdx = -1;
let bestScore = 0.3; // Minimum threshold
for (let i = 0; i < todos.length; i++) {
if (todos[i].completed) continue;
const score = matchScore(todos[i].keywords, actionText);
// Bonus for being the first uncompleted item (sequential preference)
const isFirstUncompleted = !todos.slice(0, i).some((t) => !t.completed);
const adjustedScore = isFirstUncompleted ? score + 0.1 : score;
if (adjustedScore > bestScore) {
bestScore = adjustedScore;
bestIdx = i;
}
}
return bestIdx;
} }
export default function planModeHook(pi: HookAPI) { export default function planModeHook(pi: HookAPI) {
@ -299,7 +208,7 @@ export default function planModeHook(pi: HookAPI) {
ctx.ui.setStatus("plan-mode", undefined); ctx.ui.setStatus("plan-mode", undefined);
} }
// Show widget during execution // Show widget during execution (no IDs shown to user)
if (executionMode && todoItems.length > 0) { if (executionMode && todoItems.length > 0) {
const lines: string[] = []; const lines: string[] = [];
for (const item of todoItems) { for (const item of todoItems) {
@ -382,17 +291,27 @@ export default function planModeHook(pi: HookAPI) {
} }
}); });
// Track progress via tool results // Filter out stale plan mode context messages from LLM context
pi.on("tool_result", async (event, ctx) => { // This ensures the agent only sees the CURRENT state (plan mode on/off)
if (!executionMode || todoItems.length === 0) return; (pi as any).on("context", async (event: { messages: Array<{ role: string; content: unknown }> }) => {
if (event.isError) return; // Remove any previous plan-mode-context or plan-execution-context messages
// They'll be re-injected with current state via before_agent_start
// Find best matching todo item const filtered = event.messages.filter((m) => {
const matchIdx = findBestMatch(todoItems, event.toolName, event.input); if (m.role === "user" && Array.isArray(m.content)) {
if (matchIdx >= 0 && !todoItems[matchIdx].completed) { // Check for our custom message types in user messages
todoItems[matchIdx].completed = true; const hasOldContext = (m.content as Array<{ type: string; text?: string }>).some(
updateStatus(ctx); (c) =>
c.type === "text" &&
c.text &&
(c.text.includes("[PLAN MODE ACTIVE]") ||
c.text.includes("[PLAN MODE DISABLED") ||
c.text.includes("[EXECUTING PLAN]")),
);
if (hasOldContext) return false;
} }
return true;
});
return { messages: filtered };
}); });
// Inject plan mode context // Inject plan mode context
@ -424,14 +343,19 @@ Do NOT attempt to make changes - just describe what you would do.`,
} }
if (executionMode && todoItems.length > 0) { if (executionMode && todoItems.length > 0) {
const completed = todoItems.filter((t) => t.completed).length; const remaining = todoItems.filter((t) => !t.completed);
const remaining = todoItems.filter((t) => !t.completed).map((t) => t.text); const todoList = remaining.map((t) => `- [${t.id}] ${t.text}`).join("\n");
return { return {
message: { message: {
customType: "plan-execution-context", customType: "plan-execution-context",
content: `[EXECUTING PLAN - ${completed}/${todoItems.length} complete] content: `[EXECUTING PLAN]
Plan mode is OFF. You have FULL access to: read, write, edit, bash. Plan mode is OFF. You have FULL access to: read, write, edit, bash.
${remaining.length > 0 ? `Remaining steps:\n${remaining.map((t, i) => `${i + 1}. ${t}`).join("\n")}` : "All steps complete!"}`,
Remaining steps:
${todoList}
IMPORTANT: After completing each step, output [DONE:id] to mark it complete.
Example: [DONE:${remaining[0]?.id || "abc123"}]`,
display: false, display: false,
}, },
}; };
@ -440,8 +364,28 @@ ${remaining.length > 0 ? `Remaining steps:\n${remaining.map((t, i) => `${i + 1}.
// After agent finishes // After agent finishes
pi.on("agent_end", async (event, ctx) => { pi.on("agent_end", async (event, ctx) => {
// Check if all complete // Check for done tags in execution mode
if (executionMode && todoItems.length > 0) { if (executionMode && todoItems.length > 0) {
const messages = event.messages;
const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
if (lastAssistant && Array.isArray(lastAssistant.content)) {
const textContent = lastAssistant.content
.filter((block): block is { type: "text"; text: string } => block.type === "text")
.map((block) => block.text)
.join("\n");
// Find and mark completed items
const doneIds = findDoneTags(textContent);
for (const id of doneIds) {
const item = todoItems.find((t) => t.id === id);
if (item && !item.completed) {
item.completed = true;
}
}
updateStatus(ctx);
}
// Check if all complete
const allComplete = todoItems.every((t) => t.completed); const allComplete = todoItems.every((t) => t.completed);
if (allComplete) { if (allComplete) {
// Show final completed list in chat // Show final completed list in chat
@ -485,7 +429,7 @@ ${remaining.length > 0 ? `Remaining steps:\n${remaining.map((t, i) => `${i + 1}.
const hasTodos = todoItems.length > 0; const hasTodos = todoItems.length > 0;
// Show todo list in chat (no IDs, just numbered) // Show todo list in chat (no IDs shown to user, just numbered)
if (hasTodos) { if (hasTodos) {
const todoListText = todoItems.map((t, i) => `${i + 1}. ☐ ${t.text}`).join("\n"); const todoListText = todoItems.map((t, i) => `${i + 1}. ☐ ${t.text}`).join("\n");
pi.sendMessage( pi.sendMessage(
@ -510,13 +454,11 @@ ${remaining.length > 0 ? `Remaining steps:\n${remaining.map((t, i) => `${i + 1}.
pi.setActiveTools(NORMAL_MODE_TOOLS); pi.setActiveTools(NORMAL_MODE_TOOLS);
updateStatus(ctx); updateStatus(ctx);
// Simple execution message - context event filters old plan mode messages
// and before_agent_start injects fresh execution context with IDs
const execMessage = hasTodos const execMessage = hasTodos
? `[PLAN MODE DISABLED - EXECUTE NOW] ? `Execute the plan. Start with: ${todoItems[0].text}`
You now have FULL access to all tools: read, write, edit, bash. : "Execute the plan you just created.";
Execute the plan step by step. Start with: ${todoItems[0].text}`
: `[PLAN MODE DISABLED - EXECUTE NOW]
You now have FULL access to all tools: read, write, edit, bash.
Execute the plan you just created. Proceed step by step.`;
pi.sendMessage( pi.sendMessage(
{ {