@saadnvd1/agent-os 0.3.499 → 0.3.502

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 (64) hide show
  1. package/README.md +77 -0
  2. package/app/api/usage/rollups/route.ts +36 -0
  3. package/components/Chat/Activity.test.ts +22 -0
  4. package/components/Chat/Activity.tsx +17 -5
  5. package/components/Chat/ChatPanel.tsx +7 -1
  6. package/components/Devices/ConnectCard.tsx +10 -12
  7. package/components/SessionList/RowParts.tsx +13 -4
  8. package/components/SessionList/SessionList.types.ts +1 -0
  9. package/components/Tasks/TaskRow.tsx +2 -0
  10. package/components/Usage/RatchetView.tsx +249 -0
  11. package/components/Usage/UsageDialog.tsx +7 -1
  12. package/components/Usage/WindowCard.tsx +2 -1
  13. package/components/views/types.ts +2 -0
  14. package/data/chat/useChat.ts +7 -0
  15. package/data/usage/index.ts +28 -0
  16. package/lib/chat/activity.ts +24 -2
  17. package/lib/chat/drivers/claude-mapper.ts +8 -0
  18. package/lib/chat/drivers/claude.ts +46 -0
  19. package/lib/chat/drivers/codex-mapper.ts +5 -1
  20. package/lib/chat/drivers/pi-mapper.ts +4 -0
  21. package/lib/chat/events.ts +15 -1
  22. package/lib/chat/registry.ts +8 -1
  23. package/lib/chat/runner.ts +27 -0
  24. package/lib/chat/worker/host.ts +47 -0
  25. package/lib/chat/worker/main.ts +1 -0
  26. package/lib/chat/worker/protocol.ts +14 -1
  27. package/lib/connect/cli.ts +51 -72
  28. package/lib/connect/cloudflared.ts +172 -0
  29. package/lib/connect/config.ts +75 -26
  30. package/lib/connect/control-api.ts +107 -48
  31. package/lib/connect/enrol.ts +33 -107
  32. package/lib/connect/serve.ts +177 -94
  33. package/lib/connect/supervise.ts +281 -0
  34. package/lib/db/migrations.ts +72 -0
  35. package/lib/orchestrator/describe.ts +1 -1
  36. package/lib/orchestrator/facts.ts +5 -1
  37. package/lib/security/auth.ts +20 -4
  38. package/lib/security/gate.ts +8 -5
  39. package/lib/security/reach.ts +1 -1
  40. package/lib/sidebar/shelves.ts +7 -0
  41. package/lib/status/collect.ts +7 -1
  42. package/lib/tasks/index.ts +8 -0
  43. package/lib/usage/calls.ts +284 -0
  44. package/lib/usage/check-runs.ts +40 -19
  45. package/lib/usage/config.ts +56 -0
  46. package/lib/usage/nightly.ts +157 -0
  47. package/lib/usage/quality.ts +239 -0
  48. package/lib/usage/ratchet.ts +275 -0
  49. package/lib/usage/rate-limits.ts +192 -0
  50. package/lib/usage/rollup.ts +90 -0
  51. package/lib/usage/span.ts +152 -0
  52. package/lib/usage/transcript-import.ts +226 -0
  53. package/lib/usage/weights.ts +64 -0
  54. package/lib/usage/windows.ts +27 -2
  55. package/package.json +2 -4
  56. package/server.ts +7 -4
  57. package/stores/usageUi.ts +3 -3
  58. package/lib/connect/cert-check.ts +0 -32
  59. package/lib/connect/cloudflare-dns.ts +0 -56
  60. package/lib/connect/frames.ts +0 -72
  61. package/lib/connect/identity.ts +0 -98
  62. package/lib/connect/mux.ts +0 -110
  63. package/lib/connect/stream.ts +0 -106
  64. package/lib/connect/tunnel.ts +0 -98
