stagent 0.1.11 → 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 (81) hide show
  1. package/README.md +35 -4
  2. package/package.json +3 -2
  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 -20
  15. package/src/app/api/settings/pricing/route.ts +15 -0
  16. package/src/app/costs/page.tsx +53 -43
  17. package/src/app/playbook/[slug]/page.tsx +76 -0
  18. package/src/app/playbook/page.tsx +54 -0
  19. package/src/app/profiles/page.tsx +7 -4
  20. package/src/app/settings/page.tsx +2 -2
  21. package/src/components/costs/cost-dashboard.tsx +226 -320
  22. package/src/components/dashboard/activity-feed.tsx +6 -2
  23. package/src/components/notifications/batch-proposal-review.tsx +150 -0
  24. package/src/components/notifications/notification-item.tsx +6 -3
  25. package/src/components/notifications/pending-approval-host.tsx +57 -11
  26. package/src/components/playbook/adoption-heatmap.tsx +69 -0
  27. package/src/components/playbook/journey-card.tsx +110 -0
  28. package/src/components/playbook/playbook-action-button.tsx +22 -0
  29. package/src/components/playbook/playbook-browser.tsx +143 -0
  30. package/src/components/playbook/playbook-card.tsx +102 -0
  31. package/src/components/playbook/playbook-detail-view.tsx +223 -0
  32. package/src/components/playbook/playbook-homepage.tsx +142 -0
  33. package/src/components/playbook/playbook-toc.tsx +90 -0
  34. package/src/components/playbook/playbook-updated-badge.tsx +23 -0
  35. package/src/components/playbook/related-docs.tsx +30 -0
  36. package/src/components/profiles/__tests__/learned-context-panel.test.tsx +175 -0
  37. package/src/components/profiles/context-proposal-review.tsx +7 -3
  38. package/src/components/profiles/learned-context-panel.tsx +116 -8
  39. package/src/components/profiles/profile-detail-view.tsx +6 -3
  40. package/src/components/settings/__tests__/auth-config-section.test.tsx +147 -0
  41. package/src/components/settings/api-key-form.tsx +5 -43
  42. package/src/components/settings/auth-config-section.tsx +10 -6
  43. package/src/components/settings/auth-status-badge.tsx +8 -0
  44. package/src/components/settings/budget-guardrails-section.tsx +403 -620
  45. package/src/components/settings/connection-test-control.tsx +63 -0
  46. package/src/components/settings/permissions-section.tsx +85 -75
  47. package/src/components/settings/permissions-sections.tsx +24 -0
  48. package/src/components/settings/presets-section.tsx +159 -0
  49. package/src/components/settings/pricing-registry-panel.tsx +164 -0
  50. package/src/components/shared/app-sidebar.tsx +2 -0
  51. package/src/components/shared/command-palette.tsx +30 -0
  52. package/src/components/shared/light-markdown.tsx +134 -0
  53. package/src/components/workflows/loop-status-view.tsx +8 -4
  54. package/src/components/workflows/workflow-status-view.tsx +16 -9
  55. package/src/lib/agents/learned-context.ts +27 -15
  56. package/src/lib/agents/learning-session.ts +234 -0
  57. package/src/lib/agents/pattern-extractor.ts +19 -0
  58. package/src/lib/agents/profiles/__tests__/sort.test.ts +42 -0
  59. package/src/lib/agents/profiles/sort.ts +7 -0
  60. package/src/lib/constants/settings.ts +1 -0
  61. package/src/lib/db/schema.ts +3 -0
  62. package/src/lib/docs/adoption.ts +105 -0
  63. package/src/lib/docs/journey-tracker.ts +21 -0
  64. package/src/lib/docs/reader.ts +102 -0
  65. package/src/lib/docs/types.ts +54 -0
  66. package/src/lib/docs/usage-stage.ts +60 -0
  67. package/src/lib/notifications/actionable.ts +18 -10
  68. package/src/lib/settings/__tests__/budget-guardrails.test.ts +86 -24
  69. package/src/lib/settings/budget-guardrails.ts +213 -85
  70. package/src/lib/settings/permission-presets.ts +150 -0
  71. package/src/lib/settings/runtime-setup.ts +71 -0
  72. package/src/lib/usage/__tests__/ledger.test.ts +2 -2
  73. package/src/lib/usage/__tests__/pricing-registry.test.ts +78 -0
  74. package/src/lib/usage/ledger.ts +1 -1
  75. package/src/lib/usage/pricing-registry.ts +570 -0
  76. package/src/lib/usage/pricing.ts +15 -95
  77. package/src/lib/utils/__tests__/learned-context-history.test.ts +171 -0
  78. package/src/lib/utils/learned-context-history.ts +150 -0
  79. package/src/lib/validators/__tests__/settings.test.ts +23 -16
  80. package/src/lib/validators/settings.ts +3 -9
  81. package/src/lib/workflows/engine.ts +18 -0
@@ -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
  }
