@saadnvd1/agent-os 0.3.470 → 0.3.472

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 (95) hide show
  1. package/README.md +139 -36
  2. package/app/api/fresh-start/route.ts +9 -5
  3. package/app/api/models/route.ts +34 -0
  4. package/app/api/models/test/route.ts +17 -0
  5. package/app/api/sessions/[id]/summarize/route.ts +4 -1
  6. package/app/api/tasks/[id]/model/route.ts +23 -0
  7. package/app/api/tasks/route.ts +5 -1
  8. package/app/api/usage/compare/route.ts +21 -0
  9. package/app/api/usage/route.ts +8 -3
  10. package/app/api/workspaces/[id]/models/route.ts +27 -0
  11. package/components/Chat/ChatPanel.tsx +5 -5
  12. package/components/Chat/StaleChip.tsx +7 -6
  13. package/components/CommandPalette/useAppCommands.ts +19 -1
  14. package/components/Draft/DraftChips.tsx +28 -0
  15. package/components/Draft/DraftPanel.tsx +1 -1
  16. package/components/Models/ModelSelect.tsx +67 -0
  17. package/components/Models/ModelsSection.tsx +199 -0
  18. package/components/Models/ProviderCard.tsx +271 -0
  19. package/components/SessionList/SidebarHeader.tsx +5 -1
  20. package/components/Settings/SettingsDialog.test.ts +3 -0
  21. package/components/Settings/SettingsDialog.tsx +9 -0
  22. package/components/Settings/commands.ts +19 -0
  23. package/components/Settings/entry-points.test.ts +1 -0
  24. package/components/Tasks/ResumeOnModel.tsx +72 -0
  25. package/components/Tasks/TaskRow.tsx +4 -0
  26. package/components/Usage/CompareView.tsx +211 -0
  27. package/components/Usage/ModelSpend.tsx +133 -0
  28. package/components/Usage/UsageDialog.tsx +69 -5
  29. package/components/Usage/format.ts +9 -0
  30. package/components/Workspaces/FreshStartForm.tsx +13 -13
  31. package/components/Workspaces/WorkspaceModelsForm.tsx +71 -0
  32. package/components/Workspaces/WorkspaceSettings.tsx +2 -0
  33. package/data/models/index.ts +125 -0
  34. package/data/usage/index.ts +5 -0
  35. package/lib/agents/launch.ts +13 -2
  36. package/lib/chat/context.ts +1 -1
  37. package/lib/chat/refresh.ts +31 -13
  38. package/lib/chat/runner.ts +5 -0
  39. package/lib/chat/stale.ts +25 -16
  40. package/lib/chat/worker/extras.ts +12 -0
  41. package/lib/chat/worker/host.ts +70 -24
  42. package/lib/chat/worker/main.ts +5 -2
  43. package/lib/db/migrations.ts +70 -0
  44. package/lib/db/types.ts +6 -0
  45. package/lib/drafts.ts +3 -0
  46. package/lib/models/config.ts +218 -0
  47. package/lib/models/launch.ts +93 -0
  48. package/lib/models/plan-step.ts +94 -0
  49. package/lib/models/providers.ts +163 -0
  50. package/lib/models/resolve.ts +246 -0
  51. package/lib/models/resume.ts +99 -0
  52. package/lib/models/roles.ts +98 -0
  53. package/lib/models/secrets.ts +86 -0
  54. package/lib/models/session-model.ts +40 -0
  55. package/lib/models/test-provider.ts +116 -0
  56. package/lib/orchestrator/act.ts +13 -1
  57. package/lib/orchestrator/armed.ts +1 -2
  58. package/lib/orchestrator/brief.ts +5 -4
  59. package/lib/orchestrator/claude-cli.ts +48 -14
  60. package/lib/orchestrator/diff.ts +1 -1
  61. package/lib/orchestrator/external-gates.ts +8 -43
  62. package/lib/orchestrator/gates.ts +10 -10
  63. package/lib/orchestrator/home.ts +2 -2
  64. package/lib/orchestrator/pr-wake.ts +2 -2
  65. package/lib/orchestrator/rebase.ts +6 -45
  66. package/lib/orchestrator/review.ts +20 -5
  67. package/lib/orchestrator/scope.ts +11 -0
  68. package/lib/orchestrator/serve.ts +13 -0
  69. package/lib/orchestrator/signoff.ts +13 -8
  70. package/lib/orchestrator/tool-names.ts +1 -0
  71. package/lib/orchestrator/tool-schemas.ts +38 -0
  72. package/lib/orchestrator/tools.ts +3 -1
  73. package/lib/pr-generation.ts +5 -2
  74. package/lib/providers/registry.ts +1 -1
  75. package/lib/session-audit/render.ts +1 -1
  76. package/lib/session-titles.ts +1 -1
  77. package/lib/sessions/launch.ts +19 -4
  78. package/lib/settings-url.ts +1 -0
  79. package/lib/stacks/land.ts +23 -16
  80. package/lib/tasks/brief.ts +3 -14
  81. package/lib/tasks/code-review.ts +69 -107
  82. package/lib/tasks/gh.ts +3 -2
  83. package/lib/tasks/index.ts +60 -9
  84. package/lib/tasks/queue.ts +14 -5
  85. package/lib/tasks/start.ts +7 -2
  86. package/lib/tasks/state.ts +4 -4
  87. package/lib/usage/by-model.ts +105 -0
  88. package/lib/usage/check-runs.ts +170 -0
  89. package/lib/usage/compare.ts +280 -0
  90. package/lib/usage/cost.ts +71 -0
  91. package/lib/usage/report.ts +51 -0
  92. package/lib/usage/turns.ts +107 -18
  93. package/package.json +1 -1
  94. package/stores/usageUi.ts +11 -2
  95. package/scripts/check-code-review.ts +0 -21