@@ -0,0 +1,284 @@
1
+ /**
2
+ * The per-response usage ledger (usage_calls): one row per Claude API
3
+ * response, keyed by its message id. Claude Code writes one assistant message
4
+ * per content block under the same id, with usage growing, so a row is
5
+ * upserted keeping the highest of each field.
6
+ */
7
+
8
+ import { db } from "../db";
9
+ import { ANTHROPIC, sessionRole } from "../models/roles";
10
+ import { ctxOf, weighted, type TokenRow } from "./weights";
11
+
12
+ export type CallKind = "main" | "sub" | "review" | "gate" | "helper";
13
+ export type CallSource = "live" | "transcript" | "check_run" | "helper";
14
+
15
+ export interface CallRow {
16
+ callKey: string;
17
+ at: number;
18
+ sessionId: string | null;
19
+ taskId: string | null;
20
+ workspaceId: string | null;
21
+ projectId: string | null;
22
+ sessionName: string | null;
23
+ role: string | null;
24
+ kind: CallKind;
25
+ agentType: string | null;
26
+ model: string | null;
27
+ provider: string;
28
+ input: number;
29
+ cacheRead: number;
30
+ cacheWrite5m: number;
31
+ cacheWrite1h: number;
32
+ output: number;
33
+ ctx: number | null;
34
+ source: CallSource;
35
+ }
36
+
37
+ // What a driver reports for one response, before it's tied to a session.
38
+ export type CallUsage = Pick<
39
+ CallRow,
40
+ | "callKey"
41
+ | "at"
42
+ | "kind"
43
+ | "agentType"
44
+ | "model"
45
+ | "input"
46
+ | "cacheRead"
47
+ | "cacheWrite5m"
48
+ | "cacheWrite1h"
49
+ | "output"
50
+ >;
51
+
52
+ interface RawUsage {
53
+ input_tokens?: number;
54
+ output_tokens?: number;
55
+ cache_read_input_tokens?: number;
56
+ cache_creation_input_tokens?: number;
57
+ cache_creation?: {
58
+ ephemeral_5m_input_tokens?: number;
59
+ ephemeral_1h_input_tokens?: number;
60
+ } | null;
61
+ }
62
+
63
+ const n = (v: unknown) => (typeof v === "number" && v > 0 ? v : 0);
64
+
65
+ // The usage on an assistant message, or null for a synthetic one (an error
66
+ // the CLI wrote itself) or one with no id.
67
+ export function callFromAssistant(
68
+ message: { id?: string; model?: string; usage?: RawUsage } | undefined,
69
+ kind: CallKind,
70
+ agentType: string | null,
71
+ at = Date.now()
72
+ ): CallUsage | null {
73
+ if (!message?.id || !message.usage) return null;
74
+ if (!message.model || message.model === "<synthetic>") return null;
75
+ const u = message.usage;
76
+ const split = u.cache_creation;
77
+ const w1h = n(split?.ephemeral_1h_input_tokens);
78
+ // Without the split, the whole write counts as the 5-minute kind.
79
+ const w5m = split
80
+ ? n(split.ephemeral_5m_input_tokens)
81
+ : n(u.cache_creation_input_tokens);
82
+ return {
83
+ callKey: message.id,
84
+ at,
85
+ kind,
86
+ agentType,
87
+ model: message.model,
88
+ input: n(u.input_tokens),
89
+ cacheRead: n(u.cache_read_input_tokens),
90
+ cacheWrite5m: w5m,
91
+ cacheWrite1h: w1h,
92
+ output: n(u.output_tokens),
93
+ };
94
+ }
95
+
96
+ export function upsertCall(r: CallRow): void {
97
+ db.prepare(
98
+ `INSERT INTO usage_calls (call_key, at, session_id, task_id, workspace_id, project_id,
99
+ session_name, role, kind, agent_type, model, provider, input_tokens, cache_read_tokens,
100
+ cache_write_5m_tokens, cache_write_1h_tokens, output_tokens, ctx_tokens, source)
101
+ VALUES (@callKey, @at, @sessionId, @taskId, @workspaceId, @projectId, @sessionName, @role,
102
+ @kind, @agentType, @model, @provider, @input, @cacheRead, @cacheWrite5m, @cacheWrite1h,
103
+ @output, @ctx, @source)
104
+ ON CONFLICT(call_key) DO UPDATE SET
105
+ input_tokens = MAX(input_tokens, excluded.input_tokens),
106
+ cache_read_tokens = MAX(cache_read_tokens, excluded.cache_read_tokens),
107
+ cache_write_5m_tokens = MAX(cache_write_5m_tokens, excluded.cache_write_5m_tokens),
108
+ cache_write_1h_tokens = MAX(cache_write_1h_tokens, excluded.cache_write_1h_tokens),
109
+ output_tokens = MAX(output_tokens, excluded.output_tokens),
110
+ ctx_tokens = MAX(COALESCE(ctx_tokens, 0), COALESCE(excluded.ctx_tokens, 0)),
111
+ session_id = COALESCE(session_id, excluded.session_id),
112
+ task_id = COALESCE(task_id, excluded.task_id),
113
+ workspace_id = COALESCE(workspace_id, excluded.workspace_id),
114
+ project_id = COALESCE(project_id, excluded.project_id),
115
+ session_name = COALESCE(session_name, excluded.session_name),
116
+ role = COALESCE(role, excluded.role)`
117
+ ).run(r);
118
+ }
119
+
120
+ export interface SessionContext {
121
+ sessionId: string;
122
+ taskId: string | null;
123
+ workspaceId: string | null;
124
+ projectId: string | null;
125
+ sessionName: string | null;
126
+ role: string | null;
127
+ provider: string;
128
+ }
129
+
130
+ // Who a session's calls belong to. Read per call: a plan becomes
131
+ // implementation, and a name changes.
132
+ export function sessionContext(sessionId: string): SessionContext {
133
+ const s = db
134
+ .prepare(
135
+ `SELECT name, workspace_id, project_id, model_provider, role, task_status,
136
+ plan_phase, started_by FROM sessions WHERE id = ?`
137
+ )
138
+ .get(sessionId) as
139
+ | {
140
+ name: string;
141
+ workspace_id: string | null;
142
+ project_id: string | null;
143
+ model_provider: string | null;
144
+ role: string | null;
145
+ task_status: string | null;
146
+ plan_phase: string | null;
147
+ started_by: string | null;
148
+ }
149
+ | undefined;
150
+ return {
151
+ sessionId,
152
+ taskId: s?.task_status ? sessionId : null,
153
+ workspaceId: s?.workspace_id ?? null,
154
+ projectId: s?.project_id ?? null,
155
+ sessionName: s?.name ?? null,
156
+ role: s ? sessionRole(s) : null,
157
+ provider: s?.model_provider ?? ANTHROPIC,
158
+ };
159
+ }
160
+
161
+ export function recordCall(
162
+ call: CallUsage,
163
+ ctx: SessionContext,
164
+ source: CallSource
165
+ ): void {
166
+ const row: CallRow = {
167
+ ...call,
168
+ sessionId: ctx.sessionId,
169
+ taskId: ctx.taskId,
170
+ workspaceId: ctx.workspaceId,
171
+ projectId: ctx.projectId,
172
+ sessionName: ctx.sessionName,
173
+ role: ctx.role,
174
+ provider: ctx.provider,
175
+ ctx: 0,
176
+ source,
177
+ };
178
+ row.ctx = ctxOf({
179
+ model: row.model,
180
+ input_tokens: row.input,
181
+ cache_read_tokens: row.cacheRead,
182
+ cache_write_5m_tokens: row.cacheWrite5m,
183
+ cache_write_1h_tokens: row.cacheWrite1h,
184
+ output_tokens: row.output,
185
+ });
186
+ upsertCall(row);
187
+ }
188
+
189
+ // A call that never goes through a conversation (a check run).
190
+ export function recordLooseCall(
191
+ r: Omit<CallRow, "ctx" | "provider"> & { provider?: string }
192
+ ): void {
193
+ upsertCall({ ...r, provider: r.provider ?? ANTHROPIC, ctx: null });
194
+ }
195
+
196
+ // check_runs rows not yet in the ledger, as calls of their own.
197
+ export function backfillCheckRuns(): number {
198
+ const rows = db
199
+ .prepare(
200
+ `SELECT c.id, c.workspace_id, c.session_id, c.label, c.kind, c.model, c.provider,
201
+ c.input_tokens, c.cache_write_tokens, c.cache_read_tokens, c.output_tokens, c.at,
202
+ s.project_id FROM check_runs c LEFT JOIN sessions s ON s.id = c.session_id
203
+ WHERE NOT EXISTS (SELECT 1 FROM usage_calls u WHERE u.call_key = 'check_run:' || c.id)`
204
+ )
205
+ .all() as {
206
+ id: number;
207
+ workspace_id: string;
208
+ session_id: string;
209
+ label: string;
210
+ kind: string;
211
+ model: string | null;
212
+ provider: string | null;
213
+ input_tokens: number;
214
+ cache_write_tokens: number;
215
+ cache_read_tokens: number;
216
+ output_tokens: number;
217
+ at: number;
218
+ project_id: string | null;
219
+ }[];
220
+ db.transaction(() => {
221
+ for (const c of rows) recordCheckRunCall(c.id, c);
222
+ })();
223
+ return rows.length;
224
+ }
225
+
226
+ export function recordCheckRunCall(
227
+ id: number,
228
+ c: {
229
+ workspace_id: string;
230
+ session_id: string;
231
+ label: string;
232
+ kind: string;
233
+ model: string | null;
234
+ provider: string | null;
235
+ input_tokens: number;
236
+ cache_write_tokens: number;
237
+ cache_read_tokens: number;
238
+ output_tokens: number;
239
+ at: number;
240
+ project_id?: string | null;
241
+ }
242
+ ): void {
243
+ recordLooseCall({
244
+ callKey: `check_run:${id}`,
245
+ at: c.at,
246
+ sessionId: c.session_id,
247
+ taskId: c.session_id ? sessionContext(c.session_id).taskId : null,
248
+ workspaceId: c.workspace_id,
249
+ projectId: c.project_id ?? null,
250
+ sessionName: c.label,
251
+ role: `gate-${c.kind}`,
252
+ kind: "gate",
253
+ agentType: null,
254
+ model: c.model,
255
+ provider: c.provider ?? ANTHROPIC,
256
+ input: c.input_tokens,
257
+ cacheRead: c.cache_read_tokens,
258
+ cacheWrite5m: c.cache_write_tokens,
259
+ cacheWrite1h: 0,
260
+ output: c.output_tokens,
261
+ source: "check_run",
262
+ });
263
+ }
264
+
265
+ // What each task cost so far in weighted tokens: its own session, its
266
+ // subagents and its gate reviews. One grouped query for the whole list.
267
+ export function taskCosts(ids: string[]): Map<string, number> {
268
+ const out = new Map<string, number>();
269
+ if (!ids.length) return out;
270
+ const marks = ids.map(() => "?").join(",");
271
+ const rows = db
272
+ .prepare(
273
+ `SELECT task_id, model, provider, SUM(input_tokens) AS input_tokens,
274
+ SUM(cache_read_tokens) AS cache_read_tokens,
275
+ SUM(cache_write_5m_tokens) AS cache_write_5m_tokens,
276
+ SUM(cache_write_1h_tokens) AS cache_write_1h_tokens,
277
+ SUM(output_tokens) AS output_tokens
278
+ FROM usage_calls WHERE task_id IN (${marks}) GROUP BY task_id, model, provider`
279
+ )
280
+ .all(...ids) as (TokenRow & { task_id: string })[];
281
+ for (const r of rows)
282
+ out.set(r.task_id, (out.get(r.task_id) ?? 0) + weighted(r));
283
+ return out;
284
+ }
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { db } from "../db";
9
+ import { recordCheckRunCall } from "./calls";
9
10
  import { formatTokens } from "../chat/context";
