stagent 0.1.10 → 0.1.12

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 (112) hide show
  1. package/README.md +58 -27
  2. package/package.json +3 -3
  3. package/src/__tests__/e2e/blueprint.test.ts +63 -0
  4. package/src/__tests__/e2e/cross-runtime.test.ts +77 -0
  5. package/src/__tests__/e2e/helpers.ts +286 -0
  6. package/src/__tests__/e2e/parallel-workflow.test.ts +120 -0
  7. package/src/__tests__/e2e/sequence-workflow.test.ts +109 -0
  8. package/src/__tests__/e2e/setup.ts +156 -0
  9. package/src/__tests__/e2e/single-task.test.ts +170 -0
  10. package/src/app/api/command-palette/recent/route.ts +41 -18
  11. package/src/app/api/context/batch/route.ts +44 -0
  12. package/src/app/api/permissions/presets/route.ts +80 -0
  13. package/src/app/api/playbook/status/route.ts +15 -0
  14. package/src/app/api/profiles/route.ts +23 -21
  15. package/src/app/api/settings/pricing/route.ts +15 -0
  16. package/src/app/costs/page.tsx +53 -43
  17. package/src/app/globals.css +0 -5
  18. package/src/app/playbook/[slug]/page.tsx +76 -0
  19. package/src/app/playbook/page.tsx +54 -0
  20. package/src/app/profiles/page.tsx +7 -4
  21. package/src/app/settings/page.tsx +2 -2
  22. package/src/app/tasks/page.tsx +5 -0
  23. package/src/components/costs/cost-dashboard.tsx +226 -320
  24. package/src/components/dashboard/activity-feed.tsx +6 -2
  25. package/src/components/notifications/batch-proposal-review.tsx +150 -0
  26. package/src/components/notifications/notification-item.tsx +6 -3
  27. package/src/components/notifications/pending-approval-host.tsx +57 -11
  28. package/src/components/playbook/adoption-heatmap.tsx +69 -0
  29. package/src/components/playbook/journey-card.tsx +110 -0
  30. package/src/components/playbook/playbook-action-button.tsx +22 -0
  31. package/src/components/playbook/playbook-browser.tsx +143 -0
  32. package/src/components/playbook/playbook-card.tsx +102 -0
  33. package/src/components/playbook/playbook-detail-view.tsx +223 -0
  34. package/src/components/playbook/playbook-homepage.tsx +142 -0
  35. package/src/components/playbook/playbook-toc.tsx +90 -0
  36. package/src/components/playbook/playbook-updated-badge.tsx +23 -0
  37. package/src/components/playbook/related-docs.tsx +30 -0
  38. package/src/components/profiles/__tests__/learned-context-panel.test.tsx +175 -0
  39. package/src/components/profiles/context-proposal-review.tsx +7 -3
  40. package/src/components/profiles/learned-context-panel.tsx +116 -8
  41. package/src/components/profiles/profile-detail-view.tsx +7 -19
  42. package/src/components/profiles/profile-form-view.tsx +0 -22
  43. package/src/components/settings/__tests__/auth-config-section.test.tsx +147 -0
  44. package/src/components/settings/api-key-form.tsx +5 -43
  45. package/src/components/settings/auth-config-section.tsx +10 -6
  46. package/src/components/settings/auth-status-badge.tsx +8 -0
  47. package/src/components/settings/budget-guardrails-section.tsx +403 -620
  48. package/src/components/settings/connection-test-control.tsx +63 -0
  49. package/src/components/settings/permissions-section.tsx +85 -75
  50. package/src/components/settings/permissions-sections.tsx +24 -0
  51. package/src/components/settings/presets-section.tsx +159 -0
  52. package/src/components/settings/pricing-registry-panel.tsx +164 -0
  53. package/src/components/shared/app-sidebar.tsx +2 -0
  54. package/src/components/shared/command-palette.tsx +30 -0
  55. package/src/components/shared/light-markdown.tsx +134 -0
  56. package/src/components/workflows/loop-status-view.tsx +8 -4
  57. package/src/components/workflows/workflow-status-view.tsx +16 -9
  58. package/src/lib/agents/__tests__/claude-agent.test.ts +7 -2
  59. package/src/lib/agents/__tests__/learned-context.test.ts +500 -0
  60. package/src/lib/agents/__tests__/pattern-extractor.test.ts +243 -0
  61. package/src/lib/agents/__tests__/sweep.test.ts +202 -0
  62. package/src/lib/agents/claude-agent.ts +104 -78
  63. package/src/lib/agents/learned-context.ts +32 -28
  64. package/src/lib/agents/learning-session.ts +234 -0
  65. package/src/lib/agents/pattern-extractor.ts +34 -64
  66. package/src/lib/agents/profiles/__tests__/sort.test.ts +42 -0
  67. package/src/lib/agents/profiles/builtins/code-reviewer/profile.yaml +0 -1
  68. package/src/lib/agents/profiles/builtins/data-analyst/profile.yaml +0 -1
  69. package/src/lib/agents/profiles/builtins/devops-engineer/profile.yaml +0 -1
  70. package/src/lib/agents/profiles/builtins/document-writer/profile.yaml +0 -1
  71. package/src/lib/agents/profiles/builtins/general/profile.yaml +0 -1
  72. package/src/lib/agents/profiles/builtins/health-fitness-coach/profile.yaml +0 -1
  73. package/src/lib/agents/profiles/builtins/learning-coach/profile.yaml +0 -1
  74. package/src/lib/agents/profiles/builtins/project-manager/profile.yaml +0 -1
  75. package/src/lib/agents/profiles/builtins/researcher/profile.yaml +0 -1
  76. package/src/lib/agents/profiles/builtins/shopping-assistant/profile.yaml +0 -1
  77. package/src/lib/agents/profiles/builtins/sweep/profile.yaml +0 -1
  78. package/src/lib/agents/profiles/builtins/technical-writer/profile.yaml +0 -1
  79. package/src/lib/agents/profiles/builtins/travel-planner/profile.yaml +0 -1
  80. package/src/lib/agents/profiles/builtins/wealth-manager/profile.yaml +0 -1
  81. package/src/lib/agents/profiles/registry.ts +0 -1
  82. package/src/lib/agents/profiles/sort.ts +7 -0
  83. package/src/lib/agents/profiles/types.ts +0 -1
  84. package/src/lib/agents/runtime/catalog.ts +1 -1
  85. package/src/lib/agents/runtime/claude.ts +66 -0
  86. package/src/lib/constants/settings.ts +1 -0
  87. package/src/lib/constants/task-status.ts +6 -0
  88. package/src/lib/data/seed-data/profiles.ts +0 -3
  89. package/src/lib/db/schema.ts +3 -0
  90. package/src/lib/docs/adoption.ts +105 -0
  91. package/src/lib/docs/journey-tracker.ts +21 -0
  92. package/src/lib/docs/reader.ts +102 -0
  93. package/src/lib/docs/types.ts +54 -0
  94. package/src/lib/docs/usage-stage.ts +60 -0
  95. package/src/lib/notifications/actionable.ts +18 -10
  96. package/src/lib/settings/__tests__/budget-guardrails.test.ts +86 -24
  97. package/src/lib/settings/budget-guardrails.ts +213 -85
  98. package/src/lib/settings/permission-presets.ts +150 -0
  99. package/src/lib/settings/runtime-setup.ts +71 -0
  100. package/src/lib/usage/__tests__/ledger.test.ts +29 -5
  101. package/src/lib/usage/__tests__/pricing-registry.test.ts +78 -0
  102. package/src/lib/usage/ledger.ts +4 -2
  103. package/src/lib/usage/pricing-registry.ts +570 -0
  104. package/src/lib/usage/pricing.ts +15 -41
  105. package/src/lib/utils/__tests__/learned-context-history.test.ts +171 -0
  106. package/src/lib/utils/learned-context-history.ts +150 -0
  107. package/src/lib/validators/__tests__/profile.test.ts +0 -15
  108. package/src/lib/validators/__tests__/settings.test.ts +23 -16
  109. package/src/lib/validators/profile.ts +0 -1
  110. package/src/lib/validators/settings.ts +3 -9
  111. package/src/lib/workflows/__tests__/engine.test.ts +2 -0
  112. package/src/lib/workflows/engine.ts +20 -1
