diff --git a/.claude/skills/system-prompts/SKILL.md b/.claude/skills/system-prompts/SKILL.md new file mode 100644 index 000000000..d9c7ad67f --- /dev/null +++ b/.claude/skills/system-prompts/SKILL.md @@ -0,0 +1,602 @@ +--- +name: system-prompts +description: Write system prompts, tool docs, and agent definitions. Combines research-backed prompt engineering (+15-30% measured improvements) with project XML conventions. Covers tag hierarchy, structural templates, high-impact interventions, anti-patterns. +--- + +# System Prompt Engineering + +Empirically-validated techniques + consistent XML structure. Every recommendation backed by benchmarks or production data. + + +## High-Impact Interventions (+15-30% measured improvement) + +1. **Persistence**: "Keep going until fully resolved" — prevents premature termination +2. **Tool verification**: "Use tools to verify; do not guess" — reduces hallucination +3. **Planning**: "Plan approach before acting" — improves complex task success +4. **Context positioning**: Critical instructions at START and END — middle content degrades 20%+ +5. **Urgency framing**: "This matters" / "Get this right" — 8-115% improvement (EmotionPrompt) +6. **Edit format**: SEARCH/REPLACE beats line-numbers 3X on code generation + +**Minimal prompting wins.** Every instruction must justify its token cost. + + +--- + +## Tag Hierarchy + +Tags encode enforcement level. Use consistently throughout: + +| Tag | Enforcement | When to Use | +|-----|-------------|-------------| +| `` | Inviolable | Safety constraints, must-follow rules, repeat at END | +| `` | Forbidden | Actions that cause harm, never acceptable | +| `` | High priority | Deviate only with justification | +| `` | Operational | How to use a tool, perform a task | +| `` | Contextual | When rules apply, trigger criteria | +| `` | Anti-patterns | What not to do, prefer alternatives | + +**Context positioning rule**: Place `` at START for immediate priming, repeat at END for recency. Middle content suffers 20%+ degradation in long contexts. + +--- + +## Standard Tags + +### Structure Tags +``` + Agent identity and expertise (first element) + Background, situation, audience + Numbered step-by-step workflows + Bulleted operating instructions + Input specifications, types + Return value documentation + What the agent/tool excels at + Available operations (for multi-op tools like LSP) +``` + +### Special Tags +``` + Core values, ultimate objectives + Communication style, attitude + What the agent commits to doing + Domain-specific mindset/context + Behavioral rules, tool precedence +``` + +### Example Tags + +Always use `name` attribute with lowercase-kebab descriptive names: + +```xml + +Clear, correct usage + + + +What to avoid — show the mistake explicitly + + + +Domain-specific example + + + +Platform/context-specific + +``` + +Naming patterns: +- `name="single"` / `name="multi-part"` — complexity variants +- `name="good"` / `name="bad"` — correctness contrast +- `name="linux"` / `name="windows-cmd"` — platform-specific +- `name="create"` / `name="update"` / `name="delete"` — operation types + +--- + +## Structural Templates + +### Tool Documentation + +```markdown +# Tool Name + +One-line description of what the tool does. + + +- How to use it (bulleted, imperative) +- Key parameters and their effects +- Common patterns + + + +What the tool returns. Include: +- Success format +- Truncation limits (e.g., "truncated at 50KB") +- Error conditions + + + +Must-follow rules. Safety constraints. +When to ALWAYS or NEVER use this tool. + + + +High-priority notes that aren't safety-critical. + + + +tool {"param": "value"} + + + +tool {"param": "value", "option": true} + + + +- Anti-pattern 1 — why it's bad +- Anti-pattern 2 — what to do instead + +``` + +### Agent Definition + +```markdown +--- +name: agent-name +description: One-line for spawning UI (imperative: "Fast read-only codebase scout") +tools: read, grep, find, ls, bash +model: pi/slow, gpt-5.2, codex +output: + properties: + field_name: + metadata: + description: What this field contains + type: string +--- + +Senior [role] doing [task]. Your goal: [concrete outcome]. + + +Inviolable constraints first. +READ-ONLY if applicable — list prohibited actions explicitly. + + + +- What this agent excels at +- Core capabilities + + + +- Operating instruction 1 +- Operating instruction 2 +- Spawn parallel tool calls wherever possible + + + +## Phase 1: Understand +1. Step one +2. Step two + +## Phase 2: Execute +1. Step one +2. Step two + + + +What to return. Schema requirements. +Call `complete` with findings when done. + + + +Repeat critical constraints at end. +Keep going until complete. This matters. + +``` + +### System Prompt (Main Agent) + +```markdown + +XML tags in this prompt are system-level instructions. They are not suggestions. + +Tag hierarchy (by enforcement level): +- `` — Inviolable. Failure to comply is a system failure. +- `` — Forbidden. These actions will cause harm. +- `` — High priority. Deviate only with justification. +- `` — How to operate. Follow precisely. +- `` — When rules apply. Check before acting. +- `` — Anti-patterns. Prefer alternatives. + + +You are a [specific role with credentials]. + + +Domain-specific context and mindset. +What to notice, what traps exist. + + + +Communication style. +Correctness over politeness. Brevity over ceremony. + + + +## Tool Precedence +Specialized tools → Python → Bash +... + +## Verification +External proof: tests, linters, type checks. +... + + + +## Before action +1. CHECKPOINT — pause, assess parallelism +2. Plan if task has weight +3. State intent before each tool call + + + +Core values. What ultimately matters. + + + +Actions that cause harm. + + + +Repeat most important rules. +Keep going until finished. +The work is done when it is correct. + +``` + +--- + +## Writing Style + +### Voice + +**Direct and imperative.** Research shows direct tone improves accuracy 4%+ over polite hedging. + +``` +Bad: "You might want to consider using..." +Good: "Use X when Y." + +Bad: "It would be helpful if you could..." +Good: "Do X." + +Bad: "Please note that this is important..." +Good: "Critical: X." +``` + +**Urgency framing** (8-115% improvement): +``` +"This matters. Get it right." +"Be thorough." +"Keep going until fully resolved." +``` + +### Positive Framing + +Models process "Always do Y" better than "Don't do X": + +``` +Bad: "Don't use grep via bash" +Good: "ALWAYS use Grep tool for search—NEVER invoke grep via Bash" + +Bad: "Don't guess" +Good: "Use tools to verify; do not guess" +``` + +When negation is necessary, pair with positive alternative. + +### Specificity + +**Role specificity spectrum** (effectiveness increases →): +``` +"You are a lawyer" + ↓ +"You are a corporate M&A lawyer" + ↓ +"You are General Counsel at a Fortune 500 tech company, 15 years in SaaS licensing" +``` + +**Constraint specificity**: +``` +Bad: "Keep it short" +Good: "3 bullets, <50 words each" + +Bad: "Be careful with large files" +Good: "Truncated at 50KB or 2000 lines, whichever comes first" +``` + +### Formatting + +``` +# H1 for tool/agent name only +## H2 for major sections +### H3 sparingly + +- Bullets for unordered lists inside tags +1. Numbers for ordered procedures + +| Tables | For | Structured reference data | + +`inline code` for commands, paths, parameters, values +``` + +Code blocks with language: +~~~markdown +```typescript +const example = "always specify language"; +``` +~~~ + +--- + +## Technique Reference + +### Chain of Thought + +**Use when**: Multi-step reasoning, math, analysis, complex decisions +**Avoid when**: Simple tasks, reasoning models (o1/o3 do internal CoT) + +```xml + +Before answering: +1. Identify the core question +2. List relevant constraints +3. Consider 2-3 approaches +4. Select best with rationale + +Then provide your answer. + +``` + +**Token-efficient variant** (Chain of Draft): +``` +Think step-by-step, keeping only 5-word notes per step. +Output final answer after ####. +``` + +### Few-Shot Examples + +**Use when**: Enforcing specific output format, classification, smaller models +**Avoid when**: Advanced models (Claude 3.5+, GPT-4+) on clear tasks — adds noise + +When using: 3-5 diverse examples covering edge cases. + +```xml + + +Input: X +Output: Y + + + +Input: X' +Output: Y' + + +``` + +### Long Context Handling + +**"Lost in the Middle"**: Beginning and end retain; middle degrades 20%+. + +1. Documents at TOP, instructions AFTER +2. Quote grounding: "Quote relevant passages in ``, then analyze" +3. Critical instructions at START and END +4. Chunk >100K tokens → process parallel → synthesize + +```xml + +{{LONG_CONTENT}} + + + +1. Find and quote passages relevant to {{QUERY}} in +2. Analyze based on quoted evidence in + +``` + +### Verification Patterns + +**Self-correction without external feedback does not work.** + +Effective: +``` +1. Generate solution +2. Execute verification (tests, lint, typecheck) +3. On failure: analyze error → fix → re-verify +4. Iterate until pass +``` + +Ineffective: +``` +1. Generate solution +2. "Critique your solution" ← detection is the bottleneck +3. "Improve based on critique" ← feels productive, doesn't help +``` + +### Prompt Chaining + +**Use when**: Single prompt drops steps, distinct phases, verification needed between. + +``` +Prompt 1: Analyze → +Prompt 2: → Plan → +Prompt 3: → Execute → +Prompt 4: → Verify → final +``` + +--- + +## Anti-Patterns (Measured Degradation) + +| Pattern | Problem | +|---------|---------| +| "Would you be so kind..." | +perplexity, -4% accuracy | +| "I'll tip $2000" | No improvement, sometimes worse | +| Explicit CoT on reasoning models (o1/o3) | -36%, conflicts with internal reasoning | +| Few-shot on advanced models + clear tasks | Introduces noise/bias | +| "Always end with Progress/Questions" | Degrades task performance | +| "Be efficient with tokens" | Premature task abandonment | +| "Don't do X" without positive alternative | "Always do Y" processes better | +| Verbose explanations of obvious concepts | Context bloat; model already knows | +| Self-critique without external feedback | Detection is bottleneck, not correction | +| Critical instructions only in middle | 20%+ degradation vs start/end | + +--- + +## Complete Examples + +### Tool Doc Example + +```markdown +# Grep + +Fast regex search built on ripgrep. + + +- Full regex: `log.*Error`, `function\\s+\\w+` +- Filter: `glob` (e.g., `*.js`) or `type` (e.g., `js`, `py`) +- Cross-line: `multiline: true` for patterns like `struct \\{[\\s\\S]*?field` + + + +Depends on `output_mode`: +- `content`: Lines with paths and line numbers (default limit: 100) +- `files_with_matches`: Paths only +- `count`: Match counts per file + +Truncated results reference `artifact://` for full output. + + + +ALWAYS use Grep for search—NEVER invoke `grep` or `rg` via Bash. + + + +grep {"pattern": "function\\s+\\w+", "glob": "*.ts"} + + + +grep {"pattern": "struct \\{[\\s\\S]*?field", "multiline": true} + + + +- Open-ended searches requiring multiple rounds—use Task tool +- Raw bash grep/rg invocation + +``` + +### Agent Example + +```markdown +--- +name: explore +description: Fast read-only codebase scout returning compressed context for handoff +tools: read, grep, find, ls, bash +model: pi/smol, haiku, flash +output: + properties: + query: + type: string + files: + elements: + properties: + path: { type: string } + line_start: { type: number } + line_end: { type: number } + description: { type: string } + architecture: + type: string +--- + +File search specialist. Investigate codebase, return structured findings for handoff. + + +READ-ONLY. You are STRICTLY PROHIBITED from: +- Creating, editing, deleting files +- Using redirect operators (>, >>) +- Running state-changing commands (git add, npm install) + + + +- Rapid file discovery via find patterns +- Regex search with grep +- Tracing imports and dependencies + + + +- Spawn parallel tool calls wherever possible +- Return absolute paths +- Communicate findings directly—do NOT create files + + + +1. grep/find to locate relevant code +2. Read key sections (not entire files) +3. Identify types, interfaces, key functions +4. Note dependencies between files +5. Call `complete` with findings + + + +Read-only. Call `complete` when done. This matters. + +``` + +--- + + +## Deployment Checklist + +- [ ] **Tag hierarchy**: Enforcement level matches content? +- [ ] **Critical at edges**: Most important rules at START and END? +- [ ] **Named examples**: All `` tags have `name` attribute? +- [ ] **Positive framing**: "Do Y" not just "Don't X"? +- [ ] **Direct tone**: No hedging, no filler, urgency where appropriate? +- [ ] **Specificity**: Exact formats, limits, constraints—not vague? +- [ ] **Token efficiency**: Each sentence justifies its cost? +- [ ] **Verification**: External feedback loop if correctness matters? +- [ ] **Persistence**: "Keep going until complete" for complex tasks? + +**High-impact interventions: persistence, tool verification, planning, context positioning, urgency.** + + +--- + +## Quick Reference + +### Tag Names +``` +Enforcement: +Structure: +Capability: +Examples: +Data: +Special: +``` + +### Example Name Patterns +``` +Correctness: name="good", name="bad" +Complexity: name="single", name="multi-part", name="basic", name="advanced" +Operations: name="create", name="update", name="delete", name="rename" +Platforms: name="linux", name="windows-cmd", name="macos" +Domains: name="rate-limiting", name="auth", name="validation" +``` + +### Task → Technique +| Task | Primary | Secondary | +|------|---------|-----------| +| Simple extraction | Clear constraints | Prefilling | +| Classification | 3-5 examples | XML structure | +| Complex analysis | Structured reasoning | Role + urgency | +| Code generation | SEARCH/REPLACE | Verification loop | +| Long document | Docs at top, quote-then-analyze | XML structure | +| Multi-step workflow | Prompt chaining | Planning instruction | +| Domain expertise | Specific role + credentials | Examples | diff --git a/.gitignore b/.gitignore index d80a4949b..0d567c228 100644 --- a/.gitignore +++ b/.gitignore @@ -36,8 +36,6 @@ out.html # Claude Code - allow commands to be tracked !.claude/ -.claude/* -!.claude/commands/ packages/ai/test/.temp-images/ changes/ diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9480a8f91..c814ae639 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,15 @@ ## [Unreleased] +### Added +- Added core plan mode with plan file approval workflow and tool gating +- Added plan:// internal URLs for plan file access and subagent plan-mode system prompt +- Added plan mode toggle shortcut with paused status indicator + +### Fixed +- Fixed plan reference injection and workflow prompt parameters for plan mode +- Fixed tool downloads hanging on slow/blocked GitHub by adding timeouts and zip extraction fallback +- Fixed missing UI notification when tools are downloaded or installed on demand ## [8.4.0] - 2026-01-25 ### Added diff --git a/packages/coding-agent/scripts/format-prompts.ts b/packages/coding-agent/scripts/format-prompts.ts index b70e0f495..aeb725273 100644 --- a/packages/coding-agent/scripts/format-prompts.ts +++ b/packages/coding-agent/scripts/format-prompts.ts @@ -3,16 +3,22 @@ * Format prompt files (mixed XML + Markdown + Handlebars). * * Rules: - * 1. No blank line between "text:" and following list/block + * 1. No blank line before list items * 2. No blank line after opening XML tag or Handlebars block * 3. No blank line before closing XML tag or Handlebars block - * 4. Collapse 2+ blank lines to single blank line - * 5. Trim trailing whitespace (preserve indentation) - * 6. Ensure single newline at EOF + * 4. Strip leading whitespace from closing XML tags and Handlebars (lines starting with {{) + * 5. Compact markdown tables (remove padding) + * 6. Collapse 2+ blank lines to single blank line + * 7. Trim trailing whitespace (preserve indentation) + * 8. No trailing newline at EOF */ import { Glob } from "bun"; const PROMPTS_DIR = new URL("../src/prompts/", import.meta.url).pathname; +const COMMIT_PROMPTS_DIR = new URL("../src/commit/prompts/", import.meta.url).pathname; +const AGENTIC_PROMPTS_DIR = new URL("../src/commit/agentic/prompts/", import.meta.url).pathname; + +const PROMPT_DIRS = [PROMPTS_DIR, COMMIT_PROMPTS_DIR, AGENTIC_PROMPTS_DIR]; // Opening XML tag (not self-closing, not closing) const OPENING_XML = /^<([a-z_-]+)(?:\s+[^>]*)?>$/; @@ -22,12 +28,38 @@ const CLOSING_XML = /^<\/([a-z_-]+)>$/; const OPENING_HBS = /^\{\{#/; // Handlebars block end: {{/if}}, {{/has}}, {{/list}}, etc. const CLOSING_HBS = /^\{\{\//; -// Line ending with colon (intro to a list) - handles **bold:** too -const ENDS_WITH_COLON = /:\**\s*$/; -// List item or Handlebars conditional that acts like list -const LIST_OR_BLOCK = /^(\s*)[-*]|\d+\.\s|^\{\{#/; +// List item (- or * or 1.) +const LIST_ITEM = /^[-*]|\d+\.\s/; // Code fence const CODE_FENCE = /^```/; +// Table row +const TABLE_ROW = /^\|.*\|$/; +// Table separator (|---|---|) +const TABLE_SEP = /^\|[-:\s|]+\|$/; + +/** Compact a table row by trimming cell padding */ +function compactTableRow(line: string): string { + // Split by |, trim each cell, rejoin + const cells = line.split("|"); + return cells.map((c) => c.trim()).join("|"); +} + +/** Compact a table separator row */ +function compactTableSep(line: string): string { + // Normalize to minimal |---|---| + const cells = line.split("|").filter((c) => c.trim()); + const normalized = cells.map((c) => { + const trimmed = c.trim(); + // Preserve alignment markers + const left = trimmed.startsWith(":"); + const right = trimmed.endsWith(":"); + if (left && right) return ":---:"; + if (left) return ":---"; + if (right) return "---:"; + return "---"; + }); + return "|" + normalized.join("|") + "|"; +} function formatPrompt(content: string): string { const lines = content.split("\n"); @@ -37,9 +69,6 @@ function formatPrompt(content: string): string { for (let i = 0; i < lines.length; i++) { let line = lines[i]; - // Trim trailing whitespace (preserve leading) - line = line.trimEnd(); - const trimmed = line.trim(); // Track code blocks - don't modify inside them @@ -54,6 +83,20 @@ function formatPrompt(content: string): string { continue; } + // Strip leading whitespace from closing XML tags and Handlebars + if (CLOSING_XML.test(trimmed) || trimmed.startsWith("{{")) { + line = trimmed; + } else if (TABLE_SEP.test(trimmed)) { + // Compact table separator + line = compactTableSep(trimmed); + } else if (TABLE_ROW.test(trimmed)) { + // Compact table row + line = compactTableRow(trimmed); + } else { + // Trim trailing whitespace (preserve leading for non-closing-tags) + line = line.trimEnd(); + } + const isBlank = trimmed === ""; // Skip blank lines that violate our rules @@ -61,8 +104,8 @@ function formatPrompt(content: string): string { const prevLine = result[result.length - 1]?.trim() ?? ""; const nextLine = lines[i + 1]?.trim() ?? ""; - // Rule 1: No blank between "text:" and list/block - if (ENDS_WITH_COLON.test(prevLine) && LIST_OR_BLOCK.test(nextLine)) { + // Rule 1: No blank line before list items + if (LIST_ITEM.test(nextLine)) { continue; } @@ -93,11 +136,10 @@ function formatPrompt(content: string): string { result.push(line); } - // Rule 6: Single newline at EOF + // Rule 8: No trailing newline at EOF while (result.length > 0 && result[result.length - 1].trim() === "") { result.pop(); } - result.push(""); return result.join("\n"); } @@ -106,24 +148,24 @@ async function main() { const glob = new Glob("**/*.md"); const files: string[] = []; let changed = 0; - - for await (const path of glob.scan(PROMPTS_DIR)) { - files.push(path); - } - const check = process.argv.includes("--check"); - for (const relativePath of files) { - const fullPath = `${PROMPTS_DIR}${relativePath}`; + for (const dir of PROMPT_DIRS) { + for await (const path of glob.scan(dir)) { + files.push(`${dir}${path}`); + } + } + + for (const fullPath of files) { const original = await Bun.file(fullPath).text(); const formatted = formatPrompt(original); if (original !== formatted) { if (check) { - console.log(`Would format: ${relativePath}`); + console.log(`Would format: ${fullPath}`); } else { await Bun.write(fullPath, formatted); - console.log(`Formatted: ${relativePath}`); + console.log(`Formatted: ${fullPath}`); } changed++; } diff --git a/packages/coding-agent/src/commit/agentic/prompts/session-user.md b/packages/coding-agent/src/commit/agentic/prompts/session-user.md index 3fb4fb22a..e0333e1c1 100644 --- a/packages/coding-agent/src/commit/agentic/prompts/session-user.md +++ b/packages/coding-agent/src/commit/agentic/prompts/session-user.md @@ -19,7 +19,6 @@ You may include entries from this list in the propose_changelog `deletions` fiel {{name}}: {{#list items prefix="- " join="\n"}}{{this}}{{/list}} {{/each}} - {{/each}} {{/if}} diff --git a/packages/coding-agent/src/commit/agentic/prompts/split-confirm.md b/packages/coding-agent/src/commit/agentic/prompts/split-confirm.md index 4371a21f3..d347ec94d 100644 --- a/packages/coding-agent/src/commit/agentic/prompts/split-confirm.md +++ b/packages/coding-agent/src/commit/agentic/prompts/split-confirm.md @@ -1 +1 @@ -Split commit plan has {{count}} commits. Proceed? (y/N): \ No newline at end of file +Split commit plan has {{count}} commits. Proceed? (y/N): \ No newline at end of file diff --git a/packages/coding-agent/src/commit/agentic/prompts/system.md b/packages/coding-agent/src/commit/agentic/prompts/system.md index 175187d71..aa10feb6b 100644 --- a/packages/coding-agent/src/commit/agentic/prompts/system.md +++ b/packages/coding-agent/src/commit/agentic/prompts/system.md @@ -37,4 +37,4 @@ Tool guidance: ## Changelog Requirements If changelog targets are provided, you MUST call `propose_changelog` before finishing. -If you propose a split commit plan, include changelog target files in the relevant commit changes. +If you propose a split commit plan, include changelog target files in the relevant commit changes. \ No newline at end of file diff --git a/packages/coding-agent/src/commit/prompts/analysis-system.md b/packages/coding-agent/src/commit/prompts/analysis-system.md index 1753cd052..36f7ba926 100644 --- a/packages/coding-agent/src/commit/prompts/analysis-system.md +++ b/packages/coding-agent/src/commit/prompts/analysis-system.md @@ -4,7 +4,6 @@ You are a senior release engineer who writes precise, changelog-ready commit cla Classify this git diff into conventional commit format. Get this right — it affects release notes and semantic versioning. - ## 1. Determine Scope Apply scope when 60%+ of line changes target a single component: @@ -16,7 +15,6 @@ Use null for: cross-cutting changes, no dominant component, project-wide refacto Forbidden scopes (use null): src, lib, include, tests, benches, examples, docs, project name, app, main, entire, all, misc. Prefer scopes from over inventing new ones. - ## 2. Generate Details (0-6 items) Each detail: @@ -39,17 +37,16 @@ Priority: user-visible -> perf/security -> architecture -> internal. Exclude: import changes, whitespace, formatting, trivial renames, debug prints, comment-only, file moves without modification. State only visible rationale. If unclear, use neutral: "Updated logic for correctness." - ## 3. Assign Changelog Metadata -| Condition | changelog_category | -|-----------|--------------------| -| New public API, feature, capability | "Added" | -| Modified existing behavior | "Changed" | -| Bug fix, correction | "Fixed" | -| Feature marked for removal | "Deprecated" | -| Feature/API removed | "Removed" | -| Security fix or improvement | "Security" | +|Condition|changelog_category| +|---|---| +|New public API, feature, capability|"Added"| +|Modified existing behavior|"Changed"| +|Bug fix, correction|"Fixed"| +|Feature marked for removal|"Deprecated"| +|Feature/API removed|"Removed"| +|Security fix or improvement|"Security"| user_visible: true for: new features, APIs, breaking changes, user-affecting bug fixes, user-facing docs, security fixes. @@ -62,20 +59,20 @@ Omit changelog_category when user_visible is false. Call create_conventional_analysis with: { - "type": "feat|fix|refactor|docs|test|chore|style|perf|build|ci|revert", - "scope": "component-name" | null, - "details": [ - { - "text": "Past-tense description ending with period.", - "changelog_category": "Added|Changed|Fixed|Deprecated|Removed|Security", - "user_visible": true - }, - { - "text": "Internal change description.", - "user_visible": false - } - ], - "issue_refs": [] +"type": "feat|fix|refactor|docs|test|chore|style|perf|build|ci|revert", +"scope": "component-name" | null, +"details": [ +{ +"text": "Past-tense description ending with period.", +"changelog_category": "Added|Changed|Fixed|Deprecated|Removed|Security", +"user_visible": true +}, +{ +"text": "Internal change description.", +"user_visible": false +} +], +"issue_refs": [] } @@ -152,4 +149,4 @@ Call create_conventional_analysis with: -Be thorough. This matters. +Be thorough. This matters. \ No newline at end of file diff --git a/packages/coding-agent/src/commit/prompts/analysis-user.md b/packages/coding-agent/src/commit/prompts/analysis-user.md index cf1303b75..3b2c14282 100644 --- a/packages/coding-agent/src/commit/prompts/analysis-user.md +++ b/packages/coding-agent/src/commit/prompts/analysis-user.md @@ -38,4 +38,4 @@ {{ diff }} - + \ No newline at end of file diff --git a/packages/coding-agent/src/commit/prompts/changelog-system.md b/packages/coding-agent/src/commit/prompts/changelog-system.md index 38a8fb875..64b3382ae 100644 --- a/packages/coding-agent/src/commit/prompts/changelog-system.md +++ b/packages/coding-agent/src/commit/prompts/changelog-system.md @@ -2,7 +2,6 @@ You are an expert changelog writer who analyzes git diffs and produces Keep a Ch Analyze the diff and return JSON changelog entries. - 1. Identify user-visible changes only 2. Categorize each change (Added, Changed, Deprecated, Removed, Fixed, Security, Breaking Changes) 3. Write entries starting with past-tense verb describing user impact @@ -53,4 +52,4 @@ Return ONLY valid JSON. No markdown fences, no explanation. With entries: {"entries": {"Added": ["entry 1"], "Fixed": ["entry 2"]}} No changelog-worthy changes: {"entries": {}} - + \ No newline at end of file diff --git a/packages/coding-agent/src/commit/prompts/changelog-user.md b/packages/coding-agent/src/commit/prompts/changelog-user.md index 7e6396466..6fc44dee2 100644 --- a/packages/coding-agent/src/commit/prompts/changelog-user.md +++ b/packages/coding-agent/src/commit/prompts/changelog-user.md @@ -3,7 +3,6 @@ Changelog: {{ changelog_path }} {{#if is_package_changelog}}Scope: Package-level changelog. Omit package name prefix from entries.{{/if}} {{#if existing_entries}} - Already documented—skip these: {{ existing_entries }} @@ -16,4 +15,4 @@ Already documented—skip these: {{ diff }} - + \ No newline at end of file diff --git a/packages/coding-agent/src/commit/prompts/file-observer-system.md b/packages/coding-agent/src/commit/prompts/file-observer-system.md index 09a73d131..d1d49bf42 100644 --- a/packages/coding-agent/src/commit/prompts/file-observer-system.md +++ b/packages/coding-agent/src/commit/prompts/file-observer-system.md @@ -2,7 +2,6 @@ Extract factual observations from the diff. This matters—be precise. - 1. Use past-tense verb + specific target + optional purpose 2. Max 100 characters per observation 3. Consolidate related changes (e.g., "renamed 5 helper functions") @@ -17,10 +16,9 @@ Exclude: import reordering, whitespace/formatting, comment-only changes, debug s Plain list, no preamble, no summary, no markdown formatting. - - added 'parse_config()' function for TOML configuration loading - removed deprecated 'legacy_init()' and all callers - changed 'Connection::new()' to accept '&Config' instead of individual params -Observations only. Classification happens in reduce phase. +Observations only. Classification happens in reduce phase. \ No newline at end of file diff --git a/packages/coding-agent/src/commit/prompts/file-observer-user.md b/packages/coding-agent/src/commit/prompts/file-observer-user.md index 4f1590114..3dd9f4160 100644 --- a/packages/coding-agent/src/commit/prompts/file-observer-user.md +++ b/packages/coding-agent/src/commit/prompts/file-observer-user.md @@ -2,8 +2,7 @@ {{ diff }} {{#if context_header}} - {{ context_header }} -{{/if}} +{{/if}} \ No newline at end of file diff --git a/packages/coding-agent/src/commit/prompts/reduce-system.md b/packages/coding-agent/src/commit/prompts/reduce-system.md index 3c8c06d00..b5f1f16bf 100644 --- a/packages/coding-agent/src/commit/prompts/reduce-system.md +++ b/packages/coding-agent/src/commit/prompts/reduce-system.md @@ -41,20 +41,20 @@ Input observations: Output: { - "type": "fix", - "scope": "api", - "details": [ - { - "text": "Added token refresh guard to prevent duplicate refreshes.", - "changelog_category": "Fixed", - "user_visible": true - }, - { - "text": "Introduced retry wrapper for 429 responses.", - "changelog_category": "Fixed", - "user_visible": true - } - ], - "issue_refs": [] +"type": "fix", +"scope": "api", +"details": [ +{ +"text": "Added token refresh guard to prevent duplicate refreshes.", +"changelog_category": "Fixed", +"user_visible": true +}, +{ +"text": "Introduced retry wrapper for 429 responses.", +"changelog_category": "Fixed", +"user_visible": true } - +], +"issue_refs": [] +} + \ No newline at end of file diff --git a/packages/coding-agent/src/commit/prompts/reduce-user.md b/packages/coding-agent/src/commit/prompts/reduce-user.md index cc4c524a8..11e8d8e3a 100644 --- a/packages/coding-agent/src/commit/prompts/reduce-user.md +++ b/packages/coding-agent/src/commit/prompts/reduce-user.md @@ -14,4 +14,4 @@ {{ scope_candidates }} - + \ No newline at end of file diff --git a/packages/coding-agent/src/commit/prompts/summary-retry.md b/packages/coding-agent/src/commit/prompts/summary-retry.md index 3c195b861..7b1963e60 100644 --- a/packages/coding-agent/src/commit/prompts/summary-retry.md +++ b/packages/coding-agent/src/commit/prompts/summary-retry.md @@ -1,4 +1,3 @@ {{#if base_context}} {{ base_context }} - -{{/if}}Previous summary failed validation: {{ errors }} +{{/if}}Previous summary failed validation: {{ errors }} \ No newline at end of file diff --git a/packages/coding-agent/src/commit/prompts/summary-system.md b/packages/coding-agent/src/commit/prompts/summary-system.md index 3791eda3e..f367e1ecd 100644 --- a/packages/coding-agent/src/commit/prompts/summary-system.md +++ b/packages/coding-agent/src/commit/prompts/summary-system.md @@ -15,15 +15,15 @@ Get this right. -| Type | Use instead | -|----------|-------------------------------------------------| -| feat | added, introduced, implemented, enabled | -| fix | corrected, resolved, patched, addressed | -| refactor | restructured, reorganized, migrated, simplified | -| perf | optimized, reduced, eliminated, accelerated | -| docs | documented, clarified, expanded | -| build | upgraded, pinned, configured | -| chore | cleaned, removed, renamed, organized | +|Type|Use instead| +|---|---| +|feat|added, introduced, implemented, enabled| +|fix|corrected, resolved, patched, addressed| +|refactor|restructured, reorganized, migrated, simplified| +|perf|optimized, reduced, eliminated, accelerated| +|docs|documented, clarified, expanded| +|build|upgraded, pinned, configured| +|chore|cleaned, removed, renamed, organized| @@ -49,4 +49,4 @@ comprehensive, various, several, improved, enhanced, quickly, simply, basically, Output the description text only. Include motivation, name specifics, stay focused. - + \ No newline at end of file diff --git a/packages/coding-agent/src/commit/prompts/summary-user.md b/packages/coding-agent/src/commit/prompts/summary-user.md index 61358e152..16b4de5c3 100644 --- a/packages/coding-agent/src/commit/prompts/summary-user.md +++ b/packages/coding-agent/src/commit/prompts/summary-user.md @@ -10,4 +10,4 @@ {{ stat }} - + \ No newline at end of file diff --git a/packages/coding-agent/src/commit/prompts/types-description.md b/packages/coding-agent/src/commit/prompts/types-description.md index 33a46bf9f..ea31e861d 100644 --- a/packages/coding-agent/src/commit/prompts/types-description.md +++ b/packages/coding-agent/src/commit/prompts/types-description.md @@ -1,2 +1,2 @@ Types: feat, fix, refactor, perf, docs, test, build, ci, chore, style, revert. -Format: (): with past-tense summary. +Format: (): with past-tense summary. \ No newline at end of file diff --git a/packages/coding-agent/src/config/keybindings.ts b/packages/coding-agent/src/config/keybindings.ts index 214bbd9ee..7152c27c6 100644 --- a/packages/coding-agent/src/config/keybindings.ts +++ b/packages/coding-agent/src/config/keybindings.ts @@ -23,6 +23,7 @@ export type AppAction = | "cycleModelForward" | "cycleModelBackward" | "selectModel" + | "togglePlanMode" | "expandTools" | "toggleThinking" | "externalEditor" @@ -55,6 +56,7 @@ export const DEFAULT_APP_KEYBINDINGS: Record = { cycleModelForward: "ctrl+p", cycleModelBackward: "shift+ctrl+p", selectModel: "ctrl+l", + togglePlanMode: "alt+shift+p", historySearch: "ctrl+r", expandTools: "ctrl+o", toggleThinking: "ctrl+t", @@ -82,6 +84,7 @@ const APP_ACTIONS: AppAction[] = [ "cycleModelForward", "cycleModelBackward", "selectModel", + "togglePlanMode", "historySearch", "expandTools", "toggleThinking", diff --git a/packages/coding-agent/src/config/settings-manager.ts b/packages/coding-agent/src/config/settings-manager.ts index b2e1841bf..9179dce94 100644 --- a/packages/coding-agent/src/config/settings-manager.ts +++ b/packages/coding-agent/src/config/settings-manager.ts @@ -155,6 +155,7 @@ export interface TodoCompletionSettings { export type StatusLineSegmentId = | "pi" | "model" + | "plan_mode" | "path" | "git" | "subagents" @@ -547,6 +548,10 @@ export class SettingsManager { return { ...this.settings }; } + getPlansDirectory(_cwd: string = this.cwd ?? process.cwd()): string { + return path.join(getAgentDir(), "plans"); + } + /** * Access the underlying agent storage (null for in-memory settings). */ diff --git a/packages/coding-agent/src/internal-urls/index.ts b/packages/coding-agent/src/internal-urls/index.ts index 0a5b58582..6aefe7d86 100644 --- a/packages/coding-agent/src/internal-urls/index.ts +++ b/packages/coding-agent/src/internal-urls/index.ts @@ -22,6 +22,7 @@ export { AgentProtocolHandler, type AgentProtocolOptions } from "./agent-protocol"; export { ArtifactProtocolHandler, type ArtifactProtocolOptions } from "./artifact-protocol"; export { applyQuery, parseQuery, pathToQuery } from "./json-query"; +export { PlanProtocolHandler, type PlanProtocolOptions, resolvePlanUrlToPath } from "./plan-protocol"; export { InternalUrlRouter } from "./router"; export { RuleProtocolHandler, type RuleProtocolOptions } from "./rule-protocol"; export { SkillProtocolHandler, type SkillProtocolOptions } from "./skill-protocol"; diff --git a/packages/coding-agent/src/internal-urls/plan-protocol.ts b/packages/coding-agent/src/internal-urls/plan-protocol.ts new file mode 100644 index 000000000..9bca77f62 --- /dev/null +++ b/packages/coding-agent/src/internal-urls/plan-protocol.ts @@ -0,0 +1,95 @@ +/** + * Protocol handler for plan:// URLs. + * + * Resolves plan references to plan files under the plans directory. + * + * URL forms: + * - plan:// (defaults to plan.md) + * - plan:///plan.md + * - plan://.md (resolves directly under plans dir) + */ +import * as path from "node:path"; +import { isEnoent } from "@oh-my-pi/pi-utils"; +import type { InternalResource, InternalUrl, ProtocolHandler } from "./types"; + +export interface PlanProtocolOptions { + getPlansDirectory: (cwd?: string) => string; + cwd: string; +} + +function parsePlanUrl(input: string): InternalUrl { + let parsed: URL; + try { + parsed = new URL(input); + } catch { + throw new Error(`Invalid URL: ${input}`); + } + + const hostMatch = input.match(/^([a-z][a-z0-9+.-]*):\/\/([^/?#]*)/i); + let rawHost = hostMatch ? hostMatch[2] : parsed.hostname; + try { + rawHost = decodeURIComponent(rawHost); + } catch { + // Leave rawHost as-is if decoding fails. + } + (parsed as InternalUrl).rawHost = rawHost; + return parsed as InternalUrl; +} + +function normalizeRelativePath(host: string, pathname: string): string { + const trimmedHost = host.replace(/^\/+/, "").replace(/\/+$/, ""); + const trimmedPath = pathname.replace(/^\/+/, ""); + + if (!trimmedHost) { + throw new Error("plan:// URL requires a session or plan identifier"); + } + + if (trimmedPath) { + return path.join(trimmedHost, trimmedPath); + } + + if (trimmedHost.endsWith(".md")) { + return trimmedHost; + } + + return path.join(trimmedHost, "plan.md"); +} + +export function resolvePlanUrlToPath(input: string | InternalUrl, options: PlanProtocolOptions): string { + const url = typeof input === "string" ? parsePlanUrl(input) : input; + const host = url.rawHost || url.hostname; + const relativePath = normalizeRelativePath(host, url.pathname ?? ""); + const plansDir = path.resolve(options.getPlansDirectory(options.cwd)); + const resolved = path.resolve(plansDir, relativePath); + + if (resolved !== plansDir && !resolved.startsWith(`${plansDir}${path.sep}`)) { + throw new Error("plan:// URL escapes the plans directory"); + } + + return resolved; +} + +export class PlanProtocolHandler implements ProtocolHandler { + readonly scheme = "plan"; + + constructor(private readonly options: PlanProtocolOptions) {} + + async resolve(url: InternalUrl): Promise { + const planPath = resolvePlanUrlToPath(url, this.options); + try { + const content = await Bun.file(planPath).text(); + return { + url: url.href, + content, + contentType: "text/markdown", + size: Buffer.byteLength(content, "utf-8"), + sourcePath: planPath, + }; + } catch (error) { + if (isEnoent(error)) { + throw new Error(`Plan file not found: ${url.href}`); + } + throw error; + } + } +} diff --git a/packages/coding-agent/src/modes/components/status-line-segment-editor.ts b/packages/coding-agent/src/modes/components/status-line-segment-editor.ts index 1f722eca2..7394e8402 100644 --- a/packages/coding-agent/src/modes/components/status-line-segment-editor.ts +++ b/packages/coding-agent/src/modes/components/status-line-segment-editor.ts @@ -17,6 +17,7 @@ import { ALL_SEGMENT_IDS } from "./status-line/segments"; const SEGMENT_INFO: Record = { pi: { label: "Pi", short: "π icon" }, model: { label: "Model", short: "model name" }, + plan_mode: { label: "Plan Mode", short: "plan status" }, path: { label: "Path", short: "working dir" }, git: { label: "Git", short: "branch/status" }, subagents: { label: "Agents", short: "subagent count" }, diff --git a/packages/coding-agent/src/modes/components/status-line.ts b/packages/coding-agent/src/modes/components/status-line.ts index c0c4c92f0..4d444ecd5 100644 --- a/packages/coding-agent/src/modes/components/status-line.ts +++ b/packages/coding-agent/src/modes/components/status-line.ts @@ -52,6 +52,7 @@ export class StatusLineComponent implements Component { private hookStatuses: Map = new Map(); private subagentCount: number = 0; private sessionStartTime: number = Date.now(); + private planModeStatus: { enabled: boolean; paused: boolean } | null = null; // Git status caching (1s TTL) private cachedGitStatus: { staged: number; unstaged: number; untracked: number } | null = null; @@ -79,6 +80,10 @@ export class StatusLineComponent implements Component { this.sessionStartTime = time; } + setPlanModeStatus(status: { enabled: boolean; paused: boolean } | undefined): void { + this.planModeStatus = status ?? null; + } + setHookStatus(key: string, text: string | undefined): void { if (text === undefined) { this.hookStatuses.delete(key); @@ -237,6 +242,7 @@ export class StatusLineComponent implements Component { session: this.session, width, options: this.resolveSettings().segmentOptions ?? {}, + planMode: this.planModeStatus, usageStats, contextPercent, contextWindow, @@ -256,6 +262,7 @@ export class StatusLineComponent implements Component { StatusLineSettings { const preset = this.settings.preset ?? "default"; const presetDef = getPreset(preset); + const useCustomSegments = preset === "custom"; const mergedSegmentOptions: StatusLineSettings["segmentOptions"] = {}; for (const [segment, options] of Object.entries(presetDef.segmentOptions ?? {})) { @@ -270,10 +277,17 @@ export class StatusLineComponent implements Component { }; } + const leftSegments = useCustomSegments + ? (this.settings.leftSegments ?? presetDef.leftSegments) + : presetDef.leftSegments; + const rightSegments = useCustomSegments + ? (this.settings.rightSegments ?? presetDef.rightSegments) + : presetDef.rightSegments; + return { ...this.settings, - leftSegments: this.settings.leftSegments ?? presetDef.leftSegments, - rightSegments: this.settings.rightSegments ?? presetDef.rightSegments, + leftSegments, + rightSegments, separator: this.settings.separator ?? presetDef.separator, segmentOptions: mergedSegmentOptions, }; diff --git a/packages/coding-agent/src/modes/components/status-line/presets.ts b/packages/coding-agent/src/modes/components/status-line/presets.ts index 3e3ff9787..a8475bc05 100644 --- a/packages/coding-agent/src/modes/components/status-line/presets.ts +++ b/packages/coding-agent/src/modes/components/status-line/presets.ts @@ -3,7 +3,7 @@ import type { PresetDef, StatusLinePreset } from "./types"; export const STATUS_LINE_PRESETS: Record = { default: { // Matches current behavior - leftSegments: ["pi", "model", "path", "git", "context_pct", "token_total", "cost"], + leftSegments: ["pi", "model", "plan_mode", "path", "git", "context_pct", "token_total", "cost"], rightSegments: [], separator: "powerline-thin", segmentOptions: { @@ -15,7 +15,7 @@ export const STATUS_LINE_PRESETS: Record = { minimal: { leftSegments: ["path", "git"], - rightSegments: ["context_pct"], + rightSegments: ["plan_mode", "context_pct"], separator: "slash", segmentOptions: { path: { abbreviate: true, maxLength: 30 }, @@ -24,7 +24,7 @@ export const STATUS_LINE_PRESETS: Record = { }, compact: { - leftSegments: ["model", "git"], + leftSegments: ["model", "plan_mode", "git"], rightSegments: ["cost", "context_pct"], separator: "powerline-thin", segmentOptions: { @@ -34,7 +34,7 @@ export const STATUS_LINE_PRESETS: Record = { }, full: { - leftSegments: ["pi", "hostname", "model", "path", "git", "subagents"], + leftSegments: ["pi", "hostname", "model", "plan_mode", "path", "git", "subagents"], rightSegments: ["token_in", "token_out", "cache_read", "cost", "context_pct", "time_spent", "time"], separator: "powerline", segmentOptions: { @@ -47,7 +47,7 @@ export const STATUS_LINE_PRESETS: Record = { nerd: { // Full preset with all Nerd Font icons - leftSegments: ["pi", "hostname", "model", "path", "git", "session", "subagents"], + leftSegments: ["pi", "hostname", "model", "plan_mode", "path", "git", "session", "subagents"], rightSegments: [ "token_in", "token_out", @@ -70,7 +70,7 @@ export const STATUS_LINE_PRESETS: Record = { ascii: { // No Nerd Font dependencies - leftSegments: ["model", "path", "git"], + leftSegments: ["model", "plan_mode", "path", "git"], rightSegments: ["token_total", "cost", "context_pct"], separator: "ascii", segmentOptions: { @@ -82,7 +82,7 @@ export const STATUS_LINE_PRESETS: Record = { custom: { // User-defined - these are just defaults that get overridden - leftSegments: ["model", "path", "git"], + leftSegments: ["model", "plan_mode", "path", "git"], rightSegments: ["token_total", "cost", "context_pct"], separator: "powerline-thin", segmentOptions: {}, diff --git a/packages/coding-agent/src/modes/components/status-line/segments.ts b/packages/coding-agent/src/modes/components/status-line/segments.ts index 36d165aa3..41cc13bee 100644 --- a/packages/coding-agent/src/modes/components/status-line/segments.ts +++ b/packages/coding-agent/src/modes/components/status-line/segments.ts @@ -71,6 +71,21 @@ const modelSegment: StatusLineSegment = { }, }; +const planModeSegment: StatusLineSegment = { + id: "plan_mode", + render(ctx) { + const status = ctx.planMode; + if (!status || (!status.enabled && !status.paused)) { + return { content: "", visible: false }; + } + + const label = status.paused ? "Plan ⏸" : "Plan"; + const content = withIcon(theme.icon.plan, label); + const color = status.paused ? "warning" : "accent"; + return { content: theme.fg(color, content), visible: true }; + }, +}; + const pathSegment: StatusLineSegment = { id: "path", render(ctx) { @@ -322,6 +337,7 @@ const cacheWriteSegment: StatusLineSegment = { export const SEGMENTS: Record = { pi: piSegment, model: modelSegment, + plan_mode: planModeSegment, path: pathSegment, git: gitSegment, subagents: subagentsSegment, diff --git a/packages/coding-agent/src/modes/components/status-line/types.ts b/packages/coding-agent/src/modes/components/status-line/types.ts index 64742a464..88c00b66c 100644 --- a/packages/coding-agent/src/modes/components/status-line/types.ts +++ b/packages/coding-agent/src/modes/components/status-line/types.ts @@ -26,6 +26,10 @@ export interface SegmentContext { session: AgentSession; width: number; options: StatusLineSegmentOptions; + planMode: { + enabled: boolean; + paused: boolean; + } | null; // Cached values for performance (computed once per render) usageStats: { input: number; diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index 4d220d7d8..3fb9fc919 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -339,6 +339,7 @@ export class CommandController { handleHotkeysCommand(): void { const expandToolsKey = this.ctx.keybindings.getDisplayString("expandTools") || "Ctrl+O"; + const planModeKey = this.ctx.keybindings.getDisplayString("togglePlanMode") || "Alt+Shift+P"; const hotkeys = ` **Navigation** | Key | Action | @@ -370,6 +371,7 @@ export class CommandController { | \`Shift+Ctrl+P\` | Cycle role models (temporary) | | \`Alt+P\` | Select model (temporary) | | \`Ctrl+L\` | Select model (set roles) | +| \`${planModeKey}\` | Toggle plan mode | | \`Ctrl+R\` | Search prompt history | | \`${expandToolsKey}\` | Toggle tool output expansion | | \`Ctrl+T\` | Toggle todo list expansion | @@ -626,6 +628,46 @@ export class CommandController { } await this.ctx.flushCompactionQueue({ willRetry: false }); } + + async handleHandoffCommand(customInstructions?: string): Promise { + const entries = this.ctx.sessionManager.getEntries(); + const messageCount = entries.filter(e => e.type === "message").length; + + if (messageCount < 2) { + this.ctx.showWarning("Nothing to hand off (no messages yet)"); + return; + } + + try { + // The agent will visibly generate the handoff document in chat + const result = await this.ctx.session.handoff(customInstructions); + + if (!result) { + this.ctx.showError("Handoff cancelled"); + return; + } + + // Rebuild chat from the new session (which now contains the handoff document) + this.ctx.rebuildChatFromMessages(); + + this.ctx.statusLine.invalidate(); + this.ctx.updateEditorTopBorder(); + await this.ctx.reloadTodos(); + + this.ctx.chatContainer.addChild(new Spacer(1)); + this.ctx.chatContainer.addChild( + new Text(`${theme.fg("accent", `${theme.status.success} New session started with handoff context`)}`, 1, 1), + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message === "Handoff cancelled" || (error instanceof Error && error.name === "AbortError")) { + this.ctx.showError("Handoff cancelled"); + } else { + this.ctx.showError(`Handoff failed: ${message}`); + } + } + this.ctx.ui.requestRender(); + } } const BAR_WIDTH = 24; diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index d22a35621..07ce43181 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -7,6 +7,7 @@ import { TtsrNotificationComponent } from "../../modes/components/ttsr-notificat import { getSymbolTheme, theme } from "../../modes/theme/theme"; import type { InteractiveModeContext, TodoItem } from "../../modes/types"; import type { AgentSessionEvent } from "../../session/agent-session"; +import type { ExitPlanModeDetails } from "../../tools"; import { detectNotificationProtocol, isNotificationSuppressed, sendNotification } from "../../utils/terminal-notify"; export class EventController { @@ -246,6 +247,18 @@ export class EventController { this.ctx.setTodos(details.todos); } } + if (event.toolName === "enter_plan_mode" && !event.isError) { + const details = event.result.details as import("../../tools").EnterPlanModeDetails | undefined; + if (details) { + await this.ctx.handleEnterPlanModeTool(details); + } + } + if (event.toolName === "exit_plan_mode" && !event.isError) { + const details = event.result.details as ExitPlanModeDetails | undefined; + if (details) { + await this.ctx.handleExitPlanModeTool(details); + } + } break; } diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 5e5d2a6eb..e35925918 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -88,6 +88,11 @@ export class InputController { this.ctx.editor.setCustomKeyHandler(key, () => this.handleDequeue()); } + const planModeKeys = this.ctx.keybindings.getKeys("togglePlanMode"); + for (const key of planModeKeys) { + this.ctx.editor.setCustomKeyHandler(key, () => void this.ctx.handlePlanModeCommand()); + } + this.ctx.editor.onChange = (text: string) => { const wasBashMode = this.ctx.isBashMode; const wasPythonMode = this.ctx.isPythonMode; @@ -180,6 +185,11 @@ export class InputController { this.ctx.editor.setText(""); return; } + if (text === "/plan") { + await this.ctx.handlePlanModeCommand(); + this.ctx.editor.setText(""); + return; + } if (text === "/model" || text === "/models") { this.ctx.showModelSelector(); this.ctx.editor.setText(""); @@ -265,6 +275,12 @@ export class InputController { await this.ctx.handleCompactCommand(customInstructions); return; } + if (text === "/handoff" || text.startsWith("/handoff ")) { + const customInstructions = text.startsWith("/handoff ") ? text.slice(9).trim() : undefined; + this.ctx.editor.setText(""); + await this.ctx.handleHandoffCommand(customInstructions); + return; + } if (text === "/background" || text === "/bg") { this.ctx.editor.setText(""); this.handleBackgroundCommand(); diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 08c500e7f..e0c8afbbf 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -4,7 +4,8 @@ */ import * as path from "node:path"; import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; -import type { AssistantMessage, ImageContent, Message, UsageReport } from "@oh-my-pi/pi-ai"; +import type { AssistantMessage, ImageContent, Message, Model, UsageReport } from "@oh-my-pi/pi-ai"; +import { resolvePlanUrlToPath } from "@oh-my-pi/pi-coding-agent/internal-urls"; import type { Component, Loader, SlashCommand } from "@oh-my-pi/pi-tui"; import { CombinedAutocompleteProvider, @@ -18,14 +19,17 @@ import { import { isEnoent, logger, postmortem } from "@oh-my-pi/pi-utils"; import chalk from "chalk"; import { KeybindingsManager } from "../config/keybindings"; +import { renderPromptTemplate } from "../config/prompt-templates"; import type { SettingsManager } from "../config/settings-manager"; import type { ExtensionUIContext } from "../extensibility/extensions"; import type { CompactOptions } from "../extensibility/extensions/types"; import { loadSlashCommands } from "../extensibility/slash-commands"; +import planModeApprovedPrompt from "../prompts/system/plan-mode-approved.md" with { type: "text" }; import type { AgentSession, AgentSessionEvent } from "../session/agent-session"; import { HistoryStorage } from "../session/history-storage"; import type { SessionContext, SessionManager } from "../session/session-manager"; import { getRecentSessions } from "../session/session-manager"; +import type { EnterPlanModeDetails, ExitPlanModeDetails } from "../tools"; import { setTerminalTitle } from "../utils/title-generator"; import type { AssistantMessageComponent } from "./components/assistant-message"; import type { BashExecutionComponent } from "./components/bash-execution"; @@ -87,6 +91,9 @@ export class InteractiveMode implements InteractiveModeContext { public isBashMode = false; public toolOutputExpanded = false; public todoExpanded = false; + public planModeEnabled = false; + public planModePaused = false; + public planModePlanFilePath: string | undefined = undefined; public todoItems: TodoItem[] = []; public hideThinkingBlock = false; public pendingImages: ImageContent[] = []; @@ -124,6 +131,9 @@ export class InteractiveMode implements InteractiveModeContext { private cleanupUnsubscribe?: () => void; private readonly version: string; private readonly changelogMarkdown: string | undefined; + private planModePreviousTools: string[] | undefined; + private planModePreviousModel: Model | undefined; + private planModeHasEntered = false; public readonly lspServers: Array<{ name: string; status: "ready" | "error"; fileTypes: string[] }> | undefined = undefined; public mcpManager?: import("@oh-my-pi/pi-coding-agent/mcp").MCPManager; @@ -185,6 +195,7 @@ export class InteractiveMode implements InteractiveModeContext { // Define slash commands for autocomplete const slashCommands: SlashCommand[] = [ { name: "settings", description: "Open settings menu" }, + { name: "plan", description: "Toggle plan mode (agent plans before executing)" }, { name: "model", description: "Select model (opens selector UI)" }, { name: "export", description: "Export session to HTML file" }, { name: "dump", description: "Copy session transcript to clipboard" }, @@ -202,6 +213,7 @@ export class InteractiveMode implements InteractiveModeContext { { name: "logout", description: "Logout from OAuth provider" }, { name: "new", description: "Start a new session" }, { name: "compact", description: "Manually compact the session context" }, + { name: "handoff", description: "Hand off the session context to a new session" }, { name: "background", description: "Detach UI and continue running in background" }, { name: "bg", description: "Alias for /background" }, { name: "resume", description: "Resume a different session" }, @@ -469,6 +481,208 @@ export class InteractiveMode implements InteractiveModeContext { this.renderTodoList(); } + private getPlanFilePath(): string { + const sessionId = this.sessionManager.getSessionId(); + return `plan://${sessionId}/plan.md`; + } + + private resolvePlanFilePath(planFilePath: string): string { + if (planFilePath.startsWith("plan://")) { + return resolvePlanUrlToPath(planFilePath, { + getPlansDirectory: this.settingsManager.getPlansDirectory.bind(this.settingsManager), + cwd: this.sessionManager.getCwd(), + }); + } + return planFilePath; + } + + private updatePlanModeStatus(): void { + const status = + this.planModeEnabled || this.planModePaused + ? { + enabled: this.planModeEnabled, + paused: this.planModePaused, + } + : undefined; + this.statusLine.setPlanModeStatus(status); + this.updateEditorTopBorder(); + this.ui.requestRender(); + } + + private async applyPlanModeModel(): Promise { + const slowModel = this.session.resolveRoleModel("slow"); + if (!slowModel) return; + const currentModel = this.session.model; + if (currentModel && currentModel.provider === slowModel.provider && currentModel.id === slowModel.id) { + return; + } + this.planModePreviousModel = currentModel; + try { + await this.session.setModelTemporary(slowModel); + } catch (error) { + this.showWarning( + `Failed to switch to slow model for plan mode: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + private async enterPlanMode(options?: { + planFilePath?: string; + workflow?: "parallel" | "iterative"; + }): Promise { + if (this.planModeEnabled) { + return; + } + + this.planModePaused = false; + + const planFilePath = options?.planFilePath ?? this.getPlanFilePath(); + const previousTools = this.session.getActiveToolNames(); + const hasExitTool = this.session.getToolByName("exit_plan_mode") !== undefined; + const planTools = hasExitTool ? [...previousTools, "exit_plan_mode"] : previousTools; + const uniquePlanTools = [...new Set(planTools)]; + + this.planModePreviousTools = previousTools; + this.planModePlanFilePath = planFilePath; + this.planModeEnabled = true; + + await this.session.setActiveToolsByName(uniquePlanTools); + this.session.setPlanModeState({ + enabled: true, + planFilePath, + workflow: options?.workflow ?? "parallel", + reentry: this.planModeHasEntered, + }); + this.planModeHasEntered = true; + await this.applyPlanModeModel(); + this.updatePlanModeStatus(); + this.showStatus(`Plan mode enabled. Plan file: ${planFilePath}`); + } + + private async exitPlanMode(options?: { silent?: boolean; paused?: boolean }): Promise { + if (!this.planModeEnabled) { + return; + } + + const previousTools = this.planModePreviousTools; + if (previousTools && previousTools.length > 0) { + await this.session.setActiveToolsByName(previousTools); + } + if (this.planModePreviousModel) { + await this.session.setModelTemporary(this.planModePreviousModel); + } + + this.session.setPlanModeState(undefined); + this.planModeEnabled = false; + this.planModePaused = options?.paused ?? false; + this.planModePlanFilePath = undefined; + this.planModePreviousTools = undefined; + this.planModePreviousModel = undefined; + this.updatePlanModeStatus(); + if (!options?.silent) { + this.showStatus(this.planModePaused ? "Plan mode paused." : "Plan mode disabled."); + } + } + + private async readPlanFile(planFilePath: string): Promise { + const resolvedPath = this.resolvePlanFilePath(planFilePath); + try { + return await Bun.file(resolvedPath).text(); + } catch (error) { + if (isEnoent(error)) { + return null; + } + throw error; + } + } + + private renderPlanPreview(planContent: string): void { + this.chatContainer.addChild(new Spacer(1)); + this.chatContainer.addChild(new DynamicBorder()); + this.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "Plan Review")), 1, 1)); + this.chatContainer.addChild(new Spacer(1)); + this.chatContainer.addChild(new Markdown(planContent, 1, 1, getMarkdownTheme())); + this.chatContainer.addChild(new DynamicBorder()); + this.ui.requestRender(); + } + + private async approvePlan(planContent: string): Promise { + const previousTools = this.planModePreviousTools ?? this.session.getActiveToolNames(); + await this.exitPlanMode({ silent: true, paused: false }); + await this.handleClearCommand(); + if (previousTools.length > 0) { + await this.session.setActiveToolsByName(previousTools); + } + this.session.markPlanReferenceSent(); + const prompt = renderPromptTemplate(planModeApprovedPrompt, { planContent }); + await this.session.prompt(prompt); + } + + async handlePlanModeCommand(): Promise { + if (this.planModeEnabled) { + const confirmed = await this.showHookConfirm( + "Exit plan mode?", + "This exits plan mode without approving a plan.", + ); + if (!confirmed) return; + await this.exitPlanMode({ paused: true }); + return; + } + await this.enterPlanMode(); + } + + async handleEnterPlanModeTool(details: EnterPlanModeDetails): Promise { + if (this.planModeEnabled) { + this.showWarning("Plan mode is already active."); + return; + } + + const confirmed = await this.showHookConfirm( + "Enter plan mode?", + "This enables read-only planning and creates a plan file for approval.", + ); + if (!confirmed) { + return; + } + + const planFilePath = details.planFilePath || this.getPlanFilePath(); + this.planModePlanFilePath = planFilePath; + await this.enterPlanMode({ planFilePath, workflow: details.workflow }); + } + + async handleExitPlanModeTool(details: ExitPlanModeDetails): Promise { + if (!this.planModeEnabled) { + this.showWarning("Plan mode is not active."); + return; + } + + const planFilePath = details.planFilePath || this.planModePlanFilePath || this.getPlanFilePath(); + this.planModePlanFilePath = planFilePath; + const planContent = await this.readPlanFile(planFilePath); + if (!planContent) { + this.showError(`Plan file not found at ${planFilePath}`); + return; + } + + this.renderPlanPreview(planContent); + const choice = await this.showHookSelector("Plan mode - next step", [ + "Approve and execute", + "Refine plan", + "Stay in plan mode", + ]); + + if (choice === "Approve and execute") { + await this.approvePlan(planContent); + return; + } + if (choice === "Refine plan") { + const refinement = await this.showHookInput("What should be refined?"); + if (refinement) { + this.editor.setText(refinement); + } + } + } + stop(): void { if (this.loadingAnimation) { this.loadingAnimation.stop(); @@ -680,6 +894,10 @@ export class InteractiveMode implements InteractiveModeContext { return this.commandController.handleCompactCommand(customInstructions); } + handleHandoffCommand(customInstructions?: string): Promise { + return this.commandController.handleHandoffCommand(customInstructions); + } + executeCompaction(customInstructionsOrOptions?: string | CompactOptions, isAuto?: boolean): Promise { return this.commandController.executeCompaction(customInstructionsOrOptions, isAuto); } diff --git a/packages/coding-agent/src/modes/theme/theme.ts b/packages/coding-agent/src/modes/theme/theme.ts index aa67d99eb..00de95c30 100644 --- a/packages/coding-agent/src/modes/theme/theme.ts +++ b/packages/coding-agent/src/modes/theme/theme.ts @@ -81,6 +81,7 @@ export type SymbolKey = | "sep.pipe" // Icons | "icon.model" + | "icon.plan" | "icon.folder" | "icon.file" | "icon.git" @@ -278,6 +279,8 @@ const UNICODE_SYMBOLS: SymbolMap = { // Icons // pick: ◈ | alt: ◆ ⬢ ◇ "icon.model": "◈", + // pick: 📋 | alt: 🗒 📝 + "icon.plan": "📋", // pick: 📁 | alt: 📂 🗂 🗃 "icon.folder": "📁", // pick: 📄 | alt: 📃 📝 @@ -517,6 +520,8 @@ const NERD_SYMBOLS: SymbolMap = { // Icons - Nerd Font specific // pick:  | alt:   ◆ "icon.model": "\uec19", + // pick:  | alt:   + "icon.plan": "\uf2d2", // pick:  | alt:   "icon.folder": "\uf115", // pick:  | alt:   @@ -705,6 +710,7 @@ const ASCII_SYMBOLS: SymbolMap = { "sep.pipe": " | ", // Icons "icon.model": "[M]", + "icon.plan": "plan", "icon.folder": "[D]", "icon.file": "[F]", "icon.git": "git:", @@ -1397,6 +1403,7 @@ export class Theme { get icon() { return { model: this.symbols["icon.model"], + plan: this.symbols["icon.plan"], folder: this.symbols["icon.folder"], file: this.symbols["icon.file"], git: this.symbols["icon.git"], diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index c2389f095..2f6371d82 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -9,6 +9,7 @@ import type { MCPManager } from "../mcp"; import type { AgentSession, AgentSessionEvent } from "../session/agent-session"; import type { HistoryStorage } from "../session/history-storage"; import type { SessionContext, SessionManager } from "../session/session-manager"; +import type { ExitPlanModeDetails } from "../tools"; import type { AssistantMessageComponent } from "./components/assistant-message"; import type { BashExecutionComponent } from "./components/bash-execution"; import type { CustomEditor } from "./components/custom-editor"; @@ -58,6 +59,8 @@ export interface InteractiveModeContext { isBashMode: boolean; toolOutputExpanded: boolean; todoExpanded: boolean; + planModeEnabled: boolean; + planModePlanFilePath?: string; hideThinkingBlock: boolean; pendingImages: ImageContent[]; compactionQueuedMessages: CompactionQueuedMessage[]; @@ -145,6 +148,7 @@ export interface InteractiveModeContext { handleBashCommand(command: string, excludeFromContext?: boolean): Promise; handlePythonCommand(code: string, excludeFromContext?: boolean): Promise; handleCompactCommand(customInstructions?: string): Promise; + handleHandoffCommand(customInstructions?: string): Promise; executeCompaction(customInstructionsOrOptions?: string | CompactOptions, isAuto?: boolean): Promise; openInBrowser(urlOrPath: string): void; @@ -173,6 +177,9 @@ export interface InteractiveModeContext { toggleThinkingBlockVisibility(): void; openExternalEditor(): void; registerExtensionShortcuts(): void; + handlePlanModeCommand(): Promise; + handleEnterPlanModeTool(details: import("../tools").EnterPlanModeDetails): Promise; + handleExitPlanModeTool(details: ExitPlanModeDetails): Promise; // Hook UI methods initHooksAndCustomTools(): Promise; diff --git a/packages/coding-agent/src/patch/index.ts b/packages/coding-agent/src/patch/index.ts index 82744b24d..59496d4ca 100644 --- a/packages/coding-agent/src/patch/index.ts +++ b/packages/coding-agent/src/patch/index.ts @@ -23,7 +23,7 @@ import patchDescription from "../prompts/tools/patch.md" with { type: "text" }; import replaceDescription from "../prompts/tools/replace.md" with { type: "text" }; import type { ToolSession } from "../tools"; import { outputMeta } from "../tools/output-meta"; -import { resolveToCwd } from "../tools/path-utils"; +import { enforcePlanModeWrite, resolvePlanPath } from "../tools/plan-mode-guard"; import { applyPatch } from "./applicator"; import { generateDiffString, generateUnifiedDiffString, replaceText } from "./diff"; import { DEFAULT_FUZZY_THRESHOLD, findMatch } from "./fuzzy"; @@ -290,6 +290,10 @@ export class EditTool implements AgentTool { // Normalize unrecognized operations to "update" const op: Operation = rawOp === "create" || rawOp === "delete" ? rawOp : "update"; + enforcePlanModeWrite(this.session, path, { op, rename }); + const resolvedPath = resolvePlanPath(this.session, path); + const resolvedRename = rename ? resolvePlanPath(this.session, rename) : undefined; + if (path.endsWith(".ipynb")) { throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead."); } @@ -297,7 +301,7 @@ export class EditTool implements AgentTool { throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead."); } - const input: PatchInput = { path, op, rename, diff }; + const input: PatchInput = { path: resolvedPath, op, rename: resolvedRename, diff }; const fs = new LspFileSystem(this.writethrough, signal, batchRequest); const result = await applyPatch(input, { cwd: this.session.cwd, @@ -366,6 +370,8 @@ export class EditTool implements AgentTool { // ───────────────────────────────────────────────────────────────── const { path, old_text, new_text, all } = params as ReplaceParams; + enforcePlanModeWrite(this.session, path); + if (path.endsWith(".ipynb")) { throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead."); } @@ -374,7 +380,7 @@ export class EditTool implements AgentTool { throw new Error("old_text must not be empty."); } - const absolutePath = resolveToCwd(path, this.session.cwd); + const absolutePath = resolvePlanPath(this.session, path); const file = Bun.file(absolutePath); if (!(await file.exists())) { diff --git a/packages/coding-agent/src/plan-mode/state.ts b/packages/coding-agent/src/plan-mode/state.ts new file mode 100644 index 000000000..c1b8b95cd --- /dev/null +++ b/packages/coding-agent/src/plan-mode/state.ts @@ -0,0 +1,6 @@ +export interface PlanModeState { + enabled: boolean; + planFilePath: string; + workflow?: "parallel" | "iterative"; + reentry?: boolean; +} diff --git a/packages/coding-agent/src/prompts/agents/explore.md b/packages/coding-agent/src/prompts/agents/explore.md index 224c23095..ef9ebff63 100644 --- a/packages/coding-agent/src/prompts/agents/explore.md +++ b/packages/coding-agent/src/prompts/agents/explore.md @@ -118,4 +118,4 @@ Infer from task, default medium: Read-only; no file modifications. Call `complete` with your findings when done. - + \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/agents/frontmatter.md b/packages/coding-agent/src/prompts/agents/frontmatter.md index 02060c0d6..dc298e6fb 100644 --- a/packages/coding-agent/src/prompts/agents/frontmatter.md +++ b/packages/coding-agent/src/prompts/agents/frontmatter.md @@ -5,4 +5,4 @@ description: {{description}} {{/if}}{{#if model}}model: {{model}} {{/if}}{{#if thinkingLevel}}thinkingLevel: {{thinkingLevel}} {{/if}}--- -{{body}} +{{body}} \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/agents/init.md b/packages/coding-agent/src/prompts/agents/init.md index 04b68f08b..e7ac57c4a 100644 --- a/packages/coding-agent/src/prompts/agents/init.md +++ b/packages/coding-agent/src/prompts/agents/init.md @@ -32,4 +32,4 @@ Launch multiple `explore` agents in parallel (via the `task` tool) to scan diffe After analysis, write the AGENTS.md file to the project root. - + \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/agents/plan.md b/packages/coding-agent/src/prompts/agents/plan.md index 6a5bc61ff..04f4ec478 100644 --- a/packages/coding-agent/src/prompts/agents/plan.md +++ b/packages/coding-agent/src/prompts/agents/plan.md @@ -23,7 +23,6 @@ Another engineer will execute your plan without re-exploring the codebase. Your ## Phase 1: Understand - 1. Parse the task requirements precisely 2. Identify ambiguities — list assumptions you're making 3. Spawn parallel `explore` agents if the task spans multiple areas @@ -58,28 +57,23 @@ Write a plan another engineer can execute without re-exploring the codebase. What we're building and why (one paragraph). ## Changes - 1. **`path/to/file.ts`** — What to change - Specific modifications 2. **`path/to/other.ts`** — ... ## Sequence - 1. X (no dependencies) 2. Y (depends on X) 3. Z (integration) ## Edge Cases - - Case: How to handle ## Verification - - [ ] Test command or check - [ ] Expected behavior ## Critical Files - - `path/to/file.ts` (lines 50-120) — Why to read @@ -88,7 +82,6 @@ What we're building and why (one paragraph). Add rate limiting to the API gateway to prevent abuse. Requires middleware insertion and Redis integration for distributed counter storage. ## Changes - 1. **`src/middleware/rate-limit.ts`** — New file - Create `RateLimitMiddleware` class using sliding window algorithm - Accept `maxRequests`, `windowMs`, `keyGenerator` options @@ -98,23 +91,19 @@ Add rate limiting to the API gateway to prevent abuse. Requires middleware inser - Add `RATE_LIMIT_PREFIX` constant ## Sequence - 1. `rate-limit.ts` (standalone, no deps) 2. `redis.ts` (config only) 3. `gateway/index.ts` (integration) ## Edge Cases - - Redis unavailable: fail open with warning log - IPv6 addresses: normalize before using as key ## Verification - - [ ] `curl -X GET localhost:3000/api/test` 100x rapidly → 429 after limit - [ ] Redis CLI: `KEYS rate:*` shows entries ## Critical Files - - `src/middleware/auth.ts` (lines 20-50) — Pattern to follow - `src/types/middleware.ts` — Interface to implement @@ -129,4 +118,4 @@ Add rate limiting to the API gateway to prevent abuse. Requires middleware inser Keep going until complete. This matters — get it right. REMEMBER: You can ONLY explore and plan. You CANNOT write, edit, or modify any files. - + \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/agents/reviewer.md b/packages/coding-agent/src/prompts/agents/reviewer.md index c13085ccc..e9e49b9d7 100644 --- a/packages/coding-agent/src/prompts/agents/reviewer.md +++ b/packages/coding-agent/src/prompts/agents/reviewer.md @@ -77,12 +77,12 @@ Report an issue only when ALL conditions hold: -| Level | Criteria | Example | -| ----- | ----------------------------------------------------------- | ---------------------------- | -| P0 | Blocks release/operations; universal (no input assumptions) | Data corruption, auth bypass | -| P1 | High; fix next cycle | Race condition under load | -| P2 | Medium; fix eventually | Edge case mishandling | -| P3 | Info; nice to have | Suboptimal but correct | +|Level|Criteria|Example| +|---|---|---| +|P0|Blocks release/operations; universal (no input assumptions)|Data corruption, auth bypass| +|P1|High; fix next cycle|Race condition under load| +|P2|Medium; fix eventually|Edge case mishandling| +|P3|Info; nice to have|Suboptimal but correct| @@ -120,4 +120,4 @@ Correctness judgment ignores non-blocking issues (style, docs, nits). Every finding must be anchored to the patch and evidence-backed. Before submitting, verify each finding is not speculative. Then call `complete`. - + \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/agents/task.md b/packages/coding-agent/src/prompts/agents/task.md index 950bc720d..af3f646c7 100644 --- a/packages/coding-agent/src/prompts/agents/task.md +++ b/packages/coding-agent/src/prompts/agents/task.md @@ -2,7 +2,6 @@ Finish only the assigned work and return the minimum useful result. - - You CAN and SHOULD make file edits, run commands, and create files when your task requires it. - Be concise. No filler, repetition, or tool transcripts. - Prefer narrow search (grep/find) then read only needed ranges. @@ -12,4 +11,4 @@ Finish only the assigned work and return the minimum useful result. - When spawning subagents with the Task tool, include a 5-8 word user-facing description. - Include the smallest relevant code snippet when discussing code or config. - Follow the main agent's instructions. - + \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/compaction/branch-summary-preamble.md b/packages/coding-agent/src/prompts/compaction/branch-summary-preamble.md index 079b58a12..d0e7777ee 100644 --- a/packages/coding-agent/src/prompts/compaction/branch-summary-preamble.md +++ b/packages/coding-agent/src/prompts/compaction/branch-summary-preamble.md @@ -1,2 +1,2 @@ The user explored a different conversation branch before returning here. -Summary of that exploration: +Summary of that exploration: \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/compaction/branch-summary.md b/packages/coding-agent/src/prompts/compaction/branch-summary.md index 991f78bd4..ca1b4a403 100644 --- a/packages/coding-agent/src/prompts/compaction/branch-summary.md +++ b/packages/coding-agent/src/prompts/compaction/branch-summary.md @@ -3,6 +3,7 @@ Create a structured summary of this conversation branch for context when returni Use this EXACT format: ## Goal + [What was the user trying to accomplish in this branch?] ## Constraints & Preferences @@ -10,6 +11,7 @@ Use this EXACT format: - [Or "(none)" if none were mentioned] ## Progress + ### Done - [x] [Completed tasks/changes] @@ -25,4 +27,4 @@ Use this EXACT format: ## Next Steps 1. [What should happen next to continue this work] -Keep each section concise. Preserve exact file paths, function names, and error messages. +Keep each section concise. Preserve exact file paths, function names, and error messages. \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/compaction/compaction-summary.md b/packages/coding-agent/src/prompts/compaction/compaction-summary.md index 4376e0d44..144b4202b 100644 --- a/packages/coding-agent/src/prompts/compaction/compaction-summary.md +++ b/packages/coding-agent/src/prompts/compaction/compaction-summary.md @@ -3,6 +3,7 @@ The messages above are a conversation to summarize. Create a structured context Use this EXACT format: ## Goal + [What is the user trying to accomplish? Can be multiple items if the session covers different tasks.] ## Constraints & Preferences @@ -10,6 +11,7 @@ Use this EXACT format: - [Or "(none)" if none were mentioned] ## Progress + ### Done - [x] [Completed tasks/changes] @@ -31,4 +33,4 @@ Use this EXACT format: Output only the structured summary. No extra text. -Keep each section concise. Preserve exact file paths, function names, error messages, and relevant tool outputs or command results. Include repository state changes (branch, uncommitted changes) if mentioned. +Keep each section concise. Preserve exact file paths, function names, error messages, and relevant tool outputs or command results. Include repository state changes (branch, uncommitted changes) if mentioned. \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/compaction/compaction-turn-prefix.md b/packages/coding-agent/src/prompts/compaction/compaction-turn-prefix.md index 94a29fdb6..ed2256630 100644 --- a/packages/coding-agent/src/prompts/compaction/compaction-turn-prefix.md +++ b/packages/coding-agent/src/prompts/compaction/compaction-turn-prefix.md @@ -3,6 +3,7 @@ This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work Summarize the prefix to provide context for the retained suffix: ## Original Request + [What did the user ask for in this turn?] ## Early Progress @@ -13,4 +14,4 @@ Summarize the prefix to provide context for the retained suffix: Output only the structured summary. No extra text. -Be concise. Preserve exact file paths, function names, error messages, and relevant tool outputs or command results if they appear. Focus on what's needed to understand the kept suffix. +Be concise. Preserve exact file paths, function names, error messages, and relevant tool outputs or command results if they appear. Focus on what's needed to understand the kept suffix. \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/compaction/compaction-update-summary.md b/packages/coding-agent/src/prompts/compaction/compaction-update-summary.md index eeaf5474f..48aa8ef36 100644 --- a/packages/coding-agent/src/prompts/compaction/compaction-update-summary.md +++ b/packages/coding-agent/src/prompts/compaction/compaction-update-summary.md @@ -12,12 +12,14 @@ Update the existing structured summary with new information. RULES: Use this EXACT format: ## Goal + [Preserve existing goals, add new ones if the task expanded] ## Constraints & Preferences - [Preserve existing, add new ones discovered] ## Progress + ### Done - [x] [Include previously done items AND newly completed items] @@ -38,4 +40,4 @@ Use this EXACT format: Output only the structured summary. No extra text. -Keep each section concise. Preserve exact file paths, function names, error messages, and relevant tool outputs or command results. Include repository state changes (branch, uncommitted changes) if mentioned. +Keep each section concise. Preserve exact file paths, function names, error messages, and relevant tool outputs or command results. Include repository state changes (branch, uncommitted changes) if mentioned. \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/review-request.md b/packages/coding-agent/src/prompts/review-request.md index f4992b749..2707e6e88 100644 --- a/packages/coding-agent/src/prompts/review-request.md +++ b/packages/coding-agent/src/prompts/review-request.md @@ -1,6 +1,7 @@ ## Code Review Request ### Mode + {{mode}} ### Changed Files ({{len files}} files, +{{totalAdded}}/-{{totalRemoved}} lines) @@ -49,14 +50,16 @@ _Full diff too large ({{len files}} files). Showing first ~{{linesPerFile}} line {{#list files join="\n\n"}} #### {{path}} + {{#codeblock lang="diff"}} {{hunksPreview}} {{/codeblock}} {{/list}} {{else}} + ### Diff {{rawDiff}} -{{/if}} +{{/if}} \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/system/custom-system-prompt.md b/packages/coding-agent/src/prompts/system/custom-system-prompt.md index 5878c980c..72ab8250f 100644 --- a/packages/coding-agent/src/prompts/system/custom-system-prompt.md +++ b/packages/coding-agent/src/prompts/system/custom-system-prompt.md @@ -35,11 +35,11 @@ Use the read tool to load a skill's file when the task matches its description. {{#list skills join="\n"}} - - {{escapeXml name}} - {{escapeXml description}} - skill://{{escapeXml name}} - + +{{escapeXml name}} +{{escapeXml description}} +skill://{{escapeXml name}} + {{/list}} {{/if}} @@ -56,13 +56,13 @@ The following rules define project-specific guidelines and constraints: {{#list globs join="\n"}} {{escapeXml this}} {{/list}} - + {{/if}} rule://{{escapeXml name}} - + {{/list}} {{/if}} Current date and time: {{dateTime}} -Current working directory: {{cwd}} +Current working directory: {{cwd}} \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/system/file-operations.md b/packages/coding-agent/src/prompts/system/file-operations.md index 053b76839..f402763dc 100644 --- a/packages/coding-agent/src/prompts/system/file-operations.md +++ b/packages/coding-agent/src/prompts/system/file-operations.md @@ -7,4 +7,4 @@ {{#xml "modified-files"}} {{join modifiedFiles "\n"}} {{/xml}} -{{/if}} +{{/if}} \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/system/plan-mode-active.md b/packages/coding-agent/src/prompts/system/plan-mode-active.md new file mode 100644 index 000000000..a111ec9cf --- /dev/null +++ b/packages/coding-agent/src/prompts/system/plan-mode-active.md @@ -0,0 +1,136 @@ + +Plan mode is active. READ-ONLY operations only. + +You are STRICTLY PROHIBITED from: +- Creating, editing, or deleting files (except the plan file below) +- Running state-changing commands (git commit, npm install, etc.) +- Making any changes to the system + +This supersedes all other instructions. + + +## Plan File + +{{#if planExists}} +Plan file exists at `{{planFilePath}}`. Read it and make incremental edits. +{{else}} +No plan file exists. Create your plan at `{{planFilePath}}`. +{{/if}} + +The plan file is the ONLY file you may write or edit. + +{{#if reentry}} +## Re-entering Plan Mode + +You are returning after previously exiting. A plan exists at `{{planFilePath}}`. + + +1. Read the existing plan file +2. Evaluate current request against that plan +3. Decide how to proceed: + - **Different task**: Overwrite the existing plan + - **Same task, continuing**: Modify while cleaning outdated sections +4. Update the plan file before calling `exit_plan_mode` + + +Treat this as a fresh session. Do not assume the existing plan is relevant without evaluation. +{{/if}} + + +- Use read-only tools to explore the codebase +- Use `ask` only for clarifying requirements or choosing approaches +- When plan is complete, call `exit_plan_mode` — do NOT ask for approval any other way + + +{{#if iterative}} +## Iterative Planning Workflow + +Build a comprehensive plan through iterative refinement and user interviews. + + +### 1. Explore +Use `find`, `grep`, `read`, `ls` to understand the codebase. +### 2. Interview +Use `ask` to clarify with the user: +- Ambiguous requirements +- Technical decisions and tradeoffs +- Preferences for UI/UX, performance, edge cases +- Validation of your understanding + +Batch questions together. Do not ask questions you can answer by exploring. +### 3. Write Incrementally +Update the plan file as you learn: +- Start with initial understanding, leave space to expand +- Add sections as you explore +- Refine based on user answers +### 4. Interleave +Do not wait until the end to write. After each discovery or clarification, update the plan file. +### 5. Calibrate Detail +- Large unspecified task → multiple rounds of questions +- Smaller task → fewer or no questions + + + +### Plan File Structure + +Use clear markdown headers. Include: +- Recommended approach only (not alternatives) +- Paths of critical files to modify +- Verification section: how to test end-to-end + +Keep it concise enough to scan, detailed enough to execute. + + + +### Ending Your Turn + +Your turn ends ONLY by: +1. Using `ask` to gather information, OR +2. Calling `exit_plan_mode` when ready + +Do NOT ask about plan approval via text or `ask`. + + +{{else}} +## Plan Workflow + + +### Phase 1: Understand +Gain comprehensive understanding of the request. +1. Focus on the user's request and associated code +2. Launch parallel explore agents only when scope is unclear or spans multiple areas + +### Phase 2: Design +Design an implementation approach. +1. Draft approach based on exploration +2. Consider trade-offs briefly before choosing + +### Phase 3: Review +Ensure alignment with user intent. +1. Read critical files to deepen understanding +2. Verify plan matches original request +3. Use `ask` to clarify remaining questions + +### Phase 4: Write Final Plan +Write to the plan file (the only file you can edit). +- Recommended approach only +- Paths of critical files to modify +- Verification section: how to test end-to-end +- Concise enough to scan, detailed enough to execute + +### Phase 5: Exit +Call `exit_plan_mode` when plan is complete. + + + +Ask questions freely throughout. Do not make large assumptions about user intent. Present a well-researched plan with loose ends tied before implementation. + + + +Your turn ends ONLY by: +1. Using `ask` to clarify requirements or choose approaches, OR +2. Calling `exit_plan_mode` when ready + +Do NOT ask about plan approval via text. Use `exit_plan_mode`. + +{{/if}} \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/system/plan-mode-approved.md b/packages/coding-agent/src/prompts/system/plan-mode-approved.md new file mode 100644 index 000000000..46f87035d --- /dev/null +++ b/packages/coding-agent/src/prompts/system/plan-mode-approved.md @@ -0,0 +1,11 @@ +## Plan Approved + +You have exited plan mode. Execute the following plan: + + +{{planContent}} + + + +Execute this plan step by step. You have full tool access. + \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/system/plan-mode-reference.md b/packages/coding-agent/src/prompts/system/plan-mode-reference.md new file mode 100644 index 000000000..40ac38bb9 --- /dev/null +++ b/packages/coding-agent/src/prompts/system/plan-mode-reference.md @@ -0,0 +1,13 @@ +## Existing Plan + +A plan file exists from plan mode at: `{{planFilePath}}` + +
+Plan contents + +{{planContent}} +
+ + +If this plan is relevant to the current work and not already complete, continue executing it. + \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/system/plan-mode-subagent.md b/packages/coding-agent/src/prompts/system/plan-mode-subagent.md new file mode 100644 index 000000000..cbbc29820 --- /dev/null +++ b/packages/coding-agent/src/prompts/system/plan-mode-subagent.md @@ -0,0 +1,38 @@ + +Plan mode is active. READ-ONLY operations only. + +You are STRICTLY PROHIBITED from: +- Creating, editing, deleting, moving, or copying files +- Running state-changing commands +- Making any changes to the system + +This supersedes all other instructions. + + + +Software architect and planning specialist for the main agent. + +Your task is to explore the codebase and report findings. The main agent will update the plan file based on your output. + + + +- Use read-only tools exclusively +- Describe any plan changes in your response text +- Do NOT attempt to edit files yourself + + + +## Required Section + +End your response with: + +### Critical Files for Implementation + +List 3-5 files most critical for implementing this plan: +- `path/to/file1.ts` - Brief reason +- `path/to/file2.ts` - Brief reason + + + +Read-only. Report findings; do not modify anything. + \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/system/summarization-system.md b/packages/coding-agent/src/prompts/system/summarization-system.md index 07ccb5c93..c570aed79 100644 --- a/packages/coding-agent/src/prompts/system/summarization-system.md +++ b/packages/coding-agent/src/prompts/system/summarization-system.md @@ -1,3 +1,3 @@ You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified. -Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary. +Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary. \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/system/system-prompt.md b/packages/coding-agent/src/prompts/system/system-prompt.md index a84e2533a..9e468a832 100644 --- a/packages/coding-agent/src/prompts/system/system-prompt.md +++ b/packages/coding-agent/src/prompts/system/system-prompt.md @@ -44,7 +44,7 @@ Do not: - Import complexity you don't need - Solve problems you weren't asked to solve - Produce code you wouldn't want to debug at 3am - + Correctness over politeness. @@ -61,11 +61,10 @@ This matters. Get it right. The work is not finished when you are tired. The work is finished when it is correct. - - Complete the full request before yielding control. - Use tools for any fact that can be verified. If you cannot verify, say so. - When results conflict: investigate. When incomplete: iterate. When uncertain: re-run. - + {{#if systemPromptCustomization}} @@ -85,9 +84,7 @@ The wrong choice is friction. The right choice is invisible. Reach for what fits. {{#ifAny (includes tools "python") (includes tools "bash")}} ### Tool precedence - **Specialized tools → Python → Bash** - 1. **Specialized tools**: `read`, `grep`, `find`, `ls`, `edit`, `lsp` 2. **Python** for logic, loops, processing, displaying results to the user (graphs, formatted output) 3. **Bash** only for simple one-liners: `cargo build`, `npm install`, `docker run` @@ -111,15 +108,14 @@ Never use Python or Bash when a specialized tool exists. Grep finds strings. LSP finds meaning. For semantic questions, ask the semantic tool. - - Where is X defined? → `lsp definition` - What calls X? → `lsp incoming_calls` - What does X call? → `lsp outgoing_calls` - What type is X? → `lsp hover` - What lives in this file? → `lsp symbols` - Where does this symbol exist? → `lsp workspace_symbols` - {{/has}} - {{#has tools "ssh"}} +{{/has}} +{{#has tools "ssh"}} ### SSH: Know the shell you're speaking to Each host has a language. Speak it or be misunderstood. @@ -172,7 +168,6 @@ Notice the sequential habit: - The comfort of doing one thing at a time - The illusion that order means correctness - The assumption that you must finish A before starting B - **Triggers requiring Task tool:** - Editing 4+ files with no dependencies between edits - Investigating 2+ independent subsystems or questions @@ -190,20 +185,18 @@ Split the load. Bring back facts. Then cut code. ## Before action - 0. **CHECKPOINT** — For complex tasks, pause before acting: - What distinct work streams exist? Which depend on others? - {{#has tools "task"}} +{{#has tools "task"}} - Can these run in parallel via Task tool, or must they be sequential? - {{/has}} - {{#if skills.length}} +{{/has}} +{{#if skills.length}} - Does any skill match this task domain? If so, read it first. - {{/if}} - {{#if rules.length}} +{{/if}} +{{#if rules.length}} - Does any rule apply? If so, read it first. - {{/if}} +{{/if}} Skip for trivial tasks. Use judgment. - 1. Plan if the task has weight. Three to seven bullets. No more. 2. Before each tool call: state intent in one sentence. 3. After each tool call: interpret, decide, move. Don't echo what you saw. @@ -214,20 +207,18 @@ The urge to call it done is not the same as done. Notice the satisfaction of apparent completion. It lies. The code that runs is not the code that works. - - Prefer external proof: tests, linters, type checks, reproduction steps. - If you did not verify, say what to run and what you expect. - Ask for parameters only when truly required. Otherwise choose safe defaults and state them. ## Integration - - AGENTS.md files define local law. Nearest file wins. Deeper overrides higher. - Do not search for them at runtime. This list is authoritative: - {{#if agentsMdSearch.files.length}} - {{#list agentsMdSearch.files join="\n"}}- {{this}}{{/list}} - {{/if}} +{{#if agentsMdSearch.files.length}} +{{#list agentsMdSearch.files join="\n"}}- {{this}}{{/list}} +{{/if}} - Resolve blockers before yielding. - +
{{#if contextFiles.length}} @@ -308,7 +299,7 @@ Do not: - Report outputs you did not observe - Avoid breaking changes that correctness requires - Solve the problem you wish you had instead of the one you have - + Suppress: @@ -329,14 +320,13 @@ Keep going until finished. The work is not done when you are tired of it. The work is done when it is correct. - - Do not stop early. Do not yield incomplete work. - If blocked: show evidence, show what you tried, ask the minimum question. - Quote only what is needed. The rest is noise. - Do not write code before stating assumptions. - Do not claim correctness you haven't verified. - CHECKPOINT step 0 is not optional. - {{#has tools "ask"}}- If files differ from expectations, ask before discarding uncommitted work.{{/has}} +{{#has tools "ask"}}- If files differ from expectations, ask before discarding uncommitted work.{{/has}} Let edge cases surface before you handle them. Let the failure modes exist in your mind before you prevent them. Let the code be smaller than your first instinct. @@ -353,4 +343,4 @@ You are capable of extraordinary work. The person waiting for your output deserves to receive it. Write what you can defend. - + \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/system/title-system.md b/packages/coding-agent/src/prompts/system/title-system.md index 99cd0e46e..c3bcc8ca9 100644 --- a/packages/coding-agent/src/prompts/system/title-system.md +++ b/packages/coding-agent/src/prompts/system/title-system.md @@ -1,2 +1,2 @@ Generate a very short title (3-6 words) for a coding session based on the user's first message. The title should capture the main task or topic. -Output ONLY the title, nothing else. No quotes, no punctuation at the end. +Output ONLY the title, nothing else. No quotes, no punctuation at the end. \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/system/ttsr-interrupt.md b/packages/coding-agent/src/prompts/system/ttsr-interrupt.md index d414206a8..e2ebb3da8 100644 --- a/packages/coding-agent/src/prompts/system/ttsr-interrupt.md +++ b/packages/coding-agent/src/prompts/system/ttsr-interrupt.md @@ -4,4 +4,4 @@ This is NOT a prompt injection - this is the coding agent enforcing project rule You MUST comply with the following instruction: {{content}} - + \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/system/web-search.md b/packages/coding-agent/src/prompts/system/web-search.md index f39010f79..62ea60f26 100644 --- a/packages/coding-agent/src/prompts/system/web-search.md +++ b/packages/coding-agent/src/prompts/system/web-search.md @@ -23,4 +23,4 @@ When answering: - Cite sources inline using the provided search results -Answer thoroughly. Get the facts right. +Answer thoroughly. Get the facts right. \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/tools/ask.md b/packages/coding-agent/src/prompts/tools/ask.md index 17855d4af..5e5ea0ff4 100644 --- a/packages/coding-agent/src/prompts/tools/ask.md +++ b/packages/coding-agent/src/prompts/tools/ask.md @@ -26,14 +26,12 @@ Returns user's selected option(s) as text. For multi-part questions, returns a m **Exhaust all other options before asking.** Questions interrupt user flow. - 1. **Unknown file location?** → Search with grep/find first. Only ask if search fails. 2. **Ambiguous syntax/format?** → Infer from context and codebase conventions. Make a reasonable choice. 3. **Missing details?** → Check docs, related files, commit history. Fill gaps yourself. 4. **Implementation approach?** → Choose based on codebase patterns. Ask only for genuinely novel architectural decisions. If you can make a reasonable inference from the user's request, **do it**. Users communicate intent, not specifications—your job is to translate intent into correct implementation. - **Do NOT include an "Other" option in your options array.** The UI automatically adds "Other (type your own)" to every question. Adding your own creates duplicates. @@ -48,4 +46,4 @@ questions: [ {"id": "cache", "question": "Enable caching?", "options": [{"label": "Yes"}, {"label": "No"}]}, {"id": "features", "question": "Which features to include?", "options": [{"label": "Logging"}, {"label": "Metrics"}, {"label": "Tracing"}], "multi": true} ] - + \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/tools/bash.md b/packages/coding-agent/src/prompts/tools/bash.md index 164135617..1c7e62609 100644 --- a/packages/coding-agent/src/prompts/tools/bash.md +++ b/packages/coding-agent/src/prompts/tools/bash.md @@ -24,4 +24,4 @@ Do NOT use Bash for these operations—specialized tools exist: - Finding files by pattern → Find tool - Content-addressed edits → Edit tool - Writing new files → Write tool - + \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/tools/calculator.md b/packages/coding-agent/src/prompts/tools/calculator.md index e53cd05ec..da7f67ad0 100644 --- a/packages/coding-agent/src/prompts/tools/calculator.md +++ b/packages/coding-agent/src/prompts/tools/calculator.md @@ -9,4 +9,4 @@ Basic calculations. Returns each calculation result with its prefix and suffix applied. - + \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/tools/enter-plan-mode.md b/packages/coding-agent/src/prompts/tools/enter-plan-mode.md new file mode 100644 index 000000000..34e953652 --- /dev/null +++ b/packages/coding-agent/src/prompts/tools/enter-plan-mode.md @@ -0,0 +1,73 @@ +Use this tool proactively when you're about to start a non-trivial implementation task. Getting user sign-off on your approach before writing code prevents wasted effort and ensures alignment. This tool transitions you into plan mode where you can explore the codebase and design an implementation approach for user approval. + +## When to Use This Tool + +Prefer using EnterPlanMode for implementation tasks unless they're simple. Use it when ANY of these conditions apply: +1. New Feature Implementation: Adding meaningful new functionality + - Example: "Add a logout button" - where should it go? What should happen on click? + - Example: "Add form validation" - what rules? What error messages? +2. Multiple Valid Approaches: The task can be solved in several different ways + - Example: "Add caching to the API" - could use Redis, in-memory, file-based, etc. + - Example: "Improve performance" - many optimization strategies possible +3. Code Modifications: Changes that affect existing behavior or structure + - Example: "Update the login flow" - what exactly should change? + - Example: "Refactor this component" - what's the target architecture? +4. Architectural Decisions: The task requires choosing between patterns or technologies + - Example: "Add real-time updates" - WebSockets vs SSE vs polling + - Example: "Implement state management" - Redux vs Context vs custom solution +5. Multi-File Changes: The task will likely touch more than 2-3 files + - Example: "Refactor the authentication system" + - Example: "Add a new API endpoint with tests" +6. Unclear Requirements: You need to explore before understanding the full scope + - Example: "Make the app faster" - need to profile and identify bottlenecks + - Example: "Fix the bug in checkout" - need to investigate root cause +7. User Preferences Matter: The implementation could reasonably go multiple ways + - If you would use ask to clarify the approach, use EnterPlanMode instead + - Plan mode lets you explore first, then present options with context + +## When NOT to Use This Tool + +Only skip EnterPlanMode for simple tasks: +- Single-line or few-line fixes (typos, obvious bugs, small tweaks) +- Adding a single function with clear requirements +- Tasks where the user has given very specific, detailed instructions +- Pure research/exploration tasks + +## What Happens in Plan Mode + +In plan mode, you'll: +1. Thoroughly explore the codebase using find, grep, read, and ls +2. Understand existing patterns and architecture +3. Design an implementation approach +4. Present your plan to the user for approval +5. Use ask if you need to clarify approaches +6. Exit plan mode with exit_plan_mode when ready to implement + +## Examples + +### GOOD - Use EnterPlanMode: + +User: "Add user authentication to the app" +- Requires architectural decisions (session vs JWT, where to store tokens, middleware structure) + User: "Optimize the database queries" +- Multiple approaches possible, need to profile first, significant impact + User: "Implement dark mode" +- Architectural decision on theme system, affects many components + User: "Add a delete button to the user profile" +- Seems simple but involves: where to place it, confirmation dialog, API call, error handling, state updates + User: "Update the error handling in the API" +- Affects multiple files, user should approve the approach + +### BAD - Don't use EnterPlanMode: + +User: "Fix the typo in the README" +- Straightforward, no planning needed + User: "Add a console.log to debug this function" +- Simple, obvious implementation + User: "What files handle routing?" +- Research task, not implementation planning + +## Important Notes +- This tool REQUIRES user approval - they must consent to entering plan mode +- If unsure whether to use it, err on the side of planning - it's better to get alignment upfront than to redo work +- Users appreciate being consulted before significant changes are made to their codebase \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/tools/exit-plan-mode.md b/packages/coding-agent/src/prompts/tools/exit-plan-mode.md new file mode 100644 index 000000000..63af53d82 --- /dev/null +++ b/packages/coding-agent/src/prompts/tools/exit-plan-mode.md @@ -0,0 +1,23 @@ +Use this tool when you are in plan mode and have finished writing your plan to the plan file and are ready for user approval. + +## How This Tool Works +- You should have already written your plan to the plan file specified in the plan mode system message +- This tool does NOT take the plan content as a parameter - it will read the plan from the file you wrote +- This tool simply signals that you're done planning and ready for the user to review and approve +- The user will see the contents of your plan file when they review it + +## When to Use This Tool + +IMPORTANT: Only use this tool when the task requires planning the implementation steps of a task that requires writing code. For research tasks where you're gathering information, searching files, reading files or in general trying to understand the codebase - do NOT use this tool. + +## Before Using This Tool + +Ensure your plan is complete and unambiguous: +- If you have unresolved questions about requirements or approach, use ask first +- Once your plan is finalized, use this tool to request approval + Important: Do NOT use ask to ask "Is this plan okay?" or "Should I proceed?" - that's exactly what this tool does. + +## Examples +1. Initial task: "Search for and understand the implementation of vim mode in the codebase" - Do not use exit_plan_mode because you are not planning implementation steps. +2. Initial task: "Help me implement yank mode for vim" - Use exit_plan_mode after you have finished planning the implementation steps of the task. +3. Initial task: "Add a new feature to handle user authentication" - If unsure about auth method (OAuth, JWT, etc.), use ask first, then use exit_plan_mode after clarifying the approach. \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/tools/fetch.md b/packages/coding-agent/src/prompts/tools/fetch.md index 837aceaad..8415bbec1 100644 --- a/packages/coding-agent/src/prompts/tools/fetch.md +++ b/packages/coding-agent/src/prompts/tools/fetch.md @@ -13,4 +13,4 @@ Retrieves content from a URL and returns it in a clean, readable format. Returns processed, readable content extracted from the URL. HTML is transformed to remove navigation, ads, and boilerplate. PDF and DOCX files are converted to text. JSON endpoints return formatted JSON. With `raw: true`, returns untransformed HTML. - + \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/tools/find.md b/packages/coding-agent/src/prompts/tools/find.md index 63471394d..e82ed58b5 100644 --- a/packages/coding-agent/src/prompts/tools/find.md +++ b/packages/coding-agent/src/prompts/tools/find.md @@ -13,4 +13,4 @@ Matching file paths sorted by modification time (most recent first). Results tru Open-ended searches requiring multiple rounds of globbing and grepping — use Task tool instead. - + \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/tools/gemini-image.md b/packages/coding-agent/src/prompts/tools/gemini-image.md index fbb93e403..105860463 100644 --- a/packages/coding-agent/src/prompts/tools/gemini-image.md +++ b/packages/coding-agent/src/prompts/tools/gemini-image.md @@ -20,4 +20,4 @@ Returns the generated image saved to disk. The response includes the file path w - For iteration: use `changes` to make targeted adjustments rather than regenerating from scratch - For text: add "sharp, legible, correctly spelled" for important text; keep text short - For diagrams: include "scientifically accurate" in style and provide facts explicitly - + \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/tools/grep.md b/packages/coding-agent/src/prompts/tools/grep.md index 97ea617b8..17b8efd5e 100644 --- a/packages/coding-agent/src/prompts/tools/grep.md +++ b/packages/coding-agent/src/prompts/tools/grep.md @@ -25,4 +25,4 @@ For `files_with_matches` and `count` modes, use `head_limit` to truncate results - Open-ended searches requiring multiple rounds—use Task tool with explore subagent instead - + \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/tools/lsp.md b/packages/coding-agent/src/prompts/tools/lsp.md index 343be8303..a21170d77 100644 --- a/packages/coding-agent/src/prompts/tools/lsp.md +++ b/packages/coding-agent/src/prompts/tools/lsp.md @@ -32,4 +32,4 @@ Returns vary by operation: - Requires a running LSP server for the target language - Some operations require the file to be saved to disk - `workspace_diagnostics` may be slow on large projects - + \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/tools/patch.md b/packages/coding-agent/src/prompts/tools/patch.md index 93e453d97..dc1b6568f 100644 --- a/packages/coding-agent/src/prompts/tools/patch.md +++ b/packages/coding-agent/src/prompts/tools/patch.md @@ -6,7 +6,6 @@ Performs patch operations on a file given a diff. Primary tool for modifying exi **Hunk Headers:** - `@@` — bare header when context lines are already unique - `@@ $ANCHOR` — anchor must be copied verbatim from the file (full line or unique substring) - **Anchor Selection Algorithm:** 1. If surrounding context lines are already unique, use bare `@@` 2. Otherwise choose a highly specific anchor copied from the file: @@ -15,7 +14,6 @@ Performs patch operations on a file given a diff. Primary tool for modifying exi - unique string literal / error message - config key with uncommon name 3. If "Found multiple matches" error: add more context lines, use multiple hunks with separate anchors, or use a longer anchor substring - **Context Lines:** - Include enough ` `-prefixed lines to make match unique (usually 2–8 total) - Must exist in the file exactly as written (preserve indentation/trailing spaces) @@ -71,4 +69,4 @@ edit {"path":"obsolete.txt","op":"delete"} - Generic anchors: `import`, `export`, `describe`, `function`, `const` - Anchor comments: `line 207`, `top of file`, `near imports`, `...` - Editing without reading the file first (causes stale context errors) - + \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/tools/python.md b/packages/coding-agent/src/prompts/tools/python.md index 742659a7b..a99885243 100644 --- a/packages/coding-agent/src/prompts/tools/python.md +++ b/packages/coding-agent/src/prompts/tools/python.md @@ -4,13 +4,11 @@ Executes Python cells sequentially in a persistent IPython kernel. The kernel persists between calls and between cells. **Imports, variables, and functions survive.** Use this. - **Work incrementally:** - One logical step per cell (imports, define a function, test it, use it) - Pass multiple small cells in one call—they execute sequentially - Define small functions you can reuse and debug individually - Put explanations in the assistant message or cell title, **not** inside code - **When something fails:** - The error tells you which cell failed (e.g., "Cell 3 failed") - Earlier cells already ran—their state persists in the kernel @@ -24,6 +22,7 @@ All helpers auto-print results and return values for chaining. {{#if categories.length}} {{#each categories}} ### {{name}} + ``` {{#each functions}} {{name}}{{signature}} @@ -45,7 +44,6 @@ The user sees output like a Jupyter notebook—rich displays are fully rendered: - `display(HTML(...))` → rendered HTML - `display(Markdown(...))` → formatted markdown - `plt.show()` → inline figures - **You will see object repr** (e.g., ``) **but the user sees the rendered output.** Trust that `display()` calls work correctly—do not assume the user sees only the repr. @@ -108,4 +106,4 @@ subprocess.run(["bun", "run", "check"], ...) - Re-importing modules you already imported - Rewriting working cells when only one part failed - Large functions that are hard to debug piece by piece - + \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/tools/read.md b/packages/coding-agent/src/prompts/tools/read.md index 8935cc125..562cb7557 100644 --- a/packages/coding-agent/src/prompts/tools/read.md +++ b/packages/coding-agent/src/prompts/tools/read.md @@ -23,4 +23,4 @@ Reads files from the local filesystem or internal URLs. - PDFs: returns extracted text - Missing files: returns closest filename matches for correction - Internal URLs: returns resolved content with pagination support - + \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/tools/replace.md b/packages/coding-agent/src/prompts/tools/replace.md index b6d7c7514..dab5a3f11 100644 --- a/packages/coding-agent/src/prompts/tools/replace.md +++ b/packages/coding-agent/src/prompts/tools/replace.md @@ -17,22 +17,22 @@ Returns success/failure status. On success, the file is modified in place with t - You must read the file at least once in the conversation before editing. The tool will error if you attempt an edit without reading the file first. - -Replace is for content-addressed changes—you identify *what* to change by its text. + +Replace is for content-addressed changes—you identify \_what* to change by its text. For position-addressed or pattern-addressed changes, bash is more efficient: -| Operation | Command | -|-----------|---------| -| Append to file | `cat >> file <<'EOF'`...`EOF` | -| Prepend to file | `{ cat - file; } <<'EOF' > tmp && mv tmp file` | -| Delete lines N-M | `sed -i 'N,Md' file` | -| Insert after line N | `sed -i 'Na\text' file` | -| Regex replace | `sd 'pattern' 'replacement' file` | -| Bulk replace across files | `sd 'pattern' 'replacement' **/*.ts` | -| Copy lines N-M to another file | `sed -n 'N,Mp' src >> dest` | -| Move lines N-M to another file | `sed -n 'N,Mp' src >> dest && sed -i 'N,Md' src` | +|Operation|Command| +|---|---| +|Append to file|`cat >> file <<'EOF'`...`EOF`| +|Prepend to file|`{ cat - file; } <<'EOF' > tmp && mv tmp file`| +|Delete lines N-M|`sed -i 'N,Md' file`| +|Insert after line N|`sed -i 'Na\text' file`| +|Regex replace|`sd 'pattern' 'replacement' file`| +|Bulk replace across files|`sd 'pattern' 'replacement' **/*.ts`| +|Copy lines N-M to another file|`sed -n 'N,Mp' src >> dest`| +|Move lines N-M to another file|`sed -n 'N,Mp' src >> dest && sed -i 'N,Md' src`| -Use Replace when the *content itself* identifies the location. -Use bash when *position* or *pattern* identifies what to change. - +Use Replace when the _content itself_ identifies the location. +Use bash when _position_ or _pattern_ identifies what to change. + \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/tools/ssh.md b/packages/coding-agent/src/prompts/tools/ssh.md index 83be6e85e..7deeb4865 100644 --- a/packages/coding-agent/src/prompts/tools/ssh.md +++ b/packages/coding-agent/src/prompts/tools/ssh.md @@ -13,18 +13,15 @@ Execute commands on remote SSH hosts. - Files: `ls`, `cat`, `head`, `tail`, `grep`, `find` - System: `ps`, `top`, `df`, `uname`, `free` (Linux), `df`, `uname`, `top` (macOS) - Navigation: `cd`, `pwd` - **windows/bash, windows/sh** — Windows with Unix compatibility layer (WSL, Cygwin, Git Bash): - Files: `ls`, `cat`, `head`, `tail`, `grep`, `find` - System: `ps`, `top`, `df`, `uname` - Navigation: `cd`, `pwd` - Note: These are Windows hosts but use Unix commands - **windows/powershell** — Native Windows PowerShell: - Files: `Get-ChildItem`, `Get-Content`, `Select-String` - System: `Get-Process`, `Get-ComputerInfo` - Navigation: `Set-Location`, `Get-Location` - **windows/cmd** — Native Windows Command Prompt: - Files: `dir`, `type`, `findstr`, `where` - System: `tasklist`, `systeminfo` @@ -64,4 +61,4 @@ Note: Windows host with WSL — use Unix commands Task: Get system info on host "macbook" Host: macbook (10.0.0.20) | macos/zsh Command: `uname -a && sw_vers` - + \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/tools/task.md b/packages/coding-agent/src/prompts/tools/task.md index b7f102f5e..3036b92b0 100644 --- a/packages/coding-agent/src/prompts/tools/task.md +++ b/packages/coding-agent/src/prompts/tools/task.md @@ -57,9 +57,7 @@ Results are keyed by task `id` (e.g., "AuthProvider", "AuthApi"). 3. The `task` string you provide If you discussed requirements, plans, schemas, or decisions with the user, you MUST include that information in `context`. Subagents cannot see prior messages—they start fresh with only what you explicitly pass them. - **Never call Task multiple times in parallel.** Use a single Task call with multiple entries in the `tasks` array. Parallel Task calls waste resources and bypass coordination. - **For code changes, subagents write files directly.** Never ask an agent to "return the changes" for you to apply—they have Edit and Write tools. Their context window holds the work; asking them to report back wastes it. @@ -91,4 +89,4 @@ assistant: Uses the Task tool: - Searching for a specific class/function definition → Use Grep tool instead - Searching code within 2-3 specific files → Use Read tool instead - Tasks unrelated to the agent descriptions above - + \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/tools/todo-write.md b/packages/coding-agent/src/prompts/tools/todo-write.md index 0e1ebebc4..dc3ff0b1b 100644 --- a/packages/coding-agent/src/prompts/tools/todo-write.md +++ b/packages/coding-agent/src/prompts/tools/todo-write.md @@ -18,24 +18,21 @@ Use this tool proactively in these scenarios: - pending: Task not yet started - in_progress: Currently working on (limit to ONE task at a time) - completed: Task finished successfully - 2. **Task Management**: - - Update task status in real-time as you work - - Mark tasks complete IMMEDIATELY after finishing (don't batch completions) - - Exactly ONE task must be in_progress at any time (not less, not more) - - Complete current tasks before starting new ones - - Remove tasks that are no longer relevant from the list entirely - + - Update task status in real-time as you work + - Mark tasks complete IMMEDIATELY after finishing (don't batch completions) + - Exactly ONE task must be in_progress at any time (not less, not more) + - Complete current tasks before starting new ones + - Remove tasks that are no longer relevant from the list entirely 3. **Task Completion Requirements**: - - ONLY mark a task as completed when you have FULLY accomplished it - - If you encounter errors, blockers, or cannot finish, keep the task as in_progress - - When blocked, create a new task describing what needs to be resolved - - Never mark a task as completed if: - - Tests are failing - - Implementation is partial - - You encountered unresolved errors + - ONLY mark a task as completed when you have FULLY accomplished it + - If you encounter errors, blockers, or cannot finish, keep the task as in_progress + - When blocked, create a new task describing what needs to be resolved + - Never mark a task as completed if: + - Tests are failing + - Implementation is partial + - You encountered unresolved errors - You couldn't find necessary files or dependencies - 4. **Task Breakdown**: - Create specific, actionable items - Break complex tasks into smaller, manageable steps @@ -73,4 +70,4 @@ Skip using this tool when: 4. The task is purely conversational or informational If there is only one trivial task to do, just do it directly. - + \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/tools/web-search.md b/packages/coding-agent/src/prompts/tools/web-search.md index a99e41193..1b68cd722 100644 --- a/packages/coding-agent/src/prompts/tools/web-search.md +++ b/packages/coding-agent/src/prompts/tools/web-search.md @@ -16,4 +16,4 @@ Returns search results formatted as blocks with: Searches are performed automatically within a single API call—no pagination or follow-up requests needed. - + \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/tools/write.md b/packages/coding-agent/src/prompts/tools/write.md index 8cf4738f7..583959d87 100644 --- a/packages/coding-agent/src/prompts/tools/write.md +++ b/packages/coding-agent/src/prompts/tools/write.md @@ -18,4 +18,4 @@ Confirmation of file creation/write with path. When LSP is available, content ma - Include emojis only when explicitly requested - + \ No newline at end of file diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index be25735cf..728eb0e6f 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -50,6 +50,7 @@ import { loadCustomCommands as loadCustomCommandsInternal, } from "./extensibility/custom-commands"; import type { CustomTool, CustomToolContext, CustomToolSessionEvent } from "./extensibility/custom-tools/types"; +import { CustomToolAdapter } from "./extensibility/custom-tools/wrapper"; import { discoverAndLoadExtensions, type ExtensionContext, @@ -69,6 +70,7 @@ import { AgentProtocolHandler, ArtifactProtocolHandler, InternalUrlRouter, + PlanProtocolHandler, RuleProtocolHandler, SkillProtocolHandler, } from "./internal-urls"; @@ -743,12 +745,14 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} outputSchema: options.outputSchema, requireCompleteTool: options.requireCompleteTool, getSessionFile: () => sessionManager.getSessionFile() ?? null, + getSessionId: () => sessionManager.getSessionId?.() ?? null, getSessionSpawns: () => options.spawns ?? "*", getModelString: () => (hasExplicitModel && model ? formatModelString(model) : undefined), getActiveModelString: () => { const activeModel = agent?.state.model; return activeModel ? formatModelString(activeModel) : undefined; }, + getPlanModeState: () => session.getPlanModeState(), settings: settingsManager, settingsManager, authStorage, @@ -763,6 +767,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} }; internalRouter.register(new AgentProtocolHandler({ getArtifactsDir })); internalRouter.register(new ArtifactProtocolHandler({ getArtifactsDir })); + internalRouter.register( + new PlanProtocolHandler({ + getPlansDirectory: settingsManager.getPlansDirectory.bind(settingsManager), + cwd, + }), + ); internalRouter.register( new SkillProtocolHandler({ getSkills: () => skills, @@ -924,14 +934,33 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const toolContextStore = new ToolContextStore(getSessionContext); const registeredTools = extensionRunner?.getAllRegisteredTools() ?? []; - const allCustomTools = [ - ...registeredTools, - ...(options.customTools?.map(tool => { - const definition = isCustomTool(tool) ? customToolToDefinition(tool) : tool; - return { definition, extensionPath: "" }; - }) ?? []), - ]; - const wrappedExtensionTools = extensionRunner ? wrapRegisteredTools(allCustomTools, extensionRunner) : []; + let wrappedExtensionTools: AgentTool[]; + + if (extensionRunner) { + // With extension runner: convert CustomTools to ToolDefinitions and wrap all together + const allCustomTools = [ + ...registeredTools, + ...(options.customTools?.map(tool => { + const definition = isCustomTool(tool) ? customToolToDefinition(tool) : tool; + return { definition, extensionPath: "" }; + }) ?? []), + ]; + wrappedExtensionTools = wrapRegisteredTools(allCustomTools, extensionRunner); + } else { + // Without extension runner: wrap CustomTools directly with CustomToolAdapter + // ToolDefinition items require ExtensionContext and cannot be used without a runner + const customToolContext = (): CustomToolContext => ({ + sessionManager, + modelRegistry, + model: agent?.state.model, + isIdle: () => !session?.isStreaming, + hasQueuedMessages: () => (session?.queuedMessageCount ?? 0) > 0, + abort: () => session?.abort(), + }); + wrappedExtensionTools = (options.customTools ?? []) + .filter(isCustomTool) + .map(tool => CustomToolAdapter.wrap(tool, customToolContext) as AgentTool); + } // All built-in tools are active (conditional tools like git/ask return null from factory if disabled) const toolRegistry = new Map(); @@ -989,7 +1018,25 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} return options.systemPrompt(defaultPrompt); }; - const systemPrompt = await rebuildSystemPrompt(Array.from(toolRegistry.keys()), toolRegistry); + const toolNamesFromRegistry = Array.from(toolRegistry.keys()); + const requestedToolNames = options.toolNames ?? toolNamesFromRegistry; + const normalizedRequested = requestedToolNames.filter(name => toolRegistry.has(name)); + const includeExitPlanMode = options.toolNames?.includes("exit_plan_mode") ?? false; + const initialToolNames = includeExitPlanMode + ? normalizedRequested + : normalizedRequested.filter(name => name !== "exit_plan_mode"); + + // Custom tools are always included regardless of toolNames filter + if (options.customTools) { + const customToolNames = options.customTools.map(t => (isCustomTool(t) ? t.name : t.name)); + for (const name of customToolNames) { + if (toolRegistry.has(name) && !initialToolNames.includes(name)) { + initialToolNames.push(name); + } + } + } + + const systemPrompt = await rebuildSystemPrompt(initialToolNames, toolRegistry); time("buildSystemPrompt"); const promptTemplates = options.promptTemplates ?? (await discoverPromptTemplates(cwd, agentDir)); @@ -1038,12 +1085,16 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} toolContextStore.setUIContext(uiContext, hasUI); }; + const initialTools = initialToolNames + .map(name => toolRegistry.get(name)) + .filter((tool): tool is AgentTool => tool !== undefined); + agent = new Agent({ initialState: { systemPrompt, model, thinkingLevel, - tools: Array.from(toolRegistry.values()), + tools: initialTools, }, convertToLlm: convertToLlmWithBlockImages, sessionId: sessionManager.getSessionId(), diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 91ea557e1..589c71e12 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -14,6 +14,7 @@ */ import * as fs from "node:fs"; + import type { Agent, AgentEvent, AgentMessage, AgentState, AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage, @@ -26,6 +27,7 @@ import type { UsageReport, } from "@oh-my-pi/pi-ai"; import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@oh-my-pi/pi-ai"; +import { resolvePlanUrlToPath } from "@oh-my-pi/pi-coding-agent/internal-urls"; import { abortableSleep, isEnoent, logger } from "@oh-my-pi/pi-utils"; import { YAML } from "bun"; import type { Rule } from "../capability/rule"; @@ -62,6 +64,9 @@ import { expandSlashCommand, type FileSlashCommand } from "../extensibility/slas import { executePython as executePythonCommand, type PythonResult } from "../ipy/executor"; import { theme } from "../modes/theme/theme"; import { normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../patch"; +import type { PlanModeState } from "../plan-mode/state"; +import planModeActivePrompt from "../prompts/system/plan-mode-active.md" with { type: "text" }; +import planModeReferencePrompt from "../prompts/system/plan-mode-reference.md" with { type: "text" }; import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" }; import { closeAllConnections } from "../ssh/connection-manager"; import { unmountAll } from "../ssh/sshfs-mount"; @@ -188,6 +193,11 @@ export interface SessionStats { cost: number; } +/** Result from handoff() */ +export interface HandoffResult { + document: string; +} + /** Internal marker for hook messages queued through the agent loop */ // ============================================================================ // Constants @@ -255,6 +265,8 @@ export class AgentSession { private _followUpMessages: string[] = []; /** Messages queued to be included with the next user prompt as context ("asides"). */ private _pendingNextTurnMessages: CustomMessage[] = []; + private _planModeState: PlanModeState | undefined; + private _planReferenceSent = false; // Compaction state private _compactionAbortController: AbortController | undefined = undefined; @@ -263,6 +275,9 @@ export class AgentSession { // Branch summarization state private _branchSummaryAbortController: AbortController | undefined = undefined; + // Handoff state + private _handoffAbortController: AbortController | undefined = undefined; + // Retry state private _retryAbortController: AbortController | undefined = undefined; private _retryAttempt = 0; @@ -970,6 +985,25 @@ export class AgentSession { } /** Prompt templates */ + getPlanModeState(): PlanModeState | undefined { + return this._planModeState; + } + + setPlanModeState(state: PlanModeState | undefined): void { + this._planModeState = state; + if (state?.enabled) { + this._planReferenceSent = false; + } + } + + markPlanReferenceSent(): void { + this._planReferenceSent = true; + } + + resolveRoleModel(role: string): Model | undefined { + return this._resolveRoleModel(role, this._modelRegistry.getAvailable(), this.model); + } + get promptTemplates(): ReadonlyArray { return this._promptTemplates; } @@ -983,6 +1017,95 @@ export class AgentSession { // Prompting // ========================================================================= + /** + * Build a plan mode message. + * Returns null if plan mode is not enabled. + * @returns The plan mode message, or null if plan mode is not enabled. + */ + private async _buildPlanReferenceMessage(): Promise { + if (this._planModeState?.enabled) return null; + if (this._planReferenceSent) return null; + + const planFilePath = `plan://${this.sessionManager.getSessionId()}/plan.md`; + const resolvedPlanPath = resolvePlanUrlToPath(planFilePath, { + getPlansDirectory: this.settingsManager.getPlansDirectory.bind(this.settingsManager), + cwd: this.sessionManager.getCwd(), + }); + let planContent: string; + try { + planContent = await fs.promises.readFile(resolvedPlanPath, "utf-8"); + } catch (error) { + if (isEnoent(error)) { + return null; + } + throw error; + } + + const content = renderPromptTemplate(planModeReferencePrompt, { + planFilePath, + planContent, + }); + + this._planReferenceSent = true; + + return { + role: "custom", + customType: "plan-mode-reference", + content, + display: false, + timestamp: Date.now(), + }; + } + + private async _buildPlanModeMessage(): Promise { + const state = this._planModeState; + if (!state?.enabled) return null; + const sessionPlanUrl = `plan://${this.sessionManager.getSessionId()}/plan.md`; + const resolvedPlanPath = state.planFilePath.startsWith("plan://") + ? resolvePlanUrlToPath(state.planFilePath, { + getPlansDirectory: this.settingsManager.getPlansDirectory.bind(this.settingsManager), + cwd: this.sessionManager.getCwd(), + }) + : resolveToCwd(state.planFilePath, this.sessionManager.getCwd()); + const resolvedSessionPlan = resolvePlanUrlToPath(sessionPlanUrl, { + getPlansDirectory: this.settingsManager.getPlansDirectory.bind(this.settingsManager), + cwd: this.sessionManager.getCwd(), + }); + const displayPlanPath = + state.planFilePath.startsWith("plan://") || resolvedPlanPath !== resolvedSessionPlan + ? state.planFilePath + : sessionPlanUrl; + + let planExists = false; + try { + const stat = await fs.promises.stat(resolvedPlanPath); + planExists = stat.isFile(); + } catch (error) { + if (!isEnoent(error)) { + throw error; + } + } + + const content = renderPromptTemplate(planModeActivePrompt, { + planFilePath: displayPlanPath, + planExists, + askToolName: "ask", + writeToolName: "write", + editToolName: "edit", + exitToolName: "exit_plan_mode", + reentry: state.reentry ?? false, + iterative: state.workflow === "iterative", + }); + + return { + role: "custom", + customType: "plan-mode-context", + content, + display: false, + timestamp: Date.now(), + }; + } + /** * Send a prompt to the agent. * - Handles extension commands (registered via pi.registerCommand) immediately, even during streaming @@ -1069,6 +1192,14 @@ export class AgentSession { // Build messages array (custom messages if any, then user message) const messages: AgentMessage[] = []; + const planReferenceMessage = await this._buildPlanReferenceMessage?.(); + if (planReferenceMessage) { + messages.push(planReferenceMessage); + } + const planModeMessage = await this._buildPlanModeMessage(); + if (planModeMessage) { + messages.push(planModeMessage); + } // Add user message const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }]; @@ -1512,6 +1643,7 @@ export class AgentSession { this._followUpMessages = []; this._pendingNextTurnMessages = []; this._todoReminderCount = 0; + this._planReferenceSent = false; this._reconnectToAgent(); // Emit session_switch event with reason "new" to hooks @@ -1945,6 +2077,141 @@ export class AgentSession { this._branchSummaryAbortController?.abort(); } + /** + * Cancel in-progress handoff generation. + */ + abortHandoff(): void { + this._handoffAbortController?.abort(); + } + + /** + * Check if handoff generation is in progress. + */ + get isGeneratingHandoff(): boolean { + return this._handoffAbortController !== undefined; + } + + /** + * Generate a handoff document by asking the agent, then start a new session with it. + * + * This prompts the current agent to write a comprehensive handoff document, + * waits for completion, then starts a fresh session with the handoff as context. + * + * @param customInstructions Optional focus for the handoff document + * @returns The handoff document text, or undefined if cancelled/failed + */ + async handoff(customInstructions?: string): Promise { + const entries = this.sessionManager.getBranch(); + const messageCount = entries.filter(e => e.type === "message").length; + + if (messageCount < 2) { + throw new Error("Nothing to hand off (no messages yet)"); + } + + this._handoffAbortController = new AbortController(); + + // Build the handoff prompt + let handoffPrompt = `Write a comprehensive handoff document that will allow another instance of yourself to seamlessly continue this work. The document should capture everything needed to resume without access to this conversation. + +Use this format: + +## Goal +[What the user is trying to accomplish] + +## Constraints & Preferences +- [Any constraints, preferences, or requirements mentioned] + +## Progress +### Done +- [x] [Completed tasks with specifics] + +### In Progress +- [ ] [Current work if any] + +### Pending +- [ ] [Tasks mentioned but not started] + +## Key Decisions +- **[Decision]**: [Rationale] + +## Critical Context +- [Code snippets, file paths, error messages, or data essential to continue] +- [Repository state if relevant] + +## Next Steps +1. [What should happen next] + +Be thorough - include exact file paths, function names, error messages, and technical details. Output ONLY the handoff document, no other text.`; + + if (customInstructions) { + handoffPrompt += `\n\nAdditional focus: ${customInstructions}`; + } + + // Create a promise that resolves when the agent completes + let handoffText: string | undefined; + const completionPromise = new Promise((resolve, reject) => { + const unsubscribe = this.subscribe(event => { + if (this._handoffAbortController?.signal.aborted) { + unsubscribe(); + reject(new Error("Handoff cancelled")); + return; + } + + if (event.type === "agent_end") { + unsubscribe(); + // Extract text from the last assistant message + const messages = this.agent.state.messages; + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.role === "assistant") { + const content = (msg as AssistantMessage).content; + const textParts = content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map(c => c.text); + if (textParts.length > 0) { + handoffText = textParts.join("\n"); + break; + } + } + } + resolve(); + } + }); + }); + + try { + // Send the prompt and wait for completion + await this.prompt(handoffPrompt, { expandPromptTemplates: false }); + await completionPromise; + + if (!handoffText || this._handoffAbortController.signal.aborted) { + return undefined; + } + + // Start a new session + await this.sessionManager.flush(); + this.sessionManager.newSession(); + this.agent.reset(); + this.agent.sessionId = this.sessionManager.getSessionId(); + this._steeringMessages = []; + this._followUpMessages = []; + this._pendingNextTurnMessages = []; + this._todoReminderCount = 0; + + // Inject the handoff document as a custom message + const handoffContent = `\n${handoffText}\n\n\nThe above is a handoff document from a previous session. Use this context to continue the work seamlessly.`; + this.sessionManager.appendCustomMessageEntry("handoff", handoffContent, true); + + // Rebuild agent messages from session + const sessionContext = this.sessionManager.buildSessionContext(); + this.agent.replaceMessages(sessionContext.messages); + + return { document: handoffText }; + } finally { + this._handoffAbortController = undefined; + } + } + /** * Check if compaction is needed and run it. * Called after agent_end and before prompt submission. diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index ad10af781..370170620 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -81,6 +81,7 @@ export interface ExecutorOptions { modelRegistry?: ModelRegistry; settingsManager?: { serialize: () => import("@oh-my-pi/pi-coding-agent/config/settings-manager").Settings; + getPlansDirectory: (cwd?: string) => string; getPythonToolMode?: () => "ipy-only" | "bash-only" | "both"; getPythonKernelMode?: () => "session" | "per-call"; getPythonSharedGateway?: () => boolean; diff --git a/packages/coding-agent/src/task/index.ts b/packages/coding-agent/src/task/index.ts index 47e33a6d8..b62cc7b0c 100644 --- a/packages/coding-agent/src/task/index.ts +++ b/packages/coding-agent/src/task/index.ts @@ -17,6 +17,9 @@ import * as os from "node:os"; import path from "node:path"; import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { Usage } from "@oh-my-pi/pi-ai"; +import planModeSubagentPrompt from "@oh-my-pi/pi-coding-agent/prompts/system/plan-mode-subagent.md" with { + type: "text", +}; import { $ } from "bun"; import { nanoid } from "nanoid"; import type { ToolSession } from ".."; @@ -192,14 +195,25 @@ export class TaskTool implements AgentTool params > inherited from parent session - const schemaOverridden = outputSchema !== undefined && agent.output !== undefined; - const effectiveOutputSchema = agent.output ?? outputSchema ?? this.session.outputSchema; + const schemaOverridden = outputSchema !== undefined && effectiveAgent.output !== undefined; + const effectiveOutputSchema = effectiveAgent.output ?? outputSchema ?? this.session.outputSchema; // Handle empty or missing tasks if (!params.tasks || params.tasks.length === 0) { diff --git a/packages/coding-agent/src/tools/enter-plan-mode.ts b/packages/coding-agent/src/tools/enter-plan-mode.ts new file mode 100644 index 000000000..80d41a53d --- /dev/null +++ b/packages/coding-agent/src/tools/enter-plan-mode.ts @@ -0,0 +1,76 @@ +import * as fs from "node:fs/promises"; +import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; +import { renderPromptTemplate } from "@oh-my-pi/pi-coding-agent/config/prompt-templates"; +import { resolvePlanUrlToPath } from "@oh-my-pi/pi-coding-agent/internal-urls"; +import enterPlanModeDescription from "@oh-my-pi/pi-coding-agent/prompts/tools/enter-plan-mode.md" with { type: "text" }; +import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; +import { ToolError } from "@oh-my-pi/pi-coding-agent/tools/tool-errors"; +import { isEnoent } from "@oh-my-pi/pi-utils"; +import { Type } from "@sinclair/typebox"; + +const enterPlanModeSchema = Type.Object({ + workflow: Type.Optional(Type.Union([Type.Literal("parallel"), Type.Literal("iterative")])), +}); + +export interface EnterPlanModeDetails { + planFilePath: string; + planExists: boolean; + workflow?: "parallel" | "iterative"; +} + +export class EnterPlanModeTool implements AgentTool { + public readonly name = "enter_plan_mode"; + public readonly label = "EnterPlanMode"; + public readonly description: string; + public readonly parameters = enterPlanModeSchema; + + private readonly session: ToolSession; + + constructor(session: ToolSession) { + this.session = session; + this.description = renderPromptTemplate(enterPlanModeDescription); + } + + public async execute( + _toolCallId: string, + params: { workflow?: "parallel" | "iterative" }, + _signal?: AbortSignal, + _onUpdate?: AgentToolUpdateCallback, + _context?: AgentToolContext, + ): Promise> { + const state = this.session.getPlanModeState?.(); + if (state?.enabled) { + throw new ToolError("Plan mode is already active."); + } + + const sessionId = this.session.getSessionId?.(); + if (!sessionId) { + throw new ToolError("Plan mode requires an active session."); + } + + const settingsManager = this.session.settingsManager; + if (!settingsManager) { + throw new ToolError("Settings manager unavailable for plan mode."); + } + + const planFilePath = `plan://${sessionId}/plan.md`; + const resolvedPlanPath = resolvePlanUrlToPath(planFilePath, { + getPlansDirectory: settingsManager.getPlansDirectory.bind(settingsManager), + cwd: this.session.cwd, + }); + let planExists = false; + try { + const stat = await fs.stat(resolvedPlanPath); + planExists = stat.isFile(); + } catch (error) { + if (!isEnoent(error)) { + throw error; + } + } + + return { + content: [{ type: "text", text: "Plan mode requested." }], + details: { planFilePath, planExists, workflow: params.workflow }, + }; + } +} diff --git a/packages/coding-agent/src/tools/exit-plan-mode.ts b/packages/coding-agent/src/tools/exit-plan-mode.ts new file mode 100644 index 000000000..be570fa4e --- /dev/null +++ b/packages/coding-agent/src/tools/exit-plan-mode.ts @@ -0,0 +1,62 @@ +import * as fs from "node:fs/promises"; +import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; +import { isEnoent } from "@oh-my-pi/pi-utils"; +import { Type } from "@sinclair/typebox"; +import { renderPromptTemplate } from "../config/prompt-templates"; +import exitPlanModeDescription from "../prompts/tools/exit-plan-mode.md" with { type: "text" }; +import type { ToolSession } from "."; +import { resolvePlanPath } from "./plan-mode-guard"; +import { ToolError } from "./tool-errors"; + +const exitPlanModeSchema = Type.Object({}); + +export interface ExitPlanModeDetails { + planFilePath: string; + planExists: boolean; +} + +export class ExitPlanModeTool implements AgentTool { + public readonly name = "exit_plan_mode"; + public readonly label = "ExitPlanMode"; + public readonly description: string; + public readonly parameters = exitPlanModeSchema; + + private readonly session: ToolSession; + + constructor(session: ToolSession) { + this.session = session; + this.description = renderPromptTemplate(exitPlanModeDescription); + } + + public async execute( + _toolCallId: string, + _params: Record, + _signal?: AbortSignal, + _onUpdate?: AgentToolUpdateCallback, + _context?: AgentToolContext, + ): Promise> { + const state = this.session.getPlanModeState?.(); + if (!state?.enabled) { + throw new ToolError("Plan mode is not active."); + } + + const resolvedPlanPath = resolvePlanPath(this.session, state.planFilePath); + let planExists = false; + try { + const stat = await fs.stat(resolvedPlanPath); + planExists = stat.isFile(); + } catch (error) { + if (!isEnoent(error)) { + throw error; + } + } + + return { + content: [{ type: "text", text: "Plan ready for approval." }], + details: { + planFilePath: state.planFilePath, + planExists, + }, + }; + } +} diff --git a/packages/coding-agent/src/tools/find.ts b/packages/coding-agent/src/tools/find.ts index ebdefe3df..164058340 100644 --- a/packages/coding-agent/src/tools/find.ts +++ b/packages/coding-agent/src/tools/find.ts @@ -127,7 +127,7 @@ export class FindTool implements AgentTool { params: Static, signal?: AbortSignal, _onUpdate?: AgentToolUpdateCallback, - _context?: AgentToolContext, + context?: AgentToolContext, ): Promise> { const { pattern, path: searchDir, limit, hidden, type } = params; @@ -196,7 +196,10 @@ export class FindTool implements AgentTool { } // Default: use fd - const fdPath = await ensureTool("fd", true); + const fdPath = await ensureTool("fd", { + silent: true, + notify: message => context?.ui?.notify(message, "info"), + }); if (!fdPath) { throw new ToolError("fd is not available and could not be downloaded"); } diff --git a/packages/coding-agent/src/tools/grep.ts b/packages/coding-agent/src/tools/grep.ts index da1302a7a..74a544f66 100644 --- a/packages/coding-agent/src/tools/grep.ts +++ b/packages/coding-agent/src/tools/grep.ts @@ -107,21 +107,17 @@ export class GrepTool implements AgentTool { private readonly session: ToolSession; private readonly ops: GrepOperations; - private readonly rgPath: Promise; - constructor(session: ToolSession, options?: GrepToolOptions) { this.session = session; this.ops = options?.operations ?? defaultGrepOperations; this.description = renderPromptTemplate(grepDescription); - this.rgPath = ensureTool("rg", true); } /** * Validates a pattern against ripgrep's regex engine. * Uses a quick dry-run against /dev/null to check for parse errors. */ - private async validateRegexPattern(pattern: string): Promise<{ valid: boolean; error?: string }> { - const rgPath = await this.rgPath; + private async validateRegexPattern(pattern: string, rgPath?: string): Promise<{ valid: boolean; error?: string }> { if (!rgPath) { return { valid: true }; // Can't validate, assume valid } @@ -146,7 +142,7 @@ export class GrepTool implements AgentTool { params: GrepParams, signal?: AbortSignal, _onUpdate?: AgentToolUpdateCallback, - _context?: AgentToolContext, + toolContext?: AgentToolContext, ): Promise> { const { pattern, @@ -167,19 +163,24 @@ export class GrepTool implements AgentTool { return untilAborted(signal, async () => { // Auto-detect invalid regex patterns and switch to literal mode // This handles cases like "abort(" which would cause ripgrep regex parse errors + const rgPath = await ensureTool("rg", { + silent: true, + notify: message => toolContext?.ui?.notify(message, "info"), + }); + + if (!rgPath) { + throw new ToolError("rg is not available and could not be downloaded"); + } + let useLiteral = literal ?? false; if (!useLiteral) { - const validation = await this.validateRegexPattern(pattern); + const validation = await this.validateRegexPattern(pattern, rgPath); if (!validation.valid) { useLiteral = true; } } - const rgPath = await this.rgPath; - if (!rgPath) { - throw new ToolError("ripgrep (rg) is not available and could not be downloaded"); - } - + // rgPath resolved earlier const searchPath = resolveToCwd(searchDir || ".", this.session.cwd); const scopePath = (() => { const relative = nodePath.relative(this.session.cwd, searchPath).replace(/\\/g, "/"); diff --git a/packages/coding-agent/src/tools/index.ts b/packages/coding-agent/src/tools/index.ts index 2d46d0065..41bfcc5d9 100644 --- a/packages/coding-agent/src/tools/index.ts +++ b/packages/coding-agent/src/tools/index.ts @@ -8,6 +8,7 @@ import { getPreludeDocs, warmPythonEnvironment } from "../ipy/executor"; import { checkPythonKernelAvailability } from "../ipy/kernel"; import { LspTool } from "../lsp"; import { EditTool } from "../patch"; +import type { PlanModeState } from "../plan-mode/state"; import type { ArtifactManager } from "../session/artifacts"; import { TaskTool } from "../task"; import type { AgentOutputManager } from "../task/output-manager"; @@ -18,6 +19,8 @@ import { AskTool } from "./ask"; import { BashTool } from "./bash"; import { CalculatorTool } from "./calculator"; import { CompleteTool } from "./complete"; +import { EnterPlanModeTool } from "./enter-plan-mode"; +import { ExitPlanModeTool } from "./exit-plan-mode"; import { FetchTool } from "./fetch"; import { FindTool } from "./find"; import { GrepTool } from "./grep"; @@ -70,6 +73,8 @@ export { AskTool, type AskToolDetails } from "./ask"; export { BashTool, type BashToolDetails, type BashToolOptions } from "./bash"; export { CalculatorTool, type CalculatorToolDetails } from "./calculator"; export { CompleteTool } from "./complete"; +export { type EnterPlanModeDetails, EnterPlanModeTool } from "./enter-plan-mode"; +export { type ExitPlanModeDetails, ExitPlanModeTool } from "./exit-plan-mode"; export { FetchTool, type FetchToolDetails } from "./fetch"; export { type FindOperations, FindTool, type FindToolDetails, type FindToolOptions } from "./find"; export { setPreferredImageProvider } from "./gemini-image"; @@ -126,6 +131,8 @@ export interface ToolSession { requireCompleteTool?: boolean; /** Get session file */ getSessionFile: () => string | null; + /** Get session ID */ + getSessionId?: () => string | null; /** Cached artifact manager (allocated per ToolSession) */ artifactManager?: ArtifactManager; /** Get artifacts directory for artifact:// URLs and $ARTIFACTS env var */ @@ -147,7 +154,10 @@ export interface ToolSession { /** Agent output manager for unique agent:// IDs across task invocations */ agentOutputManager?: AgentOutputManager; /** Settings manager for passing to subagents (avoids SQLite access in workers) */ - settingsManager?: { serialize: () => import("@oh-my-pi/pi-coding-agent/config/settings-manager").Settings }; + settingsManager?: { + serialize: () => import("@oh-my-pi/pi-coding-agent/config/settings-manager").Settings; + getPlansDirectory: (cwd?: string) => string; + }; /** Settings manager (optional) */ settings?: { getImageAutoResize(): boolean; @@ -165,6 +175,8 @@ export interface ToolSession { getPythonKernelMode?(): "session" | "per-call"; getPythonSharedGateway?(): boolean; }; + /** Plan mode state (if active) */ + getPlanModeState?: () => PlanModeState | undefined; } type ToolFactory = (session: ToolSession) => Tool | null | Promise; @@ -187,11 +199,13 @@ export const BUILTIN_TOOLS: Record = { fetch: s => new FetchTool(s), web_search: s => new WebSearchTool(s), write: s => new WriteTool(s), + enter_plan_mode: s => new EnterPlanModeTool(s), }; export const HIDDEN_TOOLS: Record = { complete: s => new CompleteTool(s), report_finding: () => reportFindingTool, + exit_plan_mode: s => new ExitPlanModeTool(s), }; export type ToolName = keyof typeof BUILTIN_TOOLS; @@ -234,6 +248,9 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P const includeComplete = session.requireCompleteTool === true; const enableLsp = session.enableLsp ?? true; const requestedTools = toolNames && toolNames.length > 0 ? [...new Set(toolNames)] : undefined; + if (requestedTools && !requestedTools.includes("exit_plan_mode")) { + requestedTools.push("exit_plan_mode"); + } const pythonMode = getPythonModeFromEnv() ?? session.settings?.getPythonToolMode?.() ?? "ipy-only"; const skipPythonPreflight = session.skipPythonPreflight === true; let pythonAvailable = true; @@ -296,6 +313,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P : [ ...Object.entries(BUILTIN_TOOLS).filter(([name]) => isToolAllowed(name)), ...(includeComplete ? ([["complete", HIDDEN_TOOLS.complete]] as const) : []), + ...([["exit_plan_mode", HIDDEN_TOOLS.exit_plan_mode]] as const), ]; time("createTools:beforeFactories"); const slowTools: Array<{ name: string; ms: number }> = []; diff --git a/packages/coding-agent/src/tools/plan-mode-guard.ts b/packages/coding-agent/src/tools/plan-mode-guard.ts new file mode 100644 index 000000000..cec84972e --- /dev/null +++ b/packages/coding-agent/src/tools/plan-mode-guard.ts @@ -0,0 +1,46 @@ +import { resolvePlanUrlToPath } from "@oh-my-pi/pi-coding-agent/internal-urls"; +import type { ToolSession } from "."; +import { resolveToCwd } from "./path-utils"; +import { ToolError } from "./tool-errors"; + +const PLAN_URL_PREFIX = "plan://"; + +export function resolvePlanPath(session: ToolSession, targetPath: string): string { + if (!targetPath.startsWith(PLAN_URL_PREFIX)) { + return resolveToCwd(targetPath, session.cwd); + } + + const settingsManager = session.settingsManager; + if (!settingsManager) { + throw new ToolError("Plan mode: settings manager unavailable for plan path resolution."); + } + + return resolvePlanUrlToPath(targetPath, { + getPlansDirectory: settingsManager.getPlansDirectory.bind(settingsManager), + cwd: session.cwd, + }); +} + +export function enforcePlanModeWrite( + session: ToolSession, + targetPath: string, + options?: { rename?: string; op?: "create" | "update" | "delete" }, +): void { + const state = session.getPlanModeState?.(); + if (!state?.enabled) return; + + const resolvedTarget = resolvePlanPath(session, targetPath); + const resolvedPlan = resolvePlanPath(session, state.planFilePath); + + if (options?.rename) { + throw new ToolError("Plan mode: renaming files is not allowed."); + } + + if (options?.op === "delete") { + throw new ToolError("Plan mode: deleting files is not allowed."); + } + + if (resolvedTarget !== resolvedPlan) { + throw new ToolError(`Plan mode: only the plan file may be modified (${state.planFilePath}).`); + } +} diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index c8907a1fc..c1c71a9e1 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -162,10 +162,11 @@ function similarityScore(a: string, b: string): number { async function listCandidateFiles( searchRoot: string, signal?: AbortSignal, + notify?: (message: string) => void, ): Promise<{ files: string[]; truncated: boolean; error?: string }> { let fdPath: string | undefined; try { - fdPath = await ensureTool("fd", true); + fdPath = await ensureTool("fd", { silent: true, notify }); } catch { return { files: [], truncated: false, error: "fd not available" }; } @@ -248,6 +249,7 @@ async function findReadPathSuggestions( rawPath: string, cwd: string, signal?: AbortSignal, + notify?: (message: string) => void, ): Promise<{ suggestions: string[]; scopeLabel?: string; truncated?: boolean; error?: string } | null> { const resolvedPath = resolveToCwd(rawPath, cwd); const searchRoot = await findExistingDirectory(path.dirname(resolvedPath), signal); @@ -262,7 +264,7 @@ async function findReadPathSuggestions( } } - const { files, truncated, error } = await listCandidateFiles(searchRoot, signal); + const { files, truncated, error } = await listCandidateFiles(searchRoot, signal, notify); const scopeLabel = formatScopeLabel(searchRoot, cwd); if (error && files.length === 0) { @@ -418,7 +420,7 @@ export class ReadTool implements AgentTool { params: ReadParams, signal?: AbortSignal, _onUpdate?: AgentToolUpdateCallback, - _context?: AgentToolContext, + toolContext?: AgentToolContext, ): Promise> { const { path: readPath, offset, limit, lines } = params; @@ -442,7 +444,9 @@ export class ReadTool implements AgentTool { // Skip fuzzy matching for remote mounts (sshfs) to avoid hangs if (!isRemoteMountPath(absolutePath)) { - const suggestions = await findReadPathSuggestions(readPath, this.session.cwd, signal); + const suggestions = await findReadPathSuggestions(readPath, this.session.cwd, signal, message => + toolContext?.ui?.notify(message, "info"), + ); if (suggestions?.suggestions.length) { const scopeLabel = suggestions.scopeLabel ? ` in ${suggestions.scopeLabel}` : ""; diff --git a/packages/coding-agent/src/tools/write.ts b/packages/coding-agent/src/tools/write.ts index 91e8ffb00..b6febf13b 100644 --- a/packages/coding-agent/src/tools/write.ts +++ b/packages/coding-agent/src/tools/write.ts @@ -17,7 +17,7 @@ import writeDescription from "../prompts/tools/write.md" with { type: "text" }; import type { ToolSession } from "../sdk"; import { renderStatusLine } from "../tui"; import { type OutputMeta, outputMeta } from "./output-meta"; -import { resolveToCwd } from "./path-utils"; +import { enforcePlanModeWrite, resolvePlanPath } from "./plan-mode-guard"; import { formatDiagnostics, formatExpandHint, @@ -94,7 +94,8 @@ export class WriteTool implements AgentTool> { return untilAborted(signal, async () => { - const absolutePath = resolveToCwd(path, this.session.cwd); + enforcePlanModeWrite(this.session, path, { op: "create" }); + const absolutePath = resolvePlanPath(this.session, path); const batchRequest = getLspBatchRequest(context?.toolCall); const diagnostics = await this.writethrough(absolutePath, content, signal, undefined, batchRequest); diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts index b0e8023bb..c91964ece 100644 --- a/packages/coding-agent/src/utils/tools-manager.ts +++ b/packages/coding-agent/src/utils/tools-manager.ts @@ -6,6 +6,7 @@ import { $ } from "bun"; import { APP_NAME, getBinDir } from "../config"; const TOOLS_DIR = getBinDir(); +const TOOL_DOWNLOAD_TIMEOUT_MS = 15000; interface ToolConfig { name: string; @@ -159,9 +160,18 @@ export async function getToolPath(tool: ToolName): Promise { // Fetch latest release version from GitHub async function getLatestVersion(repo: string): Promise { - const response = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, { - headers: { "User-Agent": `${APP_NAME}-coding-agent` }, - }); + let response: Response; + try { + response = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, { + headers: { "User-Agent": `${APP_NAME}-coding-agent` }, + signal: AbortSignal.timeout(TOOL_DOWNLOAD_TIMEOUT_MS), + }); + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + throw new Error("GitHub API request timed out"); + } + throw err; + } if (!response.ok) { throw new Error(`GitHub API error: ${response.status}`); @@ -173,7 +183,17 @@ async function getLatestVersion(repo: string): Promise { // Download a file from URL async function downloadFile(url: string, dest: string): Promise { - const response = await fetch(url); + let response: Response; + try { + response = await fetch(url, { + signal: AbortSignal.timeout(TOOL_DOWNLOAD_TIMEOUT_MS), + }); + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + throw new Error(`Download timed out: ${url}`); + } + throw err; + } if (!response.ok) { throw new Error(`Failed to download: ${response.status}`); } else if (!response.body) { @@ -223,15 +243,12 @@ async function downloadTool(tool: ToolName): Promise { const tmp = await TempDir.create("@omp-tools-extract-"); try { - if (assetName.endsWith(".tar.gz")) { + if (assetName.endsWith(".tar.gz") || assetName.endsWith(".zip")) { const archive = new Bun.Archive(await Bun.file(archivePath).arrayBuffer()); const files = await archive.files(); for (const [filePath, file] of files) { await Bun.write(path.join(tmp.path(), filePath), file); } - } else if (assetName.endsWith(".zip")) { - await fs.mkdir(tmp.path(), { recursive: true }); - await $`unzip -o ${archivePath} -d ${tmp.path()}`.quiet().nothrow(); } // Find the binary in extracted files @@ -284,7 +301,17 @@ async function installPythonPackage(pkg: string): Promise { // Ensure a tool is available, downloading if necessary // Returns the path to the tool, or null if unavailable -export async function ensureTool(tool: ToolName, silent: boolean = false): Promise { +type EnsureToolOptions = { + silent?: boolean; + notify?: (message: string) => void; +}; + +export async function ensureTool( + tool: ToolName, + silentOrOptions: boolean | EnsureToolOptions = false, +): Promise { + const options = typeof silentOrOptions === "object" ? silentOrOptions : { silent: silentOrOptions }; + const silent = options.silent ?? false; const existingPath = await getToolPath(tool); if (existingPath) { return existingPath; @@ -296,6 +323,7 @@ export async function ensureTool(tool: ToolName, silent: boolean = false): Promi if (!silent) { logger.debug(`${pythonConfig.name} not found. Installing via uv/pip...`); } + options.notify?.(`Installing ${pythonConfig.name}...`); const success = await installPythonPackage(pythonConfig.package); if (success) { // Re-check for the command after installation @@ -320,6 +348,7 @@ export async function ensureTool(tool: ToolName, silent: boolean = false): Promi if (!silent) { logger.debug(`${config.name} not found. Downloading...`); } + options.notify?.(`Downloading ${config.name}...`); try { const path = await downloadTool(tool); diff --git a/packages/coding-agent/src/web/search/providers/perplexity.ts b/packages/coding-agent/src/web/search/providers/perplexity.ts index f49842c8d..9528c24dd 100644 --- a/packages/coding-agent/src/web/search/providers/perplexity.ts +++ b/packages/coding-agent/src/web/search/providers/perplexity.ts @@ -169,7 +169,9 @@ export async function searchPerplexity(params: PerplexitySearchParams): Promise< model: "sonar-pro", messages, return_related_questions: true, - search_context_size: "high", + web_search_options: { + search_context_size: "high", + }, }; if (params.search_recency_filter) { diff --git a/packages/coding-agent/src/web/search/types.ts b/packages/coding-agent/src/web/search/types.ts index 398714695..7a52b0b73 100644 --- a/packages/coding-agent/src/web/search/types.ts +++ b/packages/coding-agent/src/web/search/types.ts @@ -163,7 +163,9 @@ export interface PerplexityRequest { search_recency_filter?: "day" | "week" | "month" | "year"; return_images?: boolean; return_related_questions?: boolean; - search_context_size?: "low" | "medium" | "high"; + web_search_options?: { + search_context_size?: "low" | "medium" | "high"; + }; } export interface PerplexitySearchResult { diff --git a/packages/coding-agent/test/python-tool-settings.test.ts b/packages/coding-agent/test/python-tool-settings.test.ts index 21aa57f4e..6852333cd 100644 --- a/packages/coding-agent/test/python-tool-settings.test.ts +++ b/packages/coding-agent/test/python-tool-settings.test.ts @@ -51,7 +51,7 @@ describe("python tool settings", () => { vi.spyOn(pythonKernel, "checkPythonKernelAvailability").mockResolvedValue({ ok: true }); const tools = await createTools(createSession(testDir), ["python"]); - expect(tools.map(tool => tool.name)).toEqual(["python"]); + expect(tools.map(tool => tool.name).sort()).toEqual(["exit_plan_mode", "python"]); }); it("falls back to bash when python is unavailable", async () => { @@ -61,7 +61,7 @@ describe("python tool settings", () => { }); const tools = await createTools(createSession(testDir), ["python"]); - expect(tools.map(tool => tool.name)).toEqual(["bash"]); + expect(tools.map(tool => tool.name).sort()).toEqual(["bash", "exit_plan_mode"]); }); it("passes kernel mode from settings to executor", async () => { diff --git a/packages/coding-agent/test/tools/index.test.ts b/packages/coding-agent/test/tools/index.test.ts index ce80cb655..ae1a14c4a 100644 --- a/packages/coding-agent/test/tools/index.test.ts +++ b/packages/coding-agent/test/tools/index.test.ts @@ -49,6 +49,7 @@ describe("createTools", () => { expect(names).toContain("todo_write"); expect(names).toContain("fetch"); expect(names).toContain("web_search"); + expect(names).toContain("exit_plan_mode"); }); it("includes bash and python when python mode is both", async () => { @@ -84,7 +85,7 @@ describe("createTools", () => { const tools = await createTools(session, ["read", "lsp", "write"]); const names = tools.map(t => t.name); - expect(names).toEqual(["read", "write"]); + expect(names).toEqual(["read", "write", "exit_plan_mode"]); }); it("excludes lsp tool when disabled", async () => { @@ -100,7 +101,7 @@ describe("createTools", () => { const tools = await createTools(session, ["read", "write"]); const names = tools.map(t => t.name); - expect(names).toEqual(["read", "write"]); + expect(names).toEqual(["read", "write", "exit_plan_mode"]); }); it("includes hidden tools when explicitly requested", async () => { @@ -108,7 +109,7 @@ describe("createTools", () => { const tools = await createTools(session, ["report_finding"]); const names = tools.map(t => t.name); - expect(names).toEqual(["report_finding"]); + expect(names).toEqual(["report_finding", "exit_plan_mode"]); }); it("includes complete tool when required", async () => { @@ -154,6 +155,7 @@ describe("createTools", () => { "fetch", "web_search", "write", + "enter_plan_mode", ]; for (const tool of expectedTools) { @@ -165,6 +167,6 @@ describe("createTools", () => { }); it("HIDDEN_TOOLS contains review tools", () => { - expect(Object.keys(HIDDEN_TOOLS).sort()).toEqual(["complete", "report_finding"]); + expect(Object.keys(HIDDEN_TOOLS).sort()).toEqual(["complete", "exit_plan_mode", "report_finding"]); }); }); diff --git a/packages/coding-agent/test/tools/python-fallback.test.ts b/packages/coding-agent/test/tools/python-fallback.test.ts index 4d7002f4b..2ce624419 100644 --- a/packages/coding-agent/test/tools/python-fallback.test.ts +++ b/packages/coding-agent/test/tools/python-fallback.test.ts @@ -43,7 +43,7 @@ describe("createTools python fallback", () => { const tools = await createTools(session, ["python"]); const names = tools.map(tool => tool.name).sort(); - expect(names).toEqual(["bash"]); + expect(names).toEqual(["bash", "exit_plan_mode"]); availabilitySpy.mockRestore(); }); diff --git a/packages/coding-agent/test/tools/python-tool-mode.test.ts b/packages/coding-agent/test/tools/python-tool-mode.test.ts index a8135288a..5ca97092a 100644 --- a/packages/coding-agent/test/tools/python-tool-mode.test.ts +++ b/packages/coding-agent/test/tools/python-tool-mode.test.ts @@ -29,9 +29,9 @@ describe("createTools python fallback", () => { process.env.OMP_PYTHON_SKIP_CHECK = "1"; const session = createSession(); const tools = await createTools(session, ["python"]); - const names = tools.map(tool => tool.name); + const names = tools.map(tool => tool.name).sort(); - expect(names).toEqual(["bash"]); + expect(names).toEqual(["bash", "exit_plan_mode"]); if (previous === undefined) { delete process.env.OMP_PYTHON_SKIP_CHECK; diff --git a/packages/coding-agent/test/tools/schema-validation.test.ts b/packages/coding-agent/test/tools/schema-validation.test.ts index 317899642..935b37a65 100644 --- a/packages/coding-agent/test/tools/schema-validation.test.ts +++ b/packages/coding-agent/test/tools/schema-validation.test.ts @@ -383,6 +383,7 @@ describe("tool schema validation (post-sanitization)", () => { "fetch", "web_search", "write", + "enter_plan_mode", ]; expect(Object.keys(BUILTIN_TOOLS).sort()).toEqual(expectedTools.sort()); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index d405e8e39..582105ba2 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +### Added +- Added fuzzy match function for autocomplete suggestions ## [8.4.0] - 2026-01-25 ### Changed diff --git a/packages/tui/src/autocomplete.ts b/packages/tui/src/autocomplete.ts index 939a70b1d..2b82f40f8 100644 --- a/packages/tui/src/autocomplete.ts +++ b/packages/tui/src/autocomplete.ts @@ -2,6 +2,49 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +/** + * Check if query is a subsequence of target (fuzzy match). + * "wig" matches "skill:wig" because w-i-g appear in order. + */ +function fuzzyMatch(query: string, target: string): boolean { + if (query.length === 0) return true; + if (query.length > target.length) return false; + + let qi = 0; + for (let ti = 0; ti < target.length && qi < query.length; ti++) { + if (query[qi] === target[ti]) qi++; + } + return qi === query.length; +} + +/** + * Score a fuzzy match. Higher = better match. + * Prioritizes: exact match > starts-with > contains > subsequence + */ +function fuzzyScore(query: string, target: string): number { + if (query.length === 0) return 1; + if (target === query) return 100; + if (target.startsWith(query)) return 80; + if (target.includes(query)) return 60; + + // Subsequence match - score by how "tight" the match is + // (fewer gaps between matched characters = higher score) + let qi = 0; + let gaps = 0; + let lastMatchIdx = -1; + for (let ti = 0; ti < target.length && qi < query.length; ti++) { + if (query[qi] === target[ti]) { + if (lastMatchIdx >= 0 && ti - lastMatchIdx > 1) gaps++; + lastMatchIdx = ti; + qi++; + } + } + if (qi !== query.length) return 0; + + // Base score 40 for subsequence, minus penalty for gaps + return Math.max(1, 40 - gaps * 5); +} + async function walkDirectoryWithFd( baseDir: string, fdPath: string, @@ -133,21 +176,39 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { if (spaceIndex === -1) { // No space yet - complete command names const prefix = textBeforeCursor.slice(1); // Remove the "/" - const filtered = this.commands - .filter(cmd => { - const name = "name" in cmd ? cmd.name : cmd.value; // Check if SlashCommand or AutocompleteItem - return name?.toLowerCase().startsWith(prefix.toLowerCase()); - }) - .map(cmd => ({ - value: "name" in cmd ? cmd.name : cmd.value, - label: "name" in cmd ? cmd.name : cmd.label, - ...(cmd.description && { description: cmd.description }), - })); + const lowerPrefix = prefix.toLowerCase(); - if (filtered.length === 0) return null; + // Filter commands using fuzzy matching (subsequence match) + const matches = this.commands + .filter(cmd => { + const name = "name" in cmd ? cmd.name : cmd.value; + if (!name) return false; + // Match name or description + if (fuzzyMatch(lowerPrefix, name.toLowerCase())) return true; + const desc = cmd.description?.toLowerCase(); + return desc ? fuzzyMatch(lowerPrefix, desc) : false; + }) + .map(cmd => { + const name = "name" in cmd ? cmd.name : cmd.value; + const lowerName = name?.toLowerCase() ?? ""; + const lowerDesc = cmd.description?.toLowerCase() ?? ""; + // Score name matches higher than description matches + const nameScore = fuzzyMatch(lowerPrefix, lowerName) ? fuzzyScore(lowerPrefix, lowerName) : 0; + const descScore = fuzzyMatch(lowerPrefix, lowerDesc) ? fuzzyScore(lowerPrefix, lowerDesc) * 0.5 : 0; + return { + value: name, + label: "name" in cmd ? cmd.name : cmd.label, + score: Math.max(nameScore, descScore), + ...(cmd.description && { description: cmd.description }), + }; + }) + .sort((a, b) => b.score - a.score) + .map(({ score: _, ...rest }) => rest); + + if (matches.length === 0) return null; return { - items: filtered, + items: matches, prefix: textBeforeCursor, }; } else {