@@ -0,0 +1,72 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Cpu } from "lucide-react";
5
+ import { Button } from "@/components/ui/button";
6
+ import { ModelSelect } from "@/components/Models/ModelSelect";
7
+ import { useModelsQuery, useResumeOnModel } from "@/data/models";
8
+ import type { TaskView } from "@/lib/tasks";
9
+
10
+ // "Resume on model…": the task carries on in the same conversation and
11
+ // worktree on the model picked, which it keeps from then on.
12
+ export function ResumeOnModel({ task }: { task: TaskView }) {
13
+ const [open, setOpen] = useState(false);
14
+ const [ref, setRef] = useState<string | null>(null);
15
+ const models = useModelsQuery(open);
16
+ const resume = useResumeOnModel();
17
+ if (!open)
18
+ return (
19
+ <Button
20
+ size="sm"
21
+ variant="outline"
22
+ className="h-11 md:h-8"
23
+ title={task.model ? `Runs on ${task.model}` : undefined}
24
+ onClick={() => setOpen(true)}
25
+ >
26
+ <Cpu className="h-3.5 w-3.5" />
27
+ Resume on model…
28
+ </Button>
29
+ );
30
+ return (
31
+ <div className="bg-muted/40 w-full space-y-2 rounded-lg p-2">
32
+ <p className="text-muted-foreground text-xs">
33
+ Now on {task.model ?? "its role's model"}
34
+ {task.planning ? ", planning" : ""}. Its conversation and worktree carry
35
+ on.
36
+ </p>
37
+ <ModelSelect
38
+ value={ref}
39
+ effective={models.data?.effective}
40
+ disabled={!models.data || resume.isPending}
41
+ onChange={setRef}
42
+ />
43
+ <div className="flex gap-2">
44
+ <Button
45
+ size="sm"
46
+ className="h-11 md:h-8"
47
+ disabled={!ref || resume.isPending}
48
+ onClick={() =>
49
+ ref &&
50
+ resume.mutate(
51
+ { taskId: task.id, model: ref },
52
+ { onSuccess: () => setOpen(false) }
53
+ )
54
+ }
55
+ >
56
+ {resume.isPending ? "Resuming…" : "Resume"}
57
+ </Button>
58
+ <Button
59
+ size="sm"
60
+ variant="ghost"
61
+ className="h-11 md:h-8"
62
+ onClick={() => setOpen(false)}
63
+ >
64
+ Cancel
65
+ </Button>
66
+ </div>
67
+ {resume.isError && (
68
+ <p className="text-destructive text-xs">{resume.error.message}</p>
69
+ )}
70
+ </div>
71
+ );
72
+ }
@@ -15,6 +15,7 @@ import { tmuxAttachActions } from "@/stores/tmuxAttach";
15
15
  import { tasksUiActions } from "@/stores/tasksUi";
