limn-review 0.1.135 → 0.1.136

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 (45) hide show
  1. package/dist/ai/budget.js +495 -0
  2. package/dist/ai/runtime.js +46 -1
  3. package/dist/api/plugins/rate-limit.js +7 -0
  4. package/dist/api/routes/ai-budget.js +36 -0
  5. package/dist/app.js +5 -0
  6. package/dist/coding/ai-fix/auto-fix.js +4 -0
  7. package/dist/db/export-account.js +2 -0
  8. package/dist/db/migrations/0125_account_ai_budget.sql +10 -0
  9. package/dist/db/migrations/0126_workspace_auto_diagrams.sql +8 -0
  10. package/dist/db/migrations/meta/_journal.json +14 -0
  11. package/dist/db/schema.sqlite.js +15 -0
  12. package/dist/platform/context.js +2 -0
  13. package/dist/platform/insights/sprint-report.js +5 -0
  14. package/dist/platform/migrations/0041_workspace_hourly_cost.sql +11 -0
  15. package/dist/platform/settings/routes.js +8 -0
  16. package/dist/platform/settings/schema.sqlite.js +3 -0
  17. package/dist/platform/settings/store.js +19 -0
  18. package/dist/platform/slack/cron.js +6 -1
  19. package/dist/platform/slack/report.js +8 -3
  20. package/dist/review/agent-context.js +2 -0
  21. package/dist/review/budget-gate.js +15 -0
  22. package/dist/review/ci-review/sweep.js +6 -0
  23. package/dist/review/claude-review/auto-settings.js +23 -2
  24. package/dist/review/claude-review/auto.js +14 -1
  25. package/dist/review/claude-review/manager.js +5 -0
  26. package/dist/review/diagrams/persist.js +17 -0
  27. package/dist/review/diagrams/trigger.js +20 -2
  28. package/dist/review/sprint-briefs/index.js +9 -3
  29. package/dist/review/ticket-review/sweep.js +8 -0
  30. package/dist/shared/ai-budget.js +426 -0
  31. package/dist/shared/flow-trend.js +7 -0
  32. package/dist/shared/index.js +1 -0
  33. package/dist/tracker/sprint/hooks.js +15 -2
  34. package/dist/tracker/sprint/routes.js +3 -1
  35. package/package.json +1 -1
  36. package/public/assets/AiFixTab-FwTFw_Ub.js +3 -0
  37. package/public/assets/ClaudeReviewTab-CJ6BRXTq.js +20 -0
  38. package/public/assets/{PrBotBehaviourTab-C4haeRPP.js → PrBotBehaviourTab-BkY9gzPl.js} +1 -1
  39. package/public/assets/index-BB6qXSAO.css +1 -0
  40. package/public/assets/index-Cconixya.js +80 -0
  41. package/public/index.html +2 -2
  42. package/public/assets/AiFixTab-CF2ALoVQ.js +0 -3
  43. package/public/assets/ClaudeReviewTab-2Tbx3UFN.js +0 -20
  44. package/public/assets/index-DDzOcB3p.js +0 -79
  45. package/public/assets/index-QULbVZYy.css +0 -1