10
11
 
11
12
  export interface RunUsage {
@@ -93,27 +94,47 @@ export function recordCheckRun(
93
94
  u: RunUsage,
94
95
  at = Date.now()
95
96
  ): void {
96
- db.prepare(
97
- `INSERT INTO check_runs (workspace_id, session_id, label, pr_number, sha, kind, model, provider,
97
+ const info = db
98
+ .prepare(
99
+ `INSERT INTO check_runs (workspace_id, session_id, label, pr_number, sha, kind, model, provider,
98
100
  input_tokens, cache_write_tokens, cache_read_tokens, output_tokens, cost_usd, duration_ms, at)
99
101
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
100
- ).run(
101
- target.workspaceId,
102
- target.sessionId,
103
- target.label,
104
- target.prNumber,
105
- target.sha,
106
- target.kind,
107
- u.model,
108
- u.provider ?? "anthropic",
109
- u.inputTokens,
110
- u.cacheWriteTokens,
111
- u.cacheReadTokens,
112
- u.outputTokens,
113
- u.costUsd,
114
- u.durationMs,
115
- at
116
- );
102
+ )
103
+ .run(
104
+ target.workspaceId,
105
+ target.sessionId,
106
+ target.label,
107
+ target.prNumber,
108
+ target.sha,
109
+ target.kind,
110
+ u.model,
111
+ u.provider ?? "anthropic",
112
+ u.inputTokens,
113
+ u.cacheWriteTokens,
114
+ u.cacheReadTokens,
115
+ u.outputTokens,
116
+ u.costUsd,
117
+ u.durationMs,
118
+ at
119
+ );
120
+ try {
121
+ recordCheckRunCall(Number(info.lastInsertRowid), {
122
+ workspace_id: target.workspaceId,
123
+ session_id: target.sessionId,
124
+ label: target.label,
125
+ kind: target.kind,
126
+ model: u.model,
127
+ provider: u.provider ?? "anthropic",
128
+ input_tokens: u.inputTokens,
129
+ cache_write_tokens: u.cacheWriteTokens,
130
+ cache_read_tokens: u.cacheReadTokens,
131
+ output_tokens: u.outputTokens,
132
+ at,
133
+ });
134
+ } catch (e) {
135
+ // The nightly backfill picks it up.
136
+ console.error(`[check-runs] ledger ${target.label}:`, e);
137
+ }
117
138
  }
