/** * Pure utility functions for plan mode. * Extracted for testability. */ // Destructive commands blocked in plan mode const DESTRUCTIVE_PATTERNS = [ /\brm\b/i, /\brmdir\b/i, /\bmv\b/i, /\bcp\b/i, /\bmkdir\b/i, /\btouch\b/i, /\bchmod\b/i, /\bchown\b/i, /\bchgrp\b/i, /\bln\b/i, /\btee\b/i, /\btruncate\b/i, /\bdd\b/i, /\bshred\b/i, /(^|[^<])>(?!>)/, />>/, /\bnpm\s+(install|uninstall|update|ci|link|publish)/i, /\byarn\s+(add|remove|install|publish)/i, /\bpnpm\s+(add|remove|install|publish)/i, /\bpip\s+(install|uninstall)/i, /\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i, /\bbrew\s+(install|uninstall|upgrade)/i, /\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i, /\bsudo\b/i, /\bsu\b/i, /\bkill\b/i, /\bpkill\b/i, /\bkillall\b/i, /\breboot\b/i, /\bshutdown\b/i, /\bsystemctl\s+(start|stop|restart|enable|disable)/i, /\bservice\s+\S+\s+(start|stop|restart)/i, /\b(vim?|nano|emacs|code|subl)\b/i, ]; // Safe read-only commands allowed in plan mode const SAFE_PATTERNS = [ /^\s*cat\b/, /^\s*head\b/, /^\s*tail\b/, /^\s*less\b/, /^\s*more\b/, /^\s*grep\b/, /^\s*find\b/, /^\s*ls\b/, /^\s*pwd\b/, /^\s*echo\b/, /^\s*printf\b/, /^\s*wc\b/, /^\s*sort\b/, /^\s*uniq\b/, /^\s*diff\b/, /^\s*file\b/, /^\s*stat\b/, /^\s*du\b/, /^\s*df\b/, /^\s*tree\b/, /^\s*which\b/, /^\s*whereis\b/, /^\s*type\b/, /^\s*env\b/, /^\s*printenv\b/, /^\s*uname\b/, /^\s*whoami\b/, /^\s*id\b/, /^\s*date\b/, /^\s*cal\b/, /^\s*uptime\b/, /^\s*ps\b/, /^\s*top\b/, /^\s*htop\b/, /^\s*free\b/, /^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i, /^\s*git\s+ls-/i, /^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i, /^\s*yarn\s+(list|info|why|audit)/i, /^\s*node\s+--version/i, /^\s*python\s+--version/i, /^\s*curl\s/i, /^\s*wget\s+-O\s*-/i, /^\s*jq\b/, /^\s*sed\s+-n/i, /^\s*awk\b/, /^\s*rg\b/, /^\s*fd\b/, /^\s*bat\b/, /^\s*eza\b/, ]; export function isSafeCommand(command: string): boolean { const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(command)); const isSafe = SAFE_PATTERNS.some((p) => p.test(command)); return !isDestructive && isSafe; } export interface TodoItem { step: number; text: string; completed: boolean; } export function cleanStepText(text: string): string { let cleaned = text .replace(/\*{1,2}([^*]+)\*{1,2}/g, "$1") // Remove bold/italic .replace(/`([^`]+)`/g, "$1") // Remove code .replace( /^(Use|Run|Execute|Create|Write|Read|Check|Verify|Update|Modify|Add|Remove|Delete|Install)\s+(the\s+)?/i, "", ) .replace(/\s+/g, " ") .trim(); if (cleaned.length > 0) { cleaned = cleaned.charAt(0).toUpperCase() + cleaned.slice(1); } // Keep full text: the exec message and widget display need the whole step. // The widget truncates for display (see updateStatus in index.ts). return cleaned; } export function extractTodoItems(message: string): TodoItem[] { const items: TodoItem[] = []; // Take the LAST "Plan:" section: since getTextContent includes thinking blocks, // a draft in the reasoning block can precede the user-visible plan in the text // block — the text block comes last in content order. const headers = [...message.matchAll(/\*{0,2}Plan:\*{0,2}\s*\n/gi)]; if (headers.length === 0) return items; const lastHeader = headers[headers.length - 1]; const planSection = message.slice((lastHeader.index ?? 0) + lastHeader[0].length); // Full-line capture: step titles may contain bold/italic/backticks ("1. **Step** \`x\` …"). // cleanStepText strips the markdown; / and - items are still skipped below. const numberedPattern = /^\s*(\d+)[.)]\s+(.+)$/gm; for (const match of planSection.matchAll(numberedPattern)) { const text = match[2] .trim() .replace(/\*{1,2}$/, "") .trim(); if (text.length > 5 && !text.startsWith("/") && !text.startsWith("-")) { const cleaned = cleanStepText(text); if (cleaned.length > 3) { items.push({ step: items.length + 1, text: cleaned, completed: false }); } } } return items; } export function extractDoneSteps(message: string): number[] { const steps: number[] = []; // Canonical and bracket variants: [DONE:1], [DONE: 1], [DONE 1], [Done:1] for (const match of message.matchAll(/\[DONE\s*:?\s*(\d+)\]/gi)) { steps.push(Number(match[1])); } // "Done: 1" without brackets for (const match of message.matchAll(/\bDone\s*:\s*(\d+)\b/gi)) { steps.push(Number(match[1])); } // "Step 1 done", "Step 2 is complete", "Step 3 finished" — adjacency-anchored, // so "Step 9 is not done" / "Step 9 incomplete" never match. for (const match of message.matchAll(/\bStep\s*(\d+)\s+(?:is\s+)?(?:done|complete|completed|finished)\b/gi)) { steps.push(Number(match[1])); } return [...new Set(steps)]; } export function markCompletedSteps(text: string, items: TodoItem[]): number { const doneSteps = extractDoneSteps(text); let marked = 0; for (const step of doneSteps) { const item = items.find((t) => t.step === step && !t.completed); if (item) { item.completed = true; marked++; } } return marked; } // Whole-plan completion phrasing, used as a fallback when the model summarizes // without [DONE:n] tags. Only consulted on the run's final message (agent_end). export function allStepsComplete(message: string): boolean { return ( /(?:all|every)\s+steps?\s+(?:are|is\s+)?(?:done|complete|completed|finished)/i.test(message) || /\b(?:the\s+)?plan\s+(?:is\s+)?(?:complete|completed|done)\b/i.test(message) ); }