stagent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (333) hide show
  1. package/LICENSE +191 -0
  2. package/README.md +399 -0
  3. package/components.json +21 -0
  4. package/dist/cli.js +171 -0
  5. package/drizzle.config.ts +12 -0
  6. package/next.config.mjs +15 -0
  7. package/package.json +114 -0
  8. package/postcss.config.mjs +8 -0
  9. package/public/icon-512.png +0 -0
  10. package/public/icon.svg +13 -0
  11. package/public/readme/home-workspace.png +0 -0
  12. package/public/readme/inbox-approvals.png +0 -0
  13. package/public/readme/workflow-blueprints.png +0 -0
  14. package/public/stagent-s-128.png +0 -0
  15. package/public/stagent-s-64.png +0 -0
  16. package/src/app/api/blueprints/[id]/instantiate/route.ts +27 -0
  17. package/src/app/api/blueprints/[id]/route.ts +39 -0
  18. package/src/app/api/blueprints/import/route.ts +68 -0
  19. package/src/app/api/blueprints/route.ts +29 -0
  20. package/src/app/api/command-palette/recent/route.ts +31 -0
  21. package/src/app/api/data/clear/route.ts +22 -0
  22. package/src/app/api/data/seed/route.ts +22 -0
  23. package/src/app/api/documents/[id]/file/route.ts +44 -0
  24. package/src/app/api/documents/[id]/route.ts +123 -0
  25. package/src/app/api/documents/route.ts +59 -0
  26. package/src/app/api/logs/stream/route.ts +101 -0
  27. package/src/app/api/notifications/[id]/route.ts +36 -0
  28. package/src/app/api/notifications/mark-all-read/route.ts +13 -0
  29. package/src/app/api/notifications/pending-approvals/route.ts +10 -0
  30. package/src/app/api/notifications/pending-approvals/stream/route.ts +101 -0
  31. package/src/app/api/notifications/route.ts +34 -0
  32. package/src/app/api/permissions/route.ts +46 -0
  33. package/src/app/api/profiles/[id]/route.ts +79 -0
  34. package/src/app/api/profiles/[id]/test/route.ts +42 -0
  35. package/src/app/api/profiles/import/route.ts +108 -0
  36. package/src/app/api/profiles/route.ts +50 -0
  37. package/src/app/api/projects/[id]/route.ts +72 -0
  38. package/src/app/api/projects/route.ts +53 -0
  39. package/src/app/api/schedules/[id]/route.ts +185 -0
  40. package/src/app/api/schedules/route.ts +117 -0
  41. package/src/app/api/settings/budgets/route.ts +24 -0
  42. package/src/app/api/settings/openai/route.ts +24 -0
  43. package/src/app/api/settings/route.ts +21 -0
  44. package/src/app/api/settings/test/route.ts +26 -0
  45. package/src/app/api/tasks/[id]/cancel/route.ts +21 -0
  46. package/src/app/api/tasks/[id]/execute/route.ts +90 -0
  47. package/src/app/api/tasks/[id]/logs/route.ts +95 -0
  48. package/src/app/api/tasks/[id]/output/route.ts +47 -0
  49. package/src/app/api/tasks/[id]/respond/route.ts +64 -0
  50. package/src/app/api/tasks/[id]/resume/route.ts +76 -0
  51. package/src/app/api/tasks/[id]/route.ts +77 -0
  52. package/src/app/api/tasks/assist/route.ts +35 -0
  53. package/src/app/api/tasks/route.ts +82 -0
  54. package/src/app/api/uploads/[id]/route.ts +81 -0
  55. package/src/app/api/uploads/cleanup/route.ts +7 -0
  56. package/src/app/api/uploads/route.ts +66 -0
  57. package/src/app/api/workflows/[id]/execute/route.ts +82 -0
  58. package/src/app/api/workflows/[id]/route.ts +133 -0
  59. package/src/app/api/workflows/[id]/status/route.ts +54 -0
  60. package/src/app/api/workflows/[id]/steps/[stepId]/retry/route.ts +22 -0
  61. package/src/app/api/workflows/route.ts +61 -0
  62. package/src/app/apple-icon.tsx +31 -0
  63. package/src/app/costs/page.tsx +256 -0
  64. package/src/app/dashboard/page.tsx +44 -0
  65. package/src/app/documents/[id]/page.tsx +46 -0
  66. package/src/app/documents/page.tsx +45 -0
  67. package/src/app/error.tsx +26 -0
  68. package/src/app/global-error.tsx +23 -0
  69. package/src/app/globals.css +733 -0
  70. package/src/app/icon.tsx +30 -0
  71. package/src/app/inbox/loading.tsx +15 -0
  72. package/src/app/inbox/page.tsx +35 -0
  73. package/src/app/layout.tsx +78 -0
  74. package/src/app/manifest.ts +32 -0
  75. package/src/app/monitor/page.tsx +37 -0
  76. package/src/app/page.tsx +162 -0
  77. package/src/app/profiles/[id]/edit/page.tsx +39 -0
  78. package/src/app/profiles/[id]/page.tsx +33 -0
  79. package/src/app/profiles/new/page.tsx +22 -0
  80. package/src/app/profiles/page.tsx +19 -0
  81. package/src/app/projects/[id]/page.tsx +134 -0
  82. package/src/app/projects/loading.tsx +17 -0
  83. package/src/app/projects/page.tsx +32 -0
  84. package/src/app/schedules/[id]/page.tsx +47 -0
  85. package/src/app/schedules/page.tsx +18 -0
  86. package/src/app/settings/loading.tsx +24 -0
  87. package/src/app/settings/page.tsx +27 -0
  88. package/src/app/tasks/[id]/page.tsx +45 -0
  89. package/src/app/tasks/new/page.tsx +27 -0
  90. package/src/app/workflows/[id]/edit/page.tsx +66 -0
  91. package/src/app/workflows/[id]/page.tsx +37 -0
  92. package/src/app/workflows/blueprints/[id]/page.tsx +40 -0
  93. package/src/app/workflows/blueprints/new/page.tsx +20 -0
  94. package/src/app/workflows/blueprints/page.tsx +11 -0
  95. package/src/app/workflows/new/page.tsx +36 -0
  96. package/src/app/workflows/page.tsx +18 -0
  97. package/src/components/charts/donut-ring.tsx +64 -0
  98. package/src/components/charts/mini-bar.tsx +75 -0
  99. package/src/components/charts/sparkline.tsx +107 -0
  100. package/src/components/costs/cost-dashboard.tsx +877 -0
  101. package/src/components/costs/cost-filters.tsx +179 -0
  102. package/src/components/dashboard/activity-feed.tsx +95 -0
  103. package/src/components/dashboard/greeting.tsx +30 -0
  104. package/src/components/dashboard/priority-queue.tsx +79 -0
  105. package/src/components/dashboard/quick-actions.tsx +62 -0
  106. package/src/components/dashboard/recent-projects.tsx +79 -0
  107. package/src/components/dashboard/stats-cards.tsx +114 -0
  108. package/src/components/documents/document-browser.tsx +235 -0
  109. package/src/components/documents/document-detail-view.tsx +367 -0
  110. package/src/components/documents/document-grid.tsx +78 -0
  111. package/src/components/documents/document-preview.tsx +68 -0
  112. package/src/components/documents/document-table.tsx +119 -0
  113. package/src/components/documents/document-upload-dialog.tsx +153 -0
  114. package/src/components/documents/types.ts +6 -0
  115. package/src/components/documents/utils.ts +57 -0
  116. package/src/components/monitoring/connection-indicator.tsx +14 -0
  117. package/src/components/monitoring/log-entry.tsx +79 -0
  118. package/src/components/monitoring/log-filters.tsx +57 -0
  119. package/src/components/monitoring/log-stream.tsx +144 -0
  120. package/src/components/monitoring/monitor-overview-wrapper.tsx +64 -0
  121. package/src/components/monitoring/monitor-overview.tsx +119 -0
  122. package/src/components/notifications/failure-action.tsx +38 -0
  123. package/src/components/notifications/inbox-list.tsx +165 -0
  124. package/src/components/notifications/message-response.tsx +196 -0
  125. package/src/components/notifications/notification-item.tsx +250 -0
  126. package/src/components/notifications/pending-approval-host.tsx +478 -0
  127. package/src/components/notifications/permission-action.tsx +37 -0
  128. package/src/components/notifications/permission-response-actions.tsx +126 -0
  129. package/src/components/notifications/unread-badge.tsx +35 -0
  130. package/src/components/profiles/profile-browser.tsx +117 -0
  131. package/src/components/profiles/profile-card.tsx +78 -0
  132. package/src/components/profiles/profile-detail-view.tsx +564 -0
  133. package/src/components/profiles/profile-form-view.tsx +480 -0
  134. package/src/components/profiles/profile-import-dialog.tsx +113 -0
  135. package/src/components/projects/project-card.tsx +58 -0
  136. package/src/components/projects/project-create-dialog.tsx +140 -0
  137. package/src/components/projects/project-detail.tsx +68 -0
  138. package/src/components/projects/project-edit-dialog.tsx +219 -0
  139. package/src/components/projects/project-list.tsx +108 -0
  140. package/src/components/schedules/schedule-create-dialog.tsx +403 -0
  141. package/src/components/schedules/schedule-detail-view.tsx +274 -0
  142. package/src/components/schedules/schedule-list.tsx +242 -0
  143. package/src/components/schedules/schedule-status-badge.tsx +16 -0
  144. package/src/components/settings/api-key-form.tsx +141 -0
  145. package/src/components/settings/auth-config-section.tsx +141 -0
  146. package/src/components/settings/auth-method-selector.tsx +67 -0
  147. package/src/components/settings/auth-status-badge.tsx +40 -0
  148. package/src/components/settings/auth-status-dot.tsx +59 -0
  149. package/src/components/settings/budget-guardrails-section.tsx +842 -0
  150. package/src/components/settings/data-management-section.tsx +141 -0
  151. package/src/components/settings/openai-runtime-section.tsx +104 -0
  152. package/src/components/settings/permissions-section.tsx +91 -0
  153. package/src/components/shared/app-sidebar.tsx +123 -0
  154. package/src/components/shared/card-skeleton.tsx +42 -0
  155. package/src/components/shared/command-palette.tsx +250 -0
  156. package/src/components/shared/confirm-dialog.tsx +52 -0
  157. package/src/components/shared/empty-state.tsx +24 -0
  158. package/src/components/shared/error-state.tsx +32 -0
  159. package/src/components/shared/form-section-card.tsx +33 -0
  160. package/src/components/shared/section-heading.tsx +14 -0
  161. package/src/components/shared/stagent-logo.tsx +21 -0
  162. package/src/components/shared/theme-toggle.tsx +46 -0
  163. package/src/components/tasks/ai-assist-panel.tsx +210 -0
  164. package/src/components/tasks/content-preview.tsx +89 -0
  165. package/src/components/tasks/empty-board.tsx +12 -0
  166. package/src/components/tasks/file-upload.tsx +120 -0
  167. package/src/components/tasks/kanban-board.tsx +275 -0
  168. package/src/components/tasks/kanban-column.tsx +75 -0
  169. package/src/components/tasks/skeleton-board.tsx +21 -0
  170. package/src/components/tasks/task-attachments.tsx +114 -0
  171. package/src/components/tasks/task-card.tsx +101 -0
  172. package/src/components/tasks/task-create-panel.tsx +360 -0
  173. package/src/components/tasks/task-detail-view.tsx +356 -0
  174. package/src/components/ui/alert-dialog.tsx +196 -0
  175. package/src/components/ui/badge.tsx +50 -0
  176. package/src/components/ui/button.tsx +71 -0
  177. package/src/components/ui/card.tsx +92 -0
  178. package/src/components/ui/checkbox.tsx +32 -0
  179. package/src/components/ui/command.tsx +184 -0
  180. package/src/components/ui/dialog.tsx +158 -0
  181. package/src/components/ui/dropdown-menu.tsx +257 -0
  182. package/src/components/ui/form.tsx +167 -0
  183. package/src/components/ui/input.tsx +21 -0
  184. package/src/components/ui/label.tsx +24 -0
  185. package/src/components/ui/popover.tsx +89 -0
  186. package/src/components/ui/progress.tsx +31 -0
  187. package/src/components/ui/radio-group.tsx +45 -0
  188. package/src/components/ui/scroll-area.tsx +58 -0
  189. package/src/components/ui/select.tsx +190 -0
  190. package/src/components/ui/separator.tsx +28 -0
  191. package/src/components/ui/sheet.tsx +143 -0
  192. package/src/components/ui/sidebar.tsx +726 -0
  193. package/src/components/ui/skeleton.tsx +13 -0
  194. package/src/components/ui/slider.tsx +63 -0
  195. package/src/components/ui/sonner.tsx +36 -0
  196. package/src/components/ui/switch.tsx +35 -0
  197. package/src/components/ui/table.tsx +116 -0
  198. package/src/components/ui/tabs.tsx +91 -0
  199. package/src/components/ui/textarea.tsx +18 -0
  200. package/src/components/ui/tooltip.tsx +57 -0
  201. package/src/components/workflows/blueprint-editor.tsx +109 -0
  202. package/src/components/workflows/blueprint-gallery.tsx +155 -0
  203. package/src/components/workflows/blueprint-preview.tsx +240 -0
  204. package/src/components/workflows/loop-status-view.tsx +272 -0
  205. package/src/components/workflows/swarm-dashboard.tsx +185 -0
  206. package/src/components/workflows/workflow-form-view.tsx +1376 -0
  207. package/src/components/workflows/workflow-list.tsx +230 -0
  208. package/src/components/workflows/workflow-status-view.tsx +477 -0
  209. package/src/hooks/use-mobile.ts +19 -0
  210. package/src/instrumentation.ts +7 -0
  211. package/src/lib/agents/claude-agent.ts +737 -0
  212. package/src/lib/agents/execution-manager.ts +27 -0
  213. package/src/lib/agents/profiles/assignment-validation.ts +75 -0
  214. package/src/lib/agents/profiles/builtins/code-reviewer/SKILL.md +21 -0
  215. package/src/lib/agents/profiles/builtins/code-reviewer/profile.yaml +28 -0
  216. package/src/lib/agents/profiles/builtins/data-analyst/SKILL.md +25 -0
  217. package/src/lib/agents/profiles/builtins/data-analyst/profile.yaml +27 -0
  218. package/src/lib/agents/profiles/builtins/devops-engineer/SKILL.md +34 -0
  219. package/src/lib/agents/profiles/builtins/devops-engineer/profile.yaml +27 -0
  220. package/src/lib/agents/profiles/builtins/document-writer/SKILL.md +16 -0
  221. package/src/lib/agents/profiles/builtins/document-writer/profile.yaml +27 -0
  222. package/src/lib/agents/profiles/builtins/general/SKILL.md +13 -0
  223. package/src/lib/agents/profiles/builtins/general/profile.yaml +18 -0
  224. package/src/lib/agents/profiles/builtins/health-fitness-coach/SKILL.md +34 -0
  225. package/src/lib/agents/profiles/builtins/health-fitness-coach/profile.yaml +26 -0
  226. package/src/lib/agents/profiles/builtins/learning-coach/SKILL.md +35 -0
  227. package/src/lib/agents/profiles/builtins/learning-coach/profile.yaml +26 -0
  228. package/src/lib/agents/profiles/builtins/project-manager/SKILL.md +26 -0
  229. package/src/lib/agents/profiles/builtins/project-manager/profile.yaml +26 -0
  230. package/src/lib/agents/profiles/builtins/researcher/SKILL.md +15 -0
  231. package/src/lib/agents/profiles/builtins/researcher/profile.yaml +27 -0
  232. package/src/lib/agents/profiles/builtins/shopping-assistant/SKILL.md +34 -0
  233. package/src/lib/agents/profiles/builtins/shopping-assistant/profile.yaml +26 -0
  234. package/src/lib/agents/profiles/builtins/technical-writer/SKILL.md +31 -0
  235. package/src/lib/agents/profiles/builtins/technical-writer/profile.yaml +29 -0
  236. package/src/lib/agents/profiles/builtins/travel-planner/SKILL.md +23 -0
  237. package/src/lib/agents/profiles/builtins/travel-planner/profile.yaml +26 -0
  238. package/src/lib/agents/profiles/builtins/wealth-manager/SKILL.md +24 -0
  239. package/src/lib/agents/profiles/builtins/wealth-manager/profile.yaml +26 -0
  240. package/src/lib/agents/profiles/compatibility.ts +109 -0
  241. package/src/lib/agents/profiles/registry.ts +293 -0
  242. package/src/lib/agents/profiles/test-runner.ts +18 -0
  243. package/src/lib/agents/profiles/test-types.ts +20 -0
  244. package/src/lib/agents/profiles/types.ts +43 -0
  245. package/src/lib/agents/router.ts +56 -0
  246. package/src/lib/agents/runtime/catalog.ts +85 -0
  247. package/src/lib/agents/runtime/claude-sdk.ts +12 -0
  248. package/src/lib/agents/runtime/claude.ts +370 -0
  249. package/src/lib/agents/runtime/codex-app-server-client.ts +289 -0
  250. package/src/lib/agents/runtime/index.ts +167 -0
  251. package/src/lib/agents/runtime/openai-codex.ts +1089 -0
  252. package/src/lib/agents/runtime/task-assist-types.ts +8 -0
  253. package/src/lib/agents/runtime/types.ts +30 -0
  254. package/src/lib/constants/settings.ts +13 -0
  255. package/src/lib/constants/status-colors.ts +44 -0
  256. package/src/lib/constants/task-status.ts +49 -0
  257. package/src/lib/data/clear.ts +63 -0
  258. package/src/lib/data/seed-data/documents.ts +715 -0
  259. package/src/lib/data/seed-data/logs.ts +195 -0
  260. package/src/lib/data/seed-data/notifications.ts +141 -0
  261. package/src/lib/data/seed-data/profiles.ts +175 -0
  262. package/src/lib/data/seed-data/projects.ts +61 -0
  263. package/src/lib/data/seed-data/schedules.ts +108 -0
  264. package/src/lib/data/seed-data/tasks.ts +341 -0
  265. package/src/lib/data/seed-data/usage-ledger.ts +130 -0
  266. package/src/lib/data/seed-data/workflows.ts +213 -0
  267. package/src/lib/data/seed.ts +129 -0
  268. package/src/lib/db/index.ts +221 -0
  269. package/src/lib/db/migrations/0000_aromatic_gargoyle.sql +59 -0
  270. package/src/lib/db/migrations/0001_first_iron_patriot.sql +6 -0
  271. package/src/lib/db/migrations/0002_add_resume_count.sql +1 -0
  272. package/src/lib/db/migrations/0003_add_settings.sql +5 -0
  273. package/src/lib/db/migrations/0004_add_documents.sql +20 -0
  274. package/src/lib/db/migrations/0005_add_document_preprocessing.sql +4 -0
  275. package/src/lib/db/migrations/0006_add_agent_profile.sql +2 -0
  276. package/src/lib/db/migrations/0007_add_usage_metering_ledger.sql +30 -0
  277. package/src/lib/db/migrations/0008_add_document_version.sql +1 -0
  278. package/src/lib/db/migrations/meta/0000_snapshot.json +416 -0
  279. package/src/lib/db/migrations/meta/0001_snapshot.json +461 -0
  280. package/src/lib/db/migrations/meta/0002_snapshot.json +469 -0
  281. package/src/lib/db/migrations/meta/_journal.json +27 -0
  282. package/src/lib/db/schema.ts +227 -0
  283. package/src/lib/documents/cleanup.ts +50 -0
  284. package/src/lib/documents/context-builder.ts +75 -0
  285. package/src/lib/documents/output-scanner.ts +166 -0
  286. package/src/lib/documents/processor.ts +120 -0
  287. package/src/lib/documents/processors/image.ts +21 -0
  288. package/src/lib/documents/processors/office.ts +36 -0
  289. package/src/lib/documents/processors/pdf.ts +12 -0
  290. package/src/lib/documents/processors/spreadsheet.ts +18 -0
  291. package/src/lib/documents/processors/text.ts +8 -0
  292. package/src/lib/documents/registry.ts +25 -0
  293. package/src/lib/notifications/actionable.ts +108 -0
  294. package/src/lib/notifications/permissions.ts +169 -0
  295. package/src/lib/queries/chart-data.ts +184 -0
  296. package/src/lib/schedules/interval-parser.ts +110 -0
  297. package/src/lib/schedules/scheduler.ts +220 -0
  298. package/src/lib/settings/auth.ts +98 -0
  299. package/src/lib/settings/budget-guardrails.ts +590 -0
  300. package/src/lib/settings/helpers.ts +23 -0
  301. package/src/lib/settings/openai-auth.ts +80 -0
  302. package/src/lib/settings/permissions.ts +102 -0
  303. package/src/lib/usage/ledger.ts +489 -0
  304. package/src/lib/usage/pricing.ts +68 -0
  305. package/src/lib/utils/crypto.ts +90 -0
  306. package/src/lib/utils/format-timestamp.ts +46 -0
  307. package/src/lib/utils/session-cleanup.ts +26 -0
  308. package/src/lib/utils/stagent-paths.ts +18 -0
  309. package/src/lib/utils.ts +6 -0
  310. package/src/lib/validators/blueprint.ts +43 -0
  311. package/src/lib/validators/profile.ts +64 -0
  312. package/src/lib/validators/project.ts +17 -0
  313. package/src/lib/validators/settings.ts +57 -0
  314. package/src/lib/validators/task.ts +30 -0
  315. package/src/lib/workflows/blueprints/builtins/code-review-pipeline.yaml +72 -0
  316. package/src/lib/workflows/blueprints/builtins/documentation-generation.yaml +62 -0
  317. package/src/lib/workflows/blueprints/builtins/investment-research.yaml +81 -0
  318. package/src/lib/workflows/blueprints/builtins/meal-planning.yaml +73 -0
  319. package/src/lib/workflows/blueprints/builtins/product-research.yaml +72 -0
  320. package/src/lib/workflows/blueprints/builtins/research-report.yaml +77 -0
  321. package/src/lib/workflows/blueprints/builtins/sprint-planning.yaml +77 -0
  322. package/src/lib/workflows/blueprints/builtins/travel-planning.yaml +80 -0
  323. package/src/lib/workflows/blueprints/instantiator.ts +131 -0
  324. package/src/lib/workflows/blueprints/registry.ts +128 -0
  325. package/src/lib/workflows/blueprints/template.ts +58 -0
  326. package/src/lib/workflows/blueprints/types.ts +38 -0
  327. package/src/lib/workflows/definition-validation.ts +121 -0
  328. package/src/lib/workflows/engine.ts +1113 -0
  329. package/src/lib/workflows/loop-executor.ts +270 -0
  330. package/src/lib/workflows/parallel.ts +55 -0
  331. package/src/lib/workflows/swarm.ts +97 -0
  332. package/src/lib/workflows/types.ts +112 -0
  333. package/tsconfig.json +41 -0
