@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,90 @@
1
+ /**
2
+ * One day of usage and quality, from the ledger and the quality readers.
3
+ * Rows keep their sums as well as their ratios, so any span of days
4
+ * aggregates as a ratio of sums: three days' cost per PR is their total cost
5
+ * over their total PRs, never a mean of three ratios.
6
+ */
7
+
8
+ import { db } from "../db";
9
+ import { currentWindow } from "./rate-limits";
10
+ import {
11
+ escalationsIn,
12
+ escapesIn,
13
+ mergedTasks,
14
+ qualityOf,
15
+ scopeChangesIn,
16
+ type Gh,
17
+ } from "./quality";
18
+
19
+ export * from "./span";
20
+ import { computeRollup, type CallGroup, type Rollup } from "./span";
21
+
22
+ export const dayKey = (ms: number) => {
23
+ const d = new Date(ms);
24
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
25
+ };
26
+ export const dayStart = (day: string) => {
27
+ const [y, m, d] = day.split("-").map(Number);
28
+ return new Date(y, m - 1, d).getTime();
29
+ };
30
+ export const dayEnd = (day: string) => {
31
+ const [y, m, d] = day.split("-").map(Number);
32
+ return new Date(y, m - 1, d + 1).getTime();
33
+ };
34
+
35
+ function groupsIn(from: number, to: number): CallGroup[] {
36
+ return db
37
+ .prepare(
38
+ `SELECT kind, role, model, provider, COUNT(*) AS calls,
39
+ SUM(input_tokens) AS input_tokens, SUM(cache_read_tokens) AS cache_read_tokens,
40
+ SUM(cache_write_5m_tokens) AS cache_write_5m_tokens,
41
+ SUM(cache_write_1h_tokens) AS cache_write_1h_tokens,
42
+ SUM(output_tokens) AS output_tokens,
43
+ SUM(ctx_tokens IS NOT NULL) AS ctxKnown, SUM(ctx_tokens > 200000) AS over200k
44
+ FROM usage_calls WHERE at >= ? AND at < ?
45
+ GROUP BY kind, role, model, provider`
46
+ )
47
+ .all(from, to) as CallGroup[];
48
+ }
49
+
50
+ export async function rollupDay(
51
+ day: string,
52
+ now = Date.now(),
53
+ gh?: Gh
54
+ ): Promise<Rollup> {
55
+ const from = dayStart(day);
56
+ const to = dayEnd(day);
57
+ const tasks = mergedTasks(from, to);
58
+ const q = qualityOf(tasks);
59
+ const weekly = currentWindow("seven_day", Math.min(now, to));
60
+ return computeRollup({
61
+ day,
62
+ groups: groupsIn(from, to),
63
+ merged: tasks.length,
64
+ clean: q.filter((t) => t.clean).length,
65
+ fixRounds: q.reduce((t, x) => t + x.fixRounds, 0),
66
+ escalations: escalationsIn(from, to),
67
+ scopeChanges: scopeChangesIn(from, to),
68
+ escapes: await escapesIn(tasks, gh),
69
+ weeklyPct: weekly?.pct ?? null,
70
+ weeklyEstimated: weekly?.estimated ?? false,
71
+ });
72
+ }
73
+
74
+ export function saveRollup(r: Rollup, final: boolean, now = Date.now()): void {
75
+ db.prepare(
76
+ `INSERT INTO usage_rollups (day, data, final, computed_at) VALUES (?, ?, ?, ?)
77
+ ON CONFLICT(day) DO UPDATE SET data = excluded.data, final = excluded.final,
78
+ computed_at = excluded.computed_at`
79
+ ).run(r.day, JSON.stringify(r), final ? 1 : 0, now);
80
+ }
81
+
82
+ export function loadRollups(limit = 60): Rollup[] {
83
+ return (
84
+ db
85
+ .prepare(`SELECT data FROM usage_rollups ORDER BY day DESC LIMIT ?`)
86
+ .all(limit) as { data: string }[]
87
+ )
88
+ .map((r) => JSON.parse(r.data) as Rollup)
89
+ .reverse();
90
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * The pure half of the daily rollup: one day from its inputs, and any span
3
+ * of days as ratios of sums. No database, so the Usage view can use it too.
4
+ * A day keeps its sums as well as its ratios, so three days' cost per PR is
5
+ * their total cost over their total PRs, never a mean of three ratios.
6
+ */
7
+
8
+ import { weighted, family, type TokenRow } from "./weights";
9
+
10
+ export interface CallGroup extends TokenRow {
11
+ kind: string;
12
+ role: string | null;
13
+ calls: number;
14
+ // Calls with a known context size, and those of them above 200k.
15
+ ctxKnown: number;
16
+ over200k: number;
17
+ }
18
+
19
+ export interface Rollup {
20
+ day: string;
21
+ weighted: number;
22
+ // Everything but a person's own sessions.
23
+ work: number;
24
+ review: number;
25
+ orchestrator: number;
26
+ mix: { opus: number; sonnet: number; haiku: number };
27
+ calls: number;
28
+ ctxKnown: number;
29
+ over200k: number;
30
+ merged: number;
31
+ clean: number;
32
+ fixRounds: number;
33
+ escalations: number;
34
+ scopeChanges: number;
35
+ escapes: number | null;
36
+ weeklyPct: number | null;
37
+ weeklyEstimated: boolean;
38
+ }
39
+
40
+ export interface DayInputs {
41
+ day: string;
42
+ groups: CallGroup[];
43
+ merged: number;
44
+ clean: number;
45
+ fixRounds: number;
46
+ escalations: number;
47
+ scopeChanges: number;
48
+ escapes: number | null;
49
+ weeklyPct: number | null;
50
+ weeklyEstimated: boolean;
51
+ }
52
+
53
+ export function computeRollup(i: DayInputs): Rollup {
54
+ const r: Rollup = {
55
+ day: i.day,
56
+ weighted: 0,
57
+ work: 0,
58
+ review: 0,
59
+ orchestrator: 0,
60
+ mix: { opus: 0, sonnet: 0, haiku: 0 },
61
+ calls: 0,
62
+ ctxKnown: 0,
63
+ over200k: 0,
64
+ merged: i.merged,
65
+ clean: i.clean,
66
+ fixRounds: i.fixRounds,
67
+ escalations: i.escalations,
68
+ scopeChanges: i.scopeChanges,
69
+ escapes: i.escapes,
70
+ weeklyPct: i.weeklyPct,
71
+ weeklyEstimated: i.weeklyEstimated,
72
+ };
73
+ for (const g of i.groups) {
74
+ const w = weighted(g);
75
+ if (w <= 0 && g.provider && g.provider !== "anthropic") continue;
76
+ r.weighted += w;
77
+ r.mix[family(g.model)] += w;
78
+ r.calls += g.calls;
79
+ r.ctxKnown += g.ctxKnown;
80
+ r.over200k += g.over200k;
81
+ if (g.role !== "interactive") r.work += w;
82
+ if (g.kind === "review" || g.kind === "gate") r.review += w;
83
+ if (g.role === "orchestrator") r.orchestrator += w;
84
+ }
85
+ return r;
86
+ }
87
+
88
+ // A span of days, as ratios of sums.
89
+ export interface Span {
90
+ days: number;
91
+ weighted: number;
92
+ work: number;
93
+ merged: number;
94
+ clean: number;
95
+ costPerMerged: number | null;
96
+ costPerClean: number | null;
97
+ cleanRate: number | null;
98
+ reviewPerPr: number | null;
99
+ over200kShare: number | null;
100
+ orchestratorShare: number | null;
101
+ opusShare: number | null;
102
+ fixRoundsPerPr: number | null;
103
+ escalations: number;
104
+ scopeChanges: number;
105
+ escapes: number | null;
106
+ }
107
+
108
+ const ratio = (a: number, b: number) => (b > 0 ? a / b : null);
109
+
110
+ export function spanOf(rows: Rollup[]): Span {
111
+ const sum = (f: (r: Rollup) => number) => rows.reduce((t, r) => t + f(r), 0);
112
+ const work = sum((r) => r.work);
113
+ const merged = sum((r) => r.merged);
114
+ const clean = sum((r) => r.clean);
115
+ const weightedTotal = sum((r) => r.weighted);
116
+ const known = rows.filter((r) => r.escapes !== null);
117
+ return {
118
+ days: rows.length,
119
+ weighted: weightedTotal,
120
+ work,
121
+ merged,
122
+ clean,
123
+ costPerMerged: ratio(work, merged),
124
+ costPerClean: ratio(work, clean),
125
+ cleanRate: ratio(clean, merged),
126
+ reviewPerPr: ratio(
127
+ sum((r) => r.review),
128
+ merged
129
+ ),
130
+ over200kShare: ratio(
131
+ sum((r) => r.over200k),
132
+ sum((r) => r.ctxKnown)
133
+ ),
134
+ orchestratorShare: ratio(
135
+ sum((r) => r.orchestrator),
136
+ weightedTotal
137
+ ),
138
+ opusShare: ratio(
139
+ sum((r) => r.mix.opus),
140
+ weightedTotal
141
+ ),
142
+ fixRoundsPerPr: ratio(
143
+ sum((r) => r.fixRounds),
144
+ merged
145
+ ),
146
+ escalations: sum((r) => r.escalations),
147
+ scopeChanges: sum((r) => r.scopeChanges),
148
+ escapes: known.length
149
+ ? known.reduce((t, r) => t + (r.escapes ?? 0), 0)
150
+ : null,
151
+ };
152
+ }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Usage from Claude's own transcripts, for the sessions AgentOS runs that it
3
+ * can't watch live (a terminal session) and for the days before live capture.
4
+ * Messages are deduped by id against what the workers recorded, and each file
5
+ * is read from where the last pass stopped.
6
+ */
7
+
8
+ import fs from "fs";
9
+ import os from "os";
10
+ import path from "path";
11
+ import { db } from "../db";
12
+ import {
13
+ callFromAssistant,
14
+ recordCall,
15
+ sessionContext,
16
+ type CallKind,
17
+ } from "./calls";
18
+
19
+ const claudeDir = () =>
20
+ process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
21
+
22
+ interface TranscriptFile {
23
+ file: string;
24
+ sessionId: string;
25
+ // Set for a subagent's file.
26
+ agentType: string | null;
27
+ sidechain: boolean;
28
+ }
29
+
30
+ // Every transcript of a session AgentOS knows, modified since `since`.
31
+ export async function transcriptFiles(
32
+ since: number,
33
+ root = path.join(claudeDir(), "projects")
34
+ ): Promise<TranscriptFile[]> {
35
+ const known = new Map<string, string>();
36
+ const rows = db
37
+ .prepare(
38
+ `SELECT id, claude_session_id FROM sessions WHERE claude_session_id IS NOT NULL`
39
+ )
40
+ .all() as { id: string; claude_session_id: string }[];
41
+ for (const r of rows) known.set(r.claude_session_id, r.id);
42
+ const out: TranscriptFile[] = [];
43
+ const dirs = await fs.promises.readdir(root).catch(() => [] as string[]);
44
+ for (const d of dirs) {
45
+ const dir = path.join(root, d);
46
+ const entries = await fs.promises
47
+ .readdir(dir, { withFileTypes: true })
48
+ .catch(() => []);
49
+ for (const e of entries) {
50
+ if (e.isFile() && e.name.endsWith(".jsonl")) {
51
+ const claudeId = e.name.slice(0, -".jsonl".length);
52
+ const sid = known.get(claudeId);
53
+ if (!sid || !(await newer(path.join(dir, e.name), since))) continue;
54
+ out.push({
55
+ file: path.join(dir, e.name),
56
+ sessionId: sid,
57
+ agentType: null,
58
+ sidechain: false,
59
+ });
60
+ } else if (e.isDirectory() && known.has(e.name)) {
61
+ const sid = known.get(e.name)!;
62
+ const sub = path.join(dir, e.name, "subagents");
63
+ const files = await fs.promises
64
+ .readdir(sub)
65
+ .catch(() => [] as string[]);
66
+ for (const f of files) {
67
+ if (!f.endsWith(".jsonl")) continue;
68
+ const file = path.join(sub, f);
69
+ if (!(await newer(file, since))) continue;
70
+ out.push({
71
+ file,
72
+ sessionId: sid,
73
+ agentType: await agentTypeOf(file),
74
+ sidechain: true,
75
+ });
76
+ }
77
+ }
78
+ }
79
+ }
80
+ return out;
81
+ }
82
+
83
+ async function newer(file: string, since: number): Promise<boolean> {
84
+ const st = await fs.promises.stat(file).catch(() => null);
85
+ return !!st && st.mtimeMs >= since;
86
+ }
87
+
88
+ async function agentTypeOf(file: string): Promise<string | null> {
89
+ try {
90
+ const meta = JSON.parse(
91
+ await fs.promises.readFile(file.replace(/\.jsonl$/, ".meta.json"), "utf8")
92
+ ) as { agentType?: unknown };
93
+ return typeof meta.agentType === "string" ? meta.agentType : null;
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ // Each line of the file from `start` that ends in a newline, as bytes; a last
100
+ // line still being written is left for the next pass.
101
+ async function* completeLines(
102
+ file: string,
103
+ start: number,
104
+ end: number
105
+ ): AsyncGenerator<Buffer> {
106
+ if (end <= start) return;
107
+ let rest: Buffer = Buffer.alloc(0);
108
+ // `end` is inclusive, so a line appended during the read waits too.
109
+ const stream = fs.createReadStream(file, { start, end: end - 1 });
110
+ for await (const chunk of stream) {
111
+ rest = rest.length
112
+ ? Buffer.concat([rest, chunk as Buffer])
113
+ : (chunk as Buffer);
114
+ let from = 0;
115
+ for (let i = rest.indexOf(10, from); i !== -1; i = rest.indexOf(10, from)) {
116
+ yield rest.subarray(from, i);
117
+ from = i + 1;
118
+ }
119
+ rest = rest.subarray(from);
120
+ }
121
+ }
122
+
123
+ interface Line {
124
+ type?: string;
125
+ timestamp?: string;
126
+ attributionAgent?: string;
127
+ attributionSkill?: string;
128
+ message?: Parameters<typeof callFromAssistant>[0];
129
+ }
130
+
131
+ // Imports one file from its stored offset. Returns the calls it saw.
132
+ export async function importFile(
133
+ f: TranscriptFile,
134
+ since: number
135
+ ): Promise<number> {
136
+ const st = await fs.promises.stat(f.file);
137
+ const saved = db
138
+ .prepare(`SELECT offset, mtime FROM usage_import_offsets WHERE path = ?`)
139
+ .get(f.file) as { offset: number; mtime: number } | undefined;
140
+ // A file shorter than where we stopped was rewritten: start again.
141
+ const start = saved && saved.offset <= st.size ? saved.offset : 0;
142
+ if (
143
+ saved &&
144
+ saved.offset === st.size &&
145
+ saved.mtime === Math.floor(st.mtimeMs)
146
+ )
147
+ return 0;
148
+ const ctx = sessionContext(f.sessionId);
149
+ let offset = start;
150
+ let n = 0;
151
+ const pending: (() => void)[] = [];
152
+ for await (const raw of completeLines(f.file, start, st.size)) {
153
+ // Counted in bytes as they sit in the file, whatever ends the line.
154
+ offset += raw.length + 1;
155
+ const line = raw.toString("utf8");
156
+ if (!line.includes('"assistant"')) continue;
157
+ let d: Line;
158
+ try {
159
+ d = JSON.parse(line) as Line;
160
+ } catch {
161
+ continue;
162
+ }
163
+ if (d.type !== "assistant") continue;
164
+ const at = d.timestamp ? Date.parse(d.timestamp) : NaN;
165
+ if (!Number.isFinite(at) || at < since) continue;
166
+ const agent = f.agentType ?? d.attributionAgent ?? null;
167
+ const kind: CallKind = !f.sidechain
168
+ ? "main"
169
+ : agent?.startsWith("review-") || d.attributionSkill === "do-code-review"
170
+ ? "review"
171
+ : "sub";
172
+ const call = callFromAssistant(
173
+ d.message,
174
+ kind,
175
+ f.sidechain ? agent : null,
176
+ at
177
+ );
178
+ if (!call) continue;
179
+ pending.push(() => recordCall(call, ctx, "transcript"));
180
+ n++;
181
+ }
182
+ db.transaction(() => {
183
+ for (const w of pending) w();
184
+ db.prepare(
185
+ `INSERT INTO usage_import_offsets (path, offset, mtime) VALUES (?, ?, ?)
186
+ ON CONFLICT(path) DO UPDATE SET offset = excluded.offset, mtime = excluded.mtime`
187
+ ).run(f.file, offset, Math.floor(st.mtimeMs));
188
+ })();
189
+ return n;
190
+ }
191
+
192
+ // Whether a file is as the last pass left it.
193
+ function unchanged(f: TranscriptFile, st: fs.Stats): boolean {
194
+ const saved = db
195
+ .prepare(`SELECT offset, mtime FROM usage_import_offsets WHERE path = ?`)
196
+ .get(f.file) as { offset: number; mtime: number } | undefined;
197
+ return (
198
+ !!saved &&
199
+ saved.offset === st.size &&
200
+ saved.mtime === Math.floor(st.mtimeMs)
201
+ );
202
+ }
203
+
204
+ // One pass over everything modified since `since`, yielding between files so
205
+ // the server keeps answering. Files already up to date don't count against
206
+ // the cap, and the rest go oldest first, so a backlog drains over passes
207
+ // instead of the same first files being taken every time.
208
+ export async function importTranscripts(
209
+ since: number,
210
+ maxFiles = 400,
211
+ root?: string
212
+ ): Promise<{ files: number; calls: number }> {
213
+ const todo: { f: TranscriptFile; mtime: number }[] = [];
214
+ for (const f of await transcriptFiles(since, root)) {
215
+ const st = await fs.promises.stat(f.file).catch(() => null);
216
+ if (st && !unchanged(f, st)) todo.push({ f, mtime: st.mtimeMs });
217
+ }
218
+ todo.sort((a, b) => a.mtime - b.mtime);
219
+ let calls = 0;
220
+ const batch = todo.slice(0, maxFiles);
221
+ for (const { f } of batch) {
222
+ calls += await importFile(f, since).catch(() => 0);
223
+ await new Promise((r) => setImmediate(r));
224
+ }
225
+ return { files: batch.length, calls };
226
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Weighted tokens: the API's price ratios by token type and model family, as
3
+ * a proxy for how much of the plan's limits a call uses. Opus input = 1.
4
+ * An estimate, not what the plan charges.
5
+ */
6
+
7
+ import { ANTHROPIC } from "../models/roles";
8
+
9
+ export type Family = "opus" | "sonnet" | "haiku";
10
+
11
+ export interface TokenRow {
12
+ model: string | null;
13
+ provider?: string | null;
14
+ input_tokens: number;
15
+ cache_read_tokens: number;
16
+ cache_write_5m_tokens: number;
17
+ cache_write_1h_tokens: number;
18
+ output_tokens: number;
19
+ }
20
+
21
+ // Unknown Claude ids weigh as Opus.
22
+ export const family = (model: string | null | undefined): Family => {
23
+ const m = (model ?? "").toLowerCase();
24
+ return m.includes("sonnet")
25
+ ? "sonnet"
26
+ : m.includes("haiku")
27
+ ? "haiku"
28
+ : "opus";
29
+ };
30
+
31
+ const MULT: Record<Family, number> = { opus: 1, sonnet: 0.5, haiku: 0.25 };
32
+ const READ: Record<Family, number> = { opus: 0.1, sonnet: 0.1, haiku: 0.05 };
33
+
34
+ export const WEIGHTS = {
35
+ input: 1,
36
+ cacheWrite5m: 1.25,
37
+ cacheWrite1h: 2,
38
+ output: 5,
39
+ } as const;
40
+
41
+ export function weighted(r: TokenRow): number {
42
+ if (r.provider && r.provider !== ANTHROPIC) return 0;
43
+ const f = family(r.model);
44
+ return (
45
+ MULT[f] *
46
+ (WEIGHTS.input * r.input_tokens +
47
+ WEIGHTS.cacheWrite5m * r.cache_write_5m_tokens +
48
+ WEIGHTS.cacheWrite1h * r.cache_write_1h_tokens +
49
+ WEIGHTS.output * r.output_tokens) +
50
+ READ[f] * r.cache_read_tokens
51
+ );
52
+ }
53
+
54
+ export const CTX_LIMIT = 200_000;
55
+
56
+ // What the call sent: fresh input, cache writes and cache reads.
57
+ export const ctxOf = (r: TokenRow): number =>
58
+ r.input_tokens +
59
+ r.cache_read_tokens +
60
+ r.cache_write_5m_tokens +
61
+ r.cache_write_1h_tokens;
62
+
63
+ export const overLimit = (ctx: number | null) =>
64
+ ctx !== null && ctx > CTX_LIMIT;
@@ -9,6 +9,7 @@ import os from "os";
9
9
  import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
10
10
  import { InputQueue } from "../chat/queue";
11
11
  import { readUsage } from "../orchestrator/usage";
12
+ import { currentWindow, type WindowReading } from "./rate-limits";
12
13
 
13
14
  export interface WindowView {
14
15
  pct: number;
@@ -17,6 +18,8 @@ export interface WindowView {
17
18
  // Seconds until it runs out at the current rate, when before it resets.
18
19
  capsIn?: number | null;
19
20
  burnPerMin?: number | null;
21
+ // Worked out from recorded usage, not read from the account.
22
+ estimated?: boolean;
20
23
  }
21
24
 
22
25
  export interface UsageWindows {
@@ -108,8 +111,30 @@ export function fromPlan(
108
111
  };
109
112
  }
110
113
 
114
+ const fromReading = (
115
+ r: WindowReading | null,
116
+ nowMs: number
117
+ ): WindowView | null =>
118
+ r
119
+ ? {
120
+ pct: Math.round(r.pct),
121
+ resetsIn: r.resetsAt
122
+ ? Math.max(0, Math.round((r.resetsAt - nowMs) / 1000))
123
+ : null,
124
+ estimated: r.estimated || undefined,
125
+ }
126
+ : null;
127
+
111
128
  export async function usageWindows(): Promise<UsageWindows> {
112
129
  const sampled = readUsage();
130
+ const read = (w: "five_hour" | "seven_day") => {
131
+ try {
132
+ const now = Date.now();
133
+ return fromReading(currentWindow(w, now), now);
134
+ } catch {
135
+ return null;
136
+ }
137
+ };
113
138
  const plan = await planLimits();
114
139
  const fiveHour =
115
140
  "window" in sampled && sampled.window
@@ -119,8 +144,8 @@ export async function usageWindows(): Promise<UsageWindows> {
119
144
  capsIn: sampled.window.capsIn,
120
145
  burnPerMin: sampled.window.burnPerMin,
121
146
  }
122
- : fromPlan(plan.limits?.five_hour);
123
- const sevenDay = fromPlan(plan.limits?.seven_day);
147
+ : (fromPlan(plan.limits?.five_hour) ?? read("five_hour"));
148
+ const sevenDay = fromPlan(plan.limits?.seven_day) ?? read("seven_day");
124
149
  const note =
125
150
  !sevenDay || !fiveHour
126
151
  ? (plan.error ??
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.499",
3
+ "version": "0.3.502",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -12,8 +12,7 @@
12
12
  "data/",
13
13
  "hooks/",
14
14
  "lib/",
15
- "!lib/connect/test-pki.ts",
16
- "!lib/connect/test-relay.ts",
15
+ "!lib/security/test-pki.ts",
17
16
  "!lib/**/*.test.ts",
18
17
  "mcp/",
19
18
  "public/",
@@ -121,7 +120,6 @@
121
120
  "@xterm/addon-webgl": "^0.19.0",
122
121
  "@xterm/headless": "6.0.0",
123
122
  "@xterm/xterm": "^6.0.0",
124
- "acme-client": "^5.4.0",
125
123
  "better-sqlite3": "^13.0.3",
126
124
  "class-variance-authority": "^0.7.1",
127
125
  "clsx": "^2.1.1",
package/server.ts CHANGED
@@ -13,6 +13,7 @@ import { stackStartGate } from "./lib/orchestrator/brakes";
13
13
  import { buildId } from "./lib/build";
14
14
  import { startOrchestratorWatcher } from "./lib/orchestrator/watcher";
15
15
  import { realDeps, schedulesEnabled, startScheduler } from "./lib/schedules";
16
+ import { startUsageRollup, usageRollupEnabled } from "./lib/usage/nightly";
16
17
  import { resumePhoneOutbox } from "./lib/notify";
17
18
  import {
18
19
  bindAddresses,
@@ -39,6 +40,7 @@ import {
39
40
  } from "./lib/security/next-internals";
40
41
  import { lanEnabled } from "./lib/security/network-settings";
41
42
  import { startConnect } from "./lib/connect/serve";
43
+ import { serverPort } from "./lib/connect/config";
42
44
  import { startTailnetHttps } from "./lib/security/tailnet-https";
43
45
  import {
44
46
  setStatusSource,
@@ -73,9 +75,7 @@ const dev = process.env.NODE_ENV !== "production";
73
75
  const hostname = "127.0.0.1";
74
76
 
75
77
  // Support: npm run dev -- -p 3012
76
- const pFlagIndex = process.argv.indexOf("-p");
77
- const portArg = pFlagIndex !== -1 ? process.argv[pFlagIndex + 1] : undefined;
78
- const port = parseInt(portArg || process.env.PORT || "3011", 10);
78
+ const port = serverPort();
79
79
  claimAgentosEnv(port);
80
80
  // Demo mode: seeded data, nothing that runs code (lib/security/demo).
81
81
  const demo = demoMode();
@@ -313,7 +313,8 @@ app.prepare().then(async () => {
313
313
  : []),
314
314
  ];
315
315
  };
316
- // AgentOS Connect: reachable at <id>.<machine domain> through the relay.
316
+ // AgentOS Connect: reachable at m-<16 hex>.runagentos.com through a
317
+ // Cloudflare tunnel, served on a listener of its own (lib/connect/serve).
317
318
  const connect = demo
318
319
  ? { hostname: () => null }
319
320
  : startConnect({ onRequest, onUpgrade }, port);
@@ -393,6 +394,8 @@ app.prepare().then(async () => {
393
394
  // Schedules tick here, once a minute, and nowhere else.
394
395
  if (schedulesEnabled(process.env)) startScheduler(realDeps);
395
396
  resumePhoneOutbox();
397
+ // The usage ledger's daily rollup and its ratchet alerts.
398
+ if (usageRollupEnabled(process.env)) startUsageRollup();
396
399
  // Machine load: a gauge, notes on heavy commands, one alert when red.
397
400
  if (loadEnabled()) startLoadMonitor();
398
401
  });
package/stores/usageUi.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  import { proxy } from "valtio";
2
2
 
3
- export type UsageTab = "spend" | "models" | "compare";
3
+ export type UsageTab = "daily" | "spend" | "models" | "compare";
4
4
 
5
5
  export const usageUi = proxy<{ open: boolean; tab: UsageTab }>({
6
6
  open: false,
7
- tab: "spend",
7
+ tab: "daily",
8
8
  });
9
9
 
10
10
  export const usageUiActions = {
11
- open: (tab: UsageTab = "spend") => {
11
+ open: (tab: UsageTab = "daily") => {
12
12
  usageUi.tab = tab;
13
13
  usageUi.open = true;
14
14
  },