Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
33 changes: 32 additions & 1 deletion apps/desktop/src/components/agents/agent-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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(),
Expand All @@ -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 },
Expand Down Expand Up @@ -201,6 +206,10 @@ function validate(v: AgentFormValues): Record<string, string> {
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<string>();
v.subagents.forEach((s, i) => {
const n = slugify(s.name);
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -647,6 +656,28 @@ export function AgentForm({
<p className="text-xs text-muted-foreground">A run stops when it would cost more than this.</p>
)}
</div>
<div className="grid gap-1.5 @md:max-w-xs">
<Label htmlFor="agent-month-budget">Monthly budget</Label>
<div className="relative">
<span className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-sm text-muted-foreground">$</span>
<Input
id="agent-month-budget"
inputMode="decimal"
value={values.monthlyBudgetUsd}
onChange={(e) => set("monthlyBudgetUsd", e.target.value.replace(",", "."))}
placeholder="No budget"
aria-invalid={!!err("monthlyBudgetUsd")}
className="pl-7"
/>
</div>
{err("monthlyBudgetUsd") ? (
<p className="text-xs text-destructive">{err("monthlyBudgetUsd")}</p>
) : (
<p className="text-xs text-muted-foreground">
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.
</p>
)}
</div>
</div>
</FormSection>

Expand Down
29 changes: 29 additions & 0 deletions apps/desktop/src/components/agents/detail/overview-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -50,6 +52,7 @@ export function OverviewTab({ agent }: { agent: Agent }) {
return (
<div className="space-y-6">
<Plate agent={agent} onRunTask={() => setRunTask(true)} />
<AgentBudget agent={agent} />
<RunTaskDialog agent={agent} open={runTask} onOpenChange={setRunTask} />

<div className="grid grid-cols-2 gap-3 @4xl:grid-cols-4">
Expand Down Expand Up @@ -176,3 +179,29 @@ function Sparkbars({ values }: { values: number[] }) {
</div>
);
}

/** 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 (
<section aria-label="Budget" className="rounded-xl border bg-card p-4 shadow-card">
<h2 className="mb-2 text-sm font-medium tracking-[-0.01em]">Budget</h2>
{own?.budgetUsd ? (
<BudgetMeter status={own} resetsAt={data.resetsAt} whose={`${agent.name}'s`} setLink={`/agents/${agent.id}/settings#permissions`} release={{ scope: "agent", agentId: agent.id }} />
) : null}
{teamOut && (
<p className={cn("text-[13px] text-muted-foreground", own?.budgetUsd && "mt-2")}>
The team's budget for this month is used up too —{" "}
<Link to="/settings/ai" className="underline-offset-2 hover:text-foreground hover:underline">
see Settings
</Link>
.
</p>
)}
</section>
);
}
92 changes: 92 additions & 0 deletions apps/desktop/src/components/budgets/budget-meter.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<p className={cn("text-[13px] text-muted-foreground", className)}>
Spent <span className="font-medium text-foreground tabular-nums">{formatUsd(spentUsd)}</span> this month.{" "}
{setLink && (
<Link to={setLink} className="underline-offset-2 hover:text-foreground hover:underline">
Set a budget
</Link>
)}
</p>
);
}
const share = Math.min(1, spentUsd / budgetUsd);
const sentence = `Spent ${formatUsd(spentUsd)} of ${formatUsd(budgetUsd)} this month`;
return (
<div className={cn("space-y-1.5", className)}>
<div className="flex flex-wrap items-baseline gap-x-2 text-[13px]">
<span className="tabular-nums">
Spent <span className="font-medium">{formatUsd(spentUsd)}</span> <span className="text-muted-foreground">of {formatUsd(budgetUsd)} this month</span>
</span>
{state === "warning" && <span className="rounded-[4px] bg-warning/10 px-1.5 text-[11px] font-medium text-warning tabular-nums">{Math.floor(share * 100)}% used</span>}
<span className="ml-auto text-xs text-muted-foreground">Resets {format(new Date(resetsAt), "MMM d")}</span>
</div>
<div role="meter" aria-valuemin={0} aria-valuemax={budgetUsd} aria-valuenow={Math.min(spentUsd, budgetUsd)} aria-valuetext={sentence} className="h-1.5 overflow-hidden rounded-full bg-muted">
<div
className={cn("h-full rounded-full transition-[width] duration-500", state === "exhausted" ? "bg-destructive" : state === "warning" ? "bg-warning" : "bg-foreground/60")}
style={{ width: `${Math.max(2, share * 100)}%` }}
/>
</div>
{state === "exhausted" && (
<div className="flex flex-wrap items-center gap-2 pt-0.5 text-xs text-muted-foreground">
<span className="min-w-0 flex-1 basis-60">
Used up — {whose} automations, follow-ups and board tickets wait. Chats you start still run.
{held > 0 && <span className="font-medium text-foreground"> {held === 1 ? "1 run is held." : `${held} runs are held.`}</span>}
</span>
{held > 0 && release && (
<Button size="xs" variant="outline" disabled={letRun.isPending} onClick={() => letRun.mutate()} title="Run them although the budget is used up">
{letRun.isPending ? <Spinner /> : <Play />} Let {held === 1 ? "it" : "them"} run
</Button>
)}
{setLink && (
<Button size="xs" variant="ghost" asChild>
<Link to={setLink}>Raise budget</Link>
</Button>
)}
</div>
)}
</div>
);
}
49 changes: 45 additions & 4 deletions apps/desktop/src/components/chat/pause.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 <QuestionBar conversationId={conversationId} pause={pause} agentName={agentName} />;
if (pause.reason === "budget") return <HeldBar conversationId={conversationId} pause={pause} agentName={agentName} queued={queued} />;
return <StandStillBar conversationId={conversationId} pause={pause} agentName={agentName} queued={queued} />;
}

Expand Down Expand Up @@ -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 (
<div className="mb-2 flex items-center gap-3 rounded-xl border border-warning/30 bg-card py-2 pr-2 pl-2.5 shadow-card" role="status">
<span className="grid size-8 shrink-0 place-items-center rounded-lg border border-warning/30 bg-warning/[0.08] text-warning">
<Coins className="size-4" aria-hidden />
</span>
<div className="min-w-0 flex-1 leading-snug">
<p className="truncate text-[13px] font-medium">{pause.budget ? budgetPauseTitle(pause.budget, agentName, pause.pausedAt) : "Held — a monthly budget is used up"}</p>
<p className="truncate text-xs text-muted-foreground">
{pause.auto && pause.resumeAt ? `Continues by itself ${followupWhen(pause.resumeAt)}, or when you raise the budget.` : "Raise the budget or let it run."}
{along}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button size="xs" variant="ghost" asChild className="hidden @lg:inline-flex">
<Link to={team ? "/settings/ai" : "/agents"}>Raise budget</Link>
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button size="xs" disabled={resume.isPending} onClick={() => resume.mutate()}>
{resume.isPending ? <Spinner /> : <Play className="fill-current" />} Let it run
</Button>
</TooltipTrigger>
<TooltipContent side="top">Run it although the budget is used up</TooltipContent>
</Tooltip>
<Button size="xs" variant="ghost" className="text-muted-foreground hover:text-destructive" disabled={stop.isPending} onClick={() => stop.mutate(pause.runId)}>
{stop.isPending ? <Spinner /> : <Square className="size-2.5 fill-current" />} Stop
</Button>
</div>
</div>
);
}

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);
Expand Down Expand Up @@ -191,14 +231,15 @@ type PauseBlock = Extract<MessageBlock, { type: "pause" }>;
/** 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 (
<div role="note" className="flex items-center gap-3 py-0.5 text-[11px] text-muted-foreground">
<span className="h-px flex-1 bg-border" />
<span className="inline-flex items-center gap-1.5 tabular-nums">
<Icon className={cn("size-3.5", limit ? "text-warning" : "fill-current text-foreground/70")} aria-hidden />
<span className="font-medium text-foreground">{limit ? `${limitTitle(block.limit)} reached` : "Paused"}</span>
<Icon className={cn("size-3.5", limit || held ? "text-warning" : "fill-current text-foreground/70")} aria-hidden />
<span className="font-medium text-foreground">{limit ? `${limitTitle(block.limit)} reached` : held ? "Held · budget used up" : "Paused"}</span>
<time dateTime={block.at}>{time(block.at)}</time>
{block.resumedAt ? (
<span>
Expand Down
8 changes: 7 additions & 1 deletion apps/desktop/src/components/layout/recent-chats.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,13 @@ export function RecentChats() {
<Pause className="size-3 shrink-0 fill-current" aria-hidden />
)}
<span className="truncate">
{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"}
</span>
</span>
) : c.followup ? (
Expand Down
Loading
Loading