/** * ask-claude — Stream Claude agent reviews into pi. * * For AGENTS to use. Delegates to specialized Claude agents or raw models * for review, analysis, debugging, and second opinions. * * Tool (callable by the LLM): * ask_claude(prompt, agent?, model?, question?, session_id?) * agent — any agent name from ~/.claude/agents/ (e.g. "plan_review", "code_review", "oracle", "debug") * model — model override: "opus", "sonnet", or full model ID * question — specific focus prepended as a review header * session_id — resume a prior conversation (returned in every response) * * Commands: * /review-plan — editor → Claude Opus plan_review → inject result * /review-code — editor → Claude Sonnet code_review → inject result */ import { defineTool, getMarkdownTheme } from "@mariozechner/pi-coding-agent"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Type } from "@mariozechner/pi-ai"; import { Box, Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; import { type ClaudeDetails, type Theme, renderToolCallLine, renderToolResultBox, renderToolBlock, formatUsage, buildEditDiff, formatAnthropicError, runClaude, type RunClaudeResult, } from "../shared/claude-stream.js"; // ============================================================================= // Rendering (ask-claude specific — uses shared tool renderers) // ============================================================================= function buildLabel(agent?: string, model?: string): string { if (agent) return `Claude [${agent}]`; if (model) return `Claude [${model}]`; return "Claude Sonnet"; } // ============================================================================= // Tool definition // ============================================================================= const AskClaudeParams = Type.Object({ prompt: Type.String({ description: "Full content to review/analyze. Include all relevant context: CLAUDE.md conventions, files explored, code or plan to review.", }), agent: Type.Optional(Type.String({ description: "Agent name from ~/.claude/agents/ (e.g. 'plan_review', 'code_review', 'oracle', 'debug'). Omit to use model= directly.", })), model: Type.Optional(Type.String({ description: "Model override: 'opus', 'sonnet', 'haiku', or a full model ID. When agent is set this overrides the agent's default. When agent is omitted this selects the model directly.", })), question: Type.Optional(Type.String({ description: "Specific question or focus area prepended to the prompt (e.g. 'Focus on security', 'Are there race conditions?').", })), session_id: Type.Optional(Type.String({ description: "Resume a prior conversation. Pass the session_id returned from a previous ask_claude call.", })), }); const askClaudeTool = defineTool({ name: "ask_claude", label: "Ask Claude", description: [ "Ask a Claude agent or model for review, analysis, or a second opinion.", "agent= runs a named agent from ~/.claude/agents/ (e.g. 'plan_review', 'code_review', 'oracle', 'debug').", "Use model= alone for free-form requests without an agent. Use question= to specify a focus.", "Pass session_id from a prior response to continue the same conversation across turns.", "CLAUDE.md and .claude/skills are loaded automatically from the project root.", ].join(" "), promptSnippet: "Ask a Claude agent or model for review, analysis, or a second opinion", promptGuidelines: [ "Use ask_claude(agent=) to invoke a specialized agent — include all relevant context in the prompt.", "Use ask_claude(model='opus', question='...') for free-form deep analysis.", "Always include the artifact to review (plan, code, problem description) in the prompt.", "Pass session_id back from the previous response to continue the conversation.", ], parameters: AskClaudeParams, async execute(_toolCallId, params, signal, onUpdate, ctx) { const fullPrompt = params.question ? `## Review Focus\n\n${params.question}\n\n## Content\n\n${params.prompt}` : params.prompt; const label = buildLabel(params.agent, params.model); const details: ClaudeDetails = { label, done: false, blocks: [], finalText: "", isResume: !!params.session_id }; try { const result: RunClaudeResult = await runClaude(fullPrompt, { agent: params.agent, model: params.model, sessionId: params.session_id, enrichEditDiffs: true, // ask-claude wants diff enrichment cwd: ctx.cwd, signal, onUpdate: (partial) => { Object.assign(details, partial); onUpdate?.({ content: [{ type: "text", text: details.finalText || "(thinking…)" }], details: { ...details }, }); }, }); details.done = true; details.finalText = result.finalText; details.blocks = result.blocks; details.sessionId = result.sessionId; details.costUsd = result.costUsd; details.inputTokens = result.inputTokens; details.outputTokens = result.outputTokens; details.cacheReadTokens = result.cacheReadTokens; details.cacheWriteTokens = result.cacheWriteTokens; const sessionNote = result.sessionId ? `\n\n---\n*session_id: \`${result.sessionId}\`*` : ""; return { content: [{ type: "text", text: (result.finalText || "(no output)") + sessionNote }], details: { ...details }, }; } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); details.done = true; details.finalText = `Error: ${errMsg}`; return { content: [{ type: "text", text: `**Claude error:** ${errMsg}` }], details: { ...details }, }; } }, renderCall(args, theme, _ctx) { const label = buildLabel(args.agent, args.model); let text = theme.fg("toolTitle", theme.bold("ask_claude ")) + theme.fg("accent", `[${label}]`); if (args.question) { text += "\n " + theme.fg("dim", theme.italic(args.question)); } else { const lines = args.prompt.split("\n").filter((l) => l.trim()).slice(0, 3); text += "\n " + theme.fg("dim", lines.join("\n ")); } return new Text(text, 0, 0); }, renderResult(result, { isPartial }, theme, _ctx) { const d = result.details as ClaudeDetails | undefined; if (!d) return new Text(theme.fg("muted", "…"), 0, 0); const isDone = d.done && !isPartial; const statusIcon = isDone ? theme.fg("success", "✓ ") : theme.fg("warning", "⏳ "); const c = new Container(); const resume = d.isResume ? theme.fg("dim", " ↩") : ""; c.addChild(new Text(statusIcon + theme.fg("toolTitle", theme.bold(d.label)) + resume, 0, 0)); for (const block of d.blocks ?? []) { if (block.type === "thinking" && block.text.trim()) { c.addChild(new Text(theme.fg("dim", theme.italic(block.text.trimEnd())), 0, 0)); } else if (block.type === "tool") { c.addChild(renderToolBlock(block, theme as any)); } else if (block.type === "text" && block.text.trim()) { c.addChild(new Spacer(1)); if (isDone) { c.addChild(new Markdown(block.text.trim(), 0, 0, getMarkdownTheme())); } else { c.addChild(new Text(theme.fg("dim", block.text.trimEnd()), 0, 0)); } } } if (isDone) { const usageLine = formatUsage(d); const parts: string[] = []; if (usageLine) parts.push(usageLine); if (d.sessionId) parts.push(`session:${d.sessionId.slice(0, 8)}`); if (parts.length > 0) { c.addChild(new Spacer(1)); c.addChild(new Text(theme.fg("dim", parts.join(" ")), 0, 0)); } } return c; }, }); // ============================================================================= // Extension entry point // ============================================================================= export default function (pi: ExtensionAPI) { pi.registerTool(askClaudeTool); // ── /review-plan ─────────────────────────────────────────────────────── pi.registerCommand("review-plan", { description: "Editor → Claude Opus plan_review → inject review into conversation", handler: async (_args, ctx) => { const input = await ctx.ui.editor( "Plan Review · Claude Opus", "Paste your plan or strategy. Claude will review for correctness, completeness, and risk.", ); if (!input?.trim()) { ctx.ui.notify("Cancelled.", "info"); return; } ctx.ui.setStatus("ask-claude", "Asking Claude Opus (plan_review)…"); try { const r = await runClaude(input, { agent: "plan_review", cwd: ctx.cwd, onUpdate: () => {} }); if (!r.finalText.trim()) { ctx.ui.notify("No output from Claude.", "warning"); return; } pi.sendMessage( { customType: "ask-claude-review", content: r.finalText.trim(), display: true, details: { label: "Claude Opus · plan_review", output: r.finalText } }, { triggerTurn: true }, ); } catch (err) { ctx.ui.notify(`Claude error: ${err instanceof Error ? err.message : String(err)}`, "error"); } finally { ctx.ui.setStatus("ask-claude", undefined); } }, }); // ── /review-code ─────────────────────────────────────────────────────── pi.registerCommand("review-code", { description: "Editor → Claude Sonnet code_review → inject review into conversation", handler: async (_args, ctx) => { const input = await ctx.ui.editor( "Code Review · Claude Sonnet", "Paste code to review. Include the plan it implements and any specific concerns.", ); if (!input?.trim()) { ctx.ui.notify("Cancelled.", "info"); return; } ctx.ui.setStatus("ask-claude", "Asking Claude Sonnet (code_review)…"); try { const r = await runClaude(input, { agent: "code_review", cwd: ctx.cwd, onUpdate: () => {} }); if (!r.finalText.trim()) { ctx.ui.notify("No output from Claude.", "warning"); return; } pi.sendMessage( { customType: "ask-claude-review", content: r.finalText.trim(), display: true, details: { label: "Claude Sonnet · code_review", output: r.finalText } }, { triggerTurn: true }, ); } catch (err) { ctx.ui.notify(`Claude error: ${err instanceof Error ? err.message : String(err)}`, "error"); } finally { ctx.ui.setStatus("ask-claude", undefined); } }, }); // ── Message renderer for injected reviews ────────────────────────────── pi.registerMessageRenderer("ask-claude-review", (message, { expanded }, theme) => { const d = message.details as { label?: string; output?: string } | undefined; const label = d?.label ?? "Claude"; const output = (d?.output ?? "").trim(); if (expanded) { const c = new Container(); c.addChild(new Text(theme.fg("accent", "◆ ") + theme.fg("toolTitle", theme.bold(label)), 0, 0)); c.addChild(new Spacer(1)); c.addChild(new Markdown(output, 0, 0, getMarkdownTheme())); return c; } let text = theme.fg("accent", "◆ ") + theme.fg("toolTitle", theme.bold(label)); const lines = output.split("\n").filter((l) => l.trim()); const preview = lines.slice(0, 4).join("\n"); if (preview) { text += "\n" + theme.fg("dim", preview); if (lines.length > 4) text += "\n" + theme.fg("muted", `… ${lines.length - 4} more (Ctrl+O)`); } return new Text(text, 0, 0); }); }