From 85cebf30123b20435c234a223a361f1079762918 Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Sun, 4 Oct 2026 21:46:03 +0200 Subject: [PATCH] Know what the team costs, and cap it with monthly budgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Spend is booked when it is spent: one ledger row per stretch of a run, so a run that continues next month counts there, and spend stays when an agent is deleted. Past runs are booked once (old runs that stored the session's total count only what they added) and again after a restore. - Activity shows today, this week, this month and all time, and for the chosen period who and what kind of work the money went to. - Monthly budgets for the team (Settings → AI) and any agent (its settings; only the human sets them). You're told at 80% and at 100%. - At 100% automations, follow-ups and board tickets are held, not failed: the chat says "Held — the team's October budget is used up", the agent says "Held · budget used up", and the work continues by itself on the 1st, as soon as you raise the budget, or now with "Let it run". Chats you start (and Run now) still run, with a note once a month. Unattended work can't hand work to an agent over budget; scheduled checks and dreams wait. - Managers can answer "what did we spend" with spend_overview; runs_list says when a run is held for a budget. - The phone says when a chat is held and can let it run. --- README.md | 1 + .../src/components/agents/agent-form.tsx | 33 ++- .../components/agents/detail/overview-tab.tsx | 29 +++ .../src/components/budgets/budget-meter.tsx | 92 +++++++ apps/desktop/src/components/chat/pause.tsx | 49 +++- .../src/components/layout/recent-chats.tsx | 8 +- .../src/components/runs/spend-strip.tsx | 193 +++++++++++++++ .../src/components/settings/ai-section.tsx | 32 ++- .../src/components/tasks/task-meta.tsx | 1 + apps/desktop/src/lib/api.ts | 15 ++ apps/desktop/src/lib/hooks.ts | 9 + apps/desktop/src/lib/queryKeys.ts | 4 + apps/desktop/src/lib/realtime.ts | 6 +- .../src/pages/activity/activity-page.tsx | 10 +- apps/desktop/src/pages/agents/agents-page.tsx | 2 +- .../src/pages/chat/chat-conversation.tsx | 8 +- apps/mobile/src/app/chat/[id].tsx | 23 +- apps/mobile/src/components/message.tsx | 4 +- docs/ARCHITECTURE.md | 26 ++ packages/core/src/agents/service.ts | 13 +- packages/core/src/backup/backup.ts | 7 +- packages/core/src/cloud/scope.ts | 4 + packages/core/src/db/migrations.ts | 51 ++++ packages/core/src/index.ts | 3 + packages/core/src/mcp/tools.ts | 45 ++++ packages/core/src/memory/dreaming.ts | 4 + packages/core/src/runner/runner.ts | 89 ++++++- packages/core/src/scheduler/scheduler.ts | 25 +- packages/core/src/server/app.ts | 2 + packages/core/src/server/routes/agents.ts | 3 +- packages/core/src/server/routes/spend.ts | 31 +++ packages/core/src/server/routes/system.ts | 6 + packages/core/src/services/budgets.ts | 233 ++++++++++++++++++ packages/core/src/services/conversations.ts | 17 +- packages/core/src/services/pauses.ts | 14 +- packages/core/src/services/routines.ts | 5 +- packages/core/src/services/settings.ts | 1 + packages/core/src/services/spend.ts | 92 +++++++ packages/core/src/tasks/service.ts | 17 +- packages/core/test/agents.test.ts | 1 + packages/core/test/budgets.test.ts | 192 +++++++++++++++ packages/shared/src/api.ts | 52 ++++ packages/shared/src/budget.ts | 51 ++++ packages/shared/src/index.ts | 1 + packages/shared/src/models.ts | 23 +- packages/shared/src/team.ts | 10 +- 46 files changed, 1484 insertions(+), 53 deletions(-) create mode 100644 apps/desktop/src/components/budgets/budget-meter.tsx create mode 100644 apps/desktop/src/components/runs/spend-strip.tsx create mode 100644 packages/core/src/server/routes/spend.ts create mode 100644 packages/core/src/services/budgets.ts create mode 100644 packages/core/src/services/spend.ts create mode 100644 packages/core/test/budgets.test.ts create mode 100644 packages/shared/src/budget.ts diff --git a/README.md b/README.md index 3d42f423..3ba34214 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ needs to be useful: | ⏰ **Follow-ups** | When a task needs waiting — a reply to an email, a delivery, a build, office hours — the agent sets itself a time and picks the chat up again on its own, with all the context, like a coworker who says “I'll check back tomorrow at 10”. You see when it comes back and can continue now, move it or cancel it. | | 🤝 **Delegation** | Agents hand tasks to peer agents or spawn short-lived subagents. The Godmode agent can list, check, create and configure all agents. Follow a handoff both ways: open the teammate's chat from the handoff card, and jump back to the chat that asked from there. | | 🏢 **Team & org chart** | Give every agent a role (*Bookkeeper*, *Research analyst*) and a lead; agents are told who does what and who they report to, so work goes to the right one. The **org chart** shows the whole team with what each is doing right now. Status tells the truth — *Working in 2 chats*, *Queued*, *Needs your answer*, *Last run failed* (one click opens the run) — and each agent's page shows **what's on its plate**: live work, waiting chats, board tickets, follow-ups and its next automations. Duplicate an agent in one click; messages from automations, the board or other agents are labelled as such, never shown as yours. | +| 💰 **Spend & budgets** | See what the team cost today, this week, this month and all time — per agent and per kind of work, booked when it was spent. Give the team (and any agent) a **monthly budget**: you're told at 80%; at 100% automations, follow-ups and board tickets are **held, not failed** — they continue by themselves next month or as soon as you raise the budget, or right away when you click *Let it run*. Chats you start always run. | | 📨 **Message queue** | Keep writing while an agent works: your messages wait in a queue above the message box, and the agent picks them up at its next step and works them into what it is doing — a correction lands right away, an extra request comes after the current step. Reword or remove a queued message until it is picked up, or hit *Send now* to interrupt. | | 🙋 **Questions & approvals** | When a decision is yours — or before a step that sends, pays or deletes something you didn't ask for — the agent asks: a question with suggested answers, or *Approve / Decline* for exactly that step. The work stands still (it doesn't pretend to be done), you're notified, and you answer with one click in the chat, the inbox, the task, from your phone or by replying in Slack, Telegram or Teams; the agent continues right where it stopped. Stop the run and the question is withdrawn. | | ⏸️ **Pause & continue** | Pause an agent or a single chat and continue later: it lets the step it is in finish, stands still, and picks the work up exactly where it stopped — same run, same message, same Claude session. When Claude's usage limit is reached mid-task, the work waits instead of failing and continues by itself once the limit has reset. | diff --git a/apps/desktop/src/components/agents/agent-form.tsx b/apps/desktop/src/components/agents/agent-form.tsx index eaaad70e..7cf5ac28 100644 --- a/apps/desktop/src/components/agents/agent-form.tsx +++ b/apps/desktop/src/components/agents/agent-form.tsx @@ -96,6 +96,8 @@ export interface AgentFormValues { delegateTo: string[]; canManageAgents: boolean; maxBudgetUsd: string; + /** What it may cost per calendar month; "" = no budget. */ + monthlyBudgetUsd: string; browserEnabled: boolean; browserProfileId: string | null; headless: boolean | null; @@ -136,6 +138,7 @@ export function agentToValues( delegateTo: source?.permissions?.delegateTo ?? [], canManageAgents: source?.permissions?.canManageAgents ?? false, maxBudgetUsd: source?.permissions?.maxBudgetUsd != null ? String(source.permissions.maxBudgetUsd) : "", + monthlyBudgetUsd: source?.permissions?.monthlyBudgetUsd != null ? String(source.permissions.monthlyBudgetUsd) : "", browserEnabled: source?.browser?.enabled ?? true, browserProfileId: source?.browser?.profileId ?? null, headless: source?.browser?.headless ?? null, @@ -152,6 +155,7 @@ export function agentToValues( export function valuesToInput(v: AgentFormValues): AgentInput { const budget = v.maxBudgetUsd.trim() ? Number(v.maxBudgetUsd) : null; + const monthly = v.monthlyBudgetUsd.trim() ? Number(v.monthlyBudgetUsd) : null; return { workspaceId: v.workspaceId, name: v.name.trim(), @@ -172,6 +176,7 @@ export function valuesToInput(v: AgentFormValues): AgentInput { delegateTo: v.allowDelegation ? v.delegateTo : [], canManageAgents: v.canManageAgents, maxBudgetUsd: budget != null && Number.isFinite(budget) && budget > 0 ? budget : null, + monthlyBudgetUsd: monthly != null && Number.isFinite(monthly) && monthly > 0 ? monthly : null, }, browser: { enabled: v.browserEnabled, profileId: v.browserProfileId, headless: v.headless }, computer: { enabled: v.computerEnabled, target: v.computerDisplayId ? { kind: "display", displayId: v.computerDisplayId } : null }, @@ -201,6 +206,10 @@ function validate(v: AgentFormValues): Record { const n = Number(v.maxBudgetUsd); if (!Number.isFinite(n) || n <= 0) errors.maxBudgetUsd = "Enter a positive amount, or leave empty for no limit"; } + if (v.monthlyBudgetUsd.trim()) { + const n = Number(v.monthlyBudgetUsd); + if (!Number.isFinite(n) || n <= 0) errors.monthlyBudgetUsd = "Enter a positive amount, or leave empty for no budget"; + } const names = new Set(); v.subagents.forEach((s, i) => { const n = slugify(s.name); @@ -295,7 +304,7 @@ export function AgentForm({ if (hasErrors) { setShowErrors(true); const first = Object.keys(errors)[0]; - document.getElementById(first === "name" ? "agent-name" : first.startsWith("subagent") ? "subagents-list" : "agent-budget")?.focus(); + document.getElementById(first === "name" ? "agent-name" : first.startsWith("subagent") ? "subagents-list" : first === "monthlyBudgetUsd" ? "agent-month-budget" : "agent-budget")?.focus(); return; } onSubmit(valuesToInput(values)); @@ -647,6 +656,28 @@ export function AgentForm({

A run stops when it would cost more than this.

)} +
+ +
+ $ + set("monthlyBudgetUsd", e.target.value.replace(",", "."))} + placeholder="No budget" + aria-invalid={!!err("monthlyBudgetUsd")} + className="pl-7" + /> +
+ {err("monthlyBudgetUsd") ? ( +

{err("monthlyBudgetUsd")}

+ ) : ( +

+ What {values.name.trim() || "it"} may cost in a calendar month. When it's used up, its automations, follow-ups and board tickets wait for you — chats you start still run. +

+ )} +
diff --git a/apps/desktop/src/components/agents/detail/overview-tab.tsx b/apps/desktop/src/components/agents/detail/overview-tab.tsx index c130cb79..d9a1195d 100644 --- a/apps/desktop/src/components/agents/detail/overview-tab.tsx +++ b/apps/desktop/src/components/agents/detail/overview-tab.tsx @@ -13,6 +13,8 @@ import { RunDetailSheet } from "@/components/runs/run-detail-sheet"; import { formatCost, formatDuration } from "@/components/runs/run-status"; import { RunTaskDialog } from "../agent-actions"; import { Plate } from "./plate"; +import { useBudgets } from "@/lib/hooks"; +import { BudgetMeter } from "@/components/budgets/budget-meter"; export function OverviewTab({ agent }: { agent: Agent }) { const runsQ = useRuns(agent.id); @@ -50,6 +52,7 @@ export function OverviewTab({ agent }: { agent: Agent }) { return (
setRunTask(true)} /> +
@@ -176,3 +179,29 @@ function Sparkbars({ values }: { values: number[] }) {
); } + +/** The agent's monthly budget (only when it has one, or the team's is used up). */ +function AgentBudget({ agent }: { agent: Agent }) { + const { data } = useBudgets(); + if (!data) return null; + const own = data.agents.find((b) => b.agentId === agent.id); + const teamOut = data.team.state === "exhausted"; + if (!own?.budgetUsd && !teamOut) return null; + return ( +
+

Budget

+ {own?.budgetUsd ? ( + + ) : null} + {teamOut && ( +

+ The team's budget for this month is used up too —{" "} + + see Settings + + . +

+ )} +
+ ); +} diff --git a/apps/desktop/src/components/budgets/budget-meter.tsx b/apps/desktop/src/components/budgets/budget-meter.tsx new file mode 100644 index 00000000..53f5211b --- /dev/null +++ b/apps/desktop/src/components/budgets/budget-meter.tsx @@ -0,0 +1,92 @@ +import { Link } from "react-router"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { format } from "date-fns"; +import { Play } from "lucide-react"; +import type { BudgetReleaseInput, BudgetStatus } from "@godmode/shared"; +import { formatUsd } from "@godmode/shared"; +import { api, errorMessage } from "@/lib/api"; +import { qk } from "@/lib/queryKeys"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; + +/** "Spent $12.40 of $50.00 this month" with a bar; used up: what waits, and the way on. */ +export function BudgetMeter({ + status, + resetsAt, + whose, + setLink, + release, + className, +}: { + status: BudgetStatus; + resetsAt: string; + /** "the team's" / "Mia's" */ + whose: string; + /** Where the budget is set (no budget: "Set a budget"). */ + setLink?: string; + release?: BudgetReleaseInput; + className?: string; +}) { + const qc = useQueryClient(); + const letRun = useMutation({ + mutationFn: () => api.budgets.release(release!), + onSuccess: ({ continued }) => { + qc.invalidateQueries({ queryKey: qk.spend }); + qc.invalidateQueries({ queryKey: qk.runs }); + toast.success(continued === 1 ? "1 run continues" : `${continued} runs continue`); + }, + onError: (err) => toast.error("Couldn't continue them", { description: errorMessage(err) }), + }); + const { budgetUsd, spentUsd, state, held } = status; + if (budgetUsd === null) { + return ( +

+ Spent {formatUsd(spentUsd)} this month.{" "} + {setLink && ( + + Set a budget + + )} +

+ ); + } + const share = Math.min(1, spentUsd / budgetUsd); + const sentence = `Spent ${formatUsd(spentUsd)} of ${formatUsd(budgetUsd)} this month`; + return ( +
+
+ + Spent {formatUsd(spentUsd)} of {formatUsd(budgetUsd)} this month + + {state === "warning" && {Math.floor(share * 100)}% used} + Resets {format(new Date(resetsAt), "MMM d")} +
+
+
+
+ {state === "exhausted" && ( +
+ + Used up — {whose} automations, follow-ups and board tickets wait. Chats you start still run. + {held > 0 && {held === 1 ? "1 run is held." : `${held} runs are held.`}} + + {held > 0 && release && ( + + )} + {setLink && ( + + )} +
+ )} +
+ ); +} diff --git a/apps/desktop/src/components/chat/pause.tsx b/apps/desktop/src/components/chat/pause.tsx index 481d2129..cc6a3115 100644 --- a/apps/desktop/src/components/chat/pause.tsx +++ b/apps/desktop/src/components/chat/pause.tsx @@ -1,7 +1,9 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { format, formatDistanceStrict, isToday } from "date-fns"; import type { ConversationWithMessages, MessageBlock, RunPause } from "@godmode/shared"; -import { ArrowUp, Hourglass, MessageCircleQuestion, Pause, Play, ShieldCheck, Square } from "lucide-react"; +import { budgetPauseTitle } from "@godmode/shared"; +import { Link } from "react-router"; +import { ArrowUp, Coins, Hourglass, MessageCircleQuestion, Pause, Play, ShieldCheck, Square } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; @@ -58,6 +60,7 @@ export function usePauseActions(conversationId: string) { /** Above the composer while the chat's run stands still: what it waits for, and the way on. */ export function PauseBar({ conversationId, pause, agentName, queued }: { conversationId: string; pause: RunPause; agentName: string; queued: number }) { if (pause.reason === "question") return ; + if (pause.reason === "budget") return ; return ; } @@ -111,6 +114,43 @@ function QuestionBar({ conversationId, pause, agentName }: { conversationId: str ); } +/** Held because a monthly budget is used up: it goes on next month or when the budget has room — or now, if the human says so. */ +function HeldBar({ conversationId, pause, agentName, queued }: { conversationId: string; pause: RunPause; agentName: string; queued: number }) { + const { resume, stop } = usePauseActions(conversationId); + const team = pause.budget?.scope === "team"; + const along = queued > 0 ? ` Your ${queued > 1 ? `${queued} messages go` : "message goes"} along.` : ""; + return ( +
+ + + +
+

{pause.budget ? budgetPauseTitle(pause.budget, agentName, pause.pausedAt) : "Held — a monthly budget is used up"}

+

+ {pause.auto && pause.resumeAt ? `Continues by itself ${followupWhen(pause.resumeAt)}, or when you raise the budget.` : "Raise the budget or let it run."} + {along} +

+
+
+ + + + + + Run it although the budget is used up + + +
+
+ ); +} + function StandStillBar({ conversationId, pause, agentName, queued }: { conversationId: string; pause: RunPause; agentName: string; queued: number }) { const now = useNow(30_000); const { resume, auto, stop } = usePauseActions(conversationId); @@ -191,14 +231,15 @@ type PauseBlock = Extract; /** Where a run stood still, inside the turn it belongs to. */ export function PauseMarker({ block }: { block: PauseBlock }) { const limit = block.reason === "limit"; - const Icon = limit ? Hourglass : Pause; + const held = block.reason === "budget"; + const Icon = limit ? Hourglass : held ? Coins : Pause; const time = (iso: string) => format(new Date(iso), "HH:mm"); return (
- - {limit ? `${limitTitle(block.limit)} reached` : "Paused"} + + {limit ? `${limitTitle(block.limit)} reached` : held ? "Held · budget used up" : "Paused"} {block.resumedAt ? ( diff --git a/apps/desktop/src/components/layout/recent-chats.tsx b/apps/desktop/src/components/layout/recent-chats.tsx index db8af64a..c1d53f4e 100644 --- a/apps/desktop/src/components/layout/recent-chats.tsx +++ b/apps/desktop/src/components/layout/recent-chats.tsx @@ -92,7 +92,13 @@ export function RecentChats() { )} - {c.paused.reason === "user" ? "Paused" : c.paused.auto && c.paused.resumeAt ? `Continues ${followupWhen(c.paused.resumeAt)}` : "Waiting for the limit"} + {c.paused.reason === "user" + ? "Paused" + : c.paused.reason === "budget" + ? "Held — budget used up" + : c.paused.auto && c.paused.resumeAt + ? `Continues ${followupWhen(c.paused.resumeAt)}` + : "Waiting for the limit"} ) : c.followup ? ( diff --git a/apps/desktop/src/components/runs/spend-strip.tsx b/apps/desktop/src/components/runs/spend-strip.tsx new file mode 100644 index 00000000..1d95920a --- /dev/null +++ b/apps/desktop/src/components/runs/spend-strip.tsx @@ -0,0 +1,193 @@ +import { useRef, type KeyboardEvent } from "react"; +import { RefreshCw } from "lucide-react"; +import type { Agent, SpendPeriod, SpendTotals } from "@godmode/shared"; +import { SPEND_KIND_LABELS, formatUsd } from "@godmode/shared"; +import { useBudgets, useSpend } from "@/lib/hooks"; +import { errorMessage } from "@/lib/api"; +import { cn } from "@/lib/utils"; +import { AgentAvatar } from "@/components/common"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { BudgetMeter } from "@/components/budgets/budget-meter"; +import { formatDuration } from "./run-status"; + +const PERIODS: { id: SpendPeriod; label: string; none: string }[] = [ + { id: "today", label: "Today", none: "No runs yet today" }, + { id: "week", label: "This week", none: "No runs this week yet" }, + { id: "month", label: "This month", none: "No runs this month yet" }, + { id: "all", label: "All time", none: "No runs yet" }, +]; + +const line = (t: SpendTotals) => [`${t.runs.toLocaleString()} run${t.runs === 1 ? "" : "s"}`, t.failed ? `${t.failed} failed` : null, t.durationMs ? `${formatDuration(t.durationMs)} working` : null].filter(Boolean).join(" · "); + +/** + * What the team (or the agent in view) cost: today, this week, this month and all time, booked when it was spent — and + * for the chosen period, who and what kind of work it went to. Chooses with arrow keys like a radio group. + */ +export function SpendStrip({ + period, + onPeriod, + agentId, + agentById, + onAgent, +}: { + period: SpendPeriod; + onPeriod: (p: SpendPeriod) => void; + agentId: string | null; + agentById: Map; + onAgent: (id: string) => void; +}) { + const report = useSpend(period, agentId); + const budgets = useBudgets(); + const tiles = useRef<(HTMLButtonElement | null)[]>([]); + const data = report.data; + const budget = agentId ? budgets.data?.agents.find((b) => b.agentId === agentId) : budgets.data?.team; + const agentName = agentId ? (agentById.get(agentId)?.name ?? "This agent") : null; + + const onKey = (e: KeyboardEvent, i: number) => { + const next = e.key === "ArrowRight" || e.key === "ArrowDown" ? i + 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? i - 1 : null; + if (next === null) return; + e.preventDefault(); + const p = PERIODS[(next + PERIODS.length) % PERIODS.length]!; + onPeriod(p.id); + tiles.current[(next + PERIODS.length) % PERIODS.length]?.focus(); + }; + + const maxAgent = Math.max(0.000001, ...(data?.byAgent ?? []).map((a) => a.costUsd)); + const maxKind = Math.max(0.000001, ...(data?.byKind ?? []).map((k) => k.costUsd)); + + return ( +
+
+ {PERIODS.map((p, i) => { + const t = data?.periods[p.id]; + const selected = p.id === period; + const showBudget = p.id === "month" && budget && budget.budgetUsd !== null; + return ( + + ); + })} +
+ {report.isError && ( +

+ Couldn't load totals: {errorMessage(report.error)} + +

+ )} + + {budget && budget.budgetUsd !== null && budget.state === "exhausted" && budgets.data && ( +
+ +
+ )} + + {data && (data.byAgent.length > 0 || data.byKind.length > 0) && ( +
+ {!agentId && ( +
+

By agent

+
    + {data.byAgent.slice(0, 8).map((a) => { + const agent = agentById.get(a.agentId); + return ( +
  • + +
  • + ); + })} +
+ {data.byAgent.length > 8 &&

and {data.byAgent.length - 8} more

} +
+ )} +
+

By kind of work

+
    + {data.byKind.map((k) => ( +
  • + {SPEND_KIND_LABELS[k.kind]} + + + + + {formatUsd(k.costUsd)} · {k.runs} run{k.runs === 1 ? "" : "s"} + +
  • + ))} +
+
+
+ )} + {data && data.byAgent.length === 0 && !report.isLoading &&

Nothing ran in this period.

} +
+ ); +} diff --git a/apps/desktop/src/components/settings/ai-section.tsx b/apps/desktop/src/components/settings/ai-section.tsx index c7e4aaf9..afa5a901 100644 --- a/apps/desktop/src/components/settings/ai-section.tsx +++ b/apps/desktop/src/components/settings/ai-section.tsx @@ -17,7 +17,8 @@ import { Badge } from "@/components/ui/badge"; import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { InlineCode } from "@/components/onboarding/doctor-checklist"; -import { useModelCatalog } from "@/lib/hooks"; +import { useBudgets, useModelCatalog } from "@/lib/hooks"; +import { BudgetMeter } from "@/components/budgets/budget-meter"; import { cn } from "@/lib/utils"; import { Callout, @@ -270,6 +271,24 @@ export function AiSection({ settings }: { settings: Settings }) { onCommit={(v) => patch({ runner: { defaultMaxBudgetUsd: v === 0 ? null : v } })} /> + + patch({ runner: { monthlyBudgetUsd: v === 0 ? null : v } })} + /> + + }> @@ -320,3 +339,14 @@ export function AiSection({ settings }: { settings: Settings }) {
); } + +/** What the team spent this month against its budget, under the field that sets it. */ +function TeamBudget() { + const { data } = useBudgets(); + if (!data) return null; + return ( +
+ +
+ ); +} diff --git a/apps/desktop/src/components/tasks/task-meta.tsx b/apps/desktop/src/components/tasks/task-meta.tsx index c889aebe..ae094bc5 100644 --- a/apps/desktop/src/components/tasks/task-meta.tsx +++ b/apps/desktop/src/components/tasks/task-meta.tsx @@ -95,6 +95,7 @@ export function pauseLabel(task: Task): string | null { const title = task.pause.question?.title ?? ""; return `${task.pause.question?.kind === "approval" ? "Needs your OK" : "Needs your answer"}${title ? `: ${title}` : ""}`; } + if (task.pause.reason === "budget") return task.pause.budget?.scope === "team" ? "Held — the team's budget is used up" : "Held — its agent's budget is used up"; return task.pause.reason === "limit" ? `Waiting — Claude's ${task.pause.limit ?? "usage limit"} is reached` : "Paused"; } diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 468207d7..9495475b 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -1,4 +1,8 @@ import type { + BudgetOverview, + BudgetReleaseInput, + SpendPeriod, + SpendReport, Agent, AgentFileEntry, AgentInput, @@ -396,6 +400,17 @@ export const api = { }, }, + spend: { + /** What the team (or one agent) cost, booked when it was spent: four period totals and the chosen period per agent and kind of work. */ + report: (period: SpendPeriod, agentId?: string | null) => get("/api/spend", { period, agentId: agentId ?? undefined }), + }, + + budgets: { + get: () => get("/api/budgets"), + /** Let held work run although its budget is used up. */ + release: (input: BudgetReleaseInput) => post<{ continued: number }>("/api/budgets/release", input), + }, + agents: { list: (q: { workspaceId?: ScopeFilter } = {}) => get("/api/agents", q), get: (id: string) => get(`/api/agents/${id}`), diff --git a/apps/desktop/src/lib/hooks.ts b/apps/desktop/src/lib/hooks.ts index 5161d622..a7dbf7dd 100644 --- a/apps/desktop/src/lib/hooks.ts +++ b/apps/desktop/src/lib/hooks.ts @@ -1,3 +1,4 @@ +import type { SpendPeriod } from "@godmode/shared"; import { useQuery } from "@tanstack/react-query"; import { BUILTIN_MODELS, type ModelCatalog } from "@godmode/shared"; import { api, type ScopeFilter } from "./api"; @@ -79,6 +80,14 @@ export function useVaultStatus() { } /** What agents asked the human; "open" ones keep a run standing still until they are answered. */ +export function useSpend(period: SpendPeriod, agentId?: string | null) { + return useQuery({ queryKey: qk.spendReport(period, agentId ?? null), queryFn: () => api.spend.report(period, agentId) }); +} + +export function useBudgets() { + return useQuery({ queryKey: qk.budgets, queryFn: api.budgets.get }); +} + export function useQuestions(status = "open") { return useQuery({ queryKey: [...qk.questions, status], queryFn: () => api.questions.list({ status }) }); } diff --git a/apps/desktop/src/lib/queryKeys.ts b/apps/desktop/src/lib/queryKeys.ts index f160e583..284b7f5d 100644 --- a/apps/desktop/src/lib/queryKeys.ts +++ b/apps/desktop/src/lib/queryKeys.ts @@ -57,6 +57,10 @@ export const qk = { /** Times agents set to continue their chats on their own. */ followups: ["followups"] as unknown[], runs: ["runs"] as unknown[], + /** Spend and budgets (refreshed when runs end or stand still, and when settings or agents change). */ + spend: ["spend"] as unknown[], + spendReport: (period: string, agentId: string | null) => ["spend", "report", period, agentId ?? "all"] as const, + budgets: ["spend", "budgets"] as unknown[], /** Runs handed over by a run (under `runs`, so run events refresh them). */ runChildren: (runId: string) => ["runs", "children", runId] as const, runList: (agentId: string, status: string) => ["runs", "list", agentId, status], diff --git a/apps/desktop/src/lib/realtime.ts b/apps/desktop/src/lib/realtime.ts index 0b427d40..dd841cce 100644 --- a/apps/desktop/src/lib/realtime.ts +++ b/apps/desktop/src/lib/realtime.ts @@ -38,7 +38,7 @@ function resync(runId: string) { const ENTITY_KEYS: Record = { workspaces: [qk.workspaces], - agents: [qk.agents], + agents: [qk.agents, qk.spend], routines: [qk.routines], credentials: [qk.credentials], totp: [qk.totp], @@ -49,7 +49,7 @@ const ENTITY_KEYS: Record = { "missing-logins": [qk.missingLogins, qk.bootstrap], questions: [qk.questions, qk.bootstrap], notifications: [qk.notifications, qk.bootstrap], - settings: [qk.settings, qk.bootstrap], + settings: [qk.settings, qk.bootstrap, qk.spend], runs: [qk.runs], models: [qk.models], computer: [qk.computer], @@ -164,6 +164,7 @@ function handle(qc: QueryClient, event: ServerEvent) { qc.invalidateQueries({ queryKey: qk.conversationsAll }); qc.invalidateQueries({ queryKey: qk.runs }); qc.invalidateQueries({ queryKey: qk.agents }); + qc.invalidateQueries({ queryKey: qk.spend }); break; case "run.finished": live.runFinished(event.run); @@ -171,6 +172,7 @@ function handle(qc: QueryClient, event: ServerEvent) { qc.invalidateQueries({ queryKey: qk.conversationsAll }); qc.invalidateQueries({ queryKey: qk.runs }); qc.invalidateQueries({ queryKey: qk.agents }); + qc.invalidateQueries({ queryKey: qk.spend }); qc.invalidateQueries({ queryKey: qk.bootstrap }); break; case "message.created": diff --git a/apps/desktop/src/pages/activity/activity-page.tsx b/apps/desktop/src/pages/activity/activity-page.tsx index 44ea2dbb..7e3a1603 100644 --- a/apps/desktop/src/pages/activity/activity-page.tsx +++ b/apps/desktop/src/pages/activity/activity-page.tsx @@ -9,6 +9,7 @@ import { errorMessage } from "@/lib/api"; import { useLive, type LiveRun } from "@/stores/live"; import { cn } from "@/lib/utils"; import { AgentAvatar, EmptyState, PageBody, PageHeader } from "@/components/common"; +import { SpendStrip } from "@/components/runs/spend-strip"; import { Orb } from "@/components/aicss/Orb"; import { LiveDot, WorkingTicks } from "@/components/aicss/Motion"; import { Button } from "@/components/ui/button"; @@ -114,7 +115,7 @@ export default function ActivityPage() { } title="Activity" - description="Every run across your agents — live, automated and delegated." + description="Every run across your agents — and what it cost." actions={