@@ -0,0 +1,495 @@
1
+ // THE CLAUDE SPEND BUDGET — the ONE decision and the ONE gate (packages/shared ai-budget.ts holds
2
+ // the types, the defaults, the subscription rule and the estimate table).
3
+ //
4
+ // decideAiBudget(input) PURE. Settings + credential route + the latest usage reading +
5
+ // Limn's own recorded spend (ai_usage) + its typical cycle cost →
6
+ // AiBudgetStatus.
7
+ // aiBudgetStatus(accountId) gathers those inputs (and, when useful, runs the cached usage
8
+ // probe) and calls decideAiBudget. Never throws.
9
+ // allowAutomaticRun(accountId) THE GATE. Every AUTOMATIC model run asks it first (auto review and
10
+ // its thread checks, auto fix, the CI and ticket review sweepers,
11
+ // the post-review diagrams, the sprint after-read chain, the Slack
12
+ // digest's sprint summary). Over budget ⇒ the run is SKIPPED, not
13
+ // queued: the sweepers re-derive what is due every tick, so it
14
+ // becomes due again by itself once the week resets.
15
+ // Logged once per pause, never per tick.
16
+ //
17
+ // A run the READER starts is never refused here — the SPA reads GET /api/ai/budget and asks for a
18
+ // second press.
19
+ //
20
+ // SUBSCRIPTION: Limn's SHARE of the week = its own ai_usage spend in the current week ÷ the
21
+ // subscription's estimated weekly allowance (SUBSCRIPTION_ESTIMATES); likewise of the 5-hour session
22
+ // (the setting follows the weekly share unless the reader set its own). Over when EITHER share reaches
23
+ // its setting. Each window is [reset − length, reset) when Claude's reset has been read and is still
24
+ // ahead, otherwise rolling. The pure rules live in packages/shared (`decideAiBudget`, `aiUsageView`)
25
+ // so the static demo runs the same ones; the status carries percentages only.
26
+ //
27
+ // THE USAGE READING (login route only) gives the subscription type, the reset times and the header's
28
+ // account-wide figures — it never blocks. Two sources fold into ONE latest reading, kept in memory and
29
+ // persisted on `accounts.ai_budget_reading` so a restart keeps it:
30
+ // • every agent run's `rate_limit_event` (ai/runtime.ts taps every Query): the 5-hour, weekly and
31
+ // per-model weekly windows' resets and utilisation — a 0–1 FRACTION on this path (the CLI's
32
+ // header parsing), converted to 0–100 HERE, explicitly;
33
+ // • a cheap probe through the Agent SDK's EXPERIMENTAL usage method (login route, at most every
34
+ // PROBE_MIN_INTERVAL_MS, a 20s timeout, in the BACKGROUND — a request never waits for it): the
35
+ // subscription type and every window, utilisation ALREADY 0–100 (measured on a real login) and
36
+ // kept as-is. A failure keeps the last reading.
37
+ // There is no shared "≤ 1 means a fraction" guess: each path knows its own scale. Any throw or shape
38
+ // mismatch is "no reading" — the windows are then rolling and the subscription is the reader's pick.
39
+ //
40
+ // One budget per ACCOUNT (= per machine), never per workspace.
41
+ import { and, eq, gte, inArray } from 'drizzle-orm';
42
+ import { AI_BUDGET_DAY_MS, AI_BUDGET_WEEK_MS, aiUsageView, decideAiBudget, liveFiveHourReset, liveWeekReset, resolveAiBudgetSettings, rollingSpend, } from '../shared/index.js';
43
+ import { db, schema } from '../db/client.js';
44
+ import { LOCAL_ACCOUNT_ID } from '../auth/account.js';
45
+ import { resolveAiCredential, agentAuthOptions } from './credentials.js';
46
+ import { ISOLATED_MCP } from './agent-isolation.js';
47
+ import { claudeExecutableOptions, getAiRuntimeStatus, loadAgentSdk, setAgentMessageObserver, } from './runtime.js';
48
+ export const DAY_MS = AI_BUDGET_DAY_MS;
49
+ export const WEEK_MS = AI_BUDGET_WEEK_MS;
50
+ /** The window "typical cycle cost" is averaged over. */
51
+ export const CYCLE_COST_WINDOW_MS = 28 * DAY_MS;
52
+ /** The usage probe runs at most this often (the header polls every ~2 minutes while visible). */
53
+ export const PROBE_MIN_INTERVAL_MS = 5 * 60 * 1000;
54
+ const PROBE_TIMEOUT_MS = 20_000;
55
+ /** At most this many per-model windows are kept from one reading. */
56
+ const MAX_MODEL_WINDOWS = 8;
57
+ export { decideAiBudget, rollingSpend, liveWeekReset, liveFiveHourReset, aiUsageView };
58
+ // ---- folding the SDK's usage reports (pure) ---------------------------------------------------
59
+ function num(v) {
60
+ return typeof v === 'number' && Number.isFinite(v) ? v : null;
61
+ }
62
+ /** Epoch seconds or ms → ms. */
63
+ function epochMs(v) {
64
+ const n = num(v);
65
+ if (n == null || n <= 0)
66
+ return null;
67
+ return n < 1e12 ? n * 1000 : n;
68
+ }
69
+ /** A `rate_limit_event` utilisation is a 0–1 FRACTION (the CLI parses it from the rate-limit header):
70
+ * → 0–100, explicitly. Out of range or not a number → null. */
71
+ function eventFractionToPct(v) {
72
+ const n = num(v);
73
+ if (n == null || n < 0 || n > 1.5)
74
+ return null;
75
+ return n * 100;
76
+ }
77
+ /** Claude's window keys this module keeps: the 5-hour, the week, and per-model weeks. */
78
+ const MODEL_WINDOW_KEY = /^seven_day_[a-z0-9_]{1,40}$/;
79
+ /** Put one window into a reading under Claude's key. Unknown keys are ignored. Pure. */
80
+ function withWindow(reading, key, w) {
81
+ if (key === 'five_hour')
82
+ return { ...reading, fiveHour: w };
83
+ if (key === 'seven_day')
84
+ return { ...reading, week: w };
85
+ if (MODEL_WINDOW_KEY.test(key)) {
86
+ const models = { ...(reading.models ?? {}) };
87
+ if (!(key in models) && Object.keys(models).length >= MAX_MODEL_WINDOWS)
88
+ return reading;
89
+ models[key] = w;
90
+ return { ...reading, models };
91
+ }
92
+ return reading;
93
+ }
94
+ const isWindowKey = (k) => k === 'five_hour' || k === 'seven_day' || MODEL_WINDOW_KEY.test(k);
95
+ /**
96
+ * Fold one Agent SDK message into the reading. Returns the NEW reading (the windows this message names
97
+ * merged over the rest), or null when the message names none. Pure.
98
+ *
99
+ * Reads `rate_limit_event.rate_limit_info`: every window in `unifiedWindows` (when the CLI sends
100
+ * them) and otherwise the one named by `rateLimitType` ('five_hour', 'seven_day', 'seven_day_opus',
101
+ * …). Reset times arrive in epoch seconds. Utilisation is a fraction on this path.
102
+ */
103
+ export function foldRateLimitEvent(reading, message, nowMs) {
104
+ if (!message || typeof message !== 'object')
105
+ return null;
106
+ const m = message;
107
+ if (m.type !== 'rate_limit_event' || !m.rate_limit_info || typeof m.rate_limit_info !== 'object')
108
+ return null;
109
+ const info = m.rate_limit_info;
110
+ const unified = info.unifiedWindows && typeof info.unifiedWindows === 'object'
111
+ ? info.unifiedWindows
112
+ : null;
113
+ const sources = [];
114
+ if (unified) {
115
+ for (const [k, v] of Object.entries(unified)) {
116
+ if (isWindowKey(k) && v && typeof v === 'object')
117
+ sources.push([k, v]);
118
+ }
119
+ }
120
+ else if (typeof info.rateLimitType === 'string' && isWindowKey(info.rateLimitType)) {
121
+ sources.push([info.rateLimitType, info]);
122
+ }
123
+ let out = {};
124
+ let any = false;
125
+ for (const [key, src] of sources) {
126
+ let utilization = eventFractionToPct(src.utilization);
127
+ if (utilization == null && src.status === 'rejected')
128
+ utilization = 100;
129
+ const resetsAtMs = epochMs(src.resetsAt);
130
+ if (utilization == null && resetsAtMs == null)
131
+ continue;
132
+ out = withWindow(out, key, { utilization, resetsAtMs, observedAtMs: nowMs });
133
+ any = true;
134
+ }
135
+ return any ? mergeReadings(reading, out) : null;
136
+ }
137
+ /** Parse the experimental usage method's answer. Its utilisation is ALREADY 0–100 (measured on a
138
+ * real login: `seven_day.utilization: 31`) and is kept as-is. Null on any shape mismatch. Pure. */
139
+ export function parseUsageProbe(resp, nowMs) {
140
+ if (!resp || typeof resp !== 'object')
141
+ return null;
142
+ const r = resp;
143
+ const subscriptionType = typeof r.subscription_type === 'string' && r.subscription_type !== '' ? r.subscription_type : null;
144
+ let out = {};
145
+ let any = false;
146
+ if (r.rate_limits_available === true && r.rate_limits && typeof r.rate_limits === 'object') {
147
+ for (const [key, v] of Object.entries(r.rate_limits)) {
148
+ if (!isWindowKey(key) || !v || typeof v !== 'object')
149
+ continue;
150
+ const o = v;
151
+ const u = num(o.utilization);
152
+ const t = typeof o.resets_at === 'string' ? Date.parse(o.resets_at) : NaN;
153
+ const utilization = u != null && u >= 0 && u <= 150 ? u : null;
154
+ const resetsAtMs = Number.isFinite(t) ? t : null;
155
+ if (utilization == null && resetsAtMs == null)
156
+ continue;
157
+ out = withWindow(out, key, { utilization, resetsAtMs, observedAtMs: nowMs });
158
+ any = true;
159
+ }
160
+ }
161
+ if (!any && subscriptionType == null)
162
+ return null;
163
+ return { ...out, subscriptionType };
164
+ }
165
+ function storedWindow(v) {
166
+ if (!v || typeof v !== 'object')
167
+ return null;
168
+ const w = v;
169
+ const observedAtMs = num(w.observedAtMs);
170
+ return observedAtMs == null ? null : { utilization: num(w.utilization), resetsAtMs: num(w.resetsAtMs), observedAtMs };
171
+ }
172
+ /** A stored reading of any age → today's shape. Never throws. */
173
+ export function normaliseStoredReading(raw) {
174
+ if (!raw || typeof raw !== 'object')
175
+ return null;
176
+ const r = raw;
177
+ const week = storedWindow(r.week);
178
+ const fiveHour = storedWindow(r.fiveHour);
179
+ let models = null;
180
+ if (r.models && typeof r.models === 'object') {
181
+ for (const [k, v] of Object.entries(r.models).slice(0, MAX_MODEL_WINDOWS)) {
182
+ const w = MODEL_WINDOW_KEY.test(k) ? storedWindow(v) : null;
183
+ if (w)
184
+ (models ??= {})[k] = w;
185
+ }
186
+ }
187
+ const subscriptionType = typeof r.subscriptionType === 'string' ? r.subscriptionType : null;
188
+ if (week == null && fiveHour == null && models == null && subscriptionType == null)
189
+ return null;
190
+ return { week, fiveHour, models, subscriptionType };
191
+ }
192
+ // ---- the reading: memory + persistence --------------------------------------------------------
193
+ let memReading; // undefined = not loaded yet
194
+ async function loadReading() {
195
+ if (memReading !== undefined)
196
+ return memReading;
197
+ try {
198
+ const rows = await db
199
+ .select({ r: schema.accounts.aiBudgetReading })
200
+ .from(schema.accounts)
201
+ .where(eq(schema.accounts.id, LOCAL_ACCOUNT_ID))
202
+ .limit(1)
203
+ .execute();
204
+ memReading = normaliseStoredReading(rows[0]?.r);
205
+ }
206
+ catch {
207
+ memReading = null;
208
+ }
209
+ return memReading;
210
+ }
211
+ /** One window merged: the new one wins, but keeps the known reset when it read none. */
212
+ function mergeWindow(prev, next) {
213
+ if (next == null)
214
+ return prev ?? null;
215
+ if (next.resetsAtMs == null && prev?.resetsAtMs != null)
216
+ return { ...next, resetsAtMs: prev.resetsAtMs };
217
+ return next;
218
+ }
219
+ /** Merge two readings (pure): each window independently; what `next` does not mention is kept (an
220
+ * event never names the subscription, and names one or two windows). */
221
+ export function mergeReadings(prev, next) {
222
+ const models = { ...(prev?.models ?? {}) };
223
+ for (const [k, w] of Object.entries(next.models ?? {})) {
224
+ const merged = mergeWindow(models[k], w);
225
+ if (merged && (k in models || Object.keys(models).length < MAX_MODEL_WINDOWS))
226
+ models[k] = merged;
227
+ }
228
+ return {
229
+ week: mergeWindow(prev?.week, next.week),
230
+ fiveHour: mergeWindow(prev?.fiveHour, next.fiveHour),
231
+ models: Object.keys(models).length === 0 ? null : models,
232
+ subscriptionType: next.subscriptionType ?? prev?.subscriptionType ?? null,
233
+ };
234
+ }
235
+ function storeReading(next) {
236
+ memReading = mergeReadings(memReading ?? null, next);
237
+ const value = memReading;
238
+ void db
239
+ .update(schema.accounts)
240
+ .set({ aiBudgetReading: value })
241
+ .where(eq(schema.accounts.id, LOCAL_ACCOUNT_ID))
242
+ .execute()
243
+ .catch(() => {
244
+ /* in memory still; the next reading tries again */
245
+ });
246
+ }
247
+ /** The observer ai/runtime.ts calls with every agent message. */
248
+ export function noteAgentMessage(message, nowMs = Date.now()) {
249
+ const next = foldRateLimitEvent(null, message, nowMs);
250
+ if (next == null)
251
+ return;
252
+ // ⚠ Merge over the PERSISTED reading, never over "not loaded yet": a run's event arriving before
253
+ // the boot load finished would otherwise overwrite the stored subscription type and the other
254
+ // windows (storeReading merges `next` over what is in memory).
255
+ if (memReading === undefined) {
256
+ void loadReading().then(() => storeReading(next));
257
+ return;
258
+ }
259
+ storeReading(next);
260
+ }
261
+ /** Install the observer. Called once at boot when AI is on. */
262
+ export function installAiBudgetObserver() {
263
+ void loadReading();
264
+ setAgentMessageObserver((m) => noteAgentMessage(m));
265
+ }
266
+ // ---- the usage probe ----------------------------------------------------------------------------
267
+ let lastProbeAtMs = 0;
268
+ let probeInFlight = null;
269
+ /** Ask the Agent SDK's experimental usage method. Spawns the CLI with an empty input stream (no model
270
+ * call), asks, closes. Throws on anything unexpected; the caller swallows. */
271
+ const sdkUsageProbe = async () => {
272
+ const sdk = await loadAgentSdk();
273
+ let release = () => { };
274
+ const held = new Promise((r) => {
275
+ release = r;
276
+ });
277
+ // An input stream that never yields a turn: the CLI starts, answers control requests, and spends
278
+ // nothing.
279
+ async function* noTurns() {
280
+ await held;
281
+ }
282
+ const q = sdk.query({
283
+ prompt: noTurns(),
284
+ options: {
285
+ allowedTools: [],
286
+ settingSources: [],
287
+ ...ISOLATED_MCP,
288
+ ...claudeExecutableOptions(),
289
+ ...agentAuthOptions(),
290
+ },
291
+ });
292
+ try {
293
+ const method = q
294
+ .usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET;
295
+ if (typeof method !== 'function')
296
+ throw new Error('usage method not available');
297
+ let timer;
298
+ try {
299
+ return await Promise.race([
300
+ method.call(q, { skipBehaviors: true }),
301
+ new Promise((_, reject) => {
302
+ timer = setTimeout(() => reject(new Error('usage probe timed out')), PROBE_TIMEOUT_MS);
303
+ }),
304
+ ]);
305
+ }
306
+ finally {
307
+ if (timer)
308
+ clearTimeout(timer);
309
+ }
310
+ }
311
+ finally {
312
+ release();
313
+ try {
314
+ q.close?.();
315
+ }
316
+ catch {
317
+ /* already gone */
318
+ }
319
+ }
320
+ };
321
+ let probeImpl = sdkUsageProbe;
322
+ /** Test hook. */
323
+ export function _setUsageProbeForTest(p) {
324
+ probeImpl = p ?? sdkUsageProbe;
325
+ lastProbeAtMs = 0;
326
+ probeInFlight = null;
327
+ }
328
+ /** Test hook. */
329
+ export function _resetAiBudgetForTest() {
330
+ memReading = undefined;
331
+ lastProbeAtMs = 0;
332
+ probeInFlight = null;
333
+ pausedAccounts.clear();
334
+ }
335
+ /** Start the probe IN THE BACKGROUND when it can help: login route (the caller checks), runtime
336
+ * ready, not run in the last PROBE_MIN_INTERVAL_MS. Single-flight. Never throws, never awaited by a
337
+ * request: the caller serves the cached reading. Returns the in-flight promise for tests. */
338
+ export function kickUsageProbe(nowMs = Date.now()) {
339
+ if (probeInFlight)
340
+ return probeInFlight;
341
+ if (nowMs - lastProbeAtMs < PROBE_MIN_INTERVAL_MS)
342
+ return null;
343
+ if (probeImpl === sdkUsageProbe && getAiRuntimeStatus().runtime !== 'ready')
344
+ return null;
345
+ lastProbeAtMs = nowMs;
346
+ probeInFlight = (async () => {
347
+ try {
348
+ await loadReading();
349
+ const parsed = parseUsageProbe(await probeImpl(), Date.now());
350
+ if (parsed)
351
+ storeReading(parsed);
352
+ }
353
+ catch {
354
+ /* no reading — the last one stays */
355
+ }
356
+ finally {
357
+ probeInFlight = null;
358
+ }
359
+ })();
360
+ return probeInFlight;
361
+ }
362
+ // ---- settings -----------------------------------------------------------------------------------
363
+ export async function readAiBudgetSettings(accountId) {
364
+ const rows = await db
365
+ .select({ s: schema.accounts.aiBudgetSettings })
366
+ .from(schema.accounts)
367
+ .where(eq(schema.accounts.id, accountId))
368
+ .limit(1)
369
+ .execute();
370
+ const raw = rows[0]?.s ?? null;
371
+ return { settings: resolveAiBudgetSettings(raw), isDefault: raw == null };
372
+ }
373
+ /** Merge a VALIDATED body over the stored settings and store the whole resolved object. */
374
+ export async function writeAiBudgetSettings(accountId, body) {
375
+ const { settings: current } = await readAiBudgetSettings(accountId);
376
+ const next = resolveAiBudgetSettings({ ...current, ...body });
377
+ await db
378
+ .update(schema.accounts)
379
+ .set({ aiBudgetSettings: next })
380
+ .where(eq(schema.accounts.id, accountId))
381
+ .execute();
382
+ return next;
383
+ }
384
+ // ---- the status and the gate ----------------------------------------------------------------------
385
+ async function readLedger(accountId, sinceMs) {
386
+ const { aiUsage } = schema;
387
+ const rows = await db
388
+ .select({ at: aiUsage.occurredAt, usd: aiUsage.costUsd, inTok: aiUsage.inputTokens, outTok: aiUsage.outputTokens })
389
+ .from(aiUsage)
390
+ .where(and(eq(aiUsage.accountId, accountId), gte(aiUsage.occurredAt, new Date(sinceMs))))
391
+ .execute();
392
+ return rows.map((r) => ({
393
+ atMs: r.at.getTime(),
394
+ usd: Number(r.usd) || 0,
395
+ inputTokens: r.inTok ?? null,
396
+ outputTokens: r.outTok ?? null,
397
+ }));
398
+ }
399
+ /** The account's typical automatic-review cycle: the mean recorded cost of a Claude review and of a
400
+ * diagram run over the last 28 days, and whether any of its workspaces draws diagrams by itself. */
401
+ export async function readCycleCosts(accountId, nowMs) {
402
+ const { aiUsage, workspaces } = schema;
403
+ const [rows, ws] = await Promise.all([
404
+ db
405
+ .select({ feature: aiUsage.feature, usd: aiUsage.costUsd })
406
+ .from(aiUsage)
407
+ .where(and(eq(aiUsage.accountId, accountId), inArray(aiUsage.feature, ['claude_review', 'sequence_diagram']), gte(aiUsage.occurredAt, new Date(nowMs - CYCLE_COST_WINDOW_MS))))
408
+ .execute(),
409
+ db
410
+ .select({ id: workspaces.id })
411
+ .from(workspaces)
412
+ .where(and(eq(workspaces.accountId, accountId), eq(workspaces.autoDiagramsEnabled, true)))
413
+ .limit(1)
414
+ .execute(),
415
+ ]);
416
+ const mean = (feature) => {
417
+ const xs = rows.filter((r) => r.feature === feature).map((r) => Number(r.usd)).filter((n) => Number.isFinite(n));
418
+ return xs.length === 0 ? null : xs.reduce((a, b) => a + b, 0) / xs.length;
419
+ };
420
+ return { reviewMeanUsd: mean('claude_review'), diagramMeanUsd: mean('sequence_diagram'), autoDiagrams: ws.length > 0 };
421
+ }
422
+ const NO_CYCLE_COSTS = { reviewMeanUsd: null, diagramMeanUsd: null, autoDiagrams: false };
423
+ const OFF_STATUS = (route) => ({
424
+ enabled: false,
425
+ mode: 'off',
426
+ route,
427
+ subscription: null,
428
+ apiKey: null,
429
+ overBudget: false,
430
+ limitedBy: null,
431
+ reason: null,
432
+ resumesAt: null,
433
+ });
434
+ /** Local midnight on this machine (the server IS the reader's machine), for "today". */
435
+ function localMidnightMs(nowMs) {
436
+ const d = new Date(nowMs);
437
+ d.setHours(0, 0, 0, 0);
438
+ return d.getTime();
439
+ }
440
+ /**
441
+ * Everything GET /api/ai/budget returns, from ONE set of reads: the settings, the decision and the
442
+ * header's usage view. Serves the CACHED reading; the probe (when asked) refreshes it in the
443
+ * background for the next call.
444
+ */
445
+ export async function aiBudgetSnapshot(accountId, opts = {}) {
446
+ const nowMs = opts.nowMs ?? Date.now();
447
+ const route = resolveAiCredential().route;
448
+ const { settings, isDefault } = await readAiBudgetSettings(accountId);
449
+ const probe = opts.probe ?? 'when_on';
450
+ if (route === 'login' && (probe === 'always' || (probe === 'when_on' && settings.enabled)))
451
+ kickUsageProbe(nowMs);
452
+ const [reading, ledger, costs] = await Promise.all([
453
+ route === 'login' ? loadReading() : Promise.resolve(null),
454
+ readLedger(accountId, Math.min(nowMs - WEEK_MS, localMidnightMs(nowMs))),
455
+ settings.enabled && route === 'login' ? readCycleCosts(accountId, nowMs) : Promise.resolve(NO_CYCLE_COSTS),
456
+ ]);
457
+ return {
458
+ settings,
459
+ isDefault,
460
+ status: decideAiBudget({ settings, route, reading, ledger, costs, nowMs }),
461
+ usage: aiUsageView({ settings, route, reading, ledger, nowMs, todayFromMs: localMidnightMs(nowMs) }),
462
+ };
463
+ }
464
+ /** The budget's status for an account. Never throws: a failed read is "off" (never blocks). */
465
+ export async function aiBudgetStatus(accountId, opts = {}) {
466
+ try {
467
+ return (await aiBudgetSnapshot(accountId, opts)).status;
468
+ }
469
+ catch {
470
+ return OFF_STATUS(resolveAiCredential().route);
471
+ }
472
+ }
473
+ // account → when its current pause was first logged (log once per pause).
474
+ const pausedAccounts = new Map();
475
+ /**
476
+ * THE GATE before every AUTOMATIC model run. True ⇒ go. False ⇒ skip this run (do not queue it);
477
+ * the caller's next tick asks again. `what` names the run for the one log line.
478
+ */
479
+ export async function allowAutomaticRun(accountId, what, log, statusOf = (a) => aiBudgetStatus(a)) {
480
+ const status = await statusOf(accountId);
481
+ if (!status.overBudget) {
482
+ if (pausedAccounts.delete(accountId))
483
+ log?.info('claude budget: back under the limit, automatic runs resume');
484
+ return true;
485
+ }
486
+ if (!pausedAccounts.has(accountId)) {
487
+ pausedAccounts.set(accountId, Date.now());
488
+ log?.info(`claude budget: automatic runs paused (${what} skipped)${status.resumesAt ? `; resumes about ${status.resumesAt}` : ''}`);
489
+ }
490
+ return false;
491
+ }
492
+ export function buildAiBudgetGate(log) {
493
+ return { allowAutomaticRun: (accountId, what) => allowAutomaticRun(accountId, what, log) };
494
+ }
495
+ //# sourceMappingURL=budget.js.map
@@ -548,7 +548,52 @@ export function installAiRuntime(onProgress, opts) {
548
548
  export function aiRuntimePins() {
549
549
  return runtime.pins();
550
550
  }
551
- export const loadAgentSdk = () => runtime.load('@anthropic-ai/claude-agent-sdk');
551
+ export const loadAgentSdk = async () => tapAgentSdk(await runtime.load('@anthropic-ai/claude-agent-sdk'));
552
+ let agentMessageObserver = null;
553
+ /** Install (or clear) the one observer every agent run's messages pass through. */
554
+ export function setAgentMessageObserver(fn) {
555
+ agentMessageObserver = fn;
556
+ }
557
+ function observe(message) {
558
+ if (!agentMessageObserver)
559
+ return;
560
+ try {
561
+ agentMessageObserver(message);
562
+ }
563
+ catch {
564
+ /* never into the run */
565
+ }
566
+ }
567
+ /** Wrap a Query (an AsyncGenerator with extra methods) so each yielded message is observed. */
568
+ export function tapQuery(q) {
569
+ const proxy = new Proxy(q, {
570
+ get(target, prop) {
571
+ if (prop === 'next') {
572
+ return async (...args) => {
573
+ const r = await target.next(...args);
574
+ if (!r.done)
575
+ observe(r.value);
576
+ return r;
577
+ };
578
+ }
579
+ if (prop === Symbol.asyncIterator)
580
+ return () => proxy;
581
+ const v = Reflect.get(target, prop, target);
582
+ return typeof v === 'function' ? v.bind(target) : v;
583
+ },
584
+ });
585
+ return proxy;
586
+ }
587
+ const tappedSdks = new WeakMap();
588
+ function tapAgentSdk(mod) {
589
+ const hit = tappedSdks.get(mod);
590
+ if (hit)
591
+ return hit;
592
+ const query = ((params) => tapQuery(mod.query(params)));
593
+ const tapped = { ...mod, query };
594
+ tappedSdks.set(mod, tapped);
595
+ return tapped;
596
+ }
552
597
  export const loadAnthropicSdk = () => runtime.load('@anthropic-ai/sdk');
553
598
  export const loadMcpSdk = () => runtime.load('@modelcontextprotocol/sdk');
554
599
  export const loadZodModule = () => runtime.load('zod');
@@ -65,6 +65,13 @@ function tierFor(method, path) {
65
65
  // over the user's own bandwidth, single-flight in ml/runtime.ts), the same bucket, spelled exactly.
66
66
  if (path === '/api/ml/install')
67
67
  return [TIERS.sync];
68
+ // THE CLAUDE SPEND BUDGET: GET/PUT /api/ai/budget (ai/budget.ts). FOLLOWING THE TOKEN: no model
69
+ // call and no GitHub call. The PUT is one validated UPDATE of one column; the GET (the header's usage
70
+ // item polls it every ~2 minutes) reads the ledger and may START the usage probe in the background,
71
+ // which is single-flight and at most once per 5 minutes inside ai/budget.ts — a loop cannot
72
+ // multiply it. `read`, decided rather than inherited; EXACT `===`. No other usage route exists.
73
+ if (path === '/api/ai/budget')
74
+ return [TIERS.read];
68
75
  // ---- Workspace CRUD: `read`, RECORDED rather than inherited ----
69
76
  //
70
77
  // The six surviving routes (list / create / rename+reassign / delete / move-one-repo-in /
@@ -0,0 +1,36 @@
1
+ import { aiBudgetSettingsView, validateAiBudgetBody, } from '../../shared/index.js';
2
+ import { config } from '../../config.js';
3
+ import { accountIdOf } from '../plugins/auth.js';
4
+ import { aiBudgetSnapshot, installAiBudgetObserver, writeAiBudgetSettings } from '../../ai/budget.js';
5
+ // THE CLAUDE SPEND BUDGET (ai/budget.ts; types in packages/shared ai-budget.ts).
6
+ //
7
+ // GET /api/ai/budget → AiBudgetResponse: the settings (defaults filled in; `weeklyUsd` null off
8
+ // the API-key route), whether anything is stored, the ONE decision
9
+ // (`status`) and the header's usage view (`usage`). Serves the CACHED usage
10
+ // reading and, on the login route, starts the usage probe IN THE BACKGROUND
11
+ // (at most every 5 minutes, 20s timeout, no model call) — it never waits.
12
+ // The header polls it every ~2 minutes while the tab is visible.
13
+ // PUT /api/ai/budget AiBudgetBody (any subset; validated) → AiBudgetResponse.
14
+ //
15
+ // Registered only when `config.aiEnabled`. Registering it also installs the message observer every
16
+ // agent run's `rate_limit_event`s pass through, so the reading exists before anyone opens Settings.
17
+ export async function aiBudgetRoutes(app) {
18
+ if (!config.aiEnabled)
19
+ return;
20
+ installAiBudgetObserver();
21
+ const respond = async (accountId) => {
22
+ const { settings, isDefault, status, usage } = await aiBudgetSnapshot(accountId, { probe: 'always' });
23
+ // A subscription carries no US$ on the wire: the API key's weekly figure is sent only on that route.
24
+ return { settings: aiBudgetSettingsView(settings, status.route), isDefault, status, usage };
25
+ };
26
+ app.get('/api/ai/budget', async (req) => respond(accountIdOf(req)));
27
+ app.put('/api/ai/budget', async (req, reply) => {
28
+ const accountId = accountIdOf(req);
29
+ const problem = validateAiBudgetBody(req.body);
30
+ if (problem != null)
31
+ return reply.code(400).send({ error: 'InvalidAiBudget', message: problem });
32
+ await writeAiBudgetSettings(accountId, req.body);
33
+ return respond(accountId);
34
+ });
35
+ }
36
+ //# sourceMappingURL=ai-budget.js.map
package/dist/app.js CHANGED
@@ -8,6 +8,7 @@ import { corsOriginDelegate, registerCrossOriginGuard, registerHostGuard, regist
8
8
  import { registerRateLimit } from './api/plugins/rate-limit.js';
9
9
  import { healthRoutes } from './api/routes/health.js';
10
10
  import { aiRuntimeRoutes } from './api/routes/ai-runtime.js';
11
+ import { aiBudgetRoutes } from './api/routes/ai-budget.js';
11
12
  import { mlRuntimeRoutes } from './api/routes/ml-runtime.js';
12
13
  import { registerAgenticRoutes } from './review/agentic.js';
13
14
  import { registerTracker } from './tracker/index.js';
@@ -200,6 +201,10 @@ export async function buildApp() {
200
201
  // LIMN_AI_DISABLED.
201
202
  if (config.aiEnabled)
202
203
  await app.register(aiRuntimeRoutes);
204
+ // The Claude spend budget (ai/budget.ts): GET/PUT /api/ai/budget. Not registered under
205
+ // LIMN_AI_DISABLED.
206
+ if (config.aiEnabled)
207
+ await app.register(aiBudgetRoutes);
203
208
  // "Set up" for local bot-comment scoring (ml/runtime.ts). Not registered under LIMN_ML_DISABLED.
204
209
  if (config.mlLocalEnabled)
205
210
  await app.register(mlRuntimeRoutes);
@@ -48,6 +48,7 @@ import { isFixRunning, loadReviewSeed, startReviewFix } from './manager.js';
48
48
  import { parseChangeReport, parseReviewItems } from './persist.js';
49
49
  import { getFixPrContext } from './pr-context.js';
50
50
  import { readWorkspaceAutoFixSettingsForPr } from '../../review/claude-review/auto-settings.js';
51
+ import { automaticRunAllowed } from '../../review/budget-gate.js';
51
52
  // At most AUTO_FIX_DAILY_CAP (shared) AUTO fixes per PR in any rolling AUTO_FIX_WINDOW_MS.
52
53
  export { AUTO_FIX_DAILY_CAP };
53
54
  export const AUTO_FIX_WINDOW_MS = 24 * 60 * 60 * 1000;
@@ -137,6 +138,9 @@ export async function maybeStartAutoFix(ctx, input, deps = defaultDeps, nowMs =
137
138
  const ws = await readWorkspaceAutoFixSettingsForPr(ctx, accountId, prId);
138
139
  if (!ws?.enabled)
139
140
  return skip('off');
141
+ // THE CLAUDE SPEND BUDGET (ai/budget.ts): over it, no auto fix — the next auto review decides again.
142
+ if (!(await automaticRunAllowed(ctx, accountId, 'auto fix')))
143
+ return skip('over_budget');
140
144
  // WHAT an auto fix carries is the workspace's "Always include" sections (AutoFixSettings).
141
145
  const include = ws.settings.include;
142
146
  const loaded = await deps.loadReviewSeed(ctx, {
@@ -51,6 +51,8 @@ export async function exportAccountData(accountId) {
51
51
  largePrCodeLocThreshold: accounts.largePrCodeLocThreshold,
52
52
  blastRadiusConfig: accounts.blastRadiusConfig,
53
53
  myTurnSettings: accounts.myTurnSettings,
54
+ aiBudgetSettings: accounts.aiBudgetSettings,
55
+ aiBudgetReading: accounts.aiBudgetReading,
54
56
  lastLoginAt: accounts.lastLoginAt,
55
57
  lastActiveAt: accounts.lastActiveAt,
56
58
  createdAt: accounts.createdAt,
@@ -0,0 +1,10 @@
1
+ -- THE CLAUDE SPEND BUDGET (core; packages/shared ai-budget.ts, ai/budget.ts).
2
+ --
3
+ -- accounts.ai_budget_settings: the optional per-machine budget that pauses AUTOMATIC model runs —
4
+ -- the subscription thresholds (5-hour / 7-day %), the subscription picked for the estimate, and the
5
+ -- API-key US$ budget. NULL = never set = off. One writer: PUT /api/ai/budget.
6
+ -- accounts.ai_budget_reading: the latest subscription usage reading (utilisation + reset time per
7
+ -- window), so a restart keeps it. Both nullable, no backfill.
8
+ ALTER TABLE `accounts` ADD `ai_budget_settings` text;
9
+ --> statement-breakpoint
10
+ ALTER TABLE `accounts` ADD `ai_budget_reading` text;
@@ -0,0 +1,8 @@
1
+ -- AUTOMATIC DESIGN DIAGRAMS, PER WORKSPACE — `workspaces.auto_diagrams_enabled`. After every
2
+ -- succeeded Claude review (manual or auto), review/diagrams/trigger.ts may queue the PR's design
3
+ -- diagram and its tickets' story diagrams. This is that step's switch, edited in Settings beside
4
+ -- auto review and written by `setWorkspaceAutoReview` (review/claude-review/auto-settings.ts).
5
+ -- ⚠ DEFAULT TRUE (1) and NOT NULL: diagrams were drawn after every review before this column
6
+ -- existed, so every workspace, existing and new, keeps that until someone switches it off. Off
7
+ -- stops only the AUTOMATIC draw; the Draw / Redraw button still works.
8
+ ALTER TABLE `workspaces` ADD `auto_diagrams_enabled` integer DEFAULT 1 NOT NULL;