From 6e0cb1fdfb1fd777ca9dfec4f6bde310b621d5f2 Mon Sep 17 00:00:00 2001 From: codehippie1 Date: Thu, 28 May 2026 17:25:42 -0400 Subject: [PATCH 01/46] v0.9.1: cost-per-PR, MCP auto-policy, VS Code panel, soft budget alert - burnwall cost-per-pr: approximate cost of the current git branch/PR by attributing local session-log spend to the branch's active window (oldest commit on base..HEAD, else a fallback). Git metadata + logs only; never reads prompts. New src/observe/attribution.rs (pure attribute() + git_context), unit-tested. - MCP permission auto-policy: [mcp].auto_approve / auto_deny globs matched on /; auto-deny always blocks, auto-approve skips the approval gate in enforce mode. Glob matcher unit-tested. - VS Code inline panel: status-bar click opens a webview (cost-by-model, security blocks, MCP tools) from local CLI JSON. Pure panel_view.ts unit-tested. - Soft budget alert line in burnwall status once spend crosses warn_percent. --- editor/vscode/package.json | 5 +- editor/vscode/src/cli.ts | 9 +- editor/vscode/src/extension.ts | 4 +- editor/vscode/src/panel.ts | 31 ++++ editor/vscode/src/panel_view.ts | 79 ++++++++++ editor/vscode/test/panel.test.ts | 39 +++++ src/cli/cost_per_pr.rs | 122 +++++++++++++++ src/cli/mcp_watch.rs | 10 ++ src/cli/mod.rs | 4 + src/cli/status.rs | 9 ++ src/config/types.rs | 12 ++ src/mcp/mod.rs | 99 +++++++++++- src/observe/attribution.rs | 235 ++++++++++++++++++++++++++++ src/observe/mod.rs | 1 + tests/integration/mcp_watch_test.rs | 2 + 15 files changed, 649 insertions(+), 12 deletions(-) create mode 100644 editor/vscode/src/panel.ts create mode 100644 editor/vscode/src/panel_view.ts create mode 100644 editor/vscode/test/panel.test.ts create mode 100644 src/cli/cost_per_pr.rs create mode 100644 src/observe/attribution.rs diff --git a/editor/vscode/package.json b/editor/vscode/package.json index 1f002fe..5f5437d 100644 --- a/editor/vscode/package.json +++ b/editor/vscode/package.json @@ -2,7 +2,7 @@ "name": "burnwall", "displayName": "Burnwall", "description": "Cost + security for your AI coding agents, at a glance β€” reads your local Burnwall CLI.", - "version": "0.9.0", + "version": "0.9.2", "publisher": "intbot", "license": "FSL-1.1-MIT", "repository": { "type": "git", "url": "https://github.com/intbot/burnwall" }, @@ -12,7 +12,8 @@ "main": "./out/src/extension.js", "contributes": { "commands": [ - { "command": "burnwall.showBreakdown", "title": "Burnwall: Show Today's Breakdown" }, + { "command": "burnwall.showPanel", "title": "Burnwall: Open Panel" }, + { "command": "burnwall.showBreakdown", "title": "Burnwall: Show Today's Breakdown (terminal)" }, { "command": "burnwall.refresh", "title": "Burnwall: Refresh" }, { "command": "burnwall.install", "title": "Burnwall: Install the CLI" } ], diff --git a/editor/vscode/src/cli.ts b/editor/vscode/src/cli.ts index d1bc785..c540db4 100644 --- a/editor/vscode/src/cli.ts +++ b/editor/vscode/src/cli.ts @@ -9,9 +9,14 @@ const execFileAsync = promisify(execFile); /** Run `burnwall status --json` and return its stdout. */ export async function runStatusJson(cliPath: string): Promise { - const { stdout } = await execFileAsync(cliPath, ["status", "--json"], { + return runJson(cliPath, ["status", "--json"]); +} + +/** Run `burnwall ` and return its stdout (args should include `--json`). */ +export async function runJson(cliPath: string, args: string[]): Promise { + const { stdout } = await execFileAsync(cliPath, args, { timeout: 10_000, - maxBuffer: 4 * 1024 * 1024, + maxBuffer: 8 * 1024 * 1024, }); return stdout; } diff --git a/editor/vscode/src/extension.ts b/editor/vscode/src/extension.ts index f1abb4e..9e07894 100644 --- a/editor/vscode/src/extension.ts +++ b/editor/vscode/src/extension.ts @@ -2,6 +2,7 @@ import * as vscode from "vscode"; import { cliAvailable, runStatusJson } from "./cli"; import { StatusJson, statusBarText, summarize, tooltip } from "./format"; +import { showPanel } from "./panel"; const INSTALL_URL = "https://github.com/intbot/burnwall#install"; @@ -16,6 +17,7 @@ export function activate(context: vscode.ExtensionContext): void { context.subscriptions.push( vscode.commands.registerCommand("burnwall.refresh", refresh), vscode.commands.registerCommand("burnwall.showBreakdown", showBreakdown), + vscode.commands.registerCommand("burnwall.showPanel", () => showPanel(cliPath())), vscode.commands.registerCommand("burnwall.install", () => vscode.env.openExternal(vscode.Uri.parse(INSTALL_URL)), ), @@ -53,7 +55,7 @@ async function refresh(): Promise { const summary = summarize(json); item.text = statusBarText(summary); item.tooltip = tooltip(summary); - item.command = "burnwall.showBreakdown"; + item.command = "burnwall.showPanel"; } catch (err) { item.text = "$(flame) Burnwall: error"; item.tooltip = `Failed to read burnwall status: ${err}`; diff --git a/editor/vscode/src/panel.ts b/editor/vscode/src/panel.ts new file mode 100644 index 0000000..43a2756 --- /dev/null +++ b/editor/vscode/src/panel.ts @@ -0,0 +1,31 @@ +// Inline "Burnwall" panel (v0.9.1): a webview surfacing the window digest +// (cost by model, security blocks, MCP tools) + today's status, built from the +// local CLI JSON. The pure HTML builder lives in panel_view.ts (testable); +// this module is the `vscode`-dependent wiring. + +import * as vscode from "vscode"; + +import { runJson } from "./cli"; +import { Digest, panelHtml, Status } from "./panel_view"; + +async function safeJson(cliPath: string, args: string[]): Promise { + try { + return JSON.parse(await runJson(cliPath, args)) as T; + } catch { + return {} as T; + } +} + +export async function showPanel(cliPath: string): Promise { + const [digest, status] = await Promise.all([ + safeJson(cliPath, ["digest", "--json"]), + safeJson(cliPath, ["status", "--json"]), + ]); + const panel = vscode.window.createWebviewPanel( + "burnwall", + "Burnwall", + vscode.ViewColumn.Active, + { enableScripts: false }, + ); + panel.webview.html = panelHtml(digest, status); +} diff --git a/editor/vscode/src/panel_view.ts b/editor/vscode/src/panel_view.ts new file mode 100644 index 0000000..d744847 --- /dev/null +++ b/editor/vscode/src/panel_view.ts @@ -0,0 +1,79 @@ +// Pure view model for the Burnwall panel β€” no `vscode` import, so it is +// unit-testable under plain Node (see test/panel.test.ts). The webview wiring +// (which needs `vscode`) lives in panel.ts. + +export interface Digest { + total_cost_usd?: number; + turns?: number; + blocked?: number; + mcp_tool_calls?: number; + models?: Array<{ provider?: string; model?: string; requests?: number; cost_usd?: number }>; + security_by_type?: Array<{ event_type?: string; count?: number }>; + mcp_tools?: Array<{ server?: string; tool?: string; trust_state?: string }>; +} + +export interface Status { + total_cost_usd?: number; + blocked_requests?: number; + security_events?: number; + budget?: { daily_limit_usd?: number; spent_today_usd?: number }; +} + +function esc(s: unknown): string { + return String(s ?? "").replace(/[&<>"]/g, (c) => { + return { "&": "&", "<": "<", ">": ">", '"': """ }[c] as string; + }); +} + +function money(n: unknown): string { + const v = typeof n === "number" ? n : 0; + return `$${v.toFixed(2)}`; +} + +/** Render the panel HTML from the digest + status JSON. Pure. */ +export function panelHtml(digest: Digest, status: Status): string { + const today = money(status.total_cost_usd); + const limit = status.budget?.daily_limit_usd ?? 0; + const budgetLine = + limit > 0 ? `${today} of ${money(limit)} today` : `${today} today (no daily limit set)`; + + const modelRows = + (digest.models ?? []) + .map( + (m) => + `${esc(m.provider)}/${esc(m.model)}${esc(m.requests ?? 0)}${money(m.cost_usd)}`, + ) + .join("") || `(no spend in window)`; + + const secRows = + (digest.security_by_type ?? []) + .map((s) => `
  • ${esc(s.event_type)}: ${esc(s.count ?? 0)}
  • `) + .join("") || "
  • (none)
  • "; + + const mcpRows = + (digest.mcp_tools ?? []) + .map((t) => `
  • ${esc(t.server)}/${esc(t.tool)} β€” ${esc(t.trust_state)}
  • `) + .join("") || "
  • (none)
  • "; + + return ` + + +
    πŸ›‘οΈ Burnwall
    +

    ${esc(budgetLine)} Β· ${esc(digest.turns ?? 0)} turns Β· ${esc(digest.blocked ?? 0)} blocked Β· window cost ${money(digest.total_cost_usd)}

    + +

    Cost by model (window)

    + ${modelRows}
    provider/modelreqcost
    + +

    Security blocks

    +
      ${secRows}
    + +

    MCP tools (${esc(digest.mcp_tool_calls ?? 0)} calls)

    +
      ${mcpRows}
    +`; +} diff --git a/editor/vscode/test/panel.test.ts b/editor/vscode/test/panel.test.ts new file mode 100644 index 0000000..d1af4c0 --- /dev/null +++ b/editor/vscode/test/panel.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert"; +import { test } from "node:test"; + +import { panelHtml } from "../src/panel_view"; + +test("panelHtml renders models, security, MCP, and budget", () => { + const html = panelHtml( + { + total_cost_usd: 3.5, + turns: 10, + blocked: 1, + mcp_tool_calls: 4, + models: [{ provider: "anthropic", model: "claude-opus-4-7", requests: 10, cost_usd: 3.5 }], + security_by_type: [{ event_type: "path_blocked", count: 1 }], + mcp_tools: [{ server: "fs", tool: "read", trust_state: "approved" }], + }, + { total_cost_usd: 1.25, budget: { daily_limit_usd: 10, spent_today_usd: 1.25 } }, + ); + assert.ok(html.includes("claude-opus-4-7"), html); + assert.ok(html.includes("$3.50"), html); + assert.ok(html.includes("path_blocked: 1"), html); + assert.ok(html.includes("fs/read"), html); + assert.ok(html.includes("$1.25 of $10.00 today"), html); +}); + +test("panelHtml degrades on empty/missing fields", () => { + const html = panelHtml({}, {}); + assert.ok(html.includes("(no spend in window)"), html); + assert.ok(html.includes("no daily limit set"), html); +}); + +test("panelHtml escapes HTML in field values", () => { + const html = panelHtml( + { models: [{ provider: "x", model: "