@saadnvd1/agent-os 0.3.499 → 0.3.501

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 (47) hide show
  1. package/README.md +70 -0
  2. package/app/api/usage/rollups/route.ts +36 -0
  3. package/components/Devices/ConnectCard.tsx +10 -12
  4. package/components/Tasks/TaskRow.tsx +2 -0
  5. package/components/Usage/RatchetView.tsx +249 -0
  6. package/components/Usage/UsageDialog.tsx +7 -1
  7. package/components/Usage/WindowCard.tsx +2 -1
  8. package/data/usage/index.ts +28 -0
  9. package/lib/chat/drivers/claude-mapper.ts +1 -0
  10. package/lib/chat/drivers/claude.ts +46 -0
  11. package/lib/chat/events.ts +3 -0
  12. package/lib/chat/worker/host.ts +21 -0
  13. package/lib/chat/worker/protocol.ts +8 -1
  14. package/lib/connect/cli.ts +51 -72
  15. package/lib/connect/cloudflared.ts +172 -0
  16. package/lib/connect/config.ts +75 -26
  17. package/lib/connect/control-api.ts +107 -48
  18. package/lib/connect/enrol.ts +33 -107
  19. package/lib/connect/serve.ts +177 -94
  20. package/lib/connect/supervise.ts +281 -0
  21. package/lib/db/migrations.ts +72 -0
  22. package/lib/security/auth.ts +20 -4
  23. package/lib/security/gate.ts +8 -5
  24. package/lib/security/reach.ts +1 -1
  25. package/lib/tasks/index.ts +8 -0
  26. package/lib/usage/calls.ts +284 -0
  27. package/lib/usage/check-runs.ts +40 -19
  28. package/lib/usage/config.ts +56 -0
  29. package/lib/usage/nightly.ts +157 -0
  30. package/lib/usage/quality.ts +239 -0
  31. package/lib/usage/ratchet.ts +275 -0
  32. package/lib/usage/rate-limits.ts +192 -0
  33. package/lib/usage/rollup.ts +90 -0
  34. package/lib/usage/span.ts +152 -0
  35. package/lib/usage/transcript-import.ts +226 -0
  36. package/lib/usage/weights.ts +64 -0
  37. package/lib/usage/windows.ts +27 -2
  38. package/package.json +2 -4
  39. package/server.ts +7 -4
  40. package/stores/usageUi.ts +3 -3
  41. package/lib/connect/cert-check.ts +0 -32
  42. package/lib/connect/cloudflare-dns.ts +0 -56
  43. package/lib/connect/frames.ts +0 -72
  44. package/lib/connect/identity.ts +0 -98
  45. package/lib/connect/mux.ts +0 -110
  46. package/lib/connect/stream.ts +0 -106
  47. package/lib/connect/tunnel.ts +0 -98
package/README.md CHANGED
@@ -556,6 +556,43 @@ environment (`ANTHROPIC_BASE_URL` with `ANTHROPIC_AUTH_TOKEN` or
556
556
  log; a terminal task's shell reads it from the reference itself. Errors that
557
557
  might echo a key are scrubbed of it.
558
558
 
559
+ **Usage → Daily: the usage ledger and the ratchet.** Every Claude response
560
+ an AgentOS chat makes (subagents and the orchestrator included) is written to
561
+ `usage_calls` as it arrives, with its input, cache read, 5-minute and 1-hour
562
+ cache write and output tokens, its model, its context size and the
563
+ session, task, workspace and role it belongs to. Terminal sessions, which no
564
+ worker watches, are read from Claude's transcripts, and gate reviews come
565
+ from `check_runs`; the same message id is never counted twice. Tokens are
566
+ _weighted_ by the API's price ratios (Opus input 1, 5-minute write 1.25,
567
+ 1-hour write 2, cache read 0.1, output 5; Sonnet half, Haiku a quarter), an
568
+ estimate of how much of the plan's limits a call uses.
569
+
570
+ The 5-hour and weekly windows come from the `rate_limit_event`s Claude Code
571
+ sends on each response (the `anthropic-ratelimit-unified-*` headers), stored
572
+ with their time in `rate_limit_readings` with no extra calls. An event
573
+ carries one window, so the weekly figure can go stale: when it is over 6
574
+ hours old it is estimated from recorded usage and marked `~`.
575
+
576
+ A rollup job inside the server (no model) writes one `usage_rollups` row per
577
+ day: weighted total, cost per merged PR, cost per _clean_-merged PR (the
578
+ headline: cost against quality), review cost per PR, the share of calls above
579
+ 200k context, model mix, orchestrator share, the weekly %, and the quality
580
+ readings from existing data: first-try clean-merge rate (the first reviewed
581
+ head passed review and CI, no gate failures, no later head), fix rounds per
582
+ PR, gate escalations, scope changes, and escapes (a revert or fix PR touching
583
+ the same files within 3 days, from `gh`; unknown rather than zero if `gh`
584
+ fails). Spans of days are ratios of sums. The baseline is the first week of
585
+ rollups and only ever moves to a better value. One phone alert fires (and
586
+ stays quiet until it clears) when the 3-day cost per merged PR is over
587
+ baseline by 15%, or the clean-merge rate is under it by 10 points, naming the
588
+ supporting metric that moved most. The margins, the days averaged and the
589
+ minimum merges to judge are settings (`usage.alert_cost_pct`,
590
+ `usage.alert_clean_points`, `usage.avg_days`, `usage.alert_min_merges`,
591
+ `usage.baseline_days`, `usage.weekly_stale_hours`). A dev server doesn't run
592
+ it unless `AGENTOS_USAGE_ROLLUP=on`; `AGENTOS_USAGE_ROLLUP=off` turns it off
593
+ in production. `POST /api/usage/rollups` runs the nightly pass now. Task
594
+ cards show each task's weighted cost so far.
595
+
559
596
  **Usage by model.** **Usage → By model** shows tokens and estimated cost per