16
16
  import { cn } from "@/lib/utils";
17
17
  import { MoveTaskButton } from "./MoveTaskButton";
18
+ import { ResumeOnModel } from "./ResumeOnModel";
18
19
 
19
20
  const STATE: Record<TaskState, { label: string; tone: string }> = {
20
21
  working: { label: "Working", tone: "text-muted-foreground" },
@@ -58,6 +59,8 @@ export function TaskRow({ task }: { task: TaskView }) {
58
59
  <p className="text-muted-foreground truncate font-mono text-[11px]">
59
60
  {task.hostName && `${task.hostName} · `}
60
61
  {task.projectName} · {task.branch}
62
+ {task.model &&
63
+ ` · ${task.model}${task.planning ? " (planning)" : ""}`}
61
64
  </p>
62
65
  {task.cardUrl && (
63
66
  <a
@@ -114,6 +117,7 @@ export function TaskRow({ task }: { task: TaskView }) {
114
117
  </Button>
115
118
  )}
116
119
  <MoveTaskButton task={task} onError={setMoveError} />
120
+ {!task.hostId && <ResumeOnModel task={task} />}
117
121
  <span className="flex-1" />
118
122
  {confirmDrop ? (
119
123
  <Button
@@ -0,0 +1,211 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { useCompareQuery } from "@/data/models";
5
+ import type { Cohort, Comparison } from "@/lib/usage/compare";
6
+ import { ROLE_LABELS, isModelRole } from "@/lib/models/roles";
7
+ import { cn } from "@/lib/utils";
8
+ import { formatPct, formatTokens, formatUsd } from "./format";
9
+ import { SpendTable } from "./ModelSpend";
10
+ import { emptySpend, sumSpend } from "@/lib/usage/by-model";
11
+
12
+ const RANGES = [7, 30, 90] as const;
13
+
14
+ const per = (n: number, tasks: number) => (tasks ? n / tasks : 0);
15
+ const minutes = (ms: number) => `${Math.round(ms / 60000)}m`;
16
+
17
+ const totalCohort = (cs: Cohort[]): Cohort =>
18
+ cs.reduce<Cohort>(
19
+ (t, c) => ({
20
+ ...t,
21
+ tasks: t.tasks + c.tasks,
22
+ spend: sumSpend([t.spend, c.spend]),
23
+ planPct: t.planPct + c.planPct,
24
+ wallMs: t.wallMs + c.wallMs,
25
+ ciFirstGreen: add(t.ciFirstGreen, c.ciFirstGreen),
26
+ reviewFirstPass: add(t.reviewFirstPass, c.reviewFirstPass),
27
+ gateFailures: t.gateFailures + c.gateFailures,
28
+ rework: t.rework + c.rework,
29
+ merged: add(t.merged, c.merged),
30
+ dropped: add(t.dropped, c.dropped),
31
+ }),
32
+ {
33
+ key: "Total",
34
+ baseline: false,
35
+ tasks: 0,
36
+ smallSample: false,
37
+ spend: emptySpend(),
38
+ planPct: 0,
39
+ wallMs: 0,
40
+ ciFirstGreen: { yes: 0, of: 0 },
41
+ reviewFirstPass: { yes: 0, of: 0 },
42
+ gateFailures: 0,
43
+ rework: 0,
44
+ merged: { yes: 0, of: 0 },
45
+ dropped: { yes: 0, of: 0 },
46
+ }
47
+ );
48
+
49
+ function add(a: { yes: number; of: number }, b: { yes: number; of: number }) {
50
+ return { yes: a.yes + b.yes, of: a.of + b.of };
51
+ }
52
+
53
+ const ROWS: { label: string; value: (c: Cohort) => string }[] = [
54
+ { label: "Tasks", value: (c) => String(c.tasks) },
55
+ { label: "Turns", value: (c) => String(c.spend.turns) },
56
+ {
57
+ label: "Tokens",
58
+ value: (c) =>
59
+ formatTokens(
60
+ c.spend.inputTokens +
61
+ c.spend.outputTokens +
62
+ c.spend.cacheReadTokens +
63
+ c.spend.cacheWriteTokens
64
+ ),
65
+ },
66
+ { label: "Est. cost", value: (c) => formatUsd(c.spend.costUsd) },
67
+ {
68
+ label: "Cost per task",
69
+ value: (c) => formatUsd(per(c.spend.costUsd, c.tasks)),
70
+ },
71
+ {
72
+ label: "Claude window per task",
73
+ value: (c) =>
74
+ c.planPct ? `${per(c.planPct, c.tasks).toFixed(1)} pts` : "—",
75
+ },
76
+ {
77
+ label: "Agent time per task",
78
+ value: (c) => minutes(per(c.wallMs, c.tasks)),
79
+ },
80
+ {
81
+ label: "CI green on first push",
82
+ value: (c) => formatPct(c.ciFirstGreen.yes, c.ciFirstGreen.of),
83
+ },
84
+ {
85
+ label: "Review passed first try",
86
+ value: (c) => formatPct(c.reviewFirstPass.yes, c.reviewFirstPass.of),
87
+ },
88
+ {
89
+ label: "Gate failures per task",
90
+ value: (c) => per(c.gateFailures, c.tasks).toFixed(1),
91
+ },
92
+ {
93
+ label: "Rework commits per task",
94
+ value: (c) => per(c.rework, c.tasks).toFixed(1),
95
+ },
96
+ { label: "Merged", value: (c) => formatPct(c.merged.yes, c.merged.of) },
97
+ { label: "Dropped", value: (c) => formatPct(c.dropped.yes, c.dropped.of) },
98
+ ];
99
+
100
+ // Claude-only tasks against routed ones, side by side, over a date range.
101
+ export function CompareView({ enabled }: { enabled: boolean }) {
102
+ const [days, setDays] = useState<number>(30);
103
+ const { data, isPending, isError, error } = useCompareQuery(days, enabled);
104
+ return (
105
+ <section className="space-y-3">
106
+ <div className="flex items-center justify-between gap-2">
107
+ <h3 className="text-sm font-medium">Before and after routing</h3>
108
+ <div className="bg-foreground/[0.04] flex rounded-lg p-0.5">
109
+ {RANGES.map((d) => (
110
+ <button
111
+ key={d}
112
+ type="button"
113
+ onClick={() => setDays(d)}
114
+ aria-pressed={days === d}
115
+ className={cn(
116
+ "h-11 rounded-md px-3 text-xs md:h-8",
117
+ days === d
118
+ ? "bg-card text-foreground shadow-sm"
119
+ : "text-muted-foreground"
120
+ )}
121
+ >
122
+ {d} days
123
+ </button>
124
+ ))}
125
+ </div>
126
+ </div>
127
+ {isError && <p className="text-destructive text-sm">{error.message}</p>}
128
+ {isPending && !data && (
129
+ <div className="bg-foreground/[0.04] h-40 animate-pulse rounded-xl" />
130
+ )}
131
+ {data && <CompareTables data={data} />}
132
+ </section>
133
+ );
134
+ }
135
+
136
+ function CompareTables({ data }: { data: Comparison }) {
137
+ if (!data.cohorts.length)
138
+ return (
139
+ <p className="text-muted-foreground text-xs">
140
+ No tasks ran in this range.
141
+ </p>
142
+ );
143
+ const columns = [...data.cohorts, totalCohort(data.cohorts)];
144
+ const cell = "py-1.5 pr-2 text-right tabular-nums";
145
+ return (
146
+ <>
147
+ <div className="overflow-x-auto">
148
+ <table className="w-full text-xs">
149
+ <thead>
150
+ <tr>
151
+ <th className="py-1 pr-2 text-left font-normal" />
152
+ {columns.map((c) => (
153
+ <th
154
+ key={c.key}
155
+ className={cn(cell, "align-bottom font-medium")}
156
+ >
157
+ <span className="font-mono">{c.key}</span>
158
+ {c.baseline && (
159
+ <span className="text-muted-foreground block font-normal">
160
+ baseline
161
+ </span>
162
+ )}
163
+ {c.smallSample && (
164
+ <span className="block font-normal text-amber-600 dark:text-amber-400">
165
+ small sample
166
+ </span>
167
+ )}
168
+ </th>
169
+ ))}
170
+ </tr>
171
+ </thead>
172
+ <tbody>
173
+ {ROWS.map((row) => (
174
+ <tr key={row.label} className="border-border border-t">
175
+ <td className="text-muted-foreground py-1.5 pr-2">
176
+ {row.label}
177
+ </td>
178
+ {columns.map((c) => (
179
+ <td
180
+ key={c.key}
181
+ className={cn(cell, c.key === "Total" && "font-medium")}
182
+ >
183
+ {row.value(c)}
184
+ </td>
185
+ ))}
186
+ </tr>
187
+ ))}
188
+ </tbody>
189
+ </table>
190
+ </div>
191
+ <SpendTable
192
+ title="Role · model"
193
+ rows={data.roles.map((r) => ({
194
+ ...r,
195
+ key: `${r.role} ${r.provider}/${r.model}`,
196
+ label: `${isModelRole(r.role) ? ROLE_LABELS[r.role].label : "Before routing"} · ${r.provider}/${r.model}`,
197
+ }))}
198
+ total={sumSpend(data.roles)}
199
+ />
200
+ <p className="text-muted-foreground text-[11px]">
201
+ A task counts under the provider its own turns ran on (anthropic is the
202
+ baseline); its reviews and checks count toward its cost. Dollars are
203
+ list-price estimates; for Claude, the plan&apos;s real measure is its
204
+ 5-hour window, shared out by how far each turn moved it (an estimate).
205
+ Outcomes come from the orchestrator&apos;s checks, so only its tasks
206
+ have them. Under 10 tasks a column is a small sample: read it as a hint,
207
+ not a result.
208
+ </p>
209
+ </>
210
+ );
211
+ }
@@ -0,0 +1,133 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import type { SpendByModel, TokenSpend } from "@/lib/usage/by-model";
5
+ import { ROLE_LABELS, isModelRole } from "@/lib/models/roles";
6
+ import { cn } from "@/lib/utils";
7
+ import { formatTokens, formatUsd } from "./format";
8
+
9
+ const roleLabel = (r: string) =>
10
+ isModelRole(r) ? ROLE_LABELS[r].label : "Before routing";
11
+
12
+ // Tokens and estimated cost by role and by model, in the plan's 5-hour
13
+ // window or the last 24 hours.
14
+ export function ModelSpend({
15
+ data,
16
+ }: {
17
+ data: { fiveHour: SpendByModel; day: SpendByModel } | undefined;
18
+ }) {
19
+ const [span, setSpan] = useState<"fiveHour" | "day">("fiveHour");
20
+ if (!data) return null;
21
+ const shown = data[span];
22
+ return (
23
+ <section className="space-y-3">
24
+ <div className="flex items-center justify-between gap-2">
25
+ <h3 className="text-sm font-medium">By model and role</h3>
26
+ <div className="bg-foreground/[0.04] flex rounded-lg p-0.5">
27
+ {(
28
+ [
29
+ ["fiveHour", "5-hour window"],
30
+ ["day", "24 hours"],
31
+ ] as const
32
+ ).map(([v, label]) => (
33
+ <button
34
+ key={v}
35
+ type="button"
36
+ onClick={() => setSpan(v)}
37
+ aria-pressed={span === v}
38
+ className={cn(
39
+ "h-11 rounded-md px-3 text-xs md:h-8",
40
+ span === v
41
+ ? "bg-card text-foreground shadow-sm"
42
+ : "text-muted-foreground"
43
+ )}
44
+ >
45
+ {label}
46
+ </button>
47
+ ))}
48
+ </div>
49
+ </div>
50
+ <SpendTable
51
+ title="Role"
52
+ rows={shown.roles.map((r) => ({
53
+ key: r.role,
54
+ label: roleLabel(r.role),
55
+ ...r,
56
+ }))}
57
+ total={shown.total}
58
+ />
59
+ <SpendTable
60
+ title="Model"
61
+ rows={shown.models.map((m) => ({
62
+ key: `${m.provider}/${m.model}`,
63
+ label: `${m.provider}/${m.model}`,
64
+ ...m,
65
+ }))}
66
+ total={shown.total}
67
+ />
68
+ </section>
69
+ );
70
+ }
71
+
72
+ export function SpendTable({
73
+ title,
74
+ rows,
75
+ total,
76
+ }: {
77
+ title: string;
78
+ rows: (TokenSpend & { key: string; label: string })[];
79
+ total: TokenSpend;
80
+ }) {
81
+ if (!rows.length)
82
+ return <p className="text-muted-foreground text-xs">Nothing yet.</p>;
83
+ const cell = "py-1.5 pr-2 text-right tabular-nums";
84
+ return (
85
+ <div className="overflow-x-auto">
86
+ <table className="w-full text-xs">
87
+ <thead className="text-muted-foreground">
88
+ <tr>
89
+ <th className="py-1 pr-2 text-left font-normal">{title}</th>
90
+ <th className={cn(cell, "font-normal")}>In</th>
91
+ <th className={cn(cell, "font-normal")}>Out</th>
92
+ <th className={cn(cell, "font-normal")}>Cache r/w</th>
93
+ <th className={cn(cell, "font-normal")}>Turns</th>
94
+ <th className={cn(cell, "font-normal")}>Est. cost</th>
95
+ </tr>
96
+ </thead>
97
+ <tbody>
98
+ {[...rows, { ...total, key: "__total", label: "Total" }].map((r) => (
99
+ <tr
100
+ key={r.key}
101
+ className={cn(
102
+ "border-border border-t",
103
+ r.key === "__total" && "font-medium"
104
+ )}
105
+ >
106
+ <td className="max-w-48 truncate py-1.5 pr-2 font-mono md:max-w-72">
107
+ {r.label}
108
+ </td>
109
+ <td className={cell}>{formatTokens(r.inputTokens)}</td>
110
+ <td className={cell}>{formatTokens(r.outputTokens)}</td>
111
+ <td className={cell}>
112
+ {formatTokens(r.cacheReadTokens)}/
113
+ {formatTokens(r.cacheWriteTokens)}
114
+ </td>
115
+ <td className={cell}>{r.turns}</td>
116
+ <td
117
+ className={cell}
118
+ title={
119
+ r.unpriced
120
+ ? "Partly the agent's own figure: not in the price table"
121
+ : undefined
122
+ }
123
+ >
124
+ {formatUsd(r.costUsd)}
125
+ {r.unpriced ? "*" : ""}
126
+ </td>
127
+ </tr>
128
+ ))}
129
+ </tbody>
130
+ </table>
131
+ </div>
132
+ );
133
+ }
@@ -12,11 +12,19 @@ import {
12
12
  import { useUsageQuery } from "@/data/usage";
13
13
  import type { UsageRange } from "@/lib/usage/aggregate";
14
14
  import { cn } from "@/lib/utils";
15
- import { usageUi, usageUiActions } from "@/stores/usageUi";
15
+ import { usageUi, usageUiActions, type UsageTab } from "@/stores/usageUi";
16
+ import { CompareView } from "./CompareView";
17
+ import { ModelSpend } from "./ModelSpend";
16
18
  import { DayBars, SpendList } from "./SpendCharts";
17
19
  import { WindowCard } from "./WindowCard";
18
20
  import { formatUsd } from "./format";
19
21
 
22
+ const TABS: { value: UsageTab; label: string }[] = [
23
+ { value: "spend", label: "Spend" },
24
+ { value: "models", label: "By model" },
25
+ { value: "compare", label: "Compare" },
26
+ ];
27
+
20
28
  const RANGES: { value: UsageRange; label: string }[] = [
21
29
  { value: "today", label: "Today" },
22
30
  { value: "7d", label: "7 days" },
@@ -26,7 +34,7 @@ const RANGES: { value: UsageRange; label: string }[] = [
26
34
  // The account's rolling windows, and what chats cost by day, session and
27
35
  // workspace.
28
36
  export function UsageDialog() {
29
- const { open } = useSnapshot(usageUi);
37
+ const { open, tab } = useSnapshot(usageUi);
30
38
  const [range, setRange] = useState<UsageRange>("7d");
31
39
  const { data, isPending, isError, error } = useUsageQuery(range, open);
32
40
  const report = data?.report;
@@ -41,8 +49,8 @@ export function UsageDialog() {
41
49
  <DialogHeader>
42
50
  <DialogTitle>Usage</DialogTitle>
43
51
  <DialogDescription>
44
- The account&apos;s limits, and what chat sessions on this machine
45
- cost.
52
+ The account&apos;s limits, and what chat sessions and gate reviews
53
+ on this machine cost.
46
54
  </DialogDescription>
47
55
  </DialogHeader>
48
56
 
@@ -56,7 +64,34 @@ export function UsageDialog() {
56
64
  )}
57
65
  </section>
58
66
 
59
- <section className="space-y-3">
67
+ <div
68
+ role="tablist"
69
+ aria-label="Usage views"
70
+ className="bg-foreground/[0.04] flex rounded-lg p-0.5"
71
+ >
72
+ {TABS.map((t) => (
73
+ <button
74
+ key={t.value}
75
+ type="button"
76
+ role="tab"
77
+ aria-selected={tab === t.value}
78
+ onClick={() => usageUiActions.setTab(t.value)}
79
+ className={cn(
80
+ "h-11 flex-1 rounded-md px-3 text-xs md:h-8",
81
+ tab === t.value
82
+ ? "bg-card text-foreground shadow-sm"
83
+ : "text-muted-foreground"
84
+ )}
85
+ >
86
+ {t.label}
87
+ </button>
88
+ ))}
89
+ </div>
90
+
91
+ {tab === "models" && <ModelSpend data={data?.models} />}
92
+ {tab === "compare" && <CompareView enabled={open} />}
93
+
94
+ <section className={cn("space-y-3", tab !== "spend" && "hidden")}>
60
95
  <div className="flex items-center justify-between gap-2">
61
96
  <h3 className="text-sm font-medium">Chat spend</h3>
62
97
  <div className="bg-foreground/[0.04] flex rounded-lg p-0.5">
@@ -124,6 +159,35 @@ export function UsageDialog() {
124
159
  </>
125
160
  )}
126
161
  </section>
162
+
163
+ {data?.reviews && data.reviews.total.turns > 0 && (
164
+ <section className="space-y-3">
165
+ <h3 className="text-sm font-medium">Gate reviews</h3>
166
+ <div className="flex items-baseline gap-2">
167
+ <span className="text-2xl font-semibold tabular-nums">
168
+ {formatUsd(data.reviews.total.costUsd)}
169
+ </span>
170
+ <span className="text-muted-foreground text-xs">
171
+ estimate · {data.reviews.total.turns} runs ·{" "}
172
+ {data.reviews.total.tokens.toLocaleString()} tokens
173
+ </span>
174
+ </div>
175
+ <SpendList
176
+ title="By PR"
177
+ rows={data.reviews.sessions.map((s) => ({
178
+ key: s.id,
179
+ label: s.name,
180
+ ...s,
181
+ }))}
182
+ total={data.reviews.total}
183
+ limit={10}
184
+ />
185
+ <p className="text-muted-foreground text-[11px]">
186
+ Each run of the orchestrator&apos;s independent reviewer and scope
187
+ check, from its own result. Not part of chat spend.
188
+ </p>
189
+ </section>
190
+ )}
127
191
  </DialogContent>
128
192
  </Dialog>
129
193
  );
@@ -10,3 +10,12 @@ export function formatDuration(seconds: number): string {
10
10
  if (h < 48) return `${h}h${m % 60 ? ` ${m % 60}m` : ""}`;
11
11
  return `${Math.floor(h / 24)}d ${h % 24}h`;
12
12
  }
13
+
14
+ export function formatTokens(n: number): string {
15
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
16
+ if (n >= 1_000) return `${Math.round(n / 1_000)}k`;
17
+ return String(n);
18
+ }
19
+
20
+ export const formatPct = (yes: number, of: number) =>
21
+ of ? `${Math.round((yes / of) * 100)}%` : "—";
@@ -19,7 +19,7 @@ export function FreshStartForm() {
19
19
  return <p className="text-destructive text-sm">{error.message}</p>;
20
20
  return (
21
21
  <Form
22
- key={`${data.orchestratorTokens}:${data.taskTokens}:${data.staleCompactTokens}`}
22
+ key={`${data.orchestratorTokens}:${data.taskTokens}:${data.compactTokens}`}
23
23
  saved={data}
24
24
  />
25
25
  );
@@ -29,13 +29,13 @@ function Form({ saved }: { saved: RefreshSettings }) {
29
29
  const update = useUpdateFreshStart();
30
30
  const orchestrator = useThreshold(saved.orchestratorTokens, 200);
31
31
  const task = useThreshold(saved.taskTokens, 200);
32
- const stale = useThreshold(saved.staleCompactTokens, 150);
33
- const error = orchestrator.error ?? task.error ?? stale.error;
32
+ const compact = useThreshold(saved.compactTokens, 150);
33
+ const error = orchestrator.error ?? task.error ?? compact.error;
34
34
  const dirty =
35
35
  !error &&
36
36
  (orchestrator.tokens !== saved.orchestratorTokens ||
37
37
  task.tokens !== saved.taskTokens ||
38
- stale.tokens !== saved.staleCompactTokens);
38
+ compact.tokens !== saved.compactTokens);
39
39
 
40
40
  return (
41
41
  <form
@@ -46,7 +46,7 @@ function Form({ saved }: { saved: RefreshSettings }) {
46
46
  {
47
47
  orchestratorTokens: orchestrator.tokens,
48
48
  taskTokens: task.tokens,
49
- staleCompactTokens: stale.tokens,
49
+ compactTokens: compact.tokens,
50
50
  },
51
51
  {
52
52
  onSuccess: () => toast.success("Context settings saved"),
@@ -65,17 +65,17 @@ function Form({ saved }: { saved: RefreshSettings }) {
65
65
  </p>
66
66
  <ThresholdRow label="Orchestrators" field={orchestrator} />
67
67
  <ThresholdRow label="Tasks" field={task} />
68
- <h3 className="pt-2 text-sm font-medium">Compact idle chats</h3>
68
+ <h3 className="pt-2 text-sm font-medium">Compact</h3>
69
69
  <p className="text-muted-foreground text-sm">
70
- A message to a chat idle past its prompt cache re-reads the whole
71
- conversation at full price. Above this size, messages from the
72
- orchestrator, events and schedules compact it first; the composer offers
73
- to.
70
+ Chats and tasks compact when their context passes this size: Claude Code
71
+ does it itself, and between turns AgentOS compacts a chat over it, or
72
+ idle past its prompt cache, before the next message. The composer offers
73
+ to first. Orchestrators start fresh instead.
74
74
  </p>
75
75
  <ThresholdRow
76
- label="Idle chats"
77
- field={stale}
78
- switchLabel="Compact idle chats before a message"
76
+ label="Chats and tasks"
77
+ field={compact}
78
+ switchLabel="Compact chats and tasks at this size"
79
79
  />
80
80
  {error && <p className="text-destructive text-xs">{error}</p>}
81
81
  <div className="flex justify-end">