118
139
 
119
140
  // A recorder for one check, that never lets a write break the check.
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The ratchet's thresholds, kept in the settings table so they can change
3
+ * without a deploy. Each falls back to its default for a missing or invalid
4
+ * value.
5
+ */
6
+
7
+ import { db } from "../db";
8
+
9
+ export const USAGE_KEYS = {
10
+ costPct: "usage.alert_cost_pct",
11
+ cleanPoints: "usage.alert_clean_points",
12
+ baselineDays: "usage.baseline_days",
13
+ avgDays: "usage.avg_days",
14
+ minMerges: "usage.alert_min_merges",
15
+ weeklyStaleHours: "usage.weekly_stale_hours",
16
+ } as const;
17
+
18
+ export interface UsageConfig {
19
+ // Alert when the 3-day cost per merged PR is this many percent over baseline.
20
+ costPct: number;
21
+ // Alert when the clean-merge rate is this many points under baseline.
22
+ cleanPoints: number;
23
+ baselineDays: number;
24
+ avgDays: number;
25
+ // Fewer merges than this over the average window: too little to judge.
26
+ minMerges: number;
27
+ weeklyStaleHours: number;
28
+ }
29
+
30
+ export const USAGE_DEFAULTS: UsageConfig = {
31
+ costPct: 15,
32
+ cleanPoints: 10,
33
+ baselineDays: 7,
34
+ avgDays: 3,
35
+ minMerges: 3,
36
+ weeklyStaleHours: 6,
37
+ };
38
+
39
+ const DAY_COUNTS = new Set<keyof UsageConfig>(["baselineDays", "avgDays"]);
40
+
41
+ export function readUsageConfig(): UsageConfig {
42
+ const out: UsageConfig = { ...USAGE_DEFAULTS };
43
+ for (const [name, key] of Object.entries(USAGE_KEYS)) {
44
+ const row = db
45
+ .prepare(`SELECT value FROM settings WHERE key = ?`)
46
+ .get(key) as { value: string } | undefined;
47
+ const v = row ? Number(row.value) : NaN;
48
+ const key2 = name as keyof UsageConfig;
49
+ // A day count is a whole number of days, at least one.
50
+ const ok = DAY_COUNTS.has(key2)
51
+ ? Number.isInteger(v) && v >= 1
52
+ : Number.isFinite(v) && v >= 0;
53
+ if (ok) out[key2] = v;
54
+ }
55
+ return out;
56
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * The nightly rollup, inside the server, with no model: it imports the
3
+ * transcripts it couldn't watch, rolls each recent day up, moves the
4
+ * ratchet's baseline, and alerts once when the last days regressed. A light
5
+ * pass every half hour keeps today's row (and cost on task cards) current.
6
+ */
7
+
8
+ import { db } from "../db";
9
+ import { notifyPhone } from "../notify";
10
+ import { backfillCheckRuns } from "./calls";
11
+ import { readUsageConfig } from "./config";
12
+ import type { Gh } from "./quality";
13
+ import {
14
+ applyAlertState,
15
+ evaluateAlerts,
16
+ loadBaseline,
17
+ nextAlertState,
18
+ nextBaseline,
19
+ openAlerts,
20
+ saveBaseline,
21
+ } from "./ratchet";
22
+ import { dayKey, dayStart, loadRollups, rollupDay, saveRollup } from "./rollup";
23
+ import { importTranscripts } from "./transcript-import";
24
+
25
+ const DAY = 24 * 3600 * 1000;
26
+ const LAST_RUN = "usage.last_nightly";
27
+ const TICK_MS = 30 * 60 * 1000;
28
+ // A day's escapes settle three days after it ends.
29
+ const SETTLE_MS = 3 * DAY;
30
+
31
+ export interface NightlyDeps {
32
+ now: () => number;
33
+ gh?: Gh;
34
+ notify: (text: string) => void;
35
+ transcriptsRoot?: string;
36
+ }
37
+
38
+ export const realDeps: NightlyDeps = {
39
+ now: Date.now,
40
+ notify: (text) => notifyPhone("usage", text),
41
+ };
42
+
43
+ const addDays = (day: string, n: number) =>
44
+ dayKey(dayStart(day) + n * DAY + DAY / 2);
45
+
46
+ function setting(key: string): string | null {
47
+ const r = db.prepare(`SELECT value FROM settings WHERE key = ?`).get(key) as
48
+ | { value: string }
49
+ | undefined;
50
+ return r?.value ?? null;
51
+ }
52
+
53
+ // Imports what's new and keeps today's row current.
54
+ export async function lightPass(deps: NightlyDeps): Promise<void> {
55
+ const now = deps.now();
56
+ const seeded = loadRollups(1).length > 0;
57
+ await importTranscripts(
58
+ now - (seeded ? 3 : readUsageConfig().baselineDays + 1) * DAY,
59
+ 400,
60
+ deps.transcriptsRoot
61
+ );
62
+ backfillCheckRuns();
63
+ const today = dayKey(now);
64
+ saveRollup(await rollupDay(today, now, undefined), false, now);
65
+ }
66
+
67
+ // The once-a-day pass.
68
+ export async function nightly(deps: NightlyDeps): Promise<void> {
69
+ const now = deps.now();
70
+ const cfg = readUsageConfig();
71
+ const today = dayKey(now);
72
+ await lightPass(deps);
73
+ const existing = loadRollups(60);
74
+ const last = existing.filter((r) => r.day < today).at(-1)?.day;
75
+ let day = last ? addDays(last, -3) : addDays(today, -cfg.baselineDays);
76
+ for (; day < today; day = addDays(day, 1)) {
77
+ const final = dayStart(day) + DAY + SETTLE_MS <= now;
78
+ saveRollup(await rollupDay(day, now, deps.gh), final, now);
79
+ }
80
+ const complete = loadRollups(60).filter((r) => r.day < today);
81
+ const baseline = nextBaseline(loadBaseline(), complete, cfg, now);
82
+ if (baseline) saveBaseline(baseline);
83
+ const state = nextAlertState(
84
+ openAlerts(),
85
+ evaluateAlerts(complete, baseline, cfg)
86
+ );
87
+ applyAlertState(state, now);
88
+ for (const a of state.notify) deps.notify(a.message);
89
+ db.prepare(
90
+ `INSERT INTO settings (key, value) VALUES (?, ?)
91
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`
92
+ ).run(LAST_RUN, today);
93
+ }
94
+
95
+ // Whether this server runs the rollup: a dev server only when asked, so it
96
+ // never alerts for a database that isn't real.
97
+ export function usageRollupEnabled(
98
+ env: Record<string, string | undefined>
99
+ ): boolean {
100
+ const dev = env.AGENTOS_DEV === "1" || env.NODE_ENV !== "production";
101
+ return dev
102
+ ? env.AGENTOS_USAGE_ROLLUP === "on"
103
+ : env.AGENTOS_USAGE_ROLLUP !== "off";
104
+ }
105
+
106
+ const g = globalThis as unknown as {
107
+ __agentosUsage?: { stop: () => void };
108
+ __agentosUsageBusy?: boolean;
109
+ };
110
+
111
+ // Runs a pass unless one is already running, in this process: the timer and
112
+ // the API route share the flag (on globalThis, as the server and the routes
113
+ // load separate copies of this module). Returns whether it ran.
114
+ export async function exclusive(pass: () => Promise<void>): Promise<boolean> {
115
+ if (g.__agentosUsageBusy) return false;
116
+ g.__agentosUsageBusy = true;
117
+ try {
118
+ await pass();
119
+ } finally {
120
+ g.__agentosUsageBusy = false;
121
+ }
122
+ return true;
123
+ }
124
+
125
+ export const usageBusy = () => !!g.__agentosUsageBusy;
126
+
127
+ // The due pass: the full one once a day, just after midnight.
128
+ const duePass = (deps: NightlyDeps) =>
129
+ setting(LAST_RUN) !== dayKey(deps.now()) ? nightly(deps) : lightPass(deps);
130
+
131
+ export function startUsageRollup(deps: NightlyDeps = realDeps): {
132
+ stop: () => void;
133
+ } {
134
+ if (g.__agentosUsage) return g.__agentosUsage;
135
+ let timer: NodeJS.Timeout | undefined;
136
+ let stopped = false;
137
+ const run = () =>
138
+ exclusive(() => duePass(deps)).catch((error) =>
139
+ console.error("[usage] rollup failed:", error)
140
+ );
141
+ const next = () => {
142
+ if (stopped) return;
143
+ timer = setTimeout(() => void run().finally(next), TICK_MS);
144
+ timer.unref();
145
+ };
146
+ // Not at boot: the server is busy resuming sessions then.
147
+ timer = setTimeout(() => void run().finally(next), 60_000);
148
+ timer.unref();
149
+ g.__agentosUsage = {
150
+ stop: () => {
151
+ stopped = true;
152
+ clearTimeout(timer);
153
+ g.__agentosUsage = undefined;
154
+ },
155
+ };
156
+ return g.__agentosUsage;
157
+ }