560
597
  role and per model in the plan's 5-hour window and the last 24 hours, from
561
598
  the price table in Settings → Models (dollars per million input, cache
@@ -1474,6 +1511,39 @@ and API calls or terminal connections made by another website's page.
1474
1511
  | `AGENTOS_TOKEN` | A device token for `aos` and the MCP server when they reach AgentOS over a network that needs pairing. |
1475
1512
  | `AGENTOS_AUTH=off` | No pairing, for when your own proxy does the login. Anyone who reaches the port gets a shell. |
1476
1513
 
1514
+ ### Connect
1515
+
1516
+ Connect (in development) reaches this machine from anywhere at
1517
+ `https://m-<16 hex>.runagentos.com`. `agent-os connect` links the machine with
1518
+ a code from runagentos.com: it makes an Ed25519 key in `~/.agent-os/connect`
1519
+ that never leaves the machine, and the Connect service makes the machine a
1520
+ Cloudflare tunnel. Running it again on a linked machine links the same tunnel
1521
+ again, keeping its address. `agent-os disconnect` (or the switch in Devices)
1522
+ turns it off.
1523
+
1524
+ While Connect is on, the production server:
1525
+
1526
+ - downloads cloudflared 2026.10.0 into `~/.agent-os/connect/bin` (never one
1527
+ from `PATH`) and checks its SHA-256 before making it executable or running it;
1528
+ - asks the Connect service for a fresh connector token on every start and hands
1529
+ it to `cloudflared tunnel run` in `TUNNEL_TOKEN` only, never in argv, a file
1530
+ or a log;
1531
+ - restarts cloudflared with backoff if it exits, and stops it when Connect is
1532
+ turned off. Devices shows its state.
1533
+
1534
+ Cloudflare's edge ends TLS and cloudflared delivers requests to a listener of
1535
+ their own on `127.0.0.1:<port + 433>`. That listener never trusts anything as
1536
+ this machine, the tailnet or `AGENTOS_AUTH=off`: every request needs a paired
1537
+ device. It answers only the machine's own host name and drops
1538
+ `X-Forwarded-Host`, `Forwarded` and `X-Original-Host` before anything reads
1539
+ them.
1540
+
1541
+ | Variable | Effect |
1542
+ | ---------------------- | ------------------------------------------------------------------- |
1543
+ | `AGENTOS_CONNECT=0` | Never start Connect in this process. |
1544
+ | `AGENTOS_CONNECT_PORT` | The tunnel's loopback port (default AgentOS's port + 433). |
1545
+ | `AGENTOS_CONNECT_DIR` | Where the key and cloudflared live (default `~/.agent-os/connect`). |
1546
+
1477
1547
  ### Demo mode
1478
1548
 
1479
1549
  `AGENTOS_DEMO=1` runs a server that is safe to show to strangers, over fake
