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.
- package/README.md +35 -4
- package/package.json +3 -2
- package/src/__tests__/e2e/blueprint.test.ts +63 -0
- package/src/__tests__/e2e/cross-runtime.test.ts +77 -0
- package/src/__tests__/e2e/helpers.ts +286 -0
- package/src/__tests__/e2e/parallel-workflow.test.ts +120 -0
- package/src/__tests__/e2e/sequence-workflow.test.ts +109 -0
- package/src/__tests__/e2e/setup.ts +156 -0
- package/src/__tests__/e2e/single-task.test.ts +170 -0
- package/src/app/api/command-palette/recent/route.ts +41 -18
- package/src/app/api/context/batch/route.ts +44 -0
- package/src/app/api/permissions/presets/route.ts +80 -0
- package/src/app/api/playbook/status/route.ts +15 -0
- package/src/app/api/profiles/route.ts +23 -20
- package/src/app/api/settings/pricing/route.ts +15 -0
- package/src/app/costs/page.tsx +53 -43
- package/src/app/playbook/[slug]/page.tsx +76 -0
- package/src/app/playbook/page.tsx +54 -0
- package/src/app/profiles/page.tsx +7 -4
- package/src/app/settings/page.tsx +2 -2
- package/src/components/costs/cost-dashboard.tsx +226 -320
- package/src/components/dashboard/activity-feed.tsx +6 -2
- package/src/components/notifications/batch-proposal-review.tsx +150 -0
- package/src/components/notifications/notification-item.tsx +6 -3
- package/src/components/notifications/pending-approval-host.tsx +57 -11
- package/src/components/playbook/adoption-heatmap.tsx +69 -0
- package/src/components/playbook/journey-card.tsx +110 -0
- package/src/components/playbook/playbook-action-button.tsx +22 -0
- package/src/components/playbook/playbook-browser.tsx +143 -0
- package/src/components/playbook/playbook-card.tsx +102 -0
- package/src/components/playbook/playbook-detail-view.tsx +223 -0
- package/src/components/playbook/playbook-homepage.tsx +142 -0
- package/src/components/playbook/playbook-toc.tsx +90 -0
- package/src/components/playbook/playbook-updated-badge.tsx +23 -0
- package/src/components/playbook/related-docs.tsx +30 -0
- package/src/components/profiles/__tests__/learned-context-panel.test.tsx +175 -0
- package/src/components/profiles/context-proposal-review.tsx +7 -3
- package/src/components/profiles/learned-context-panel.tsx +116 -8
- package/src/components/profiles/profile-detail-view.tsx +6 -3
- package/src/components/settings/__tests__/auth-config-section.test.tsx +147 -0
- package/src/components/settings/api-key-form.tsx +5 -43
- package/src/components/settings/auth-config-section.tsx +10 -6
- package/src/components/settings/auth-status-badge.tsx +8 -0
- package/src/components/settings/budget-guardrails-section.tsx +403 -620
- package/src/components/settings/connection-test-control.tsx +63 -0
- package/src/components/settings/permissions-section.tsx +85 -75
- package/src/components/settings/permissions-sections.tsx +24 -0
- package/src/components/settings/presets-section.tsx +159 -0
- package/src/components/settings/pricing-registry-panel.tsx +164 -0
- package/src/components/shared/app-sidebar.tsx +2 -0
- package/src/components/shared/command-palette.tsx +30 -0
- package/src/components/shared/light-markdown.tsx +134 -0
- package/src/components/workflows/loop-status-view.tsx +8 -4
- package/src/components/workflows/workflow-status-view.tsx +16 -9
- package/src/lib/agents/learned-context.ts +27 -15
- package/src/lib/agents/learning-session.ts +234 -0
- package/src/lib/agents/pattern-extractor.ts +19 -0
- package/src/lib/agents/profiles/__tests__/sort.test.ts +42 -0
- package/src/lib/agents/profiles/sort.ts +7 -0
- package/src/lib/constants/settings.ts +1 -0
- package/src/lib/db/schema.ts +3 -0
- package/src/lib/docs/adoption.ts +105 -0
- package/src/lib/docs/journey-tracker.ts +21 -0
- package/src/lib/docs/reader.ts +102 -0
- package/src/lib/docs/types.ts +54 -0
- package/src/lib/docs/usage-stage.ts +60 -0
- package/src/lib/notifications/actionable.ts +18 -10
- package/src/lib/settings/__tests__/budget-guardrails.test.ts +86 -24
- package/src/lib/settings/budget-guardrails.ts +213 -85
- package/src/lib/settings/permission-presets.ts +150 -0
- package/src/lib/settings/runtime-setup.ts +71 -0
- package/src/lib/usage/__tests__/ledger.test.ts +2 -2
- package/src/lib/usage/__tests__/pricing-registry.test.ts +78 -0
- package/src/lib/usage/ledger.ts +1 -1
- package/src/lib/usage/pricing-registry.ts +570 -0
- package/src/lib/usage/pricing.ts +15 -95
- package/src/lib/utils/__tests__/learned-context-history.test.ts +171 -0
- package/src/lib/utils/learned-context-history.ts +150 -0
- package/src/lib/validators/__tests__/settings.test.ts +23 -16
- package/src/lib/validators/settings.ts +3 -9
- 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 =
|
|
178
|
-
return parsed
|
|
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
|
|
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
|
|
308
|
+
return normalized;
|
|
191
309
|
}
|
|
192
310
|
|
|
193
|
-
async function getUsageAggregates(
|
|
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
|
-
|
|
235
|
-
|
|
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
|
-
|
|
244
|
-
|
|
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}
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
|
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}
|
|
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}
|
|
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
|
|
522
|
-
|
|
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}
|
|
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
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
+
}
|