@@ -8,10 +8,13 @@ let tempDir: string;
8
8
  beforeEach(() => {
9
9
  tempDir = mkdtempSync(join(tmpdir(), "stagent-budget-guardrails-"));
10
10
  vi.resetModules();
11
+ vi.useFakeTimers();
12
+ vi.setSystemTime(new Date("2026-03-17T12:00:00.000Z"));
11
13
  vi.stubEnv("STAGENT_DATA_DIR", tempDir);
12
14
  });
13
15
 
14
16
  afterEach(() => {
17
+ vi.useRealTimers();
15
18
  vi.unstubAllEnvs();
16
19
  rmSync(tempDir, { recursive: true, force: true });
17
20
  });
@@ -21,8 +24,9 @@ async function loadModules() {
21
24
  const schema = await import("@/lib/db/schema");
22
25
  const ledger = await import("@/lib/usage/ledger");
23
26
  const budgets = await import("../budget-guardrails");
27
+ const auth = await import("../auth");
24
28
 
25
- return { db, ...schema, ...ledger, ...budgets };
29
+ return { db, ...schema, ...ledger, ...budgets, ...auth };
26
30
  }
27
31
 
28
32
  describe("budget guardrails", () => {
@@ -33,25 +37,22 @@ describe("budget guardrails", () => {
33
37
  recordUsageLedgerEntry,
34
38
  setBudgetPolicy,
35
39
  enforceBudgetGuardrails,
40
+ setAuthSettings,
36
41
  } = await loadModules();
37
42
 
43
+ vi.stubEnv("ANTHROPIC_API_KEY", "sk-ant-test-key");
44
+ await setAuthSettings({ method: "api_key" });
45
+
38
46
  await setBudgetPolicy({
39
47
  overall: {
40
- dailySpendCapUsd: 0.012,
41
- monthlySpendCapUsd: null,
48
+ monthlySpendCapUsd: 0.36,
42
49
  },
43
50
  runtimes: {
44
51
  "claude-code": {
45
- dailySpendCapUsd: null,
46
- monthlySpendCapUsd: null,
47
- dailyTokenCap: null,
48
- monthlyTokenCap: null,
52
+ monthlySpendCapUsd: 0.36,
49
53
  },
50
54
  "openai-codex-app-server": {
51
- dailySpendCapUsd: null,
52
55
  monthlySpendCapUsd: null,
53
- dailyTokenCap: null,
54
- monthlyTokenCap: null,
55
56
  },
56
57
  },
57
58
  });
@@ -80,9 +81,10 @@ describe("budget guardrails", () => {
80
81
  });
81
82
 
82
83
  const rows = await db.select().from(notifications);
83
- expect(rows).toHaveLength(1);
84
- expect(rows[0]?.type).toBe("budget_alert");
85
- expect(rows[0]?.title).toContain("Overall daily spend");
84
+ expect(rows).toHaveLength(2);
85
+ expect(rows.every((row) => row.type === "budget_alert")).toBe(true);
86
+ expect(rows.some((row) => row.title.includes("Overall daily spend"))).toBe(true);
87
+ expect(rows.some((row) => row.title.includes("Claude Code daily spend"))).toBe(true);
86
88
  });
87
89
 
88
90
  it("blocks new runtime activity, records a zero-cost ledger row, and fails queued tasks when requested", async () => {
@@ -95,8 +97,12 @@ describe("budget guardrails", () => {
95
97
  setBudgetPolicy,
96
98
  enforceTaskBudgetGuardrails,
97
99
  BudgetLimitExceededError,
100
+ setAuthSettings,
98
101
  } = await loadModules();
99
102
 
103
+ vi.stubEnv("ANTHROPIC_API_KEY", "sk-ant-test-key");
104
+ await setAuthSettings({ method: "api_key" });
105
+
100
106
  const taskId = crypto.randomUUID();
101
107
  const now = new Date();
102
108
 
@@ -120,21 +126,14 @@ describe("budget guardrails", () => {
120
126
 
121
127
  await setBudgetPolicy({
122
128
  overall: {
123
- dailySpendCapUsd: null,
124
- monthlySpendCapUsd: null,
129
+ monthlySpendCapUsd: 0.001,
125
130
  },
126
131
  runtimes: {
127
132
  "claude-code": {
128
- dailySpendCapUsd: null,
129
- monthlySpendCapUsd: null,
130
- dailyTokenCap: 100,
131
- monthlyTokenCap: null,
133
+ monthlySpendCapUsd: 0.001,
132
134
  },
133
135
  "openai-codex-app-server": {
134
- dailySpendCapUsd: null,
135
136
  monthlySpendCapUsd: null,
136
- dailyTokenCap: null,
137
- monthlyTokenCap: null,
138
137
  },
139
138
  },
140
139
  });
@@ -159,7 +158,7 @@ describe("budget guardrails", () => {
159
158
 
160
159
  const [task] = await db.select().from(tasks);
161
160
  expect(task?.status).toBe("failed");
162
- expect(task?.result).toContain("token usage");
161
+ expect(task?.result).toContain("spend");
163
162
 
164
163
  const ledgerRows = await db.select().from(usageLedger);
165
164
  const blockedRow = ledgerRows.find((row) => row.status === "blocked");
@@ -176,6 +175,69 @@ describe("budget guardrails", () => {
176
175
 
177
176
  const budgetNotifications = await db.select().from(notifications);
178
177
  expect(budgetNotifications.at(-1)?.title).toContain("blocked");
179
- expect(budgetNotifications.at(-1)?.body).toContain("Resets");
178
+ expect(budgetNotifications.at(-1)?.body).toContain("spend");
179
+ });
180
+
181
+ it("derives daily spend caps from the monthly cap and auto-assigns a single configured runtime", async () => {
182
+ const { setBudgetPolicy, getBudgetGuardrailSnapshot, setAuthSettings } =
183
+ await loadModules();
184
+
185
+ vi.stubEnv("ANTHROPIC_API_KEY", "sk-ant-test-key");
186
+ vi.stubEnv("OPENAI_API_KEY", "");
187
+ await setAuthSettings({ method: "api_key" });
188
+
189
+ await setBudgetPolicy({
190
+ overall: {
191
+ monthlySpendCapUsd: 310,
192
+ },
193
+ runtimes: {
194
+ "claude-code": {
195
+ monthlySpendCapUsd: 155,
196
+ },
197
+ "openai-codex-app-server": {
198
+ monthlySpendCapUsd: 155,
199
+ },
200
+ },
201
+ });
202
+
203
+ const snapshot = await getBudgetGuardrailSnapshot();
204
+ const overallDaily = snapshot.statuses.find(
205
+ (status) => status.scopeId === "overall" && status.window === "daily"
206
+ );
207
+ const claudeMonthly = snapshot.policy.runtimes["claude-code"].monthlySpendCapUsd;
208
+ const openAIMonthly =
209
+ snapshot.policy.runtimes["openai-codex-app-server"].monthlySpendCapUsd;
210
+
211
+ expect(overallDaily?.limitValue).toBe(10_000_000);
212
+ expect(claudeMonthly).toBe(310);
213
+ expect(openAIMonthly).toBeNull();
214
+ });
215
+
216
+ it("splits configured provider caps from the overall budget when both runtimes are configured", async () => {
217
+ vi.stubEnv("ANTHROPIC_API_KEY", "sk-ant-test-key");
218
+ vi.stubEnv("OPENAI_API_KEY", "sk-test-openai");
219
+ const { setBudgetPolicy, getBudgetGuardrailSnapshot, setAuthSettings } =
220
+ await loadModules();
221
+ await setAuthSettings({ method: "api_key" });
222
+
223
+ await setBudgetPolicy({
224
+ overall: {
225
+ monthlySpendCapUsd: 300,
226
+ },
227
+ runtimes: {
228
+ "claude-code": {
229
+ monthlySpendCapUsd: 120,
230
+ },
231
+ "openai-codex-app-server": {
232
+ monthlySpendCapUsd: 180,
233
+ },
234
+ },
235
+ });
236
+
237
+ const snapshot = await getBudgetGuardrailSnapshot();
238
+ expect(snapshot.policy.runtimes["claude-code"].monthlySpendCapUsd).toBe(120);
239
+ expect(snapshot.policy.runtimes["openai-codex-app-server"].monthlySpendCapUsd).toBe(
240
+ 180
241
+ );
180
242
  });
181
243
  });
@@ -12,6 +12,7 @@ import {
12
12
  import { getSetting, setSetting } from "./helpers";
13
13
  import {
14
14
  budgetPolicySchema,
15
+ claudeOAuthPlanSchema,
15
16
  type BudgetPolicy,
16
17
  type RuntimeBudgetPolicy,
17
18
  type UpdateBudgetPolicyInput,
@@ -21,11 +22,20 @@ import {
21
22
  resolveUsageActivityType,
22
23
  type UsageActivityType,
23
24
  } from "@/lib/usage/ledger";
25
+ import {
26
+ getClaudeOAuthPlanPrice,
27
+ getPricingRegistrySnapshot,
28
+ type PricingRegistrySnapshot,
29
+ } from "@/lib/usage/pricing-registry";
30
+ import {
31
+ getRuntimeSetupStates,
32
+ listConfiguredRuntimeIds,
33
+ type RuntimeSetupState,
34
+ } from "./runtime-setup";
24
35
 
25
36
  const WARNING_THRESHOLD = 0.8;
26
37
 
27
38
  type BudgetWindow = "daily" | "monthly";
28
- type BudgetMetric = "spend" | "tokens";
29
39
  type BudgetHealth = "unlimited" | "ok" | "warning" | "blocked";
30
40
  type BudgetScopeId = "overall" | AgentRuntimeId;
31
41
 
@@ -34,12 +44,11 @@ interface UsageAggregate {
34
44
  totalTokens: number;
35
45
  }
36
46
 
37
- interface BudgetWindowStatus {
47
+ export interface BudgetWindowStatus {
38
48
  id: string;
39
49
  scopeId: BudgetScopeId;
40
50
  scopeLabel: string;
41
51
  runtimeId: AgentRuntimeId | null;
42
- metric: BudgetMetric;
43
52
  window: BudgetWindow;
44
53
  currentValue: number;
45
54
  limitValue: number | null;
@@ -52,11 +61,13 @@ interface BudgetWarningState {
52
61
  [statusId: string]: string;
53
62
  }
54
63
 
55
- interface BudgetSnapshot {
64
+ export interface BudgetSnapshot {
56
65
  policy: BudgetPolicy;
57
66
  statuses: Array<BudgetWindowStatus & { resetAtIso: string }>;
58
67
  dailyResetAtIso: string;
59
68
  monthlyResetAtIso: string;
69
+ runtimeStates: Record<AgentRuntimeId, RuntimeSetupState>;
70
+ pricing: PricingRegistrySnapshot;
60
71
  }
61
72
 
62
73
  interface BudgetGuardInput {
@@ -69,19 +80,34 @@ interface BudgetGuardInput {
69
80
  failTaskOnBlock?: boolean;
70
81
  }
71
82
 
83
+ function isRecord(value: unknown): value is Record<string, unknown> {
84
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
85
+ }
86
+
87
+ function toPositiveNumber(value: unknown) {
88
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
89
+ return value;
90
+ }
91
+ if (typeof value === "string" && value.trim() !== "") {
92
+ const parsed = Number(value);
93
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
94
+ }
95
+ return null;
96
+ }
97
+
98
+ function roundUsd(value: number) {
99
+ return Math.round(value * 100) / 100;
100
+ }
101
+
72
102
  function createEmptyRuntimeBudgetPolicy(): RuntimeBudgetPolicy {
73
103
  return {
74
- dailySpendCapUsd: null,
75
104
  monthlySpendCapUsd: null,
76
- dailyTokenCap: null,
77
- monthlyTokenCap: null,
78
105
  };
79
106
  }
80
107
 
81
108
  export function createEmptyBudgetPolicy(): BudgetPolicy {
82
109
  return {
83
110
  overall: {
84
- dailySpendCapUsd: null,
85
111
  monthlySpendCapUsd: null,
86
112
  },
87
113
  runtimes: Object.fromEntries(
@@ -93,6 +119,10 @@ export function createEmptyBudgetPolicy(): BudgetPolicy {
93
119
  };
94
120
  }
95
121
 
122
+ function daysInMonth(date: Date) {
123
+ return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
124
+ }
125
+
96
126
  function formatWindowKey(window: BudgetWindow, date: Date) {
97
127
  if (window === "daily") {
98
128
  return new Intl.DateTimeFormat("en-CA", {
@@ -116,10 +146,6 @@ function formatMicrosAsUsd(micros: number) {
116
146
  }).format(micros / 1_000_000);
117
147
  }
118
148
 
119
- function formatTokenCount(tokens: number) {
120
- return new Intl.NumberFormat("en-US").format(tokens);
121
- }
122
-
123
149
  function formatResetAt(date: Date) {
124
150
  return new Intl.DateTimeFormat("en-US", {
125
151
  dateStyle: "medium",
@@ -131,6 +157,13 @@ function usdToMicros(value: number | null) {
131
157
  return value == null ? null : Math.round(value * 1_000_000);
132
158
  }
133
159
 
160
+ function deriveDailyMicros(monthlySpendCapUsd: number | null, now: Date) {
161
+ if (monthlySpendCapUsd == null) {
162
+ return null;
163
+ }
164
+ return Math.round((monthlySpendCapUsd * 1_000_000) / daysInMonth(now));
165
+ }
166
+
134
167
  function getBudgetWindowBounds(now = new Date()) {
135
168
  const dailyStart = new Date(now);
136
169
  dailyStart.setHours(0, 0, 0, 0);
@@ -167,6 +200,86 @@ async function setWarningState(state: BudgetWarningState) {
167
200
  await setSetting(SETTINGS_KEYS.BUDGET_WARNING_STATE, JSON.stringify(state));
168
201
  }
169
202
 
203
+ function normalizePersistedBudgetPolicy(raw: unknown): BudgetPolicy {
204
+ const fallback = createEmptyBudgetPolicy();
205
+ if (!isRecord(raw)) {
206
+ return fallback;
207
+ }
208
+
209
+ const overall = isRecord(raw.overall) ? raw.overall : {};
210
+ const runtimes = isRecord(raw.runtimes) ? raw.runtimes : {};
211
+
212
+ const next = createEmptyBudgetPolicy();
213
+ next.overall.monthlySpendCapUsd =
214
+ toPositiveNumber(overall.monthlySpendCapUsd) ??
215
+ toPositiveNumber(overall.dailySpendCapUsd);
216
+
217
+ for (const runtimeId of SUPPORTED_AGENT_RUNTIMES) {
218
+ const runtimeRaw = isRecord(runtimes[runtimeId]) ? runtimes[runtimeId] : {};
219
+ const runtime = next.runtimes[runtimeId];
220
+ runtime.monthlySpendCapUsd =
221
+ toPositiveNumber(runtimeRaw.monthlySpendCapUsd) ??
222
+ toPositiveNumber(runtimeRaw.dailySpendCapUsd);
223
+
224
+ if (
225
+ runtimeId === "claude-code" &&
226
+ typeof runtimeRaw.claudeOAuthPlan === "string"
227
+ ) {
228
+ const parsedPlan = claudeOAuthPlanSchema.safeParse(runtimeRaw.claudeOAuthPlan);
229
+ if (parsedPlan.success) {
230
+ runtime.claudeOAuthPlan = parsedPlan.data;
231
+ }
232
+ }
233
+ }
234
+
235
+ return next;
236
+ }
237
+
238
+ function normalizeBudgetPolicyWithRuntimeSetup(input: {
239
+ policy: BudgetPolicy;
240
+ runtimeStates: Record<AgentRuntimeId, RuntimeSetupState>;
241
+ }): BudgetPolicy {
242
+ const next = createEmptyBudgetPolicy();
243
+ const overallMonthly = input.policy.overall.monthlySpendCapUsd;
244
+ const configuredRuntimeIds = listConfiguredRuntimeIds(input.runtimeStates);
245
+
246
+ next.overall.monthlySpendCapUsd = overallMonthly;
247
+ next.runtimes["claude-code"].claudeOAuthPlan =
248
+ input.policy.runtimes["claude-code"].claudeOAuthPlan ??
249
+ (input.runtimeStates["claude-code"].billingMode === "subscription"
250
+ ? "pro"
251
+ : undefined);
252
+
253
+ if (overallMonthly == null || configuredRuntimeIds.length === 0) {
254
+ return next;
255
+ }
256
+
257
+ if (configuredRuntimeIds.length === 1) {
258
+ next.runtimes[configuredRuntimeIds[0]].monthlySpendCapUsd = overallMonthly;
259
+ return next;
260
+ }
261
+
262
+ const activeRuntimeIds = configuredRuntimeIds.filter(
263
+ (runtimeId) => input.runtimeStates[runtimeId].configured
264
+ );
265
+ const totalRequested = activeRuntimeIds.reduce(
266
+ (sum, runtimeId) => sum + (input.policy.runtimes[runtimeId].monthlySpendCapUsd ?? 0),
267
+ 0
268
+ );
269
+
270
+ const claudeShare =
271
+ totalRequested > 0
272
+ ? (input.policy.runtimes["claude-code"].monthlySpendCapUsd ?? 0) / totalRequested
273
+ : 0.5;
274
+ const claudeMonthly = roundUsd(overallMonthly * claudeShare);
275
+ const openAIMonthly = roundUsd(Math.max(overallMonthly - claudeMonthly, 0));
276
+
277
+ next.runtimes["claude-code"].monthlySpendCapUsd = claudeMonthly;
278
+ next.runtimes["openai-codex-app-server"].monthlySpendCapUsd = openAIMonthly;
279
+
280
+ return next;
281
+ }
282
+
170
283
  export async function getBudgetPolicy(): Promise<BudgetPolicy> {
171
284
  const raw = await getSetting(SETTINGS_KEYS.BUDGET_POLICY);
172
285
  if (!raw) {
@@ -174,8 +287,8 @@ export async function getBudgetPolicy(): Promise<BudgetPolicy> {
174
287
  }
175
288
 
176
289
  try {
177
- const parsed = budgetPolicySchema.safeParse(JSON.parse(raw));
178
- return parsed.success ? parsed.data : createEmptyBudgetPolicy();
290
+ const parsed = JSON.parse(raw) as unknown;
291
+ return normalizePersistedBudgetPolicy(parsed);
179
292
  } catch {
180
293
  return createEmptyBudgetPolicy();
181
294
  }
@@ -185,12 +298,21 @@ export async function setBudgetPolicy(
185
298
  input: UpdateBudgetPolicyInput
186
299
  ): Promise<BudgetPolicy> {
187
300
  const parsed = budgetPolicySchema.parse(input);
188
- await setSetting(SETTINGS_KEYS.BUDGET_POLICY, JSON.stringify(parsed));
301
+ const runtimeStates = await getRuntimeSetupStates();
302
+ const normalized = normalizeBudgetPolicyWithRuntimeSetup({
303
+ policy: parsed,
304
+ runtimeStates,
305
+ });
306
+ await setSetting(SETTINGS_KEYS.BUDGET_POLICY, JSON.stringify(normalized));
189
307
  await setWarningState({});
190
- return parsed;
308
+ return normalized;
191
309
  }
192
310
 
193
- async function getUsageAggregates(now = new Date()) {
311
+ async function getUsageAggregates(
312
+ policy: BudgetPolicy,
313
+ runtimeStates: Record<AgentRuntimeId, RuntimeSetupState>,
314
+ now = new Date()
315
+ ) {
194
316
  const { dailyStart, dailyEnd, monthlyStart, monthlyEnd } =
195
317
  getBudgetWindowBounds(now);
196
318
 
@@ -209,10 +331,6 @@ async function getUsageAggregates(now = new Date()) {
209
331
  )
210
332
  );
211
333
 
212
- const overall = {
213
- daily: { costMicros: 0, totalTokens: 0 },
214
- monthly: { costMicros: 0, totalTokens: 0 },
215
- };
216
334
  const runtimes = Object.fromEntries(
217
335
  SUPPORTED_AGENT_RUNTIMES.map((runtimeId) => [
218
336
  runtimeId,
@@ -231,22 +349,41 @@ async function getUsageAggregates(now = new Date()) {
231
349
  return;
232
350
  }
233
351
 
234
- const costMicros = row.costMicros ?? 0;
235
- const totalTokens = row.totalTokens ?? 0;
236
-
237
- overall.monthly.costMicros += costMicros;
238
- overall.monthly.totalTokens += totalTokens;
239
- runtimes[runtimeId].monthly.costMicros += costMicros;
240
- runtimes[runtimeId].monthly.totalTokens += totalTokens;
352
+ runtimes[runtimeId].monthly.costMicros += row.costMicros ?? 0;
353
+ runtimes[runtimeId].monthly.totalTokens += row.totalTokens ?? 0;
241
354
 
242
355
  if (row.finishedAt >= dailyStart && row.finishedAt < dailyEnd) {
243
- overall.daily.costMicros += costMicros;
244
- overall.daily.totalTokens += totalTokens;
245
- runtimes[runtimeId].daily.costMicros += costMicros;
246
- runtimes[runtimeId].daily.totalTokens += totalTokens;
356
+ runtimes[runtimeId].daily.costMicros += row.costMicros ?? 0;
357
+ runtimes[runtimeId].daily.totalTokens += row.totalTokens ?? 0;
247
358
  }
248
359
  });
249
360
 
361
+ if (runtimeStates["claude-code"].billingMode === "subscription") {
362
+ const planPriceUsd = await getClaudeOAuthPlanPrice(
363
+ policy.runtimes["claude-code"].claudeOAuthPlan
364
+ );
365
+ const monthlyMicros = usdToMicros(planPriceUsd) ?? 0;
366
+ const dailyMicros = Math.round(monthlyMicros / daysInMonth(now));
367
+ runtimes["claude-code"].monthly.costMicros = monthlyMicros;
368
+ runtimes["claude-code"].daily.costMicros = dailyMicros;
369
+ }
370
+
371
+ const overall = {
372
+ daily: { costMicros: 0, totalTokens: 0 },
373
+ monthly: { costMicros: 0, totalTokens: 0 },
374
+ };
375
+
376
+ for (const runtimeId of SUPPORTED_AGENT_RUNTIMES) {
377
+ if (!runtimeStates[runtimeId].configured) {
378
+ continue;
379
+ }
380
+
381
+ overall.daily.costMicros += runtimes[runtimeId].daily.costMicros;
382
+ overall.daily.totalTokens += runtimes[runtimeId].daily.totalTokens;
383
+ overall.monthly.costMicros += runtimes[runtimeId].monthly.costMicros;
384
+ overall.monthly.totalTokens += runtimes[runtimeId].monthly.totalTokens;
385
+ }
386
+
250
387
  return {
251
388
  overall,
252
389
  runtimes,
@@ -258,7 +395,6 @@ function buildStatus(input: {
258
395
  scopeId: BudgetScopeId;
259
396
  scopeLabel: string;
260
397
  runtimeId: AgentRuntimeId | null;
261
- metric: BudgetMetric;
262
398
  window: BudgetWindow;
263
399
  currentValue: number;
264
400
  limitValue: number | null;
@@ -281,11 +417,10 @@ function buildStatus(input: {
281
417
  }
282
418
 
283
419
  return {
284
- id: `${input.scopeId}:${input.window}:${input.metric}`,
420
+ id: `${input.scopeId}:${input.window}:spend`,
285
421
  scopeId: input.scopeId,
286
422
  scopeLabel: input.scopeLabel,
287
423
  runtimeId: input.runtimeId,
288
- metric: input.metric,
289
424
  window: input.window,
290
425
  currentValue: input.currentValue,
291
426
  limitValue: input.limitValue,
@@ -297,7 +432,9 @@ function buildStatus(input: {
297
432
 
298
433
  function buildBudgetStatuses(
299
434
  policy: BudgetPolicy,
300
- aggregates: Awaited<ReturnType<typeof getUsageAggregates>>
435
+ runtimeStates: Record<AgentRuntimeId, RuntimeSetupState>,
436
+ aggregates: Awaited<ReturnType<typeof getUsageAggregates>>,
437
+ now: Date
301
438
  ) {
302
439
  const statuses: BudgetWindowStatus[] = [];
303
440
 
@@ -306,17 +443,15 @@ function buildBudgetStatuses(
306
443
  scopeId: "overall",
307
444
  scopeLabel: "Overall",
308
445
  runtimeId: null,
309
- metric: "spend",
310
446
  window: "daily",
311
447
  currentValue: aggregates.overall.daily.costMicros,
312
- limitValue: usdToMicros(policy.overall.dailySpendCapUsd),
448
+ limitValue: deriveDailyMicros(policy.overall.monthlySpendCapUsd, now),
313
449
  resetAt: aggregates.dailyEnd,
314
450
  }),
315
451
  buildStatus({
316
452
  scopeId: "overall",
317
453
  scopeLabel: "Overall",
318
454
  runtimeId: null,
319
- metric: "spend",
320
455
  window: "monthly",
321
456
  currentValue: aggregates.overall.monthly.costMicros,
322
457
  limitValue: usdToMicros(policy.overall.monthlySpendCapUsd),
@@ -324,7 +459,11 @@ function buildBudgetStatuses(
324
459
  })
325
460
  );
326
461
 
327
- SUPPORTED_AGENT_RUNTIMES.forEach((runtimeId) => {
462
+ for (const runtimeId of SUPPORTED_AGENT_RUNTIMES) {
463
+ if (!runtimeStates[runtimeId].configured) {
464
+ continue;
465
+ }
466
+
328
467
  const runtime = getRuntimeCatalogEntry(runtimeId);
329
468
  const runtimePolicy = policy.runtimes[runtimeId];
330
469
  const usage = aggregates.runtimes[runtimeId];
@@ -334,62 +473,32 @@ function buildBudgetStatuses(
334
473
  scopeId: runtimeId,
335
474
  scopeLabel: runtime.label,
336
475
  runtimeId,
337
- metric: "spend",
338
476
  window: "daily",
339
477
  currentValue: usage.daily.costMicros,
340
- limitValue: usdToMicros(runtimePolicy.dailySpendCapUsd),
478
+ limitValue: deriveDailyMicros(runtimePolicy.monthlySpendCapUsd, now),
341
479
  resetAt: aggregates.dailyEnd,
342
480
  }),
343
481
  buildStatus({
344
482
  scopeId: runtimeId,
345
483
  scopeLabel: runtime.label,
346
484
  runtimeId,
347
- metric: "spend",
348
485
  window: "monthly",
349
486
  currentValue: usage.monthly.costMicros,
350
487
  limitValue: usdToMicros(runtimePolicy.monthlySpendCapUsd),
351
488
  resetAt: aggregates.monthlyEnd,
352
- }),
353
- buildStatus({
354
- scopeId: runtimeId,
355
- scopeLabel: runtime.label,
356
- runtimeId,
357
- metric: "tokens",
358
- window: "daily",
359
- currentValue: usage.daily.totalTokens,
360
- limitValue: runtimePolicy.dailyTokenCap,
361
- resetAt: aggregates.dailyEnd,
362
- }),
363
- buildStatus({
364
- scopeId: runtimeId,
365
- scopeLabel: runtime.label,
366
- runtimeId,
367
- metric: "tokens",
368
- window: "monthly",
369
- currentValue: usage.monthly.totalTokens,
370
- limitValue: runtimePolicy.monthlyTokenCap,
371
- resetAt: aggregates.monthlyEnd,
372
489
  })
373
490
  );
374
- });
491
+ }
375
492
 
376
493
  return statuses;
377
494
  }
378
495
 
379
496
  function describeBudgetStatus(status: BudgetWindowStatus) {
380
- const metricLabel = status.metric === "spend" ? "spend" : "token usage";
381
- const currentLabel =
382
- status.metric === "spend"
383
- ? formatMicrosAsUsd(status.currentValue)
384
- : formatTokenCount(status.currentValue);
497
+ const currentLabel = formatMicrosAsUsd(status.currentValue);
385
498
  const limitLabel =
386
- status.limitValue == null
387
- ? "Unlimited"
388
- : status.metric === "spend"
389
- ? formatMicrosAsUsd(status.limitValue)
390
- : formatTokenCount(status.limitValue);
499
+ status.limitValue == null ? "Unlimited" : formatMicrosAsUsd(status.limitValue);
391
500
 
392
- return `${status.scopeLabel} ${status.window} ${metricLabel} is ${currentLabel} of ${limitLabel}. Resets ${formatResetAt(status.resetAt)}.`;
501
+ return `${status.scopeLabel} ${status.window} spend is ${currentLabel} of ${limitLabel}. Resets ${formatResetAt(status.resetAt)}.`;
393
502
  }
394
503
 
395
504
  async function createBudgetNotification(input: {
@@ -428,7 +537,7 @@ async function emitWarningNotifications(
428
537
  const percent = status.ratio == null ? 0 : Math.round(status.ratio * 100);
429
538
  await createBudgetNotification({
430
539
  taskId,
431
- title: `${status.scopeLabel} ${status.window} ${status.metric} at ${percent}%`,
540
+ title: `${status.scopeLabel} ${status.window} spend at ${percent}%`,
432
541
  body: describeBudgetStatus(status),
433
542
  });
434
543
  warningState[status.id] = windowKey;
@@ -518,8 +627,15 @@ export async function enforceBudgetGuardrails(input: BudgetGuardInput) {
518
627
  const runtimeId = resolveAgentRuntime(input.runtimeId ?? DEFAULT_AGENT_RUNTIME);
519
628
  const policy = await getBudgetPolicy();
520
629
  const warningState = await getWarningState();
521
- const aggregates = await getUsageAggregates();
522
- const statuses = buildBudgetStatuses(policy, aggregates).filter(
630
+ const runtimeStates = await getRuntimeSetupStates();
631
+
632
+ if (!runtimeStates[runtimeId].configured) {
633
+ return;
634
+ }
635
+
636
+ const now = new Date();
637
+ const aggregates = await getUsageAggregates(policy, runtimeStates, now);
638
+ const statuses = buildBudgetStatuses(policy, runtimeStates, aggregates, now).filter(
523
639
  (status) => status.scopeId === "overall" || status.runtimeId === runtimeId
524
640
  );
525
641
 
@@ -535,7 +651,7 @@ export async function enforceBudgetGuardrails(input: BudgetGuardInput) {
535
651
  }
536
652
 
537
653
  const runtime = getRuntimeCatalogEntry(runtimeId);
538
- const title = `${runtime.label} blocked by ${blocked.window} ${blocked.metric} cap`;
654
+ const title = `${runtime.label} blocked by ${blocked.window} spend cap`;
539
655
  const body = describeBudgetStatus(blocked);
540
656
 
541
657
  await createBudgetNotification({
@@ -574,17 +690,29 @@ export async function enforceTaskBudgetGuardrails(
574
690
  }
575
691
 
576
692
  export async function getBudgetGuardrailSnapshot(): Promise<BudgetSnapshot> {
577
- const policy = await getBudgetPolicy();
578
- const aggregates = await getUsageAggregates();
579
- const statuses = buildBudgetStatuses(policy, aggregates).map((status) => ({
580
- ...status,
581
- resetAtIso: status.resetAt.toISOString(),
582
- }));
693
+ const [runtimeStates, pricing] = await Promise.all([
694
+ getRuntimeSetupStates(),
695
+ getPricingRegistrySnapshot(),
696
+ ]);
697
+ const policy = normalizeBudgetPolicyWithRuntimeSetup({
698
+ policy: await getBudgetPolicy(),
699
+ runtimeStates,
700
+ });
701
+ const now = new Date();
702
+ const aggregates = await getUsageAggregates(policy, runtimeStates, now);
703
+ const statuses = buildBudgetStatuses(policy, runtimeStates, aggregates, now).map(
704
+ (status) => ({
705
+ ...status,
706
+ resetAtIso: status.resetAt.toISOString(),
707
+ })
708
+ );
583
709
 
584
710
  return {
585
711
  policy,
586
712
  statuses,
587
713
  dailyResetAtIso: aggregates.dailyEnd.toISOString(),
588
714
  monthlyResetAtIso: aggregates.monthlyEnd.toISOString(),
715
+ runtimeStates,
716
+ pricing,
589
717
  };
590
718
  }