@@ -0,0 +1,36 @@
1
+ import { NextResponse } from "next/server";
2
+ import { readUsageConfig } from "@/lib/usage/config";
3
+ import { realDeps, nightly, exclusive, usageBusy } from "@/lib/usage/nightly";
4
+ import { loadBaseline, SUPPORTING_LABELS } from "@/lib/usage/ratchet";
5
+ import { loadRollups, spanOf } from "@/lib/usage/rollup";
6
+ import { db } from "@/lib/db";
7
+ import { usageWindows } from "@/lib/usage/windows";
8
+
9
+ // GET /api/usage/rollups - the daily rollups (newest last), their total, the
10
+ // ratchet's baseline and thresholds, open alerts, and the account's windows.
11
+ export async function GET() {
12
+ const rollups = loadRollups(30);
13
+ const alerts = db
14
+ .prepare(
15
+ `SELECT kind, opened_at AS openedAt, message FROM usage_alerts WHERE cleared_at IS NULL`
16
+ )
17
+ .all();
18
+ return NextResponse.json({
19
+ rollups,
20
+ total: spanOf(rollups),
21
+ baseline: loadBaseline(),
22
+ labels: SUPPORTING_LABELS,
23
+ config: readUsageConfig(),
24
+ alerts,
25
+ windows: await usageWindows(),
26
+ });
27
+ }
28
+
29
+ // POST /api/usage/rollups - run the nightly pass now, in the background.
30
+ export async function POST() {
31
+ if (usageBusy()) return NextResponse.json({ running: true }, { status: 202 });
32
+ void exclusive(() => nightly(realDeps)).catch((error) =>
33
+ console.error("[usage] rollup failed:", error)
34
+ );
35
+ return NextResponse.json({ started: true }, { status: 202 });
36
+ }
@@ -7,7 +7,7 @@ import type { ConnectStatus } from "@/lib/connect/serve";
7
7
  const STATE: Record<ConnectStatus["state"], string> = {
8
8
  up: "Connected",
9
9
  connecting: "Connecting…",
10
- denied: "Refused by the relay",
10
+ denied: "Refused by Connect",
11
11
  off: "Off",
12
12
  refused: "Not running here",
13
13
  error: "Problem",
@@ -22,11 +22,12 @@ export function ConnectCard({
22
22
  canManage: boolean;
23
23
  onToggle: (on: boolean) => void;
24
24
  }) {
25
- const enrolled = !!connect.hostname;
25
+ const enrolled = !!connect.enrolled || !!connect.hostname;
26
26
  const on =
27
27
  connect.state === "up" ||
28
28
  connect.state === "connecting" ||
29
- connect.state === "denied";
29
+ connect.state === "denied" ||
30
+ (connect.state === "error" && !!connect.enrolled);
30
31
  return (
31
32
  <div className="bg-muted/40 flex items-start gap-3 rounded-lg px-3 py-2.5">
32
33
  <Cloud className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
@@ -38,27 +39,24 @@ export function ConnectCard({
38
39
  </span>
39
40
  </p>
40
41
  <p className="text-muted-foreground text-xs break-all">
41
- {enrolled ? (
42
+ {connect.hostname ? (
42
43
  <>
43
44
  Reachable from anywhere at{" "}
44
45
  <span className="text-foreground font-mono">
45
46
  https://{connect.hostname}
46
47
  </span>
47
- . The relay passes encrypted traffic through and can&apos;t read
48
- it.
48
+ . Cloudflare carries the connection; every request still needs a
49
+ paired device.
49
50
  </>
51
+ ) : enrolled ? (
52
+ <>Linked. The address appears once the tunnel starts.</>
50
53
  ) : (
51
54
  <>Not set up on this machine. Run agent-os connect in a terminal.</>
52
55
  )}
53
56
  {connect.reason && connect.state !== "off"
54
- ? ` ${connect.reason}.`
57
+ ? ` ${connect.reason.replace(/\.$/, "")}.`
55
58
  : ""}
56
59
  </p>
57
- {connect.cert?.warning && (
58
- <p className="text-destructive mt-1 text-xs">
59
- {connect.cert.warning}
60
- </p>
61
- )}
62
60
  </div>
63
61
  {enrolled && (
64
62
  <Switch
@@ -17,6 +17,7 @@ import { cn } from "@/lib/utils";
17
17
  import { MoveTaskButton } from "./MoveTaskButton";
18
18
  import { ResumeOnModel } from "./ResumeOnModel";
19
19
  import { TASK_STATE as STATE } from "./task-state-labels";
20
+ import { formatTokens } from "@/components/Usage/format";
20
21
 
21
22
  export function TaskRow({ task }: { task: TaskView }) {
22
23
  const signOff = useSignOffTask();
@@ -46,6 +47,7 @@ export function TaskRow({ task }: { task: TaskView }) {
46
47
  {task.projectName} · {task.branch}
47
48
  {task.model &&
48
49
  ` · ${task.model}${task.planning ? " (planning)" : ""}`}
50
+ {task.weighted ? ` · ${formatTokens(task.weighted)} weighted` : ""}
49
51
  </p>
50
52
  {task.plan && (
51
53
  <p className="text-muted-foreground truncate text-[11px]">
@@ -0,0 +1,249 @@
1
+ "use client";
2
+
3
+ import { useUsageRollupsQuery, type RollupsResponse } from "@/data/usage";
4
+ import type { Rollup } from "@/lib/usage/span";
5
+ import { spanOf } from "@/lib/usage/span";
6
+ import { cn } from "@/lib/utils";
7
+ import { formatTokens } from "./format";
8
+
9
+ const pct = (n: number | null, digits = 0) =>
10
+ n === null ? "—" : `${(n * 100).toFixed(digits)}%`;
11
+ const tok = (n: number | null) => (n === null ? "—" : formatTokens(n));
12
+ const num = (n: number | null) => (n === null ? "—" : n.toFixed(1));
13
+
14
+ interface Cell {
15
+ key: string;
16
+ label: string;
17
+ value: (r: Rollup) => string;
18
+ }
19
+
20
+ // Rollups store sums, so each ratio is worked out from one day's.
21
+ const COLS: Cell[] = [
22
+ { key: "w", label: "Weighted", value: (r) => tok(r.weighted) },
23
+ { key: "m", label: "Merged", value: (r) => String(r.merged) },
24
+ { key: "c", label: "Clean", value: (r) => String(r.clean) },
25
+ {
26
+ key: "cpm",
27
+ label: "Per merged",
28
+ value: (r) => tok(spanOf([r]).costPerMerged),
29
+ },
30
+ {
31
+ key: "cpc",
32
+ label: "Per clean",
33
+ value: (r) => tok(spanOf([r]).costPerClean),
34
+ },
35
+ {
36
+ key: "rev",
37
+ label: "Review / PR",
38
+ value: (r) => tok(spanOf([r]).reviewPerPr),
39
+ },
40
+ {
41
+ key: "ctx",
42
+ label: ">200k",
43
+ value: (r) => pct(spanOf([r]).over200kShare),
44
+ },
45
+ {
46
+ key: "orch",
47
+ label: "Orch.",
48
+ value: (r) => pct(spanOf([r]).orchestratorShare),
49
+ },
50
+ { key: "opus", label: "Opus", value: (r) => pct(spanOf([r]).opusShare) },
51
+ {
52
+ key: "wk",
53
+ label: "Week %",
54
+ value: (r) =>
55
+ r.weeklyPct === null
56
+ ? "—"
57
+ : `${r.weeklyEstimated ? "~" : ""}${Math.round(r.weeklyPct)}`,
58
+ },
59
+ {
60
+ key: "fix",
61
+ label: "Fix rds",
62
+ value: (r) => num(spanOf([r]).fixRoundsPerPr),
63
+ },
64
+ { key: "esc", label: "Escal.", value: (r) => String(r.escalations) },
65
+ { key: "scope", label: "Scope", value: (r) => String(r.scopeChanges) },
66
+ {
67
+ key: "escp",
68
+ label: "Escapes",
69
+ value: (r) => (r.escapes === null ? "—" : String(r.escapes)),
70
+ },
71
+ ];
72
+
73
+ function totalRow(rows: Rollup[]): Record<string, string> {
74
+ const s = spanOf(rows);
75
+ return {
76
+ w: tok(s.weighted),
77
+ m: String(s.merged),
78
+ c: String(s.clean),
79
+ cpm: tok(s.costPerMerged),
80
+ cpc: tok(s.costPerClean),
81
+ rev: tok(s.reviewPerPr),
82
+ ctx: pct(s.over200kShare),
83
+ orch: pct(s.orchestratorShare),
84
+ opus: pct(s.opusShare),
85
+ wk: "",
86
+ fix: num(s.fixRoundsPerPr),
87
+ esc: String(s.escalations),
88
+ scope: String(s.scopeChanges),
89
+ escp: s.escapes === null ? "—" : String(s.escapes),
90
+ };
91
+ }
92
+
93
+ // Daily usage and quality, the ratchet's baseline, and the open alerts.
94
+ export function RatchetView({ enabled }: { enabled: boolean }) {
95
+ const { data, isPending, isError, error } = useUsageRollupsQuery(enabled);
96
+ if (isError)
97
+ return <p className="text-destructive text-sm">{String(error)}</p>;
98
+ if (isPending || !data)
99
+ return (
100
+ <div className="bg-foreground/[0.04] h-40 animate-pulse rounded-xl" />
101
+ );
102
+ const rows = [...data.rollups].reverse();
103
+ const total = totalRow(data.rollups);
104
+ return (
105
+ <section className="min-w-0 space-y-4">
106
+ {data.alerts.map((a) => (
107
+ <p
108
+ key={a.kind}
109
+ role="alert"
110
+ className="bg-destructive/10 text-destructive rounded-xl p-3 text-xs"
111
+ >
112
+ {a.message}
113
+ </p>
114
+ ))}
115
+ <BaselineCard data={data} />
116
+ {rows.length === 0 ? (
117
+ <p className="text-muted-foreground text-sm">
118
+ No rollups yet. The first runs shortly after midnight.
119
+ </p>
120
+ ) : (
121
+ <>
122
+ <div className="hidden overflow-x-auto md:block">
123
+ <table className="w-full text-xs tabular-nums">
124
+ <thead className="text-muted-foreground">
125
+ <tr>
126
+ <th className="bg-card sticky left-0 py-1.5 pr-2 text-left font-normal whitespace-nowrap">
127
+ Day
128
+ </th>
129
+ {COLS.map((c) => (
130
+ <th
131
+ key={c.key}
132
+ className="px-2 py-1.5 text-right font-normal"
133
+ >
134
+ {c.label}
135
+ </th>
136
+ ))}
137
+ </tr>
138
+ </thead>
139
+ <tbody>
140
+ <tr className="border-foreground/10 border-y font-medium">
141
+ <td className="bg-card sticky left-0 py-1.5 pr-2 whitespace-nowrap">
142
+ {rows.length} days
143
+ </td>
144
+ {COLS.map((c) => (
145
+ <td key={c.key} className="px-2 py-1.5 text-right">
146
+ {total[c.key]}
147
+ </td>
148
+ ))}
149
+ </tr>
150
+ {rows.map((r) => (
151
+ <tr key={r.day} className="border-foreground/5 border-b">
152
+ <td className="bg-card sticky left-0 py-1.5 pr-2 whitespace-nowrap">
153
+ {r.day.slice(5)}
154
+ </td>
155
+ {COLS.map((c) => (
156
+ <td key={c.key} className="px-2 py-1.5 text-right">
157
+ {c.value(r)}
158
+ </td>
159
+ ))}
160
+ </tr>
161
+ ))}
162
+ </tbody>
163
+ </table>
164
+ </div>
165
+ <ul className="space-y-2 md:hidden">
166
+ <li className="bg-foreground/[0.06] rounded-xl p-3">
167
+ <div className="flex items-baseline justify-between">
168
+ <span className="text-sm font-medium">{rows.length} days</span>
169
+ <span className="tabular-nums">{total.w} weighted</span>
170
+ </div>
171
+ <dl className="mt-2 grid grid-cols-3 gap-x-3 gap-y-1.5 text-[11px]">
172
+ {COLS.slice(1)
173
+ .filter((c) => c.key !== "wk")
174
+ .map((c) => (
175
+ <div key={c.key}>
176
+ <dt className="text-muted-foreground">{c.label}</dt>
177
+ <dd className="tabular-nums">{total[c.key]}</dd>
178
+ </div>
179
+ ))}
180
+ </dl>
181
+ </li>
182
+ {rows.map((r) => (
183
+ <li key={r.day} className="bg-foreground/[0.03] rounded-xl p-3">
184
+ <div className="flex items-baseline justify-between">
185
+ <span className="text-sm font-medium">{r.day.slice(5)}</span>
186
+ <span className="tabular-nums">
187
+ {tok(r.weighted)} weighted
188
+ </span>
189
+ </div>
190
+ <dl className="mt-2 grid grid-cols-3 gap-x-3 gap-y-1.5 text-[11px]">
191
+ {COLS.slice(1).map((c) => (
192
+ <div key={c.key}>
193
+ <dt className="text-muted-foreground">{c.label}</dt>
194
+ <dd className="tabular-nums">{c.value(r)}</dd>
195
+ </div>
196
+ ))}
197
+ </dl>
198
+ </li>
199
+ ))}
200
+ </ul>
201
+ </>
202
+ )}
203
+ <p className="text-muted-foreground text-[11px]">
204
+ Weighted tokens: API price ratios by token type and model, an estimate
205
+ of limit use. Per merged and per clean count all agent work, not
206
+ people&apos;s own sessions, over the PRs merged. Totals are ratios of
207
+ sums. ~ marks an estimated weekly figure.
208
+ </p>
209
+ </section>
210
+ );
211
+ }
212
+
213
+ function BaselineCard({ data }: { data: RollupsResponse }) {
214
+ const b = data.baseline;
215
+ const c = data.config;
216
+ return (
217
+ <div className="bg-foreground/[0.03] space-y-2 rounded-xl p-3">
218
+ <div className="flex items-baseline justify-between gap-2">
219
+ <h3 className="text-sm font-medium">Baseline</h3>
220
+ <span className="text-muted-foreground text-[11px]">
221
+ {b
222
+ ? `set ${new Date(b.setAt).toLocaleDateString()}`
223
+ : `after ${c.baselineDays} days of rollups`}
224
+ </span>
225
+ </div>
226
+ {b && (
227
+ <dl className="grid grid-cols-3 gap-2 text-xs">
228
+ <Stat label="Per merged PR" value={tok(b.values.costPerMerged)} />
229
+ <Stat label="Per clean PR" value={tok(b.values.costPerClean)} />
230
+ <Stat label="Clean rate" value={pct(b.values.cleanRate)} />
231
+ </dl>
232
+ )}
233
+ <p className={cn("text-muted-foreground text-[11px]")}>
234
+ Only moves to better. Alerts when the {c.avgDays}-day cost per merged PR
235
+ is over it by {c.costPct}%, or the clean-merge rate under it by{" "}
236
+ {c.cleanPoints} points.
237
+ </p>
238
+ </div>
239
+ );
240
+ }
241
+
242
+ function Stat({ label, value }: { label: string; value: string }) {
243
+ return (
244
+ <div>
245
+ <dt className="text-muted-foreground text-[11px]">{label}</dt>
246
+ <dd className="text-base font-semibold tabular-nums">{value}</dd>
247
+ </div>
248
+ );
249
+ }
@@ -14,12 +14,14 @@ import type { UsageRange } from "@/lib/usage/aggregate";
14
14
  import { cn } from "@/lib/utils";
15
15
  import { usageUi, usageUiActions, type UsageTab } from "@/stores/usageUi";
16
16
  import { CompareView } from "./CompareView";
17
+ import { RatchetView } from "./RatchetView";
17
18
  import { ModelSpend } from "./ModelSpend";
18
19
  import { DayBars, SpendList } from "./SpendCharts";
19
20
  import { WindowCard } from "./WindowCard";
20
21
  import { formatUsd } from "./format";
21
22
 
22
23
  const TABS: { value: UsageTab; label: string }[] = [
24
+ { value: "daily", label: "Daily" },
23
25
  { value: "spend", label: "Spend" },
24
26
  { value: "models", label: "By model" },
25
27
  { value: "compare", label: "Compare" },
@@ -42,7 +44,10 @@ export function UsageDialog() {
42
44
  return (
43
45
  <Dialog open={open} onOpenChange={usageUiActions.setOpen}>
44
46
  <DialogContent
45
- className="max-h-[90vh] max-w-xl overflow-y-auto"
47
+ className={cn(
48
+ "max-h-[90vh] max-w-xl overflow-y-auto",
49
+ tab === "daily" && "sm:max-w-4xl"
50
+ )}
46
51
  // Nothing to type in: no focus ring on the first range button.
47
52
  onOpenAutoFocus={(e) => e.preventDefault()}
48
53
  >
@@ -88,6 +93,7 @@ export function UsageDialog() {
88
93
  ))}
89
94
  </div>
90
95
 
96
+ {tab === "daily" && <RatchetView enabled={open} />}
91
97
  {tab === "models" && <ModelSpend data={data?.models} />}
92
98
  {tab === "compare" && <CompareView enabled={open} />}
93
99
 
@@ -24,7 +24,7 @@ export function WindowCard({
24
24
  <div className="flex items-baseline justify-between gap-2">
25
25
  <span className="text-muted-foreground text-xs">{label}</span>
26
26
  <span className="text-lg font-semibold tabular-nums">
27
- {w ? `${w.pct}%` : "–"}
27
+ {w ? `${w.estimated ? "~" : ""}${w.pct}%` : "–"}
28
28
  </span>
29
29
  </div>
30
30
  <div className="bg-foreground/10 h-1.5 overflow-hidden rounded-full">
@@ -34,6 +34,7 @@ export function WindowCard({
34
34
  />
35
35
  </div>
36
36
  <p className="text-muted-foreground text-[11px]">
37
+ {w?.estimated && "Estimate from recorded usage · "}
37
38
  {!w
38
39
  ? "Not available"
39
40
  : w.capsIn
@@ -2,6 +2,9 @@ import { useQuery } from "@tanstack/react-query";
2
2
  import type { UsageRange, UsageReport } from "@/lib/usage/aggregate";
3
3
  import type { UsageWindows } from "@/lib/usage/windows";
4
4
  import type { SpendByModel } from "@/lib/usage/by-model";
5
+ import type { Rollup, Span } from "@/lib/usage/span";
6
+ import type { Baseline, Supporting } from "@/lib/usage/ratchet";
7
+ import type { UsageConfig } from "@/lib/usage/config";
5
8
 
6
9
  export const usageKeys = {
7
10
  all: ["usage"] as const,
@@ -31,3 +34,28 @@ export function useUsageQuery(range: UsageRange, enabled = true) {
31
34
  placeholderData: (prev) => prev,
32
35
  });
33
36
  }
37
+
38
+ export interface RollupsResponse {
39
+ rollups: Rollup[];
40
+ total: Span;
41
+ baseline: Baseline | null;
42
+ labels: Record<keyof Supporting, string>;
43
+ config: UsageConfig;
44
+ alerts: { kind: string; openedAt: number; message: string }[];
45
+ windows: UsageWindows;
46
+ }
47
+
48
+ export function useUsageRollupsQuery(enabled = true) {
49
+ return useQuery({
50
+ queryKey: [...usageKeys.all, "rollups"] as const,
51
+ queryFn: async () => {
52
+ const res = await fetch("/api/usage/rollups");
53
+ const data = await res.json();
54
+ if (!res.ok) throw new Error(data.error || "Request failed");
55
+ return data as RollupsResponse;
56
+ },
57
+ enabled,
58
+ refetchInterval: enabled ? 5 * 60_000 : false,
59
+ placeholderData: (prev) => prev,
60
+ });
61
+ }
@@ -30,6 +30,7 @@ export type ClaudeMessage = {
30
30
  usage?: {
31
31
  input_tokens?: number;
32
32
  output_tokens?: number;
33
+ cache_read_input_tokens?: number;
33
34
  cache_creation_input_tokens?: number;
34
35
  cache_creation?: {
35
36
  ephemeral_5m_input_tokens?: number;
@@ -21,6 +21,7 @@ import { ClaudeMapper, toCommand, type ClaudeMessage } from "./claude-mapper";
21
21
  import { SuggestionTrace } from "./suggestion-trace";
22
22
  import { redact } from "../../orchestrator/untrusted";
23
23
  import { NO_ATTRIBUTION } from "../../agents/attribution";
24
+ import { callFromAssistant } from "../../usage/calls";
24
25
 
25
26
  // Claude Code through the Agent SDK, signed in with the user's own Claude
26
27
  // Code login. Asks for approval through chat cards unless given full access,
@@ -245,6 +246,7 @@ export const claudeDriver: ChatDriver = {
245
246
  )
246
247
  .catch(() => {});
247
248
 
249
+ const agents = new Map<string, string>();
248
250
  void (async () => {
249
251
  try {
250
252
  for await (const message of q) {
@@ -252,6 +254,7 @@ export const claudeDriver: ChatDriver = {
252
254
  if (m.session_id) sessionId = m.session_id;
253
255
  planFile = writtenPlanFile(m) ?? planFile;
254
256
  trace.message(m);
257
+ for (const e of usageEvents(m, agents)) out.push(e);
255
258
  for (const e of mapper.map(m)) out.push(e);
256
259
  if (m.type === "assistant" && !m.parent_tool_use_id)
257
260
  cacheTtl = cacheTtlMs(m.message?.usage?.cache_creation) ?? cacheTtl;
@@ -369,3 +372,46 @@ export const claudeDriver: ChatDriver = {
369
372
  };
370
373
  },
371
374
  };
375
+
376
+ // The tokens of each response, subagents' too (the mapper drops those), and
377
+ // the CLI's limit readings, for the usage ledger. `agents` maps a subagent's
378
+ // parent tool call to its type, learned from the call that started it.
379
+ export function usageEvents(
380
+ m: ClaudeMessage,
381
+ agents: Map<string, string>,
382
+ at = Date.now()
383
+ ): DriverEvent[] {
384
+ if (m.type === "rate_limit_event")
385
+ return [
386
+ {
387
+ type: "rate_limit",
388
+ info: (m as { rate_limit_info?: unknown }).rate_limit_info,
389
+ },
390
+ ];
391
+ if (m.type !== "assistant") return [];
392
+ const content = m.message?.content;
393
+ if (!m.parent_tool_use_id && Array.isArray(content))
394
+ for (const b of content as unknown as {
395
+ type?: string;
396
+ id?: string;
397
+ name?: string;
398
+ input?: { subagent_type?: unknown };
399
+ }[])
400
+ if (
401
+ b.type === "tool_use" &&
402
+ b.id &&
403
+ (b.name === "Agent" || b.name === "Task") &&
404
+ typeof b.input?.subagent_type === "string"
405
+ )
406
+ agents.set(b.id, b.input.subagent_type);
407
+ const agentType = m.parent_tool_use_id
408
+ ? (agents.get(m.parent_tool_use_id) ?? null)
409
+ : null;
410
+ const kind = !m.parent_tool_use_id
411
+ ? "main"
412
+ : agentType?.startsWith("review-")
413
+ ? "review"
414
+ : "sub";
415
+ const call = callFromAssistant(m.message, kind, agentType, at);
416
+ return call ? [{ type: "call_usage", call }] : [];
417
+ }
@@ -1,3 +1,4 @@
1
+ import type { CallUsage } from "../usage/calls";
1
2
  import type { SearchResult } from "./find";
2
3
 
3
4
  /**
@@ -284,6 +285,8 @@ export type DriverEvent =
284
285
  | { type: "suggestion"; text: string } // the agent's guess at the next message
285
286
  | { type: "usage"; totals: UsageTotals } // running totals after a turn
286
287
  | { type: "usage_start"; totals: UsageTotals } // the totals it started from
288
+ | { type: "call_usage"; call: CallUsage } // one response's tokens
289
+ | { type: "rate_limit"; info: unknown } // the CLI's limit reading
287
290
  | { type: "context"; context: ChatContext } // context window use
288
291
  | { type: "turn_start" } // the agent started a turn (maybe one nobody sent)
289
292
  | { type: "at_rest" } // it has run every message it was given
@@ -48,6 +48,8 @@ import {
48
48
  refreshedNote,
49
49
  releaseConversation,
50
50
  } from "../refresh";
51
+ import { recordCall, sessionContext } from "../../usage/calls";
52
+ import { parseRateLimit, recordReadings } from "../../usage/rate-limits";
51
53
  import { compactContext } from "../compact-context";
52
54
  import { COMPACT_BRIEF, COMPACT_INSTRUCTIONS } from "../working-state";
53
55
  import { compactionDue, staleLabel, type Staleness } from "../stale";
@@ -280,6 +282,17 @@ export class ChatHost {
280
282
  this.emit({ type: "state", state });
281
283
  }
282
284
 
285
+ // The usage ledger is a measure: a failed write never touches a turn.
286
+ private ledger(write: () => void): void {
287
+ try {
288
+ write();
289
+ } catch (error) {
290
+ if (!this.ledgerFailed) console.error("[usage] ledger write:", error);
291
+ this.ledgerFailed = true;
292
+ }
293
+ }
294
+ private ledgerFailed = false;
295
+
283
296
  private async pump(): Promise<void> {
284
297
  try {
285
298
  for await (const e of this.conversation.events) {
@@ -329,6 +342,14 @@ export class ChatHost {
329
342
  );
330
343
  this.turnStartedAt = null;
331
344
  this.turnsRecorded++;
345
+ } else if (e.type === "call_usage") {
346
+ this.ledger(() =>
347
+ recordCall(e.call, sessionContext(this.session.id), "live")
348
+ );
349
+ } else if (e.type === "rate_limit") {
350
+ this.ledger(() =>
351
+ recordReadings(parseRateLimit(e.info), "event", this.session.id)
352
+ );
332
353
  } else if (e.type === "usage_start") {
333
354
  // Too late once a turn was measured without it.
334
355
  if (!this.turnsRecorded) this.usage = e.totals;