@@ -0,0 +1,842 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, type ComponentType } from "react";
4
+ import { Badge } from "@/components/ui/badge";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
7
+ import { Input } from "@/components/ui/input";
8
+ import { Separator } from "@/components/ui/separator";
9
+ import { listRuntimeCatalog } from "@/lib/agents/runtime/catalog";
10
+ import type { BudgetPolicy } from "@/lib/validators/settings";
11
+ import {
12
+ AlertTriangle,
13
+ ArrowRight,
14
+ ChevronDown,
15
+ ChevronUp,
16
+ Coins,
17
+ Landmark,
18
+ RotateCcw,
19
+ ShieldAlert,
20
+ ShieldCheck,
21
+ Wallet,
22
+ } from "lucide-react";
23
+ import { toast } from "sonner";
24
+
25
+ type BudgetHealth = "unlimited" | "ok" | "warning" | "blocked";
26
+ type BudgetMetric = "spend" | "tokens";
27
+ type BudgetWindow = "daily" | "monthly";
28
+
29
+ interface BudgetStatus {
30
+ id: string;
31
+ scopeId: string;
32
+ scopeLabel: string;
33
+ runtimeId: string | null;
34
+ metric: BudgetMetric;
35
+ window: BudgetWindow;
36
+ currentValue: number;
37
+ limitValue: number | null;
38
+ ratio: number | null;
39
+ health: BudgetHealth;
40
+ resetAtIso: string;
41
+ }
42
+
43
+ interface BudgetSnapshot {
44
+ policy: BudgetPolicy;
45
+ statuses: BudgetStatus[];
46
+ dailyResetAtIso: string;
47
+ monthlyResetAtIso: string;
48
+ }
49
+
50
+ interface BudgetFormState {
51
+ overallDailySpendCapUsd: string;
52
+ overallMonthlySpendCapUsd: string;
53
+ runtimes: Record<
54
+ string,
55
+ {
56
+ dailySpendCapUsd: string;
57
+ monthlySpendCapUsd: string;
58
+ dailyTokenCap: string;
59
+ monthlyTokenCap: string;
60
+ }
61
+ >;
62
+ }
63
+
64
+ const runtimes = listRuntimeCatalog();
65
+
66
+ interface DerivedTokenEstimate {
67
+ estimatedBudgetTokens: number | null;
68
+ estimatedRemainingTokens: number | null;
69
+ sourceLabel: string | null;
70
+ }
71
+
72
+ function toInputValue(value: number | null) {
73
+ return value == null ? "" : String(value);
74
+ }
75
+
76
+ function toNullableNumber(value: string) {
77
+ const trimmed = value.trim();
78
+ return trimmed === "" ? null : Number(trimmed);
79
+ }
80
+
81
+ function buildFormState(policy: BudgetPolicy): BudgetFormState {
82
+ return {
83
+ overallDailySpendCapUsd: toInputValue(policy.overall.dailySpendCapUsd),
84
+ overallMonthlySpendCapUsd: toInputValue(policy.overall.monthlySpendCapUsd),
85
+ runtimes: Object.fromEntries(
86
+ runtimes.map((runtime) => [
87
+ runtime.id,
88
+ {
89
+ dailySpendCapUsd: toInputValue(
90
+ policy.runtimes[runtime.id].dailySpendCapUsd
91
+ ),
92
+ monthlySpendCapUsd: toInputValue(
93
+ policy.runtimes[runtime.id].monthlySpendCapUsd
94
+ ),
95
+ dailyTokenCap: toInputValue(policy.runtimes[runtime.id].dailyTokenCap),
96
+ monthlyTokenCap: toInputValue(
97
+ policy.runtimes[runtime.id].monthlyTokenCap
98
+ ),
99
+ },
100
+ ])
101
+ ),
102
+ };
103
+ }
104
+
105
+ function buildPayload(form: BudgetFormState): BudgetPolicy {
106
+ return {
107
+ overall: {
108
+ dailySpendCapUsd: toNullableNumber(form.overallDailySpendCapUsd),
109
+ monthlySpendCapUsd: toNullableNumber(form.overallMonthlySpendCapUsd),
110
+ },
111
+ runtimes: Object.fromEntries(
112
+ runtimes.map((runtime) => [
113
+ runtime.id,
114
+ {
115
+ dailySpendCapUsd: toNullableNumber(
116
+ form.runtimes[runtime.id].dailySpendCapUsd
117
+ ),
118
+ monthlySpendCapUsd: toNullableNumber(
119
+ form.runtimes[runtime.id].monthlySpendCapUsd
120
+ ),
121
+ dailyTokenCap: toNullableNumber(form.runtimes[runtime.id].dailyTokenCap),
122
+ monthlyTokenCap: toNullableNumber(
123
+ form.runtimes[runtime.id].monthlyTokenCap
124
+ ),
125
+ },
126
+ ])
127
+ ) as BudgetPolicy["runtimes"],
128
+ };
129
+ }
130
+
131
+ function formatResetAt(value: string) {
132
+ return new Date(value).toLocaleString(undefined, {
133
+ dateStyle: "medium",
134
+ timeStyle: "short",
135
+ });
136
+ }
137
+
138
+ function formatStatusValue(status: BudgetStatus) {
139
+ if (status.metric === "tokens") {
140
+ return new Intl.NumberFormat("en-US").format(status.currentValue);
141
+ }
142
+
143
+ return new Intl.NumberFormat("en-US", {
144
+ style: "currency",
145
+ currency: "USD",
146
+ minimumFractionDigits: 2,
147
+ maximumFractionDigits: 4,
148
+ }).format(status.currentValue / 1_000_000);
149
+ }
150
+
151
+ function formatStatusLimit(status: BudgetStatus) {
152
+ if (status.limitValue == null) {
153
+ return "Unlimited";
154
+ }
155
+
156
+ if (status.metric === "tokens") {
157
+ return new Intl.NumberFormat("en-US").format(status.limitValue);
158
+ }
159
+
160
+ return new Intl.NumberFormat("en-US", {
161
+ style: "currency",
162
+ currency: "USD",
163
+ minimumFractionDigits: 2,
164
+ maximumFractionDigits: 4,
165
+ }).format(status.limitValue / 1_000_000);
166
+ }
167
+
168
+ function formatEstimatedTokens(value: number | null) {
169
+ if (value == null) {
170
+ return "Unavailable";
171
+ }
172
+
173
+ const rounded = Math.max(0, Math.round(value));
174
+ return new Intl.NumberFormat("en-US", {
175
+ notation: rounded >= 100_000 ? "compact" : "standard",
176
+ maximumFractionDigits: rounded >= 100_000 ? 1 : 0,
177
+ }).format(rounded);
178
+ }
179
+
180
+ function SectionEyebrow({
181
+ icon: Icon,
182
+ label,
183
+ }: {
184
+ icon: ComponentType<{ className?: string }>;
185
+ label: string;
186
+ }) {
187
+ return (
188
+ <div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
189
+ <Icon className="h-3.5 w-3.5" />
190
+ <span>{label}</span>
191
+ </div>
192
+ );
193
+ }
194
+
195
+ function healthBadge(status: BudgetStatus) {
196
+ if (status.health === "blocked") {
197
+ return <Badge variant="destructive">Blocked</Badge>;
198
+ }
199
+ if (status.health === "warning") {
200
+ return (
201
+ <Badge
202
+ variant="outline"
203
+ className="border-status-warning/30 bg-status-warning/10 text-status-warning"
204
+ >
205
+ Warning
206
+ </Badge>
207
+ );
208
+ }
209
+ if (status.health === "ok") {
210
+ return <Badge variant="success">Healthy</Badge>;
211
+ }
212
+ return <Badge variant="secondary">Unlimited</Badge>;
213
+ }
214
+
215
+ function getStatus(
216
+ statuses: BudgetStatus[],
217
+ scopeId: string,
218
+ window: BudgetWindow,
219
+ metric: BudgetMetric
220
+ ) {
221
+ return statuses.find(
222
+ (status) =>
223
+ status.scopeId === scopeId &&
224
+ status.window === window &&
225
+ status.metric === metric
226
+ );
227
+ }
228
+
229
+ function deriveTokenEstimate(input: {
230
+ spendCapUsd: string;
231
+ primarySpendStatus?: BudgetStatus;
232
+ primaryTokenStatus?: BudgetStatus;
233
+ fallbackSpendStatus?: BudgetStatus;
234
+ fallbackTokenStatus?: BudgetStatus;
235
+ }): DerivedTokenEstimate {
236
+ const spendCapUsd = toNullableNumber(input.spendCapUsd);
237
+ if (spendCapUsd == null) {
238
+ return {
239
+ estimatedBudgetTokens: null,
240
+ estimatedRemainingTokens: null,
241
+ sourceLabel: null,
242
+ };
243
+ }
244
+
245
+ const spendCapMicros = spendCapUsd * 1_000_000;
246
+ const primaryHasRate =
247
+ (input.primarySpendStatus?.currentValue ?? 0) > 0 &&
248
+ (input.primaryTokenStatus?.currentValue ?? 0) > 0;
249
+ const fallbackHasRate =
250
+ (input.fallbackSpendStatus?.currentValue ?? 0) > 0 &&
251
+ (input.fallbackTokenStatus?.currentValue ?? 0) > 0;
252
+
253
+ const spendStatus = primaryHasRate
254
+ ? input.primarySpendStatus
255
+ : fallbackHasRate
256
+ ? input.fallbackSpendStatus
257
+ : undefined;
258
+ const tokenStatus = primaryHasRate
259
+ ? input.primaryTokenStatus
260
+ : fallbackHasRate
261
+ ? input.fallbackTokenStatus
262
+ : undefined;
263
+
264
+ if (!spendStatus || !tokenStatus) {
265
+ return {
266
+ estimatedBudgetTokens: null,
267
+ estimatedRemainingTokens: null,
268
+ sourceLabel: null,
269
+ };
270
+ }
271
+
272
+ const tokensPerMicro = tokenStatus.currentValue / spendStatus.currentValue;
273
+ const estimatedBudgetTokens = spendCapMicros * tokensPerMicro;
274
+ const estimatedRemainingTokens = Math.max(
275
+ 0,
276
+ (spendCapMicros - spendStatus.currentValue) * tokensPerMicro
277
+ );
278
+
279
+ return {
280
+ estimatedBudgetTokens,
281
+ estimatedRemainingTokens,
282
+ sourceLabel:
283
+ spendStatus.window === input.primarySpendStatus?.window
284
+ ? `${spendStatus.window} blended pricing`
285
+ : `${spendStatus.window} blended pricing fallback`,
286
+ };
287
+ }
288
+
289
+ export function BudgetGuardrailsSection() {
290
+ const [snapshot, setSnapshot] = useState<BudgetSnapshot | null>(null);
291
+ const [form, setForm] = useState<BudgetFormState | null>(null);
292
+ const [advancedOpen, setAdvancedOpen] = useState<Record<string, boolean>>({});
293
+ const [loading, setLoading] = useState(true);
294
+ const [saving, setSaving] = useState(false);
295
+ const [error, setError] = useState<string | null>(null);
296
+
297
+ async function fetchSnapshot() {
298
+ setLoading(true);
299
+ setError(null);
300
+
301
+ try {
302
+ const res = await fetch("/api/settings/budgets");
303
+ const data = await res.json();
304
+
305
+ if (!res.ok) {
306
+ throw new Error(data?.error?.formErrors?.[0] ?? "Failed to load budget settings");
307
+ }
308
+
309
+ const parsed = data as BudgetSnapshot;
310
+ setSnapshot(parsed);
311
+ setForm(buildFormState(parsed.policy));
312
+ setAdvancedOpen(
313
+ Object.fromEntries(
314
+ runtimes.map((runtime) => [
315
+ runtime.id,
316
+ Boolean(
317
+ parsed.policy.runtimes[runtime.id].dailyTokenCap ||
318
+ parsed.policy.runtimes[runtime.id].monthlyTokenCap
319
+ ),
320
+ ])
321
+ )
322
+ );
323
+ } catch (fetchError) {
324
+ setError(
325
+ fetchError instanceof Error
326
+ ? fetchError.message
327
+ : "Failed to load budget settings"
328
+ );
329
+ } finally {
330
+ setLoading(false);
331
+ }
332
+ }
333
+
334
+ useEffect(() => {
335
+ fetchSnapshot();
336
+ }, []);
337
+
338
+ async function handleSave() {
339
+ if (!form) {
340
+ return;
341
+ }
342
+
343
+ setSaving(true);
344
+ setError(null);
345
+
346
+ try {
347
+ const res = await fetch("/api/settings/budgets", {
348
+ method: "POST",
349
+ headers: { "Content-Type": "application/json" },
350
+ body: JSON.stringify(buildPayload(form)),
351
+ });
352
+ const data = await res.json();
353
+
354
+ if (!res.ok) {
355
+ throw new Error(data?.error?.formErrors?.[0] ?? "Failed to save budget settings");
356
+ }
357
+
358
+ const parsed = data as BudgetSnapshot;
359
+ setSnapshot(parsed);
360
+ setForm(buildFormState(parsed.policy));
361
+ toast.success("Budget guardrails updated");
362
+ } catch (saveError) {
363
+ const message =
364
+ saveError instanceof Error
365
+ ? saveError.message
366
+ : "Failed to save budget settings";
367
+ setError(message);
368
+ toast.error(message);
369
+ } finally {
370
+ setSaving(false);
371
+ }
372
+ }
373
+
374
+ if (loading || !snapshot || !form) {
375
+ return (
376
+ <Card className="surface-card">
377
+ <CardHeader>
378
+ <CardTitle>Cost &amp; Usage Guardrails</CardTitle>
379
+ <CardDescription>Loading budget policy and current usage windows.</CardDescription>
380
+ </CardHeader>
381
+ </Card>
382
+ );
383
+ }
384
+
385
+ const blockedStatuses = snapshot.statuses.filter((status) => status.health === "blocked");
386
+ const warningStatuses = snapshot.statuses.filter((status) => status.health === "warning");
387
+ const groupedStatuses = snapshot.statuses.reduce<Record<string, BudgetStatus[]>>(
388
+ (acc, status) => {
389
+ const key = status.scopeId;
390
+ acc[key] ??= [];
391
+ acc[key].push(status);
392
+ return acc;
393
+ },
394
+ {}
395
+ );
396
+
397
+ return (
398
+ <Card className="surface-card">
399
+ <CardHeader>
400
+ <div className="space-y-4">
401
+ <div className="space-y-2">
402
+ <CardTitle className="flex items-center gap-2">
403
+ <Wallet className="h-5 w-5" />
404
+ Cost &amp; Usage Guardrails
405
+ </CardTitle>
406
+ <CardDescription>
407
+ Set optional daily and monthly spend caps for all runtime activity.
408
+ Runtime sections keep spend as the primary control, show derived
409
+ token guidance from recent blended pricing, and tuck hard token
410
+ ceilings into an advanced section.
411
+ </CardDescription>
412
+ </div>
413
+ <div className="grid gap-3 md:grid-cols-3">
414
+ <div className="surface-card-muted flex items-start gap-3 rounded-xl px-4 py-3">
415
+ <div className="rounded-lg bg-destructive/10 p-2 text-destructive">
416
+ <ShieldAlert className="h-4 w-4" />
417
+ </div>
418
+ <div className="min-w-0">
419
+ <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
420
+ Blocked now
421
+ </p>
422
+ <p className="mt-1 text-lg font-semibold">{blockedStatuses.length}</p>
423
+ </div>
424
+ </div>
425
+ <div className="surface-card-muted flex items-start gap-3 rounded-xl px-4 py-3">
426
+ <div className="rounded-lg bg-status-warning/10 p-2 text-status-warning">
427
+ <AlertTriangle className="h-4 w-4" />
428
+ </div>
429
+ <div className="min-w-0">
430
+ <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
431
+ Near cap
432
+ </p>
433
+ <p className="mt-1 text-lg font-semibold">{warningStatuses.length}</p>
434
+ </div>
435
+ </div>
436
+ <div className="surface-card-muted flex items-start gap-3 rounded-xl px-4 py-3">
437
+ <div className="rounded-lg bg-info/10 p-2 text-info">
438
+ <RotateCcw className="h-4 w-4" />
439
+ </div>
440
+ <div className="min-w-0">
441
+ <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
442
+ Reset windows
443
+ </p>
444
+ <p className="mt-1 text-xs text-muted-foreground">
445
+ Day: {formatResetAt(snapshot.dailyResetAtIso)}
446
+ </p>
447
+ <p className="text-xs text-muted-foreground">
448
+ Month: {formatResetAt(snapshot.monthlyResetAtIso)}
449
+ </p>
450
+ </div>
451
+ </div>
452
+ </div>
453
+ </div>
454
+ </CardHeader>
455
+ <CardContent className="space-y-6">
456
+ <div className="surface-panel rounded-xl p-4">
457
+ <div className="flex items-start gap-3">
458
+ {blockedStatuses.length > 0 ? (
459
+ <ShieldAlert className="mt-0.5 h-4 w-4 text-destructive" />
460
+ ) : (
461
+ <AlertTriangle className="mt-0.5 h-4 w-4 text-status-warning" />
462
+ )}
463
+ <div className="space-y-1 text-sm text-muted-foreground">
464
+ <p>
465
+ Warning notifications are emitted once per window when usage
466
+ reaches 80% of a configured cap.
467
+ </p>
468
+ <p>
469
+ Blocked attempts are recorded in the usage ledger with zero cost
470
+ so later audit views can explain why work did not start.
471
+ </p>
472
+ </div>
473
+ </div>
474
+ </div>
475
+
476
+ <div className="space-y-4">
477
+ <div className="space-y-3">
478
+ <div>
479
+ <SectionEyebrow icon={Landmark} label="Global Guardrails" />
480
+ <h3 className="text-sm font-semibold">Overall spend caps</h3>
481
+ <p className="text-xs text-muted-foreground">
482
+ Leave an input blank to keep that window unlimited.
483
+ </p>
484
+ </div>
485
+ <div className="grid gap-3 md:grid-cols-2">
486
+ <label className="space-y-2">
487
+ <span className="text-sm font-medium">Daily spend cap (USD)</span>
488
+ <Input
489
+ className="surface-control"
490
+ inputMode="decimal"
491
+ placeholder="Unlimited"
492
+ value={form.overallDailySpendCapUsd}
493
+ onChange={(event) =>
494
+ setForm((current) =>
495
+ current
496
+ ? {
497
+ ...current,
498
+ overallDailySpendCapUsd: event.target.value,
499
+ }
500
+ : current
501
+ )
502
+ }
503
+ />
504
+ </label>
505
+ <label className="space-y-2">
506
+ <span className="text-sm font-medium">Monthly spend cap (USD)</span>
507
+ <Input
508
+ className="surface-control"
509
+ inputMode="decimal"
510
+ placeholder="Unlimited"
511
+ value={form.overallMonthlySpendCapUsd}
512
+ onChange={(event) =>
513
+ setForm((current) =>
514
+ current
515
+ ? {
516
+ ...current,
517
+ overallMonthlySpendCapUsd: event.target.value,
518
+ }
519
+ : current
520
+ )
521
+ }
522
+ />
523
+ </label>
524
+ </div>
525
+ </div>
526
+
527
+ <Separator />
528
+
529
+ <div className="space-y-4">
530
+ {runtimes.map((runtime) => (
531
+ <div key={runtime.id} className="surface-card-muted rounded-xl p-4">
532
+ <div className="mb-3">
533
+ <SectionEyebrow icon={Wallet} label="Runtime Budget" />
534
+ <h3 className="mt-1 text-sm font-semibold">{runtime.label}</h3>
535
+ <p className="text-xs text-muted-foreground">
536
+ {runtime.description}
537
+ </p>
538
+ </div>
539
+ <div className="grid gap-3 md:grid-cols-2">
540
+ <label className="space-y-2">
541
+ <span className="text-sm font-medium">Daily spend cap (USD)</span>
542
+ <Input
543
+ className="surface-control"
544
+ inputMode="decimal"
545
+ placeholder="Unlimited"
546
+ value={form.runtimes[runtime.id].dailySpendCapUsd}
547
+ onChange={(event) =>
548
+ setForm((current) =>
549
+ current
550
+ ? {
551
+ ...current,
552
+ runtimes: {
553
+ ...current.runtimes,
554
+ [runtime.id]: {
555
+ ...current.runtimes[runtime.id],
556
+ dailySpendCapUsd: event.target.value,
557
+ },
558
+ },
559
+ }
560
+ : current
561
+ )
562
+ }
563
+ />
564
+ </label>
565
+ <label className="space-y-2">
566
+ <span className="text-sm font-medium">Monthly spend cap (USD)</span>
567
+ <Input
568
+ className="surface-control"
569
+ inputMode="decimal"
570
+ placeholder="Unlimited"
571
+ value={form.runtimes[runtime.id].monthlySpendCapUsd}
572
+ onChange={(event) =>
573
+ setForm((current) =>
574
+ current
575
+ ? {
576
+ ...current,
577
+ runtimes: {
578
+ ...current.runtimes,
579
+ [runtime.id]: {
580
+ ...current.runtimes[runtime.id],
581
+ monthlySpendCapUsd: event.target.value,
582
+ },
583
+ },
584
+ }
585
+ : current
586
+ )
587
+ }
588
+ />
589
+ </label>
590
+ </div>
591
+ {(() => {
592
+ const dailySpendStatus = getStatus(
593
+ snapshot.statuses,
594
+ runtime.id,
595
+ "daily",
596
+ "spend"
597
+ );
598
+ const dailyTokenStatus = getStatus(
599
+ snapshot.statuses,
600
+ runtime.id,
601
+ "daily",
602
+ "tokens"
603
+ );
604
+ const monthlySpendStatus = getStatus(
605
+ snapshot.statuses,
606
+ runtime.id,
607
+ "monthly",
608
+ "spend"
609
+ );
610
+ const monthlyTokenStatus = getStatus(
611
+ snapshot.statuses,
612
+ runtime.id,
613
+ "monthly",
614
+ "tokens"
615
+ );
616
+ const dailyEstimate = deriveTokenEstimate({
617
+ spendCapUsd: form.runtimes[runtime.id].dailySpendCapUsd,
618
+ primarySpendStatus: dailySpendStatus,
619
+ primaryTokenStatus: dailyTokenStatus,
620
+ fallbackSpendStatus: monthlySpendStatus,
621
+ fallbackTokenStatus: monthlyTokenStatus,
622
+ });
623
+ const monthlyEstimate = deriveTokenEstimate({
624
+ spendCapUsd: form.runtimes[runtime.id].monthlySpendCapUsd,
625
+ primarySpendStatus: monthlySpendStatus,
626
+ primaryTokenStatus: monthlyTokenStatus,
627
+ fallbackSpendStatus: dailySpendStatus,
628
+ fallbackTokenStatus: dailyTokenStatus,
629
+ });
630
+ const hasAdvancedTokenCaps =
631
+ Boolean(form.runtimes[runtime.id].dailyTokenCap) ||
632
+ Boolean(form.runtimes[runtime.id].monthlyTokenCap);
633
+
634
+ return (
635
+ <>
636
+ <div className="mt-4 grid gap-3 md:grid-cols-2">
637
+ <div className="surface-panel rounded-lg px-3 py-3">
638
+ <SectionEyebrow icon={Coins} label="Derived Guidance" />
639
+ <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
640
+ Estimated Daily Token Budget
641
+ </p>
642
+ {dailyEstimate.sourceLabel ? (
643
+ <>
644
+ <p className="mt-1 text-sm font-semibold">
645
+ ~{formatEstimatedTokens(dailyEstimate.estimatedBudgetTokens)}
646
+ </p>
647
+ <p className="mt-1 text-xs text-muted-foreground">
648
+ ~{formatEstimatedTokens(dailyEstimate.estimatedRemainingTokens)} remaining headroom based on {dailyEstimate.sourceLabel}.
649
+ </p>
650
+ </>
651
+ ) : (
652
+ <p className="mt-1 text-xs text-muted-foreground">
653
+ Set a spend cap and accumulate priced usage to see a token estimate.
654
+ </p>
655
+ )}
656
+ </div>
657
+ <div className="surface-panel rounded-lg px-3 py-3">
658
+ <SectionEyebrow icon={Coins} label="Derived Guidance" />
659
+ <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
660
+ Estimated Monthly Token Budget
661
+ </p>
662
+ {monthlyEstimate.sourceLabel ? (
663
+ <>
664
+ <p className="mt-1 text-sm font-semibold">
665
+ ~{formatEstimatedTokens(monthlyEstimate.estimatedBudgetTokens)}
666
+ </p>
667
+ <p className="mt-1 text-xs text-muted-foreground">
668
+ ~{formatEstimatedTokens(monthlyEstimate.estimatedRemainingTokens)} remaining headroom based on {monthlyEstimate.sourceLabel}.
669
+ </p>
670
+ </>
671
+ ) : (
672
+ <p className="mt-1 text-xs text-muted-foreground">
673
+ Set a spend cap and accumulate priced usage to see a token estimate.
674
+ </p>
675
+ )}
676
+ </div>
677
+ </div>
678
+
679
+ <div className="mt-4 rounded-lg border border-border/60 bg-background/40">
680
+ <button
681
+ type="button"
682
+ className="flex w-full items-center justify-between gap-3 cursor-pointer px-3 py-2 text-left text-sm font-medium"
683
+ onClick={() =>
684
+ setAdvancedOpen((current) => ({
685
+ ...current,
686
+ [runtime.id]: !(current[runtime.id] ?? hasAdvancedTokenCaps),
687
+ }))
688
+ }
689
+ >
690
+ <span className="flex items-center gap-2">
691
+ <ShieldCheck className="h-4 w-4 text-muted-foreground" />
692
+ {advancedOpen[runtime.id] ?? hasAdvancedTokenCaps
693
+ ? "Hide advanced token guardrails"
694
+ : "Show advanced token guardrails"}
695
+ </span>
696
+ {advancedOpen[runtime.id] ?? hasAdvancedTokenCaps ? (
697
+ <ChevronUp className="h-4 w-4 text-muted-foreground" />
698
+ ) : (
699
+ <ChevronDown className="h-4 w-4 text-muted-foreground" />
700
+ )}
701
+ </button>
702
+ {(advancedOpen[runtime.id] ?? hasAdvancedTokenCaps) && (
703
+ <div className="border-t border-border/60 px-3 py-3">
704
+ <SectionEyebrow icon={ShieldCheck} label="Advanced Override" />
705
+ <p className="mb-3 text-xs text-muted-foreground">
706
+ Use hard token caps only when you need a strict technical ceiling. Spend caps remain the primary operator control.
707
+ </p>
708
+ <div className="grid gap-3 md:grid-cols-2">
709
+ <label className="space-y-2">
710
+ <span className="text-sm font-medium">Daily token cap</span>
711
+ <Input
712
+ className="surface-control"
713
+ inputMode="numeric"
714
+ placeholder="Unlimited"
715
+ value={form.runtimes[runtime.id].dailyTokenCap}
716
+ onChange={(event) =>
717
+ setForm((current) =>
718
+ current
719
+ ? {
720
+ ...current,
721
+ runtimes: {
722
+ ...current.runtimes,
723
+ [runtime.id]: {
724
+ ...current.runtimes[runtime.id],
725
+ dailyTokenCap: event.target.value,
726
+ },
727
+ },
728
+ }
729
+ : current
730
+ )
731
+ }
732
+ />
733
+ </label>
734
+ <label className="space-y-2">
735
+ <span className="text-sm font-medium">Monthly token cap</span>
736
+ <Input
737
+ className="surface-control"
738
+ inputMode="numeric"
739
+ placeholder="Unlimited"
740
+ value={form.runtimes[runtime.id].monthlyTokenCap}
741
+ onChange={(event) =>
742
+ setForm((current) =>
743
+ current
744
+ ? {
745
+ ...current,
746
+ runtimes: {
747
+ ...current.runtimes,
748
+ [runtime.id]: {
749
+ ...current.runtimes[runtime.id],
750
+ monthlyTokenCap: event.target.value,
751
+ },
752
+ },
753
+ }
754
+ : current
755
+ )
756
+ }
757
+ />
758
+ </label>
759
+ </div>
760
+ </div>
761
+ )}
762
+ </div>
763
+ </>
764
+ );
765
+ })()}
766
+ </div>
767
+ ))}
768
+ </div>
769
+ </div>
770
+
771
+ <div className="flex items-center justify-between gap-3">
772
+ <div className="text-xs text-muted-foreground">
773
+ Blank fields are treated as unlimited. Changes reset warning dedupe for the current windows.
774
+ </div>
775
+ <Button onClick={handleSave} disabled={saving}>
776
+ {saving ? "Saving..." : "Save guardrails"}
777
+ </Button>
778
+ </div>
779
+
780
+ {error && (
781
+ <div className="rounded-xl border border-destructive/20 bg-destructive/10 px-3 py-2 text-sm text-destructive">
782
+ {error}
783
+ </div>
784
+ )}
785
+
786
+ <Separator />
787
+
788
+ <div className="space-y-3">
789
+ <div>
790
+ <SectionEyebrow icon={ArrowRight} label="Live Status" />
791
+ <h3 className="text-sm font-semibold">Current window status</h3>
792
+ <p className="text-xs text-muted-foreground">
793
+ Live usage is derived from the normalized usage ledger in the
794
+ machine&apos;s local timezone.
795
+ </p>
796
+ </div>
797
+ <div className="grid gap-4 xl:grid-cols-3">
798
+ {Object.entries(groupedStatuses).map(([scopeId, statuses]) => (
799
+ <div key={scopeId} className="surface-card-muted rounded-xl p-4">
800
+ <div className="mb-3 flex items-center justify-between gap-2">
801
+ <h4 className="text-sm font-semibold">{statuses[0]?.scopeLabel}</h4>
802
+ {statuses.some((status) => status.health === "blocked")
803
+ ? <Badge variant="destructive">Blocked</Badge>
804
+ : statuses.some((status) => status.health === "warning")
805
+ ? (
806
+ <Badge
807
+ variant="outline"
808
+ className="border-status-warning/30 bg-status-warning/10 text-status-warning"
809
+ >
810
+ Warning
811
+ </Badge>
812
+ )
813
+ : <Badge variant="success">Healthy</Badge>}
814
+ </div>
815
+ <div className="space-y-2">
816
+ {statuses.map((status) => (
817
+ <div key={status.id} className="surface-panel rounded-lg px-3 py-2">
818
+ <div className="flex items-center justify-between gap-2">
819
+ <div>
820
+ <p className="text-sm font-medium capitalize">
821
+ {status.window} {status.metric}
822
+ </p>
823
+ <p className="text-xs text-muted-foreground">
824
+ {formatStatusValue(status)} / {formatStatusLimit(status)}
825
+ </p>
826
+ </div>
827
+ {healthBadge(status)}
828
+ </div>
829
+ <p className="mt-2 text-xs text-muted-foreground">
830
+ Resets {formatResetAt(status.resetAtIso)}
831
+ </p>
832
+ </div>
833
+ ))}
834
+ </div>
835
+ </div>
836
+ ))}
837
+ </div>
838
+ </div>
839
+ </CardContent>
840
+ </Card>
841
+ );
842
+ }