@saadnvd1/agent-os 0.3.498 → 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.
- package/README.md +70 -0
- package/app/api/usage/rollups/route.ts +36 -0
- package/components/Devices/ConnectCard.tsx +10 -12
- package/components/Tasks/TaskRow.tsx +2 -0
- package/components/Usage/RatchetView.tsx +249 -0
- package/components/Usage/UsageDialog.tsx +7 -1
- package/components/Usage/WindowCard.tsx +2 -1
- package/data/usage/index.ts +28 -0
- package/lib/chat/drivers/claude-mapper.ts +1 -0
- package/lib/chat/drivers/claude.ts +46 -0
- package/lib/chat/events.ts +3 -0
- package/lib/chat/worker/host.ts +21 -0
- package/lib/chat/worker/protocol.ts +8 -1
- package/lib/connect/cli.ts +51 -72
- package/lib/connect/cloudflared.ts +172 -0
- package/lib/connect/config.ts +75 -26
- package/lib/connect/control-api.ts +107 -48
- package/lib/connect/enrol.ts +33 -107
- package/lib/connect/serve.ts +177 -94
- package/lib/connect/supervise.ts +281 -0
- package/lib/db/migrations.ts +86 -0
- package/lib/orchestrator/armed.ts +2 -1
- package/lib/orchestrator/deliver.ts +49 -7
- package/lib/orchestrator/events.ts +35 -2
- package/lib/security/auth.ts +20 -4
- package/lib/security/gate.ts +8 -5
- package/lib/security/reach.ts +1 -1
- package/lib/tasks/index.ts +8 -0
- package/lib/usage/calls.ts +284 -0
- package/lib/usage/check-runs.ts +40 -19
- package/lib/usage/config.ts +56 -0
- package/lib/usage/nightly.ts +157 -0
- package/lib/usage/quality.ts +239 -0
- package/lib/usage/ratchet.ts +275 -0
- package/lib/usage/rate-limits.ts +192 -0
- package/lib/usage/rollup.ts +90 -0
- package/lib/usage/span.ts +152 -0
- package/lib/usage/transcript-import.ts +226 -0
- package/lib/usage/weights.ts +64 -0
- package/lib/usage/windows.ts +27 -2
- package/package.json +2 -4
- package/server.ts +7 -4
- package/stores/usageUi.ts +3 -3
- package/lib/connect/cert-check.ts +0 -32
- package/lib/connect/cloudflare-dns.ts +0 -56
- package/lib/connect/frames.ts +0 -72
- package/lib/connect/identity.ts +0 -98
- package/lib/connect/mux.ts +0 -110
- package/lib/connect/stream.ts +0 -106
- 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
|
|
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
|
-
{
|
|
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
|
-
.
|
|
48
|
-
|
|
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'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=
|
|
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
|
package/data/usage/index.ts
CHANGED
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
package/lib/chat/events.ts
CHANGED
|
@@ -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
|
package/lib/chat/worker/host.ts
CHANGED
|
@@ -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;
|