@saadnvd1/agent-os 0.3.499 → 0.3.502

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +77 -0
  2. package/app/api/usage/rollups/route.ts +36 -0
  3. package/components/Chat/Activity.test.ts +22 -0
  4. package/components/Chat/Activity.tsx +17 -5
  5. package/components/Chat/ChatPanel.tsx +7 -1
  6. package/components/Devices/ConnectCard.tsx +10 -12
  7. package/components/SessionList/RowParts.tsx +13 -4
  8. package/components/SessionList/SessionList.types.ts +1 -0
  9. package/components/Tasks/TaskRow.tsx +2 -0
  10. package/components/Usage/RatchetView.tsx +249 -0
  11. package/components/Usage/UsageDialog.tsx +7 -1
  12. package/components/Usage/WindowCard.tsx +2 -1
  13. package/components/views/types.ts +2 -0
  14. package/data/chat/useChat.ts +7 -0
  15. package/data/usage/index.ts +28 -0
  16. package/lib/chat/activity.ts +24 -2
  17. package/lib/chat/drivers/claude-mapper.ts +8 -0
  18. package/lib/chat/drivers/claude.ts +46 -0
  19. package/lib/chat/drivers/codex-mapper.ts +5 -1
  20. package/lib/chat/drivers/pi-mapper.ts +4 -0
  21. package/lib/chat/events.ts +15 -1
  22. package/lib/chat/registry.ts +8 -1
  23. package/lib/chat/runner.ts +27 -0
  24. package/lib/chat/worker/host.ts +47 -0
  25. package/lib/chat/worker/main.ts +1 -0
  26. package/lib/chat/worker/protocol.ts +14 -1
  27. package/lib/connect/cli.ts +51 -72
  28. package/lib/connect/cloudflared.ts +172 -0
  29. package/lib/connect/config.ts +75 -26
  30. package/lib/connect/control-api.ts +107 -48
  31. package/lib/connect/enrol.ts +33 -107
  32. package/lib/connect/serve.ts +177 -94
  33. package/lib/connect/supervise.ts +281 -0
  34. package/lib/db/migrations.ts +72 -0
  35. package/lib/orchestrator/describe.ts +1 -1
  36. package/lib/orchestrator/facts.ts +5 -1
  37. package/lib/security/auth.ts +20 -4
  38. package/lib/security/gate.ts +8 -5
  39. package/lib/security/reach.ts +1 -1
  40. package/lib/sidebar/shelves.ts +7 -0
  41. package/lib/status/collect.ts +7 -1
  42. package/lib/tasks/index.ts +8 -0
  43. package/lib/usage/calls.ts +284 -0
  44. package/lib/usage/check-runs.ts +40 -19
  45. package/lib/usage/config.ts +56 -0
  46. package/lib/usage/nightly.ts +157 -0
  47. package/lib/usage/quality.ts +239 -0
  48. package/lib/usage/ratchet.ts +275 -0
  49. package/lib/usage/rate-limits.ts +192 -0
  50. package/lib/usage/rollup.ts +90 -0
  51. package/lib/usage/span.ts +152 -0
  52. package/lib/usage/transcript-import.ts +226 -0
  53. package/lib/usage/weights.ts +64 -0
  54. package/lib/usage/windows.ts +27 -2
  55. package/package.json +2 -4
  56. package/server.ts +7 -4
  57. package/stores/usageUi.ts +3 -3
  58. package/lib/connect/cert-check.ts +0 -32
  59. package/lib/connect/cloudflare-dns.ts +0 -56
  60. package/lib/connect/frames.ts +0 -72
  61. package/lib/connect/identity.ts +0 -98
  62. package/lib/connect/mux.ts +0 -110
  63. package/lib/connect/stream.ts +0 -106
  64. package/lib/connect/tunnel.ts +0 -98
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
- ): { label: string; since?: number } | null {
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 items={items} state={state} onStop={interrupt} />
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 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
@@ -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.working
19
- ? "bg-green-500 ring-[3px] ring-green-500/25"
20
- : "bg-muted-foreground/35"
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">
@@ -10,6 +10,7 @@ export interface SessionStatus {
10
10
  need?: SessionNeed | null;
11
11
  unread?: boolean;
12
12
  stalled?: boolean;
13
+ compacting?: { since: number; before?: number };
13
14
  }
14
15
 
15
16
  export interface SessionListProps {
@@ -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
@@ -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;