mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-06 23:44:36 +00:00
## What
Hardens the plan-mode example extension's step-progress tracking.
Previously, completion markers were only recognized in the exact
`[DONE:n]` form inside visible text blocks, so steps frequently never
got checked off during execution.
## Changes
- **`getTextContent`** now reads `thinking` blocks as well as text
blocks, so `[DONE:n]` tags emitted during reasoning are honored.
- **Marker tolerance** (`extractDoneSteps`): accepts `[DONE: 1]`, `[DONE
1]`, `Done: n`, and prose phrasings like "Step n done / is complete /
finished" — all adjacency-anchored so negations ("Step 9 is not done")
never match.
- **Whole-plan fallback** (`allStepsComplete`): if the run's final
message says the plan is done (e.g. "All steps complete"), all remaining
steps are marked at `agent_end`.
- **Plan extraction dedup**: only the last `Plan:` section is extracted,
so a plan drafted in a thinking block no longer duplicates the steps
from the text block.
- **No more truncation**: step text is kept in full (previously cut at
50 chars); the widget truncates for display only. The execution prompt
now shows the complete step text.
- **No more dropped steps**: steps whose text starts with a backtick
(e.g. `1. \`index.ts\` — …`) are no longer skipped.
- **Widget refresh** happens on every turn instead of only when a marker
matched.
- **Plan-complete card** is delivered with `nextTurn` instead of
triggering an extra LLM turn.
## Tests
`packages/coding-agent/test/plan-mode-utils.test.ts` updated for the new
behavior (truncation, backtick steps, marker-count semantics) and
extended with coverage for the new cases (marker variants, prose
phrasings, negation safety, thinking-draft dedup, markdown titles,
whole-plan completion).
All 43 tests in `plan-mode-utils.test.ts` +
`plan-mode-extension.test.ts` pass (`npx vitest run`).
Note: the repo-wide type-check currently fails on `main` with
pre-existing `ModelCatalog`/`never` errors unrelated to this change;
this branch adds none (same count with and without the diff).
194 lines
5.4 KiB
TypeScript
194 lines
5.4 KiB
TypeScript
/**
|
|
* 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)
|
|
);
|
|
}
|