diff --git a/pi/.pi/agent/extensions/qwencloud-usage.ts b/pi/.pi/agent/extensions/qwencloud-usage.ts deleted file mode 100644 index 7f32cc1..0000000 --- a/pi/.pi/agent/extensions/qwencloud-usage.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * qwencloud-usage: Session-cost tracker for QwenCloud models. - * - * QwenCloud Token Plan is a prepaid credits subscription without a public - * usage/balance API. This extension accumulates cost from pi's built-in - * Usage tracking (every assistant message carries token counts + cost) and - * displays a running session total in the footer. - * - * Set QWENCLOUD_MONTHLY_BUDGET (dollars) to enable a coloured - * budget-usage percentage. For example: - * QWENCLOUD_MONTHLY_BUDGET=6 # Lite plan ($6/mo) - * QWENCLOUD_MONTHLY_BUDGET=18 # Standard ($18/mo) - * QWENCLOUD_MONTHLY_BUDGET=68 # Pro ($68/mo) - * - * The extension sets the "qwencloud-usage" footer-status key and emits - * "usage:update" so prompt-frame (or any other listener) can refresh. - */ - -const STATUS_KEY = "qwencloud-usage"; -const PROVIDER = "qw"; - -function budgetPct(cost: number, budget: number): number | null { - if (!Number.isFinite(budget) || budget <= 0) return null; - return Math.round((cost / budget) * 100); -} - -function costColor(pct: number | null): "success" | "warning" | "error" { - if (pct === null) return "success"; - if (pct >= 90) return "error"; - if (pct >= 70) return "warning"; - return "success"; -} - -export default function (pi: any) { - const budget = parseFloat(process.env.QWENCLOUD_MONTHLY_BUDGET || "0"); - - let sessionCost = 0; - let sessionInput = 0; - let sessionOutput = 0; - - pi.on("message_end", (event: any, ctx: any) => { - const msg = event.message; - if (msg?.model?.provider !== PROVIDER) return; - if (!msg.usage) return; - - sessionCost += msg.usage.cost?.total ?? 0; - sessionInput += msg.usage.input ?? 0; - sessionOutput += msg.usage.output ?? 0; - - if (!ctx?.hasUI) return; - - const theme = ctx.ui.theme; - const pct = budgetPct(sessionCost, budget); - const color = costColor(pct); - const totalTokens = sessionInput + sessionOutput; - - // Build a compact status string: qw · $0.42 · 7% · 12.3k tok - const parts: string[] = []; - parts.push(theme.fg("dim", "qw")); - parts.push(theme.fg(color, `$${sessionCost.toFixed(2)}`)); - if (pct !== null) { - parts.push(theme.fg(color, `${pct}%`)); - } - parts.push(theme.fg("dim", `${formatTokens(totalTokens)} tok`)); - - ctx.ui.setStatus(STATUS_KEY, parts.join(" " + theme.fg("muted", "·") + " ")); - pi.events.emit("usage:update"); - }); - - // Reset counters at the start of every new conversation. - pi.on("session_start", () => { - sessionCost = 0; - sessionInput = 0; - sessionOutput = 0; - }); - - // Clean up when the session ends so the status doesn't linger. - pi.on("session_shutdown", (_event: any, ctx: any) => { - if (ctx?.hasUI) ctx.ui.setStatus(STATUS_KEY, undefined); - }); -} - -/** Human-readable token count (e.g. "12.3k", "1.2M"). */ -function formatTokens(n: number): string { - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; - return String(n); -} diff --git a/pi/.pi/agent/extensions/worktree.ts b/pi/.pi/agent/extensions/worktree.ts deleted file mode 100644 index 2fc879c..0000000 --- a/pi/.pi/agent/extensions/worktree.ts +++ /dev/null @@ -1,506 +0,0 @@ -/** - * Git Worktree Extension - * - * Spin up an AI agent in a git worktree to implement tasks in the background - * while you continue working in the main branch. - * - * Commands: - * /worktree — create worktree + start agent - * /worktrees — list all worktree agents and their status - * /worktree-log — show recent log output (and log file path) - * /worktree-done — remove a finished worktree - * - * The worktree is placed as a sibling of your repo: - * /../- - * - * Agent output is streamed to: - * /tmp/pi-worktrees/.log - * - * Follow live: !tail -f /tmp/pi-worktrees/.log - * - * DESIGN NOTES - * ───────────── - * Context: The full conversation history is serialized and prepended to the - * task prompt so the agent sees the plan (or whatever was discussed). - * - * Visibility: Spawns pi in --mode json so we can parse events. The widget above - * the editor shows ⏳/✓/✗ per agent, elapsed time, and the last few - * tool calls / text snippets the agent produced. - * - * Questions: The agent runs in -p (batch) mode and cannot pause to ask you - * anything. Give it enough context upfront (the conversation carry- - * over handles this). If the agent gets stuck it will make its best - * guess or fail — check the log and use /worktree-done to clean up. - */ - -import { execSync } from "node:child_process"; -import { spawn } from "node:child_process"; -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import type { - ExtensionAPI, - ExtensionCommandContext, - ExtensionContext, - SessionEntry, -} from "@mariozechner/pi-coding-agent"; -import { convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent"; - -// ─── Types ──────────────────────────────────────────────────────────────────── - -interface WorktreeAgent { - branch: string; - worktreePath: string; - task: string; - logFile: string; - startTime: number; - status: "running" | "done" | "error"; - exitCode?: number; - /** Last few activity lines parsed from the JSON event stream */ - recentActivity: string[]; - /** Accumulated final-text output from all assistant turns */ - finalOutput: string; -} - -type Ctx = ExtensionContext | ExtensionCommandContext; - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -function shortenPath(p: string): string { - const home = os.homedir(); - return p.startsWith(home) ? `~${p.slice(home.length)}` : p; -} - -function formatToolCall(toolName: string, args: Record): string { - switch (toolName) { - case "bash": { - const cmd = ((args.command as string) || "").split("\n")[0].slice(0, 60); - return `$ ${cmd}`; - } - case "read": - return `read ${shortenPath((args.file_path ?? args.path ?? "?") as string)}`; - case "write": - return `write ${shortenPath((args.file_path ?? args.path ?? "?") as string)}`; - case "edit": - return `edit ${shortenPath((args.file_path ?? args.path ?? "?") as string)}`; - case "grep": - return `grep /${args.pattern}/ in ${shortenPath((args.path ?? ".") as string)}`; - case "find": - return `find ${args.pattern} in ${shortenPath((args.path ?? ".") as string)}`; - default: - return `${toolName}(${JSON.stringify(args).slice(0, 40)})`; - } -} - -// ─── Extension ──────────────────────────────────────────────────────────────── - -export default function (pi: ExtensionAPI) { - const agents = new Map(); - - // ── Git helpers ──────────────────────────────────────────────────────────── - - function getRepoRoot(cwd: string): string | null { - try { - return execSync(`git -C "${cwd}" rev-parse --show-toplevel`, { encoding: "utf-8" }).trim(); - } catch { - return null; - } - } - - function branchExists(repoRoot: string, branch: string): boolean { - try { - execSync(`git -C "${repoRoot}" rev-parse --verify "${branch}"`, { stdio: "ignore" }); - return true; - } catch { - return false; - } - } - - // ── Widget ───────────────────────────────────────────────────────────────── - - function refreshWidget(ctx: Ctx) { - const lines: string[] = []; - for (const wt of agents.values()) { - const icon = wt.status === "running" ? "⏳" : wt.status === "done" ? "✓" : "✗"; - const elapsed = Date.now() - wt.startTime; - const timeStr = elapsed < 60_000 ? `${Math.round(elapsed / 1000)}s` : `${Math.round(elapsed / 60_000)}m`; - lines.push(`${icon} ${wt.branch} [${timeStr}]`); - for (const line of wt.recentActivity.slice(-3)) { - lines.push(` ${line}`); - } - } - ctx.ui.setWidget("worktrees", lines); - } - - pi.on("turn_start", async (_e, ctx) => refreshWidget(ctx)); - pi.on("agent_end", async (_e, ctx) => refreshWidget(ctx)); - - // ── /worktree ────────────────────────────────────────────── - - pi.registerCommand("worktree", { - description: "Create a git worktree and run an agent in it: /worktree ", - handler: async (args, ctx) => { - const trimmed = args.trim(); - const spaceIdx = trimmed.indexOf(" "); - if (spaceIdx === -1) { - ctx.ui.notify("Usage: /worktree ", "error"); - return; - } - - const branch = trimmed.slice(0, spaceIdx); - const task = trimmed.slice(spaceIdx + 1).trim(); - if (!task) { - ctx.ui.notify("Please provide a task description after the branch name", "error"); - return; - } - - if (agents.get(branch)?.status === "running") { - ctx.ui.notify(`An agent for branch '${branch}' is already running`, "error"); - return; - } - - const repoRoot = getRepoRoot(ctx.cwd); - if (!repoRoot) { - ctx.ui.notify("Not inside a git repository", "error"); - return; - } - - // ── Serialize current conversation as context ────────────────────── - // The agent needs to see whatever was discussed (e.g. the plan) so it - // can act on "implement the above plan" style instructions. - const branch_ = ctx.sessionManager.getBranch(); - const sessionMessages = branch_ - .filter((e): e is SessionEntry & { type: "message" } => e.type === "message") - .map((e) => e.message); - - let conversationContext = ""; - if (sessionMessages.length > 0) { - try { - const llmMessages = convertToLlm(sessionMessages); - conversationContext = serializeConversation(llmMessages); - } catch { - // If serialization fails, proceed without context - } - } - - // ── Create worktree ──────────────────────────────────────────────── - const repoName = path.basename(repoRoot); - const safeBranch = branch.replace(/[^\w.-]/g, "_"); - const worktreePath = path.resolve(repoRoot, "..", `${repoName}-${safeBranch}`); - - try { - if (branchExists(repoRoot, branch)) { - execSync(`git -C "${repoRoot}" worktree add "${worktreePath}" "${branch}"`, { stdio: "pipe" }); - } else { - execSync(`git -C "${repoRoot}" worktree add "${worktreePath}" -b "${branch}"`, { stdio: "pipe" }); - } - } catch (e: any) { - if (!fs.existsSync(path.join(worktreePath, ".git"))) { - const msg = (e as any).stderr?.toString().trim() || (e as Error).message; - ctx.ui.notify(`Failed to create worktree: ${msg}`, "error"); - return; - } - // Already registered — continue - } - - // ── Log + temp files ─────────────────────────────────────────────── - const logDir = path.join(os.tmpdir(), "pi-worktrees"); - fs.mkdirSync(logDir, { recursive: true }); - const logFile = path.join(logDir, `${safeBranch}.log`); - - // Build the full prompt the agent will receive. - // If there is prior conversation, prepend it so the agent has all - // the context it needs (e.g. the plan written in the last message). - const fullPrompt = - conversationContext.trim() - ? [ - "The following is the conversation that led to this task. Use it as context.", - "", - "", - conversationContext.trim(), - "", - "", - "Your task:", - task, - ].join("\n") - : task; - - // Temp file for the prompt (avoids shell-quoting issues with long text) - const promptFile = path.join(logDir, `${safeBranch}-prompt.md`); - fs.writeFileSync(promptFile, fullPrompt); - - // System-prompt addendum: ground the agent in its worktree context - const systemFile = path.join(logDir, `${safeBranch}-system.md`); - fs.writeFileSync( - systemFile, - [ - `You are an AI coding agent working in a git worktree for branch '${branch}'.`, - `Working directory: ${worktreePath}`, - `Main repository: ${repoRoot}`, - ``, - `Implement the requested changes fully and correctly.`, - `When you are finished, stage all changed files and create a git commit with a descriptive message.`, - `You cannot ask the user questions — work autonomously with the context provided.`, - ].join("\n"), - ); - - // ── Register + spawn ─────────────────────────────────────────────── - const agent: WorktreeAgent = { - branch, - worktreePath, - task, - logFile, - startTime: Date.now(), - status: "running", - recentActivity: [], - finalOutput: "", - }; - agents.set(branch, agent); - - const logStream = fs.createWriteStream(logFile); - logStream.write( - [ - `=== pi-worktree agent started ===`, - `Branch: ${branch}`, - `Path: ${worktreePath}`, - `Task: ${task}`, - `Started: ${new Date().toISOString()}`, - `Context: ${sessionMessages.length} messages from current session`, - `Log: tail -f ${logFile}`, - ``, - ``, - ].join("\n"), - ); - - // Use --mode json so we can parse events and populate the widget. - // The prompt is passed via @file reference to avoid shell-length limits. - const proc = spawn( - "pi", - [ - "--mode", "json", - "-p", - "--no-session", - "--append-system-prompt", systemFile, - `@${promptFile}`, - ], - { cwd: worktreePath, stdio: ["ignore", "pipe", "pipe"] }, - ); - - // Parse JSON event stream for widget updates; also mirror to log file - let buffer = ""; - proc.stdout?.on("data", (chunk: Buffer) => { - const text = chunk.toString(); - logStream.write(text); - buffer += text; - const lines = buffer.split("\n"); - buffer = lines.pop() ?? ""; - for (const line of lines) { - if (!line.trim()) continue; - try { - const event = JSON.parse(line) as Record; - handleJsonEvent(agent, event); - } catch { - // Non-JSON line — ignore for widget purposes - } - } - }); - - proc.stderr?.on("data", (d: Buffer) => { - logStream.write(`[stderr] ${d}`); - }); - - proc.on("close", (code) => { - const wt = agents.get(branch); - if (wt) { - wt.status = code === 0 ? "done" : "error"; - wt.exitCode = code ?? undefined; - wt.recentActivity.push(code === 0 ? "✓ done" : `✗ exited ${code}`); - } - logStream.write(`\n=== Agent exited with code ${code} — ${new Date().toISOString()} ===\n`); - logStream.end(); - cleanup(); - - // ── Hand results back to the main session ────────────────────── - // Inject a message into the main conversation so you (and the main - // agent) can see what the worktree agent did and discuss it. - const elapsed = wt ? Math.round((Date.now() - wt.startTime) / 1000) : 0; - const summary = wt?.finalOutput.trim() || "(no text output)"; - const statusLine = code === 0 - ? `✓ Worktree agent for **${branch}** finished in ${elapsed}s` - : `✗ Worktree agent for **${branch}** failed (exit ${code}) after ${elapsed}s`; - - pi.sendMessage( - { - customType: "worktree-result", - content: [ - statusLine, - `Worktree: \`${worktreePath}\``, - `Task: ${task}`, - ``, - `**Agent output:**`, - summary, - ].join("\n"), - display: true, - }, - { - // Queue for the next time the user sends a message. - // Change to "steer" + triggerTurn:true if you want an - // immediate LLM response the moment the agent finishes. - deliverAs: "nextTurn", - }, - ); - }); - - proc.on("error", (err) => { - const wt = agents.get(branch); - if (wt) { - wt.status = "error"; - wt.recentActivity.push(`spawn error: ${err.message}`); - } - logStream.write(`\n[spawn error] ${err.message}\n`); - logStream.end(); - cleanup(); - }); - - function cleanup() { - try { fs.unlinkSync(systemFile); } catch {} - try { fs.unlinkSync(promptFile); } catch {} - } - - ctx.ui.notify(`🌿 Worktree agent started for '${branch}' — tail -f ${logFile}`, "info"); - refreshWidget(ctx); - }, - }); - - // ── /worktrees ───────────────────────────────────────────────────────────── - - pi.registerCommand("worktrees", { - description: "List all worktree agents and their status", - handler: async (_args, ctx) => { - if (agents.size === 0) { - ctx.ui.notify("No worktree agents active this session", "info"); - return; - } - for (const wt of agents.values()) { - const icon = wt.status === "running" ? "⏳" : wt.status === "done" ? "✓" : "✗"; - const elapsed = Date.now() - wt.startTime; - const timeStr = elapsed < 60_000 ? `${Math.round(elapsed / 1000)}s` : `${Math.round(elapsed / 60_000)}m`; - const last = wt.recentActivity.at(-1) ?? "(no activity yet)"; - ctx.ui.notify(`${icon} ${wt.branch} [${timeStr}]\n task: ${wt.task}\n last: ${last}`, "info"); - } - }, - }); - - // ── /worktree-log ───────────────────────────────────────────────── - - pi.registerCommand("worktree-log", { - description: "Show recent log for a worktree agent: /worktree-log ", - handler: async (args, ctx) => { - const branch = args.trim(); - const wt = agents.get(branch); - if (!wt) { - ctx.ui.notify( - `No worktree agent for '${branch}'\nKnown: ${[...agents.keys()].join(", ") || "none"}`, - "error", - ); - return; - } - - ctx.ui.notify(`Log: ${wt.logFile}\nLive follow: !tail -f ${wt.logFile}`, "info"); - - try { - const raw = fs.readFileSync(wt.logFile, "utf-8"); - // Log is JSON-mode output — skip JSON lines, show text content only - const readable = raw - .split("\n") - .filter((l) => { - if (!l.trim()) return false; - try { JSON.parse(l); return false; } catch { return true; } - }) - .slice(-30) - .join("\n"); - if (readable) ctx.ui.notify(`[${branch} — last readable lines]\n\n${readable}`, "info"); - } catch { - // Log not readable yet - } - }, - }); - - // ── /worktree-done ──────────────────────────────────────────────── - - pi.registerCommand("worktree-done", { - description: "Remove a finished worktree: /worktree-done ", - handler: async (args, ctx) => { - const branch = args.trim(); - const wt = agents.get(branch); - if (!wt) { - ctx.ui.notify(`No worktree agent for '${branch}'`, "error"); - return; - } - - if (wt.status === "running") { - const ok = await ctx.ui.confirm( - "Agent still running", - `Force-remove the worktree for '${branch}' even though the agent has not finished?`, - ); - if (!ok) return; - } - - const repoRoot = getRepoRoot(ctx.cwd); - if (repoRoot) { - try { - execSync(`git -C "${repoRoot}" worktree remove "${wt.worktreePath}" --force`, { stdio: "pipe" }); - } catch (e: any) { - const msg = (e as any).stderr?.toString().trim() || (e as Error).message; - ctx.ui.notify(`Warning: could not remove worktree directory: ${msg}`, "warning"); - } - } - - agents.delete(branch); - refreshWidget(ctx); - ctx.ui.notify(`Removed worktree '${branch}'`, "info"); - }, - }); - - // ── JSON event parser ────────────────────────────────────────────────────── - // Parses --mode json events to populate recentActivity for the widget. - - function handleJsonEvent(agent: WorktreeAgent, event: Record) { - // Tool starting — show what it's about to do - if (event.type === "tool_execution_start") { - const name = event.toolName as string; - const rawArgs = event.args as Record | undefined; - if (name && rawArgs) { - pushActivity(agent, `→ ${formatToolCall(name, rawArgs)}`); - } - return; - } - - // Assistant text — grab a brief preview for the widget and accumulate - // the full text so we can hand it back to the main session on exit. - if (event.type === "message_end") { - const msg = event.message as { role?: string; content?: unknown[] } | undefined; - if (msg?.role === "assistant" && Array.isArray(msg.content)) { - for (const part of msg.content) { - const p = part as { type?: string; text?: string }; - if (p.type === "text" && p.text) { - // Widget: short preview - const preview = p.text.replace(/\s+/g, " ").slice(0, 70); - pushActivity(agent, `💬 ${preview}${p.text.length > 70 ? "…" : ""}`); - // Handback: accumulate full text across all turns - agent.finalOutput += (agent.finalOutput ? "\n\n" : "") + p.text; - break; - } - } - } - } - } - - function pushActivity(agent: WorktreeAgent, line: string) { - agent.recentActivity.push(line); - // Keep a rolling window so memory doesn't grow unboundedly - if (agent.recentActivity.length > 50) { - agent.recentActivity.splice(0, agent.recentActivity.length - 50); - } - } -}