@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.
- package/README.md +77 -0
- package/app/api/usage/rollups/route.ts +36 -0
- package/components/Chat/Activity.test.ts +22 -0
- package/components/Chat/Activity.tsx +17 -5
- package/components/Chat/ChatPanel.tsx +7 -1
- package/components/Devices/ConnectCard.tsx +10 -12
- package/components/SessionList/RowParts.tsx +13 -4
- package/components/SessionList/SessionList.types.ts +1 -0
- 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/components/views/types.ts +2 -0
- package/data/chat/useChat.ts +7 -0
- package/data/usage/index.ts +28 -0
- package/lib/chat/activity.ts +24 -2
- package/lib/chat/drivers/claude-mapper.ts +8 -0
- package/lib/chat/drivers/claude.ts +46 -0
- package/lib/chat/drivers/codex-mapper.ts +5 -1
- package/lib/chat/drivers/pi-mapper.ts +4 -0
- package/lib/chat/events.ts +15 -1
- package/lib/chat/registry.ts +8 -1
- package/lib/chat/runner.ts +27 -0
- package/lib/chat/worker/host.ts +47 -0
- package/lib/chat/worker/main.ts +1 -0
- package/lib/chat/worker/protocol.ts +14 -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 +72 -0
- package/lib/orchestrator/describe.ts +1 -1
- package/lib/orchestrator/facts.ts +5 -1
- package/lib/security/auth.ts +20 -4
- package/lib/security/gate.ts +8 -5
- package/lib/security/reach.ts +1 -1
- package/lib/sidebar/shelves.ts +7 -0
- package/lib/status/collect.ts +7 -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
|
@@ -339,6 +339,13 @@ shows one quiet line with the context before and after. An orchestrator
|
|
|
339
339
|
isn't compacted (a summary would lose the fence around what it read from
|
|
340
340
|
tasks and CI): it starts a fresh conversation from its brief and the current
|
|
341
341
|
state at its fresh-start threshold, or earlier once idle past its cache.
|
|
342
|
+
While a chat compacts (any trigger, Claude, Pi or Codex) it is shown as
|
|
343
|
+
compacting, not just working: a pulsing blue dot and a "Compacting" chip in
|
|
344
|
+
the sidebar, and "Compacting context… 182k tokens" above the composer. The
|
|
345
|
+
status API (`/api/sessions/status`) keeps `status: "running"` and adds
|
|
346
|
+
`compacting: { since, before? }`; the orchestrator's `sessions` tool says
|
|
347
|
+
"compacting context". It clears when the compaction ends, errors, is
|
|
348
|
+
stopped, or the worker goes away.
|
|
342
349
|
What survives a compaction: the summary is asked to keep the task, scope
|
|
343
350
|
changes, every edited file by path, what's done, in progress and next, and
|
|
344
351
|
the decisions. Right after it, AgentOS hands back a note built from its own
|
|
@@ -556,6 +563,43 @@ environment (`ANTHROPIC_BASE_URL` with `ANTHROPIC_AUTH_TOKEN` or
|
|
|
556
563
|
log; a terminal task's shell reads it from the reference itself. Errors that
|
|
557
564
|
might echo a key are scrubbed of it.
|
|
558
565
|
|
|
566
|
+
**Usage → Daily: the usage ledger and the ratchet.** Every Claude response
|
|
567
|
+
an AgentOS chat makes (subagents and the orchestrator included) is written to
|
|
568
|
+
`usage_calls` as it arrives, with its input, cache read, 5-minute and 1-hour
|
|
569
|
+
cache write and output tokens, its model, its context size and the
|
|
570
|
+
session, task, workspace and role it belongs to. Terminal sessions, which no
|
|
571
|
+
worker watches, are read from Claude's transcripts, and gate reviews come
|
|
572
|
+
from `check_runs`; the same message id is never counted twice. Tokens are
|
|
573
|
+
_weighted_ by the API's price ratios (Opus input 1, 5-minute write 1.25,
|
|
574
|
+
1-hour write 2, cache read 0.1, output 5; Sonnet half, Haiku a quarter), an
|
|
575
|
+
estimate of how much of the plan's limits a call uses.
|
|
576
|
+
|
|
577
|
+
The 5-hour and weekly windows come from the `rate_limit_event`s Claude Code
|
|
578
|
+
sends on each response (the `anthropic-ratelimit-unified-*` headers), stored
|
|
579
|
+
with their time in `rate_limit_readings` with no extra calls. An event
|
|
580
|
+
carries one window, so the weekly figure can go stale: when it is over 6
|
|
581
|
+
hours old it is estimated from recorded usage and marked `~`.
|
|
582
|
+
|
|
583
|
+
A rollup job inside the server (no model) writes one `usage_rollups` row per
|
|
584
|
+
day: weighted total, cost per merged PR, cost per _clean_-merged PR (the
|
|
585
|
+
headline: cost against quality), review cost per PR, the share of calls above
|
|
586
|
+
200k context, model mix, orchestrator share, the weekly %, and the quality
|
|
587
|
+
readings from existing data: first-try clean-merge rate (the first reviewed
|
|
588
|
+
head passed review and CI, no gate failures, no later head), fix rounds per
|
|
589
|
+
PR, gate escalations, scope changes, and escapes (a revert or fix PR touching
|
|
590
|
+
the same files within 3 days, from `gh`; unknown rather than zero if `gh`
|
|
591
|
+
fails). Spans of days are ratios of sums. The baseline is the first week of
|
|
592
|
+
rollups and only ever moves to a better value. One phone alert fires (and
|
|
593
|
+
stays quiet until it clears) when the 3-day cost per merged PR is over
|
|
594
|
+
baseline by 15%, or the clean-merge rate is under it by 10 points, naming the
|
|
595
|
+
supporting metric that moved most. The margins, the days averaged and the
|
|
596
|
+
minimum merges to judge are settings (`usage.alert_cost_pct`,
|
|
597
|
+
`usage.alert_clean_points`, `usage.avg_days`, `usage.alert_min_merges`,
|
|
598
|
+
`usage.baseline_days`, `usage.weekly_stale_hours`). A dev server doesn't run
|
|
599
|
+
it unless `AGENTOS_USAGE_ROLLUP=on`; `AGENTOS_USAGE_ROLLUP=off` turns it off
|
|
600
|
+
in production. `POST /api/usage/rollups` runs the nightly pass now. Task
|
|
601
|
+
cards show each task's weighted cost so far.
|
|
602
|
+
|
|
559
603
|
**Usage by model.** **Usage → By model** shows tokens and estimated cost per
|
|
560
604
|
role and per model in the plan's 5-hour window and the last 24 hours, from
|
|
561
605
|
the price table in Settings → Models (dollars per million input, cache
|
|
@@ -1474,6 +1518,39 @@ and API calls or terminal connections made by another website's page.
|
|
|
1474
1518
|
| `AGENTOS_TOKEN` | A device token for `aos` and the MCP server when they reach AgentOS over a network that needs pairing. |
|
|
1475
1519
|
| `AGENTOS_AUTH=off` | No pairing, for when your own proxy does the login. Anyone who reaches the port gets a shell. |
|
|
1476
1520
|
|
|
1521
|
+
### Connect
|
|
1522
|
+
|
|
1523
|
+
Connect (in development) reaches this machine from anywhere at
|
|
1524
|
+
`https://m-<16 hex>.runagentos.com`. `agent-os connect` links the machine with
|
|
1525
|
+
a code from runagentos.com: it makes an Ed25519 key in `~/.agent-os/connect`
|
|
1526
|
+
that never leaves the machine, and the Connect service makes the machine a
|
|
1527
|
+
Cloudflare tunnel. Running it again on a linked machine links the same tunnel
|
|
1528
|
+
again, keeping its address. `agent-os disconnect` (or the switch in Devices)
|
|
1529
|
+
turns it off.
|
|
1530
|
+
|
|
1531
|
+
While Connect is on, the production server:
|
|
1532
|
+
|
|
1533
|
+
- downloads cloudflared 2026.10.0 into `~/.agent-os/connect/bin` (never one
|
|
1534
|
+
from `PATH`) and checks its SHA-256 before making it executable or running it;
|
|
1535
|
+
- asks the Connect service for a fresh connector token on every start and hands
|
|
1536
|
+
it to `cloudflared tunnel run` in `TUNNEL_TOKEN` only, never in argv, a file
|
|
1537
|
+
or a log;
|
|
1538
|
+
- restarts cloudflared with backoff if it exits, and stops it when Connect is
|
|
1539
|
+
turned off. Devices shows its state.
|
|
1540
|
+
|
|
1541
|
+
Cloudflare's edge ends TLS and cloudflared delivers requests to a listener of
|
|
1542
|
+
their own on `127.0.0.1:<port + 433>`. That listener never trusts anything as
|
|
1543
|
+
this machine, the tailnet or `AGENTOS_AUTH=off`: every request needs a paired
|
|
1544
|
+
device. It answers only the machine's own host name and drops
|
|
1545
|
+
`X-Forwarded-Host`, `Forwarded` and `X-Original-Host` before anything reads
|
|
1546
|
+
them.
|
|
1547
|
+
|
|
1548
|
+
| Variable | Effect |
|
|
1549
|
+
| ---------------------- | ------------------------------------------------------------------- |
|
|
1550
|
+
| `AGENTOS_CONNECT=0` | Never start Connect in this process. |
|
|
1551
|
+
| `AGENTOS_CONNECT_PORT` | The tunnel's loopback port (default AgentOS's port + 433). |
|
|
1552
|
+
| `AGENTOS_CONNECT_DIR` | Where the key and cloudflared live (default `~/.agent-os/connect`). |
|
|
1553
|
+
|
|
1477
1554
|
### Demo mode
|
|
1478
1555
|
|
|
1479
1556
|
`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
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { currentActivity } from "./Activity";
|
|
3
|
+
|
|
4
|
+
describe("currentActivity while compacting", () => {
|
|
5
|
+
it("says so, with the size going in and when it began", () => {
|
|
6
|
+
expect(
|
|
7
|
+
currentActivity([], "running", { since: 5, before: 182_000 })
|
|
8
|
+
).toEqual({
|
|
9
|
+
label: "Compacting context… 182k tokens",
|
|
10
|
+
since: 5,
|
|
11
|
+
compacting: true,
|
|
12
|
+
});
|
|
13
|
+
expect(currentActivity([], "running", { since: 5 })?.label).toBe(
|
|
14
|
+
"Compacting context…"
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("is plain work without it, and nothing once the turn is over", () => {
|
|
19
|
+
expect(currentActivity([], "running")?.compacting).toBeUndefined();
|
|
20
|
+
expect(currentActivity([], "idle", { since: 5 })).toBeNull();
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { Loader2, MessageCircleQuestion, Square } from "lucide-react";
|
|
4
|
-
import type { ChatItem, ChatState } from "@/lib/chat/events";
|
|
3
|
+
import { Loader2, MessageCircleQuestion, Shrink, Square } from "lucide-react";
|
|
4
|
+
import type { ChatItem, ChatState, Compacting } from "@/lib/chat/events";
|
|
5
|
+
import { formatTokens } from "@/lib/chat/context";
|
|
5
6
|
import { formatElapsed } from "@/lib/chat/elapsed";
|
|
6
7
|
import { useNow } from "./useNow";
|
|
7
8
|
|
|
@@ -11,10 +12,17 @@ type Tool = Extract<ChatItem, { kind: "tool" }>;
|
|
|
11
12
|
// running step, else whether it's writing or thinking.
|
|
12
13
|
export function currentActivity(
|
|
13
14
|
items: ChatItem[],
|
|
14
|
-
state: ChatState
|
|
15
|
-
|
|
15
|
+
state: ChatState,
|
|
16
|
+
compacting: Compacting | null = null
|
|
17
|
+
): { label: string; since?: number; compacting?: boolean } | null {
|
|
16
18
|
if (state === "waiting") return { label: "Waiting for your answer" };
|
|
17
19
|
if (state !== "running") return null;
|
|
20
|
+
if (compacting)
|
|
21
|
+
return {
|
|
22
|
+
label: `Compacting context…${compacting.before ? ` ${formatTokens(compacting.before)} tokens` : ""}`,
|
|
23
|
+
since: compacting.since,
|
|
24
|
+
compacting: true,
|
|
25
|
+
};
|
|
18
26
|
const lastUser = items.findLast((i) => i.kind === "user");
|
|
19
27
|
const tool = items.findLast(
|
|
20
28
|
(i): i is Tool => i.kind === "tool" && i.status === "running"
|
|
@@ -39,13 +47,15 @@ export function currentActivity(
|
|
|
39
47
|
export function ActivityLine({
|
|
40
48
|
items,
|
|
41
49
|
state,
|
|
50
|
+
compacting = null,
|
|
42
51
|
onStop,
|
|
43
52
|
}: {
|
|
44
53
|
items: ChatItem[];
|
|
45
54
|
state: ChatState;
|
|
55
|
+
compacting?: Compacting | null;
|
|
46
56
|
onStop: () => void;
|
|
47
57
|
}) {
|
|
48
|
-
const activity = currentActivity(items, state);
|
|
58
|
+
const activity = currentActivity(items, state, compacting);
|
|
49
59
|
const now = useNow(!!activity?.since);
|
|
50
60
|
if (!activity) return null;
|
|
51
61
|
const waiting = state === "waiting";
|
|
@@ -53,6 +63,8 @@ export function ActivityLine({
|
|
|
53
63
|
<div className="text-muted-foreground flex min-h-9 items-center gap-2 px-2 text-xs">
|
|
54
64
|
{waiting ? (
|
|
55
65
|
<MessageCircleQuestion className="text-primary h-3.5 w-3.5 shrink-0" />
|
|
66
|
+
) : activity.compacting ? (
|
|
67
|
+
<Shrink className="h-3.5 w-3.5 shrink-0 animate-pulse text-blue-600 dark:text-blue-400" />
|
|
56
68
|
) : (
|
|
57
69
|
<Loader2 className="text-primary h-3.5 w-3.5 shrink-0 animate-spin" />
|
|
58
70
|
)}
|
|
@@ -263,6 +263,7 @@ export function ChatPanel({
|
|
|
263
263
|
suggestion,
|
|
264
264
|
requestFiles,
|
|
265
265
|
search,
|
|
266
|
+
compacting,
|
|
266
267
|
} = useChat(sessionId, (text) => setPrefill({ text, at: Date.now() }));
|
|
267
268
|
const startFresh = useFreshStart();
|
|
268
269
|
const { isMobile } = useViewport();
|
|
@@ -542,7 +543,12 @@ export function ChatPanel({
|
|
|
542
543
|
)}
|
|
543
544
|
</div>
|
|
544
545
|
<div className="mx-auto w-full max-w-3xl px-3 pb-3">
|
|
545
|
-
<ActivityLine
|
|
546
|
+
<ActivityLine
|
|
547
|
+
items={items}
|
|
548
|
+
state={state}
|
|
549
|
+
compacting={compacting}
|
|
550
|
+
onStop={interrupt}
|
|
551
|
+
/>
|
|
546
552
|
<BackgroundTasks
|
|
547
553
|
items={tasks}
|
|
548
554
|
outputs={taskOutputs}
|
|
@@ -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
|
|
@@ -4,7 +4,8 @@ import { useEffect, useRef, useState } from "react";
|
|
|
4
4
|
import { NEED_LABEL, type SidebarRow } from "@/lib/sidebar/shelves";
|
|
5
5
|
import { cn } from "@/lib/utils";
|
|
6
6
|
|
|
7
|
-
// Amber when it needs you, red when it failed, green while it works
|
|
7
|
+
// Amber when it needs you, red when it failed, green while it works, and a
|
|
8
|
+
// pulsing blue while it summarises its context.
|
|
8
9
|
export function RowDot({ row }: { row: SidebarRow }) {
|
|
9
10
|
return (
|
|
10
11
|
<span
|
|
@@ -15,9 +16,11 @@ export function RowDot({ row }: { row: SidebarRow }) {
|
|
|
15
16
|
? "bg-red-500"
|
|
16
17
|
: row.need
|
|
17
18
|
? "bg-amber-500"
|
|
18
|
-
: row.
|
|
19
|
-
? "bg-
|
|
20
|
-
:
|
|
19
|
+
: row.compacting
|
|
20
|
+
? "animate-pulse bg-blue-500 ring-[3px] ring-blue-500/25"
|
|
21
|
+
: row.working
|
|
22
|
+
? "bg-green-500 ring-[3px] ring-green-500/25"
|
|
23
|
+
: "bg-muted-foreground/35"
|
|
21
24
|
)}
|
|
22
25
|
/>
|
|
23
26
|
);
|
|
@@ -41,6 +44,12 @@ export function RowBadge({ row }: { row: SidebarRow }) {
|
|
|
41
44
|
</span>
|
|
42
45
|
);
|
|
43
46
|
}
|
|
47
|
+
if (row.compacting)
|
|
48
|
+
return (
|
|
49
|
+
<span className="rounded-md bg-blue-500/15 px-1.5 py-0.5 text-[11px] font-semibold text-blue-700 dark:text-blue-400">
|
|
50
|
+
Compacting
|
|
51
|
+
</span>
|
|
52
|
+
);
|
|
44
53
|
if (row.status?.stalled)
|
|
45
54
|
return (
|
|
46
55
|
<span className="rounded-md bg-amber-500/15 px-1.5 py-0.5 text-[11px] font-semibold text-amber-700 dark:text-amber-400">
|
|
@@ -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
|
|
@@ -13,6 +13,8 @@ export interface SessionStatus {
|
|
|
13
13
|
asks?: number;
|
|
14
14
|
need?: SessionNeed | null;
|
|
15
15
|
unread?: boolean;
|
|
16
|
+
// A chat summarising its context; status stays "running".
|
|
17
|
+
compacting?: { since: number; before?: number };
|
|
16
18
|
claudeSessionId?: string | null;
|
|
17
19
|
// From a program's OSC 7501 report: untrusted, rendered as plain text.
|
|
18
20
|
detail?: string | null;
|