Files
dotfiles/pi/.pi/agent/extensions/ask-claude.ts
2026-04-24 14:22:59 +02:00

276 lines
11 KiB
TypeScript

/**
* 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=<name> 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=<name>) 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);
});
}