diff --git a/src/electron/main.ts b/src/electron/main.ts index 93eea0d..e38985f 100644 --- a/src/electron/main.ts +++ b/src/electron/main.ts @@ -116,6 +116,12 @@ void app.whenReady().then(async () => { app, ...(agentLiteHomeDir ? { agentLiteHomeDir } : {}), onAgentIdle: workflowCoordinator.onAgentIdle, + onBudgetExceeded: (payload) => { + broadcast(ipcChannels.budgetExceeded, payload); + }, + onBudgetWarning: (payload) => { + broadcast(ipcChannels.budgetWarning, payload); + }, onItemActivityChanged: (payload) => { broadcast(ipcChannels.itemActivityUpdated, payload); }, diff --git a/src/electron/main/ipc/register-main-ipc-handlers.ts b/src/electron/main/ipc/register-main-ipc-handlers.ts index f255596..52f388e 100644 --- a/src/electron/main/ipc/register-main-ipc-handlers.ts +++ b/src/electron/main/ipc/register-main-ipc-handlers.ts @@ -142,6 +142,23 @@ export function registerMainIpcHandlers(options: RegisterMainIpcHandlersOptions) async (_event, agentId: string) => withRuntime((runtimeController) => runtimeController.selectAgent(agentId)), ); + ipcMain.handle(ipcChannels.getBudget, async (_event, agentId: string) => { + try { + return await withRuntime((runtimeController) => runtimeController.getBudget(agentId)); + } catch { + return null; + } + }); + ipcMain.handle( + ipcChannels.setBudget, + async (_event, agentId: string, config: Record) => + withRuntime((runtimeController) => runtimeController.setBudget(agentId, config)), + ); + ipcMain.handle( + ipcChannels.resumeBudget, + async (_event, agentId: string) => + withRuntime((runtimeController) => runtimeController.resumeBudget(agentId)), + ); ipcMain.handle( ipcChannels.updateAgentChannel, async (_event, input) => diff --git a/src/electron/main/runtime/agent-runtime/index.ts b/src/electron/main/runtime/agent-runtime/index.ts index ecf804e..dba90dc 100644 --- a/src/electron/main/runtime/agent-runtime/index.ts +++ b/src/electron/main/runtime/agent-runtime/index.ts @@ -434,6 +434,8 @@ export interface AgentRuntimeOptions { loadAgentLiteModule?: () => Promise; now?: () => number; onAgentIdle?: (agentId: string) => void; + onBudgetExceeded?: (payload: unknown) => void; + onBudgetWarning?: (payload: unknown) => void; onItemActivityChanged?: (payload: { itemId: string; isWorking: boolean }) => void; resolveProjectName?: (projectId: string) => Promise; resolveProjectRootPath?: (projectId: string) => Promise; @@ -497,6 +499,10 @@ export class AgentRuntime implements AgentRuntimeContract { private readonly onAgentIdle: AgentRuntimeOptions['onAgentIdle']; + private readonly onBudgetExceeded: AgentRuntimeOptions['onBudgetExceeded']; + + private readonly onBudgetWarning: AgentRuntimeOptions['onBudgetWarning']; + private readonly onItemActivityChanged: AgentRuntimeOptions['onItemActivityChanged']; /** Per-item ephemeral run state driven by AgentLite task.run.* events. */ @@ -539,6 +545,8 @@ export class AgentRuntime implements AgentRuntimeContract { this.actionServices = options.actionServices; this.homeDir = options.homeDir ?? os.homedir(); this.onAgentIdle = options.onAgentIdle; + this.onBudgetExceeded = options.onBudgetExceeded; + this.onBudgetWarning = options.onBudgetWarning; this.onItemActivityChanged = options.onItemActivityChanged; this.runtimeRoot = resolveAgentLiteRuntimeRoot(options.homeDir); this.bundledAgentDir = options.bundledAgentDir ?? resolveBundledAgentDir(); @@ -617,6 +625,7 @@ export class AgentRuntime implements AgentRuntimeContract { this.telegram.getSetupSession(sessionId), getSnapshot: () => this.getSnapshot(), listAgents: () => this.getSnapshot().agents, + callAction: async (name, input) => this.callAgentLiteAction(name, input), selectAgent: (agentId) => { this.selectAgent(agentId); }, @@ -2354,6 +2363,37 @@ export class AgentRuntime implements AgentRuntimeContract { } } + private async callAgentLiteAction( + name: string, + input: Record, + ): Promise { + const groupJid = typeof input.group_jid === 'string' ? input.group_jid : null; + const agentId = groupJid + ? toAgentPathId(groupJid) + : this.snapshot.selectedAgentId ?? this.snapshot.agents[0]?.id ?? null; + + if (!agentId) { + return null; + } + + const record = this.records.get(agentId); + + if (!record) { + return null; + } + + const duneAgent = await this.ensureAgentRuntime(record); + const alAgent = duneAgent.agentLiteAgent as AgentLiteAgent & { + callAction?: (actionName: string, actionInput: Record) => Promise; + }; + + if (typeof alAgent.callAction !== 'function') { + return null; + } + + return alAgent.callAction(name, input); + } + private async ensureAgentRuntime(record: PersistedAgentRecord): Promise { const agentId = record.agent.id; const existing = this.lifecycle.getRuntime(agentId); @@ -2457,6 +2497,22 @@ export class AgentRuntime implements AgentRuntimeContract { }); }); + alAgent.on('budget.exceeded', (event: Record) => { + this.onBudgetExceeded?.({ + agentId, + jid: toAgentChatJid(agentId), + ...event, + }); + }); + + alAgent.on('budget.warning', (event: Record) => { + this.onBudgetWarning?.({ + agentId, + jid: toAgentChatJid(agentId), + ...event, + }); + }); + alAgent.on('run.status', (event) => { this.pushActivityEvent(agentId, { id: `act-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, diff --git a/src/electron/main/runtime/desktop-runtime-controller.ts b/src/electron/main/runtime/desktop-runtime-controller.ts index 11a0f8d..61808b1 100644 --- a/src/electron/main/runtime/desktop-runtime-controller.ts +++ b/src/electron/main/runtime/desktop-runtime-controller.ts @@ -5,6 +5,7 @@ import type { AgentServiceListener, AgentServiceSnapshot, } from '@/shared/agents/agent-runtime'; +import { toAgentChatJid } from '@/shared/agents/agent-id'; import { createMockAgentRuntime } from '@/renderer/features/agents/services/mock-agent-service'; import type { AgentDefinition, @@ -164,6 +165,38 @@ export class DesktopRuntimeController { await this.activeRuntime.service.cancelItemAssignment(agentId, taskId); } + /** Gets budget config and state for an agent. */ + async getBudget(agentId: string): Promise { + try { + if (typeof this.activeRuntime.service.callAction !== 'function') return null; + return await this.activeRuntime.service.callAction('budget_get', { + group_jid: toAgentChatJid(agentId), + }); + } catch { + return null; + } + } + + /** Sets budget config for an agent. */ + async setBudget( + agentId: string, + config: { daily_limit_usd?: number | null; total_limit_usd?: number | null; reset_hour?: number }, + ): Promise { + if (typeof this.activeRuntime.service.callAction !== 'function') return; + await this.activeRuntime.service.callAction('budget_set', { + group_jid: toAgentChatJid(agentId), + ...config, + }); + } + + /** Resumes a paused agent. */ + async resumeBudget(agentId: string): Promise { + if (typeof this.activeRuntime.service.callAction !== 'function') return; + await this.activeRuntime.service.callAction('budget_resume', { + group_jid: toAgentChatJid(agentId), + }); + } + /** Returns true when the task still exists and remains active in agentlite. */ isItemTaskKnown(agentId: string, taskId: string): boolean { return this.activeRuntime.service.isItemTaskKnown(agentId, taskId); diff --git a/src/electron/main/runtime/runtime-bootstrap.ts b/src/electron/main/runtime/runtime-bootstrap.ts index 457fff8..5ab8a4f 100644 --- a/src/electron/main/runtime/runtime-bootstrap.ts +++ b/src/electron/main/runtime/runtime-bootstrap.ts @@ -12,6 +12,8 @@ interface RuntimeBootstrapOptions { agentStore: AppStorage; app: Pick; onAgentIdle: (agentId: string) => void; + onBudgetExceeded: (payload: unknown) => void; + onBudgetWarning: (payload: unknown) => void; onItemActivityChanged: (payload: { isWorking: boolean; itemId: string }) => void; onRuntimeSnapshot: (snapshot: AgentServiceSnapshot) => void; onStarted?: () => void; @@ -67,6 +69,8 @@ export function createRuntimeBootstrap(options: RuntimeBootstrapOptions) { bundledAgentDir: path.join(options.app.getAppPath(), 'agent'), ...(options.agentLiteHomeDir ? { homeDir: options.agentLiteHomeDir } : {}), onAgentIdle: options.onAgentIdle, + onBudgetExceeded: options.onBudgetExceeded, + onBudgetWarning: options.onBudgetWarning, onItemActivityChanged: options.onItemActivityChanged, resolveModelCredentials: () => resolveDefaultModelCredentials({ secretsStore: options.secretsStore, diff --git a/src/electron/preload.ts b/src/electron/preload.ts index 83312ed..8910d9e 100644 --- a/src/electron/preload.ts +++ b/src/electron/preload.ts @@ -30,6 +30,7 @@ const bridge: DesktopBridge = { ipcRenderer.invoke(ipcChannels.getProjectActivityPage, projectId, options), getAgentTranscriptPage: (agentId, options) => ipcRenderer.invoke(ipcChannels.getAgentTranscriptPage, agentId, options), + getBudget: (agentId) => ipcRenderer.invoke(ipcChannels.getBudget, agentId), getRuntimeSnapshot: () => ipcRenderer.invoke(ipcChannels.getRuntimeSnapshot), getTelegramSetupSession: (sessionId) => ipcRenderer.invoke(ipcChannels.getTelegramSetupSession, sessionId), @@ -41,10 +42,12 @@ const bridge: DesktopBridge = { ipcRenderer.invoke(ipcChannels.prepareProjectRootPath, rootPath, artifactFolderNames), reloadExternalChannels: () => ipcRenderer.invoke(ipcChannels.reloadExternalChannels), resetRuntime: () => ipcRenderer.invoke(ipcChannels.resetRuntime), + resumeBudget: (agentId) => ipcRenderer.invoke(ipcChannels.resumeBudget, agentId), restartApp: () => ipcRenderer.invoke(ipcChannels.restartApp), runIsolatedResearch: (agentId, input) => ipcRenderer.invoke(ipcChannels.runIsolatedResearch, agentId, input), selectAgent: (agentId) => ipcRenderer.invoke(ipcChannels.selectAgent, agentId), + setBudget: (agentId, config) => ipcRenderer.invoke(ipcChannels.setBudget, agentId, config), updateAgentChannel: (input) => ipcRenderer.invoke(ipcChannels.updateAgentChannel, input), updateAgentDefinition: (agentId, definition) => ipcRenderer.invoke(ipcChannels.updateAgentDefinition, agentId, definition), @@ -93,6 +96,34 @@ const bridge: DesktopBridge = { ipcRenderer.removeListener(ipcChannels.itemActivityUpdated, handler); }; }, + subscribeBudgetExceeded: (listener) => { + const handler = ( + _event: Electron.IpcRendererEvent, + payload: Parameters[0], + ) => { + listener(payload); + }; + + ipcRenderer.on(ipcChannels.budgetExceeded, handler); + + return () => { + ipcRenderer.removeListener(ipcChannels.budgetExceeded, handler); + }; + }, + subscribeBudgetWarning: (listener) => { + const handler = ( + _event: Electron.IpcRendererEvent, + payload: Parameters[0], + ) => { + listener(payload); + }; + + ipcRenderer.on(ipcChannels.budgetWarning, handler); + + return () => { + ipcRenderer.removeListener(ipcChannels.budgetWarning, handler); + }; + }, }; contextBridge.exposeInMainWorld('duneDesktop', Object.freeze(bridge)); diff --git a/src/renderer/features/agents/components/AgentPanel.tsx b/src/renderer/features/agents/components/AgentPanel.tsx index 18ddd9e..79e0189 100644 --- a/src/renderer/features/agents/components/AgentPanel.tsx +++ b/src/renderer/features/agents/components/AgentPanel.tsx @@ -1,16 +1,23 @@ // Agent panel UI. -import { type KeyboardEvent, type RefObject, useState } from 'react'; +import { type KeyboardEvent, type RefObject, useEffect, useState } from 'react'; import { ArrowUpRight } from 'lucide-react'; import { Wrench, Bot, Info } from 'lucide-react'; import { AgentMessageContent } from '@/renderer/features/agents/components/AgentMessageContent'; +import { BudgetExceededBanner } from '@/renderer/features/agents/components/BudgetExceededBanner'; +import { BudgetWarningBadge } from '@/renderer/features/agents/components/BudgetWarningBadge'; import { CodingEngineCard, groupEngineRuns } from '@/renderer/features/agents/components/CodingEngineCard'; import type { AgentActivityEvent, PresentedAgent } from '@/renderer/features/agents/types'; import { useDesktopPlatform } from '@/renderer/shared/lib/use-desktop-platform'; import { cn } from '@/renderer/shared/lib/utils'; import { Button } from '@/renderer/shared/ui/button'; +import type { + BudgetExceededPayload, + BudgetResult, + BudgetWarningPayload, +} from '@/shared/electron/desktop-bridge'; /** Agent panel props. */ interface AgentPanelProps { @@ -193,6 +200,37 @@ function formatUsageCost(costUsd: number) { return `$${costUsd.toFixed(costUsd >= 0.01 ? 4 : 6).replace(/0+$/, '').replace(/\.$/, '')}`; } +function deriveBudgetExceededPayload( + agentId: string, + budget: BudgetResult, +): BudgetExceededPayload | null { + if (!budget.state.paused) { + return null; + } + + const dailyPct = budget.usage.daily_pct ?? -1; + const totalPct = budget.usage.total_pct ?? -1; + const pausedReason = budget.state.paused_reason?.toLowerCase() ?? ''; + const limitType = pausedReason.includes('total') || totalPct > dailyPct ? 'total' : 'daily'; + const limitUsd = limitType === 'daily' + ? budget.config.daily_limit_usd + : budget.config.total_limit_usd; + const usedUsd = limitType === 'daily' + ? budget.usage.daily_cost_usd + : budget.usage.total_cost_usd; + + return { + agentId, + jid: `dune:agent:${agentId}`, + limitType, + limitUsd: limitUsd ?? usedUsd, + timestamp: budget.state.paused_at + ? new Date(budget.state.paused_at).toISOString() + : new Date().toISOString(), + usedUsd, + }; +} + /** Renders the agent panel UI. */ export function AgentPanel({ agent, @@ -206,6 +244,47 @@ export function AgentPanel({ }: AgentPanelProps) { const { modifierLabel } = useDesktopPlatform(); const composerHint = `${modifierLabel} Enter to send ยท Shift Enter for a new line`; + const [budgetExceeded, setBudgetExceeded] = useState(null); + const [budgetWarning, setBudgetWarning] = useState(null); + + useEffect(() => { + let isMounted = true; + + const loadBudget = async () => { + const budget = await window.duneDesktop?.getBudget?.(agent.id) ?? null; + + if (!isMounted || !budget) { + return; + } + + setBudgetExceeded(deriveBudgetExceededPayload(agent.id, budget)); + }; + + void loadBudget(); + + return () => { + isMounted = false; + }; + }, [agent.id]); + + useEffect(() => { + const unsubExceeded = window.duneDesktop?.subscribeBudgetExceeded?.((payload) => { + if (payload.jid === `dune:agent:${agent.id}`) { + setBudgetExceeded(payload); + setBudgetWarning(null); + } + }); + const unsubWarning = window.duneDesktop?.subscribeBudgetWarning?.((payload) => { + if (payload.jid === `dune:agent:${agent.id}`) { + setBudgetWarning(payload); + } + }); + + return () => { + unsubExceeded?.(); + unsubWarning?.(); + }; + }, [agent.id]); /** Handles key down composer. */ const handleComposerKeyDown = async ( @@ -248,6 +327,25 @@ export function AgentPanel({ + {budgetExceeded ? ( + { + setBudgetExceeded(null); + }} + usedUsd={budgetExceeded.usedUsd} + /> + ) : null} + + {budgetWarning && !budgetExceeded ? ( + + ) : null} + {buildTimeline(agent).map((item) => { if (item.type === 'message') { const { message } = item; diff --git a/src/renderer/features/agents/components/BudgetExceededBanner.tsx b/src/renderer/features/agents/components/BudgetExceededBanner.tsx new file mode 100644 index 0000000..73738c2 --- /dev/null +++ b/src/renderer/features/agents/components/BudgetExceededBanner.tsx @@ -0,0 +1,52 @@ +// Budget exceeded banner UI. + +import { AlertTriangle, RefreshCcw } from 'lucide-react'; + +import { Button } from '@/renderer/shared/ui/button'; + +interface BudgetExceededBannerProps { + agentId: string; + limitType: 'daily' | 'total'; + limitUsd: number; + onResume: () => void; + usedUsd: number; +} + +function formatUsd(value: number) { + return `$${value.toFixed(2)}`; +} + +/** Renders a budget exceeded pause banner. */ +export function BudgetExceededBanner({ + agentId, + limitType, + limitUsd, + onResume, + usedUsd, +}: BudgetExceededBannerProps) { + const resume = async () => { + await window.duneDesktop?.resumeBudget?.(agentId); + onResume(); + }; + + return ( +
+
+ +

+ Agent paused - {limitType} token budget exceeded ({formatUsd(usedUsd)} of {formatUsd(limitUsd)} used) +

+
+ +
+ ); +} diff --git a/src/renderer/features/agents/components/BudgetWarningBadge.tsx b/src/renderer/features/agents/components/BudgetWarningBadge.tsx new file mode 100644 index 0000000..fec87a0 --- /dev/null +++ b/src/renderer/features/agents/components/BudgetWarningBadge.tsx @@ -0,0 +1,44 @@ +// Budget warning badge UI. + +import { useEffect, useState } from 'react'; +import { AlertTriangle } from 'lucide-react'; + +interface BudgetWarningBadgeProps { + limitType: 'daily' | 'total'; + pctUsed: number; +} + +function formatPct(value: number) { + const pct = value <= 1 ? value * 100 : value; + + return `${Math.round(pct)}%`; +} + +/** Renders a dismissible budget warning badge. */ +export function BudgetWarningBadge({ limitType, pctUsed }: BudgetWarningBadgeProps) { + const [isVisible, setIsVisible] = useState(true); + + useEffect(() => { + setIsVisible(true); + const timeout = window.setTimeout(() => { + setIsVisible(false); + }, 30_000); + + return () => window.clearTimeout(timeout); + }, [limitType, pctUsed]); + + if (!isVisible) { + return null; + } + + return ( + + ); +} diff --git a/src/renderer/features/settings/components/BudgetSettings.tsx b/src/renderer/features/settings/components/BudgetSettings.tsx new file mode 100644 index 0000000..0579da1 --- /dev/null +++ b/src/renderer/features/settings/components/BudgetSettings.tsx @@ -0,0 +1,268 @@ +// Budget settings UI. + +import { useEffect, useMemo, useState } from 'react'; +import { PauseCircle, RefreshCcw, Save } from 'lucide-react'; + +import type { SettingsSectionComponentProps } from '@/renderer/features/settings/config/settings-sections'; +import { cn } from '@/renderer/shared/lib/utils'; +import { Button } from '@/renderer/shared/ui/button'; +import { Input } from '@/renderer/shared/ui/input'; +import type { BudgetConfig, BudgetResult } from '@/shared/electron/desktop-bridge'; + +import { SettingsSectionIntro } from './SettingsSectionIntro'; + +interface BudgetAgentCardProps { + agent: SettingsSectionComponentProps['agents'][number]; +} + +interface BudgetFormState { + dailyLimit: string; + resetHour: number; + totalLimit: string; +} + +const moneyFormatter = new Intl.NumberFormat('en-US', { + currency: 'USD', + maximumFractionDigits: 4, + minimumFractionDigits: 2, + style: 'currency', +}); + +function formatMoney(value: number) { + return moneyFormatter.format(value); +} + +function formatPct(value: number | null) { + if (value === null || !Number.isFinite(value)) { + return 'No limit'; + } + + const pct = value <= 1 ? value * 100 : value; + + return `${Math.round(pct)}%`; +} + +function limitToInput(value: number | null | undefined) { + return typeof value === 'number' && Number.isFinite(value) ? String(value) : ''; +} + +function parseLimit(value: string) { + const trimmed = value.trim(); + + if (!trimmed) { + return null; + } + + const parsed = Number(trimmed); + + return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; +} + +function createInitialForm(budget: BudgetResult | null): BudgetFormState { + return { + dailyLimit: limitToInput(budget?.config.daily_limit_usd), + resetHour: budget?.config.reset_hour ?? 0, + totalLimit: limitToInput(budget?.config.total_limit_usd), + }; +} + +function BudgetAgentCard({ agent }: BudgetAgentCardProps) { + const [budget, setBudget] = useState(null); + const [form, setForm] = useState(() => createInitialForm(null)); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [feedback, setFeedback] = useState(null); + + const loadBudget = async () => { + setIsLoading(true); + const nextBudget = await window.duneDesktop?.getBudget?.(agent.id) ?? null; + setBudget(nextBudget); + setForm(createInitialForm(nextBudget)); + setIsLoading(false); + }; + + useEffect(() => { + let isMounted = true; + + const load = async () => { + setIsLoading(true); + const nextBudget = await window.duneDesktop?.getBudget?.(agent.id) ?? null; + + if (!isMounted) { + return; + } + + setBudget(nextBudget); + setForm(createInitialForm(nextBudget)); + setIsLoading(false); + }; + + void load(); + + return () => { + isMounted = false; + }; + }, [agent.id]); + + const config = useMemo>(() => ({ + daily_limit_usd: parseLimit(form.dailyLimit), + reset_hour: form.resetHour, + total_limit_usd: parseLimit(form.totalLimit), + }), [form]); + + const dailyLimitLabel = budget?.config.daily_limit_usd === null || budget?.config.daily_limit_usd === undefined + ? 'No limit' + : formatMoney(budget.config.daily_limit_usd); + const totalLimitLabel = budget?.config.total_limit_usd === null || budget?.config.total_limit_usd === undefined + ? 'No limit' + : formatMoney(budget.config.total_limit_usd); + + const saveBudget = async () => { + setIsSaving(true); + setFeedback(null); + + try { + await window.duneDesktop?.setBudget?.(agent.id, config); + await loadBudget(); + setFeedback('Saved'); + } finally { + setIsSaving(false); + } + }; + + const resumeBudget = async () => { + await window.duneDesktop?.resumeBudget?.(agent.id); + await loadBudget(); + }; + + return ( +
+
+
+

{agent.name}

+

+ {isLoading ? 'Loading budget state...' : `Reset hour ${budget?.config.reset_hour ?? form.resetHour}:00`} +

+
+ {budget?.state.paused ? ( +
+ + Paused +
+ ) : null} +
+ +
+ + + +
+ +
+
+
Daily usage
+
+ {formatMoney(budget?.usage.daily_cost_usd ?? 0)} / {dailyLimitLabel} +
+
+ {formatPct(budget?.usage.daily_pct ?? null)} +
+
+
+
Total usage
+
+ {formatMoney(budget?.usage.total_cost_usd ?? 0)} / {totalLimitLabel} +
+
+ {formatPct(budget?.usage.total_pct ?? null)} +
+
+
+ +
+

+ {feedback ?? 'Empty limit fields are saved as no limit.'} +

+
+ {budget?.state.paused ? ( + + ) : null} + +
+
+
+ ); +} + +/** Renders the budget settings UI. */ +export function BudgetSettings({ agents }: SettingsSectionComponentProps) { + return ( + <> + + +
+ {agents.length > 0 ? ( + agents.map((agent) => ( + + )) + ) : ( +
+ Create an agent before setting token budgets. +
+ )} +
+ + ); +} diff --git a/src/renderer/features/settings/config/settings-sections.ts b/src/renderer/features/settings/config/settings-sections.ts index 6fbe04e..fe6fea5 100644 --- a/src/renderer/features/settings/config/settings-sections.ts +++ b/src/renderer/features/settings/config/settings-sections.ts @@ -4,6 +4,7 @@ import type { JSX } from 'react'; import { AppearanceSettings } from '@/renderer/features/settings/components/AppearanceSettings'; import { ArtifactsSettings } from '@/renderer/features/settings/components/ArtifactsSettings'; +import { BudgetSettings } from '@/renderer/features/settings/components/BudgetSettings'; import { ModelsSettings } from '@/renderer/features/settings/components/ModelsSettings'; import { NuclearSettings } from '@/renderer/features/settings/components/NuclearSettings'; import { NetworkSettings } from '@/renderer/features/settings/components/NetworkSettings'; @@ -66,6 +67,12 @@ export const settingsSections: SettingsSectionDefinition[] = [ description: 'Keyboard-first reference', Component: ShortcutsSettings, }, + { + id: 'budget', + title: 'Budget', + description: 'Token spend limits per agent', + Component: BudgetSettings, + }, { id: 'nuclear', title: 'Nuclear', diff --git a/src/renderer/features/settings/types.ts b/src/renderer/features/settings/types.ts index 5d5f72f..866b3dd 100644 --- a/src/renderer/features/settings/types.ts +++ b/src/renderer/features/settings/types.ts @@ -4,6 +4,7 @@ export type SettingsRoute = | 'appearance' | 'artifacts' + | 'budget' | 'models' | 'network' | 'shortcuts' diff --git a/src/shared/agents/agent-runtime.ts b/src/shared/agents/agent-runtime.ts index dae6b57..017406a 100644 --- a/src/shared/agents/agent-runtime.ts +++ b/src/shared/agents/agent-runtime.ts @@ -53,6 +53,7 @@ export interface AgentService { getTelegramSetupSession: (sessionId: string) => Promise; getSnapshot: () => AgentServiceSnapshot; listAgents: () => Agent[]; + callAction?: (name: string, input: Record) => Promise; selectAgent: (agentId: string) => void; runIsolatedResearch: ( agentId: string, diff --git a/src/shared/electron/desktop-bridge.ts b/src/shared/electron/desktop-bridge.ts index fa699ab..22a0f00 100644 --- a/src/shared/electron/desktop-bridge.ts +++ b/src/shared/electron/desktop-bridge.ts @@ -14,9 +14,64 @@ import type { import type { WorkflowProjectActivityPage } from '@/renderer/features/workflow/types'; import type { ProjectArtifactEntry } from '@/shared/workflow/project-artifacts'; +/** Budget config shape. */ +export interface BudgetConfig { + daily_limit_usd: number | null; + total_limit_usd: number | null; + reset_hour: number; +} + +/** Budget state shape. */ +export interface BudgetState { + paused: boolean; + paused_at: number | null; + paused_reason: string | null; +} + +/** Budget usage shape. */ +export interface BudgetUsage { + daily_cost_usd: number; + total_cost_usd: number; + daily_pct: number | null; + total_pct: number | null; +} + +/** Budget result shape from AgentLite budget_get action. */ +export interface BudgetResult { + config: BudgetConfig; + state: BudgetState; + usage: BudgetUsage; +} + +/** Budget exceeded event payload. */ +export interface BudgetExceededPayload { + agentId: string; + jid: string; + limitType: 'daily' | 'total'; + limitUsd: number; + usedUsd: number; + timestamp: string; +} + +/** Budget warning event payload. */ +export interface BudgetWarningPayload { + agentId: string; + jid: string; + pctUsed: number; + limitType: 'daily' | 'total'; + limitUsd: number; + usedUsd: number; + timestamp: string; +} + /** Methods are optional to support browser-only fallback (no Electron preload). */ export interface DesktopBridge { applyNetworkSettings?: () => Promise; + getBudget?: (agentId: string) => Promise; + setBudget?: (agentId: string, config: Partial) => Promise; + resumeBudget?: (agentId: string) => Promise; + subscribeBudgetExceeded?: (listener: (payload: BudgetExceededPayload) => void) => () => void; + subscribeBudgetWarning?: (listener: (payload: BudgetWarningPayload) => void) => () => void; cancelTelegramSetupSession?: (sessionId: string) => Promise; copyText?: (text: string) => Promise; platform: NodeJS.Platform; diff --git a/src/shared/electron/ipc-channels.ts b/src/shared/electron/ipc-channels.ts index 04d2db3..2ab087e 100644 --- a/src/shared/electron/ipc-channels.ts +++ b/src/shared/electron/ipc-channels.ts @@ -3,6 +3,8 @@ /** Shared IPC channel names. */ export const ipcChannels = { applyNetworkSettings: 'dune:runtime:apply-network-settings', + budgetExceeded: 'dune:budget:exceeded', + budgetWarning: 'dune:budget:warning', cancelTelegramSetupSession: 'dune:runtime:cancel-telegram-setup-session', copyText: 'dune:runtime:copy-text', createAgent: 'dune:runtime:create-agent', @@ -10,6 +12,7 @@ export const ipcChannels = { deleteAgent: 'dune:runtime:delete-agent', ensureProjectArtifactFolder: 'dune:runtime:ensure-project-artifact-folder', ensureProjectMainAgent: 'dune:runtime:ensure-project-main-agent', + getBudget: 'dune:budget:get', getAgentTranscriptPage: 'dune:runtime:get-agent-transcript-page', getProjectActivityPage: 'dune:workflow:get-project-activity-page', getRuntimeSnapshot: 'dune:runtime:get-snapshot', @@ -20,12 +23,14 @@ export const ipcChannels = { prepareProjectRootPath: 'dune:runtime:prepare-project-root-path', reloadExternalChannels: 'dune:runtime:reload-external-channels', resetRuntime: 'dune:runtime:reset', + resumeBudget: 'dune:budget:resume', restartApp: 'dune:runtime:restart-app', runIsolatedResearch: 'dune:runtime:run-isolated-research', runtimeSnapshotUpdated: 'dune:runtime:snapshot-updated', itemActivityUpdated: 'dune:workflow:item-activity-updated', selectAgent: 'dune:runtime:select-agent', sendAgentMessage: 'dune:runtime:send-agent-message', + setBudget: 'dune:budget:set', startTelegramSetupSession: 'dune:runtime:start-telegram-setup-session', selectProjectDirectory: 'dune:runtime:select-project-directory', updateAgentChannel: 'dune:runtime:update-agent-channel', diff --git a/tests/unit/src/electron/main/runtime/agent-runtime.test.ts b/tests/unit/src/electron/main/runtime/agent-runtime.test.ts index 22a9150..4f058e7 100644 --- a/tests/unit/src/electron/main/runtime/agent-runtime.test.ts +++ b/tests/unit/src/electron/main/runtime/agent-runtime.test.ts @@ -104,7 +104,7 @@ interface MockAgent { addChannel: ReturnType; channelDrivers: Map; getGroup: ReturnType; - getTask: ReturnType; + getTask: (taskId: string) => { status: string } | undefined; name: string; off: ReturnType; on: ReturnType; diff --git a/tests/unit/src/renderer/features/settings/config/settings-sections.test.ts b/tests/unit/src/renderer/features/settings/config/settings-sections.test.ts index 0dd712b..97a0580 100644 --- a/tests/unit/src/renderer/features/settings/config/settings-sections.test.ts +++ b/tests/unit/src/renderer/features/settings/config/settings-sections.test.ts @@ -6,7 +6,15 @@ import type { SettingsRoute } from '@/renderer/features/settings/types'; import { settingsSectionRegistry, settingsSections } from '@/renderer/features/settings/config/settings-sections'; -const allRoutes: SettingsRoute[] = ['appearance', 'models', 'network', 'shortcuts', 'nuclear']; +const allRoutes: SettingsRoute[] = [ + 'appearance', + 'artifacts', + 'budget', + 'models', + 'network', + 'shortcuts', + 'nuclear', +]; describe('settingsSectionRegistry', () => { it('has an entry for every SettingsRoute', () => { @@ -20,7 +28,9 @@ describe('settingsSectionRegistry', () => { 'appearance', 'models', 'network', + 'artifacts', 'shortcuts', + 'budget', 'nuclear', ]); });