@@ -0,0 +1,150 @@
1
+ import { getAllowedPermissions, addAllowedPermission, removeAllowedPermission } from "./permissions";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Preset definitions
5
+ // ---------------------------------------------------------------------------
6
+
7
+ export interface PermissionPreset {
8
+ id: string;
9
+ name: string;
10
+ description: string;
11
+ risk: "low" | "medium" | "high";
12
+ patterns: string[];
13
+ }
14
+
15
+ /**
16
+ * Built-in permission presets. Presets are layered — higher-risk presets
17
+ * include all patterns from lower-risk ones.
18
+ */
19
+ export const PRESETS: PermissionPreset[] = [
20
+ {
21
+ id: "read-only",
22
+ name: "Read Only",
23
+ description: "Safe read operations — no file mutations or shell commands",
24
+ risk: "low",
25
+ patterns: ["Read", "Glob", "Grep", "LS", "NotebookRead"],
26
+ },
27
+ {
28
+ id: "git-safe",
29
+ name: "Git Safe",
30
+ description: "Read operations plus file editing and git commands",
31
+ risk: "medium",
32
+ patterns: [
33
+ // Includes all read-only patterns
34
+ "Read",
35
+ "Glob",
36
+ "Grep",
37
+ "LS",
38
+ "NotebookRead",
39
+ // Plus write + git
40
+ "Write",
41
+ "Edit",
42
+ "Bash(command:git *)",
43
+ ],
44
+ },
45
+ {
46
+ id: "full-auto",
47
+ name: "Full Auto",
48
+ description: "All tools auto-approved — maximum agent autonomy",
49
+ risk: "high",
50
+ patterns: [
51
+ // All safe tools
52
+ "Read",
53
+ "Glob",
54
+ "Grep",
55
+ "LS",
56
+ "NotebookRead",
57
+ "Write",
58
+ "Edit",
59
+ // All bash and other tools
60
+ "Bash",
61
+ "NotebookEdit",
62
+ "WebFetch",
63
+ "WebSearch",
64
+ ],
65
+ },
66
+ ];
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Preset operations
70
+ // ---------------------------------------------------------------------------
71
+
72
+ /**
73
+ * Get a preset by ID, or undefined if not found.
74
+ */
75
+ export function getPreset(presetId: string): PermissionPreset | undefined {
76
+ return PRESETS.find((p) => p.id === presetId);
77
+ }
78
+
79
+ /**
80
+ * Check which presets are currently fully active (all patterns present).
81
+ */
82
+ export async function getActivePresets(): Promise<string[]> {
83
+ const current = await getAllowedPermissions();
84
+ const currentSet = new Set(current);
85
+
86
+ return PRESETS.filter((preset) =>
87
+ preset.patterns.every((p) => currentSet.has(p))
88
+ ).map((p) => p.id);
89
+ }
90
+
91
+ /**
92
+ * Check if a specific preset is fully active.
93
+ */
94
+ export async function isPresetActive(presetId: string): Promise<boolean> {
95
+ const preset = getPreset(presetId);
96
+ if (!preset) return false;
97
+
98
+ const current = await getAllowedPermissions();
99
+ const currentSet = new Set(current);
100
+ return preset.patterns.every((p) => currentSet.has(p));
101
+ }
102
+
103
+ /**
104
+ * Enable a preset — adds all its patterns to the permission store.
105
+ * Existing patterns are preserved (additive, no duplicates).
106
+ */
107
+ export async function applyPreset(presetId: string): Promise<void> {
108
+ const preset = getPreset(presetId);
109
+ if (!preset) {
110
+ throw new Error(`Unknown preset: ${presetId}`);
111
+ }
112
+
113
+ for (const pattern of preset.patterns) {
114
+ await addAllowedPermission(pattern);
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Disable a preset — removes only patterns that are unique to this preset
120
+ * (not present in any other active preset or individually approved).
121
+ *
122
+ * Patterns shared with other active presets are kept.
123
+ */
124
+ export async function removePreset(presetId: string): Promise<void> {
125
+ const preset = getPreset(presetId);
126
+ if (!preset) {
127
+ throw new Error(`Unknown preset: ${presetId}`);
128
+ }
129
+
130
+ // Gather patterns that belong to OTHER presets (excluding the one being removed)
131
+ const otherPresetPatterns = new Set<string>();
132
+ const activePresets = await getActivePresets();
133
+
134
+ for (const otherId of activePresets) {
135
+ if (otherId === presetId) continue;
136
+ const other = getPreset(otherId);
137
+ if (other) {
138
+ for (const p of other.patterns) {
139
+ otherPresetPatterns.add(p);
140
+ }
141
+ }
142
+ }
143
+
144
+ // Remove only patterns unique to this preset
145
+ for (const pattern of preset.patterns) {
146
+ if (!otherPresetPatterns.has(pattern)) {
147
+ await removeAllowedPermission(pattern);
148
+ }
149
+ }
150
+ }