Skip to content

Commit

The site no longer has g1t's own agents. g1t is where the coding agents you already use do their work, so the Agents mode is gone from the rail, with every page under /<workspace>/-/agents: personas, faces, skills, abilities, routines, computers, sessions and agent spend. Customize (skills, connectors, plugins, marketplaces), the context hub, model providers, the AI Gateway page, the Spend page and pill, Ask g1t, and the repository's Agents, Plans and agent settings pages go too, and their addresses now answer 404.

An issue can no longer be assigned or delegated to g1t, a pull request no longer has an agent panel, messages to the agent, agent reviews or "Resolve with g1t", and a security alert no longer offers "Fix with g1t". Direct messages are between people, an agent's old messages still show under its saved name or as a deleted agent, and a team's lead is a person. The landing page, product tour, pricing (no agent rate or model meters), Home, Code's overview and llms.txt describe connecting your own coding agent instead. What stays: a pull request's Session tab and the repository's Sessions list, fed by the sessions your agent records; Memory, for your agent's recall and remember, now without the review queue; overlaps, the merge queue, required checks and auto-merge; and the setup snippets for connecting a coding agent over MCP. Sudo drops its Models page and model catalogue.

syntaqxcommitted Parente6fa623Browse files
300 files+303−10503236 files not counted0/300 viewed
+1−22
281281 if (!allowances) return lines;
282282 if (allowances.ossRepoMicros != null) lines.push(`Open-source pool: ${usd(allowances.ossRepoMicros)} a month for each public repository`);
283283 if (allowances.trialMicros != null) lines.push(`Trial: ${usd(allowances.trialMicros)}, outside the monthly pool`);
284− if (allowances.maxConcurrentAgents != null) {
285− lines.push(`${allowances.maxConcurrentAgents} agent${allowances.maxConcurrentAgents === 1 ? "" : "s"} at once`);
286− }
287− if (allowances.runCapMicros != null) lines.push(`Run cap: ${usd(allowances.runCapMicros)} a run`);
288− if (allowances.issueCapMicros != null) lines.push(`Issue cap: ${usd(allowances.issueCapMicros)} an issue`);
289284 if (allowances.auditRetentionDays != null) lines.push(`Audit log: ${allowances.auditRetentionDays} days, in place of the plan's`);
290285 if (allowances.hold) lines.push(`Held: ${allowances.hold}`);
291286 return lines;
293288
294289 /**
295290 * The g1t plan without its price, the account's share of g1t's pools, and
296− * staff's overrides of what owners set: agents at once, the run and issue
297− * caps, the days of audit log kept (such as for an organization that pays
291+ * staff's overrides of what owners set: the days of audit log kept (such as for an organization that pays
298292 * for longer), and a hold on new compute. Accounts on a 100% discount have the plan
299293 * anyway.
300294 */
354348 </Field>
355349 <Field label="Trial $" hint="Each workspace, outside the monthly pool. Blank: the default ($5).">
356350 <Input name="trial" inputMode="decimal" placeholder="Default" defaultValue={values?.trial ?? dollarsField(current.trialMicros)} />
357− </Field>
358− <Field label="Agents at once" hint="Blank: the plan's (2 in the first month or on the trial, then 10).">
359− <Input
360− name="agents"
361− inputMode="numeric"
362− pattern="\d{1,3}"
363− placeholder="Default"
364− defaultValue={values?.agents ?? (current.maxConcurrentAgents != null ? String(current.maxConcurrentAgents) : "")}
365− />
366− </Field>
367− <Field label="Run cap $" hint="One run's spend, over the owners'. Blank: theirs, or $2.">
368− <Input name="runCap" inputMode="decimal" placeholder="Owners'" defaultValue={values?.runCap ?? dollarsField(current.runCapMicros)} />
369− </Field>
370− <Field label="Issue cap $" hint="One issue's agents in all, over the owners'. Blank: theirs, or $10.">
371− <Input name="issueCap" inputMode="decimal" placeholder="Owners'" defaultValue={values?.issueCap ?? dollarsField(current.issueCapMicros)} />
372351 </Field>
373352 <Field label="Audit log days" hint="Kept, in place of the plan's, longer or shorter, up to 400. Blank: the plan's (7 free, 90 on the plan).">
374353 <Input
+0−2
88 */
99 import {
1010 Activity,
11− Bot,
1211 Building2,
1312 Boxes,
1413 ChevronRight,
6160 usage: Gauge,
6261 stripe: CreditCard,
6362 costs: Cloud,
64− agents: Bot,
6563 abuse: ShieldAlert,
6664 incidents: Siren,
6765 inbox: Inbox,
+1−1
4848
4949 if (intent === "allowances") {
5050 // The plan without its price, the account's share of g1t's pools, and staff's overrides.
51− const values = fields(form, "plan", "oss", "trial", "agents", "runCap", "issueCap", "hold", "note");
51+ const values = fields(form, "plan", "oss", "trial", "hold", "note");
5252 if (subject.kind === "workspace" && subject.billedTo) {
5353 return failed("allowances", `This workspace is on ${subject.billedTo.name}. Set its plan and pools on the enterprise.`, values);
5454 }
+1−1
1616 settings: "Guardrails saved. They apply from the next run.",
1717 mapping: "Mapping saved. It applies from the next run; read the bill now to see it.",
1818 removed: "Mapping removed.",
19− lifted: "Breaker lifted for the rest of today (UTC). Hosted-model runs start again; it is recorded in the audit log.",
19+ lifted: "Breaker lifted for the rest of today (UTC). Paused work starts again; it is recorded in the audit log.",
2020 paused: "Paused across g1t. Every service sees it within 30 seconds; it is recorded in the audit log.",
2121 resumed: "Resumed across g1t. Every service sees it within 30 seconds; it is recorded in the audit log.",
2222 };
+4−4
128128 export function versionCells(v: { costMicros: number; priceMicros: number; basis?: string }): { cost: string; price: string; note: string | null } {
129129 if (v.basis === "weight") {
130130 const weight = Number((v.costMicros / 1_000_000).toFixed(6));
131− return { cost: "—", price: `×${weight}`, note: "A weight on the agent rate's tokens, not money" };
131+ return { cost: "—", price: `×${weight}`, note: "A weight, not money (retired agent token pricing)" };
132132 }
133133 if (v.basis === "rate") return { cost: "—", price: unitDollars(v.priceMicros), note: "g1t's own rate: no cost behind it" };
134134 return { cost: unitDollars(v.costMicros), price: unitDollars(v.priceMicros), note: null };
278278 const parts: string[] = [];
279279 if (caps.tripped) {
280280 parts.push(
281− `the daily breaker is open (${usd(caps.todayMicros)} of ${usd(caps.dailyCapMicros)} today), so new hosted-model agent runs g1t pays for wait until 00:00 UTC`,
281+ `the daily breaker is open (${usd(caps.todayMicros)} of ${usd(caps.dailyCapMicros)} today), so new work g1t pays for waits until 00:00 UTC`,
282282 );
283283 }
284284 for (const budget of caps.comped) {
327327
328328 /** What each level of the platform pause stops, for the page and the banner. */
329329 export const PAUSE_LEVELS: { level: PauseLevel; title: string; stops: string }[] = [
330− { level: "compute", title: "Compute", stops: "New agent runs, checks, workflow jobs and deploy builds, for every workspace. Runs already going finish." },
331− { level: "schedules", title: "Schedules", stops: "Actions' cron-triggered runs, and the runner's sweep that starts queued agents." },
330+ { level: "compute", title: "Compute", stops: "New checks, workflow jobs and deploy builds, for every workspace. Runs already going finish." },
331+ { level: "schedules", title: "Schedules", stops: "Actions' cron-triggered runs." },
332332 { level: "indexing", title: "Indexing", stops: "Context embeddings and backfills, and search's backfills (they go on from where they were when resumed)." },
333333 { level: "renders", title: "Renders", stops: "Social card images: a cache miss gets the brand card or the static logo." },
334334 ];
+4−9
136136 plan: true,
137137 ossRepoMicros: 5_000_000,
138138 trialMicros: 2_500_000,
139− maxConcurrentAgents: 20,
140− runCapMicros: 25_000_000,
141− issueCapMicros: 500_000_000,
139+ // Retired with g1t's own agents: always cleared, whatever an old form sends.
140+ maxConcurrentAgents: null,
141+ runCapMicros: null,
142+ issueCapMicros: null,
142143 auditRetentionDays: 365,
143144 hold: "Card disputed; waiting on the bank",
144145 },
146147 );
147148 assert.equal(parseAllowances(form({ oss: "lots" })).ok, false);
148149 assert.equal(parseAllowances(form({ trial: "5000" })).ok, false);
149− assert.equal(parseAllowances(form({ agents: "0" })).ok, false);
150− assert.equal(parseAllowances(form({ agents: "101" })).ok, false);
151− assert.equal(parseAllowances(form({ agents: "2.5" })).ok, false);
152− assert.equal(parseAllowances(form({ runCap: "0.05" })).ok, false);
153− assert.equal(parseAllowances(form({ runCap: "1000.01" })).ok, false);
154− assert.equal(parseAllowances(form({ issueCap: "10000.01" })).ok, false);
155150 // Audit log days: shorter or longer than the plan's, up to 400.
156151 assert.equal((parseAllowances(form({ auditDays: "3" })) as { value: { auditRetentionDays: number } }).value.auditRetentionDays, 3);
157152 assert.equal((parseAllowances(form({ auditDays: "400" })) as { value: { auditRetentionDays: number } }).value.auditRetentionDays, 400);
+8−28
179179
180180 /** The most staff can set an account's share of a pool to, against a slipped finger. */
181181 export const MAX_POOL_SHARE_MICROS = 1_000 * MICROS_PER_DOLLAR;
182−/** The most agents at once staff can allow one account. */
183−export const MAX_AGENTS_AT_ONCE = 100;
184−/** Staff's overrides of the owners' caps: one run, and one issue's agents in all. */
185−export const MAX_RUN_CAP_MICROS = 1_000 * MICROS_PER_DOLLAR;
186−export const MAX_ISSUE_CAP_MICROS = 10_000 * MICROS_PER_DOLLAR;
187182 /**
188183 * The most days of audit log staff can give one account: billing's
189184 * AUDIT_MAX_DAYS. The events service deletes anything older for everyone.
190185 */
191186 export const MAX_AUDIT_DAYS = 400;
192−/** The smallest cap: ten cents, as owners may set. */
193−const MIN_CAP_MICROS = 100_000;
194187 const MAX_HOLD = 200;
195188
196189 /**
197190 * Allowances from the plan-and-pools form: the g1t plan without its price,
198191 * the account's share of the open-source pool and of trials, and staff's
199− * overrides of agents at once, the run cap, the issue cap and the days of
200− * audit log kept. A blank amount means the default (for a cap, no
201− * override; for the audit log, the plan's). A hold is a line saying why
202− * new compute is held; blank is no hold.
192+ * override of the days of audit log kept. A blank amount means the default
193+ * (for the audit log, the plan's). A hold is a line saying why new compute
194+ * is held; blank is no hold. The agent overrides (agents at once, the run
195+ * and issue caps) are retired with g1t's own agents: they are always sent
196+ * cleared.
203197 */
204198 export function parseAllowances(form: FormData): Parsed<Allowances> {
205199 const amount = (name: string, what: string, max: number, maxText: string, min = 0): Parsed<number | null> => {
215209 if (!oss.ok) return oss;
216210 const trial = amount("trial", "The trial credit", MAX_POOL_SHARE_MICROS, "$1,000");
217211 if (!trial.ok) return trial;
218− const runCap = amount("runCap", "The run cap", MAX_RUN_CAP_MICROS, "$1,000", MIN_CAP_MICROS);
219− if (!runCap.ok) return runCap;
220− const issueCap = amount("issueCap", "The issue cap", MAX_ISSUE_CAP_MICROS, "$10,000", MIN_CAP_MICROS);
221− if (!issueCap.ok) return issueCap;
222−
223− let maxConcurrentAgents: number | null = null;
224− const rawAgents = text(form, "agents");
225− if (rawAgents) {
226− if (!/^\d{1,3}$/.test(rawAgents) || Number(rawAgents) < 1 || Number(rawAgents) > MAX_AGENTS_AT_ONCE) {
227− return { ok: false, error: `Agents at once is a whole number from 1 to ${MAX_AGENTS_AT_ONCE}, or blank for the default.` };
228− }
229− maxConcurrentAgents = Number(rawAgents);
230− }
231−
232212 let auditRetentionDays: number | null = null;
233213 const rawAudit = text(form, "auditDays");
234214 if (rawAudit) {
247227 plan: text(form, "plan") === "on",
248228 ossRepoMicros: oss.value,
249229 trialMicros: trial.value,
250− maxConcurrentAgents,
251− runCapMicros: runCap.value,
252− issueCapMicros: issueCap.value,
230+ maxConcurrentAgents: null,
231+ runCapMicros: null,
232+ issueCapMicros: null,
253233 auditRetentionDays,
254234 hold: hold || null,
255235 },
+3−4
6767 */
6868 export function accountPath(account: string | null | undefined): string | null {
6969 const id = (account ?? "").trim().toLowerCase();
70− // The model catalogue's changes (billing's catalogue.rs).
71− if (id === "models") return "/agents";
7270 // Shared invite links (identity's shared_invites.rs): `invites` is a reserved name, never a workspace's.
7371 if (id === SHARED_INVITES_ACCOUNT) return "/invites?tab=shared";
7472 const status = /^(incident|maintenance):([a-z0-9-]{1,64})$/.exec(id);
8482 /** What to call an account: a workspace by its slug, an enterprise by its name when known. */
8583 export function accountName(account: string, names: Map<string, string> = new Map()): string {
8684 if (names.has(account)) return names.get(account) as string;
87− if (account === "models") return "Agents & models";
85+ // The retired model catalogue, in older entries.
86+ if (account === "models") return "Model catalogue (retired)";
8887 if (account === SHARED_INVITES_ACCOUNT) return "Shared invite links";
8988 if (account.startsWith("incident:")) return "Incident";
9089 if (account.startsWith("maintenance:")) return "Maintenance";
149148 maintenance_cancelled: "Maintenance cancelled",
150149 credit_revoked: "Credit revoked",
151150 credit_expired: "Credit expired",
152− // The model catalogue (Agents & models).
151+ // The model catalogue, retired with g1t's own agents: older entries still name these.
153152 models_discovered: "Models found or gone",
154153 model_approved: "Model approved",
155154 model_retired: "Model retired",
+0−134
1−import assert from "node:assert/strict";
2−import { test } from "node:test";
3−
4−import type { CatalogueModel } from "@g1t/contracts";
5−
6−import { choicesFor, describeDefault, impact, parseApproval, parseDefault, parsePerMillion, perMillion, tokens } from "./models.ts";
7−
8−function model(id: string, name: string, typical: number, extra: Partial<CatalogueModel> = {}): CatalogueModel {
9− return {
10− model: id,
11− name,
12− provider: "anthropic",
13− kind: "chat",
14− inputMicros: 1_000_000,
15− outputMicros: 5_000_000,
16− cacheReadMicros: 100_000,
17− cacheWriteMicros: 1_250_000,
18− aliases: [],
19− family: "haiku",
20− tierHint: "small",
21− contextWindow: 0,
22− maxOutput: 0,
23− capabilities: [],
24− dimensions: 0,
25− status: "available",
26− priced: true,
27− source: "staff",
28− firstSeenAt: null,
29− lastSeenAt: null,
30− missingSince: null,
31− approvedBy: null,
32− approvedAt: null,
33− note: "",
34− typicalRunMicros: typical,
35− ...extra,
36− };
37−}
38−
39−const catalogue = [
40− model("claude-haiku-5-5", "Claude Haiku 5.5", 76_000),
41− model("claude-sonnet-5-5", "Claude Sonnet 5.5", 1_340_000, { tierHint: "large" }),
42− model("claude-haiku-6", "Claude Haiku 6", 0, { status: "new", priced: false }),
43− model("@cf/openai/gpt-oss-120b", "gpt-oss-120b", 30_000, { provider: "workers-ai" }),
44− model("claude-haiku-4-5", "Claude Haiku 4.5", 600_000, { status: "retired" }),
45−];
46−
47−function form(fields: Record<string, string>): FormData {
48− const data = new FormData();
49− for (const [name, value] of Object.entries(fields)) data.set(name, value);
50− return data;
51−}
52−
53−test("prices per million are typed in dollars to a millionth", () => {
54− assert.equal(parsePerMillion("0.125"), 125_000);
55− assert.equal(parsePerMillion("$2"), 2_000_000);
56− assert.equal(parsePerMillion("12.50"), 12_500_000);
57− assert.equal(parsePerMillion("0.000001"), 1);
58− assert.equal(parsePerMillion(""), 0);
59− assert.equal(parsePerMillion("-1"), null);
60− assert.equal(parsePerMillion("0.0000001"), null);
61− assert.equal(parsePerMillion("abc"), null);
62− assert.equal(perMillion(125_000), "0.125");
63− assert.equal(perMillion(100_000), "0.10");
64− assert.equal(perMillion(2_000_000), "2");
65−});
66−
67−test("approving a model takes its name, tier, prices and why", () => {
68− const approved = parseApproval(
69− form({ name: "Claude Haiku 6", tier_hint: "small", input: "0.08", output: "0.40", cache_read: "0.008", cache_write: "0.10", reason: "Anthropic's price page" }),
70− "chat",
71− );
72− assert.ok(approved.ok);
73− assert.equal(approved.value.prices.inputMicros, 80_000);
74− assert.equal(approved.value.prices.outputMicros, 400_000);
75− // No hour-long price: what a five-minute write costs.
76− assert.equal(approved.value.prices.cacheWrite1hMicros, 100_000);
77− assert.equal(approved.value.prices.threshold, 0);
78− assert.equal(parseApproval(form({ name: "X", input: "1", output: "", reason: "r" }), "chat").ok, false);
79− assert.equal(parseApproval(form({ name: "X", input: "0.012", reason: "r" }), "embeddings").ok, true);
80− assert.deepEqual(parseApproval(form({ name: "X", input: "1", output: "5", reason: "" }), "chat"), { ok: false, error: "Say why, for whoever looks next." });
81− assert.equal(parseApproval(form({ name: "X", input: "1", output: "5", over_input: "2", reason: "r" }), "chat").ok, false);
82− const long = parseApproval(form({ name: "X", input: "1", output: "5", threshold: "200,000", over_input: "2", over_output: "10", reason: "r" }), "chat");
83− assert.ok(long.ok);
84− assert.equal(long.value.prices.threshold, 200_000);
85− assert.equal(parseApproval(form({ name: "X", tier_hint: "huge", input: "1", output: "5", reason: "r" }), "chat").ok, false);
86− assert.equal(parseApproval(form({ name: "", input: "1", output: "5", reason: "r" }), "chat").ok, false);
87−});
88−
89−test("a model purpose takes only an available, priced Claude", () => {
90− const choices = choicesFor(catalogue);
91− assert.deepEqual(choices.map((m) => m.model), ["claude-haiku-5-5", "claude-sonnet-5-5"]);
92− const set = parseDefault(form({ purpose: "tier_small", model: "claude-sonnet-5-5", reason: "testing" }), choices);
93− assert.deepEqual(set, { ok: true, value: { purpose: "tier_small", model: "claude-sonnet-5-5", tier: null, effort: null, reason: "testing" } });
94− assert.equal(parseDefault(form({ purpose: "tier_small", model: "claude-haiku-6", reason: "x" }), choices).ok, false);
95− assert.equal(parseDefault(form({ purpose: "background", model: "@cf/openai/gpt-oss-120b", reason: "x" }), choices).ok, false);
96− assert.equal(parseDefault(form({ purpose: "tier_small", model: "claude-haiku-5-5", reason: "" }), choices).ok, false);
97− assert.equal(parseDefault(form({ purpose: "nonsense", model: "claude-haiku-5-5", reason: "x" }), choices).ok, false);
98−});
99−
100−test("a job takes a tier and an effort, or the harness's own", () => {
101− const choices = choicesFor(catalogue);
102− assert.deepEqual(parseDefault(form({ purpose: "job_plan", tier: "small", effort: "xhigh", reason: "x" }), choices), {
103− ok: true,
104− value: { purpose: "job_plan", model: null, tier: "small", effort: "xhigh", reason: "x" },
105− });
106− const own = parseDefault(form({ purpose: "job_update", tier: "small", effort: "", reason: "x" }), choices);
107− assert.ok(own.ok);
108− assert.equal(own.value.effort, null);
109− // Only a review is sized by the change.
110− assert.equal(parseDefault(form({ purpose: "job_review", tier: "change", reason: "x" }), choices).ok, true);
111− assert.equal(parseDefault(form({ purpose: "job_plan", tier: "change", reason: "x" }), choices).ok, false);
112− assert.equal(parseDefault(form({ purpose: "job_plan", tier: "small", effort: "huge", reason: "x" }), choices).ok, false);
113−});
114−
115−test("a change shows what a typical run would cost before it is saved", () => {
116− const before = { purpose: "tier_small", chosen: "claude-haiku-5-5", model: catalogue[0], capabilities: [], note: null };
117− assert.equal(
118− impact(before, catalogue[1], catalogue),
119− "A typical run: about $1.34 on Claude Sonnet 5.5, 17.6 times $0.076 on Claude Haiku 5.5.",
120− );
121− assert.equal(impact({ ...before, model: catalogue[1] }, catalogue[0], catalogue), "A typical run: about $0.076 on Claude Haiku 5.5, 94% less than $1.34 on Claude Sonnet 5.5.");
122− assert.equal(impact(before, catalogue[0], catalogue), "No change: Claude Haiku 5.5 already runs it, at about $0.076 a typical run.");
123− assert.equal(impact(undefined, catalogue[1], catalogue), "A typical run would cost about $1.34 on Claude Sonnet 5.5.");
124− assert.equal(impact(before, undefined, catalogue), null);
125−});
126−
127−test("defaults and sizes read plainly", () => {
128− assert.equal(describeDefault({ model: "claude-haiku-5-5", tier: null, effort: null }, catalogue), "Claude Haiku 5.5");
129− assert.equal(describeDefault({ model: null, tier: "small", effort: "high" }, catalogue), "Fast, high effort");
130− assert.equal(describeDefault({ model: null, tier: "change", effort: null }, catalogue), "By the change's size, the harness's own effort");
131− assert.equal(tokens(1_000_000), "1M");
132− assert.equal(tokens(200_000), "200k");
133− assert.equal(tokens(0), "—");
134−});
+0−235
1−/**
2− * Agents & models, as the page reads its forms and states its figures:
3− * prices per million tokens, each purpose's default, and what a change
4− * does to the cost of a typical run. Billing checks every value again. No
5− * Workers imports, so it can be tested under Node.
6− */
7−import type { CatalogueModel, ModelDefault, ModelPrices, ResolvedModel } from "@g1t/contracts";
8−
9−import type { Parsed } from "./forms.ts";
10−
11−/** The longest reason billing keeps. */
12−export const MAX_REASON = 500;
13−
14−/** A model purpose: what it is called and what it is for. */
15−export type PurposeInfo = { purpose: string; label: string; about: string };
16−
17−/** The model purposes, in the order the page shows them. */
18−export const MODEL_PURPOSES: PurposeInfo[] = [
19− { purpose: "tier_small", label: "Fast", about: "Auto's fast tier: catching up, answers, plans, small reviews." },
20− { purpose: "tier_large", label: "Standard", about: "Auto's standard tier: making and revising changes, most reviews." },
21− { purpose: "tier_frontier", label: "Most capable", about: "Auto's top tier: large reviews, architecture, work that failed twice." },
22− { purpose: "background", label: "Background", about: "The harness's own small tasks in every run, such as titles and summaries." },
23− {
24− purpose: "gateway_first",
25− label: "AI Gateway's first Claude",
26− about: "Listed first by GET /openai/v1/models, the one people start with.",
27− },
28−];
29−
30−/** Each kind of agent job, as the page names it. */
31−export const JOBS: { kind: string; label: string }[] = [
32− { kind: "implement", label: "Making a change" },
33− { kind: "revise", label: "Revising a change" },
34− { kind: "answer", label: "Answering a question" },
35− { kind: "review", label: "Reviewing" },
36− { kind: "update", label: "Catching up" },
37− { kind: "plan", label: "Planning" },
38−];
39−
40−export const TIER_LABELS: Record<string, string> = {
41− small: "Fast",
42− large: "Standard",
43− frontier: "Most capable",
44− change: "By the change's size",
45−};
46−
47−export const EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
48−
49−/** What a purpose is called anywhere on the page or in a notice. */
50−export function purposeLabel(purpose: string): string {
51− const model = MODEL_PURPOSES.find((p) => p.purpose === purpose);
52− if (model) return model.label;
53− const job = JOBS.find((j) => `job_${j.kind}` === purpose);
54− return job ? job.label : purpose;
55−}
56−
57−/** The catalogue models a purpose may be set to: available, priced Claude chat models. */
58−export function choicesFor(catalogue: CatalogueModel[]): CatalogueModel[] {
59− return catalogue.filter((m) => m.status === "available" && m.priced && m.provider === "anthropic" && (m.kind ?? "chat") === "chat");
60−}
61−
62−/** A price per million tokens, in dollars, to show or to fill a field: `0.125`, `2`, `12.50`. */
63−export function perMillion(micros: number | null | undefined): string {
64− if (micros == null) return "";
65− const text = (Math.max(0, micros) / 1_000_000).toFixed(6).replace(/0+$/, "").replace(/\.$/, "");
66− const [whole, cents] = text.split(".");
67− return cents && cents.length === 1 ? `${whole}.${cents}0` : text;
68−}
69−
70−/**
71− * Micros from a price per million tokens as typed: `0.125`, `$2`, `12.50`.
72− * Up to six decimal places (a millionth of a dollar per million tokens);
73− * an empty field is 0.
74− */
75−export function parsePerMillion(input: string): number | null {
76− const text = input.trim().replace(/^\$/, "").replace(/,/g, "");
77− if (text === "") return 0;
78− const match = /^(\d{1,4})(?:\.(\d{1,6}))?$/.exec(text);
79− if (!match) return null;
80− return Number(match[1]) * 1_000_000 + Number((match[2] ?? "").padEnd(6, "0"));
81−}
82−
83−/** The price fields of the approval form, by the name each is posted as. */
84−export const PRICE_FIELDS: { name: string; key: keyof ModelPrices; label: string }[] = [
85− { name: "input", key: "inputMicros", label: "Input" },
86− { name: "output", key: "outputMicros", label: "Output" },
87− { name: "cache_read", key: "cacheReadMicros", label: "Cache read" },
88− { name: "cache_write", key: "cacheWriteMicros", label: "Cache write, 5 min" },
89− { name: "cache_write_1h", key: "cacheWrite1hMicros", label: "Cache write, 1 h" },
90−];
91−
92−export const OVER_FIELDS: { name: string; key: keyof ModelPrices; label: string }[] = [
93− { name: "over_input", key: "overInputMicros", label: "Input" },
94− { name: "over_output", key: "overOutputMicros", label: "Output" },
95− { name: "over_cache_read", key: "overCacheReadMicros", label: "Cache read" },
96− { name: "over_cache_write", key: "overCacheWriteMicros", label: "Cache write, 5 min" },
97− { name: "over_cache_write_1h", key: "overCacheWrite1hMicros", label: "Cache write, 1 h" },
98−];
99−
100−/** A model's prices as the catalogue has them, for the approval form. */
101−export function pricesOf(model: CatalogueModel): ModelPrices {
102− return {
103− inputMicros: model.inputMicros,
104− outputMicros: model.outputMicros,
105− cacheReadMicros: model.cacheReadMicros,
106− cacheWriteMicros: model.cacheWriteMicros,
107− cacheWrite1hMicros: model.cacheWrite1hMicros ?? 0,
108− threshold: model.threshold ?? 0,
109− overInputMicros: model.overInputMicros ?? 0,
110− overOutputMicros: model.overOutputMicros ?? 0,
111− overCacheReadMicros: model.overCacheReadMicros ?? 0,
112− overCacheWriteMicros: model.overCacheWriteMicros ?? 0,
113− overCacheWrite1hMicros: model.overCacheWrite1hMicros ?? 0,
114− };
115−}
116−
117−/** A reason as the forms take it: required, kept short. */
118−export function parseReason(raw: string): Parsed<string> {
119− const reason = raw.trim();
120− if (!reason) return { ok: false, error: "Say why, for whoever looks next." };
121− if ([...reason].length > MAX_REASON) return { ok: false, error: `Keep the reason to ${MAX_REASON} characters.` };
122− return { ok: true, value: reason };
123−}
124−
125−export type Approval = { name: string; tierHint: string; prices: ModelPrices; reason: string };
126−
127−/**
128− * The approval form: the name people see, the tier it suits, its prices
129− * per million tokens (and above a long-prompt threshold, if it has one),
130− * and why. Input is required; a chat model needs an output price too.
131− */
132−export function parseApproval(form: FormData, kind: string): Parsed<Approval> {
133− const get = (name: string) => {
134− const value = form.get(name);
135− return typeof value === "string" ? value : "";
136− };
137− const name = get("name").trim();
138− if (!name) return { ok: false, error: "Give the name people see, such as Claude Haiku 6." };
139− if (name.length > 120) return { ok: false, error: "Keep the name to 120 characters." };
140− const tierHint = get("tier_hint").trim();
141− if (tierHint && !["small", "large", "frontier"].includes(tierHint)) return { ok: false, error: "Choose a tier it suits, or none." };
142− const prices = {} as ModelPrices;
143− for (const field of [...PRICE_FIELDS, ...OVER_FIELDS]) {
144− const micros = parsePerMillion(get(field.name));
145− if (micros == null) return { ok: false, error: `${field.label}: a price in dollars per million tokens, such as 0.125.` };
146− prices[field.key] = micros;
147− }
148− const threshold = get("threshold").trim().replace(/,/g, "");
149− if (threshold && !/^\d{1,9}$/.test(threshold)) return { ok: false, error: "The long-prompt threshold is a number of tokens, such as 200000." };
150− prices.threshold = threshold ? Number(threshold) : 0;
151− if (prices.inputMicros === 0) return { ok: false, error: "Give the input price per million tokens." };
152− if (kind === "chat" && prices.outputMicros === 0) return { ok: false, error: "Give the output price per million tokens." };
153− // An hour-long write with no price of its own costs what a five-minute one does.
154− if (prices.cacheWrite1hMicros === 0) prices.cacheWrite1hMicros = prices.cacheWriteMicros;
155− const over = prices.overInputMicros + prices.overOutputMicros + prices.overCacheReadMicros + prices.overCacheWriteMicros;
156− if (prices.threshold === 0 && over > 0) return { ok: false, error: "Long-prompt prices need the prompt length they start above." };
157− if (prices.threshold > 0 && prices.overInputMicros === 0) return { ok: false, error: "With a long-prompt threshold, give the prices above it." };
158− if (prices.threshold > 0 && prices.overCacheWrite1hMicros === 0) prices.overCacheWrite1hMicros = prices.overCacheWriteMicros;
159− const reason = parseReason(get("reason"));
160− if (!reason.ok) return reason;
161− return { ok: true, value: { name, tierHint, prices, reason: reason.value } };
162−}
163−
164−/** A default as the forms post it: a model, or a job's tier and effort. */
165−export type DefaultChange = { purpose: string; model: string | null; tier: string | null; effort: string | null; reason: string };
166−
167−/**
168− * One default's form: a model purpose takes one of `choices`; a job takes
169− * a tier (`change` only for reviews) and an effort, or none for the
170− * harness's own.
171− */
172−export function parseDefault(form: FormData, choices: CatalogueModel[]): Parsed<DefaultChange> {
173− const get = (name: string) => {
174− const value = form.get(name);
175− return typeof value === "string" ? value.trim() : "";
176− };
177− const purpose = get("purpose");
178− const reason = parseReason(get("reason"));
179− if (MODEL_PURPOSES.some((p) => p.purpose === purpose)) {
180− const model = get("model");
181− if (!choices.some((m) => m.model === model)) return { ok: false, error: "Choose an available Claude model." };
182− if (!reason.ok) return reason;
183− return { ok: true, value: { purpose, model, tier: null, effort: null, reason: reason.value } };
184− }
185− const job = JOBS.find((j) => `job_${j.kind}` === purpose);
186− if (!job) return { ok: false, error: "That is not something a default is chosen for." };
187− const tier = get("tier");
188− const tiers = job.kind === "review" ? ["small", "large", "frontier", "change"] : ["small", "large", "frontier"];
189− if (!tiers.includes(tier)) return { ok: false, error: `Choose ${job.kind === "review" ? "a tier, or by the change's size" : "a tier"}.` };
190− const effort = get("effort");
191− if (effort && !(EFFORTS as readonly string[]).includes(effort)) return { ok: false, error: "Effort is low, medium, high, xhigh or max, or the harness's own." };
192− if (!reason.ok) return reason;
193− return { ok: true, value: { purpose, model: null, tier, effort: effort || null, reason: reason.value } };
194−}
195−
196−/** How a default reads: a model's name, or `Fast, high effort`. */
197−export function describeDefault(value: { model: string | null; tier: string | null; effort: string | null }, catalogue: CatalogueModel[]): string {
198− if (value.model) return catalogue.find((m) => m.model === value.model)?.name ?? value.model;
199− const tier = TIER_LABELS[value.tier ?? ""] ?? value.tier ?? "none";
200− return value.effort ? `${tier}, ${value.effort} effort` : `${tier}, the harness's own effort`;
201−}
202−
203−/** What a change to a default does to a typical run's cost, in a sentence; null for a job. */
204−export function impact(before: ResolvedModel | undefined, after: CatalogueModel | undefined, catalogue: CatalogueModel[]): string | null {
205− if (!after) return null;
206− const was = before?.model ? catalogue.find((m) => m.model === before.model!.model) : undefined;
207− const now = after.typicalRunMicros;
208− const money = (micros: number) => (micros < 1_000_000 ? `$${(micros / 1_000_000).toFixed(3)}` : `$${(micros / 1_000_000).toFixed(2)}`);
209− if (!was || was.typicalRunMicros <= 0) return `A typical run would cost about ${money(now)} on ${after.name}.`;
210− if (was.model === after.model) return `No change: ${after.name} already runs it, at about ${money(now)} a typical run.`;
211− const ratio = now / was.typicalRunMicros;
212− const change = Math.round((ratio - 1) * 100);
213− const direction =
214− ratio >= 2
215− ? `${Number(ratio.toFixed(1))} times`
216− : change === 0
217− ? "the same as"
218− : change > 0
219− ? `${change}% more than`
220− : `${-change}% less than`;
221− return `A typical run: about ${money(now)} on ${after.name}, ${direction} ${money(was.typicalRunMicros)} on ${was.name}.`;
222−}
223−
224−/** The current default for each purpose, by purpose. */
225−export function defaultsByPurpose(defaults: ModelDefault[]): Map<string, ModelDefault> {
226− return new Map(defaults.map((d) => [d.purpose, d]));
227−}
228−
229−/** A context window or output limit: `1M`, `200k`, or a dash when not known. */
230−export function tokens(n: number): string {
231− if (!n) return "—";
232− if (n >= 1_000_000) return `${Number((n / 1_000_000).toFixed(1))}M`;
233− if (n >= 1_000) return `${Math.round(n / 1_000)}k`;
234− return String(n);
235−}
+1−1
4040
4141 test("the built pages are not marked soon", () => {
4242 const built = navItems().filter((item) => !item.soon).map((item) => item.to);
43− assert.deepEqual(built, ["/", "/reach-out", "/workspaces", "/enterprises", "/invites", "/aliases", "/requests", "/overages", "/velocity", "/invoices", "/credits", "/stripe", "/costs", "/agents", "/abuse", "/incidents", "/audit"]);
43+ assert.deepEqual(built, ["/", "/reach-out", "/workspaces", "/enterprises", "/invites", "/aliases", "/requests", "/overages", "/velocity", "/invoices", "/credits", "/stripe", "/costs", "/abuse", "/incidents", "/audit"]);
4444 });
4545
4646 test("every soon page says what it will do, why, and what it will have", () => {
+1−8
2020 | "usage"
2121 | "stripe"
2222 | "costs"
23− | "agents"
2423 | "abuse"
2524 | "incidents"
2625 | "inbox"
195194 about: "What customers used, sliced by meter, model, repository and day.",
196195 soon: {
197196 summary: [
198− "What customers used, across every workspace, sliced any way: by meter, model, repository, task and day. It answers the questions that come up in a sales call or a cost review: who uses the most sandbox time, which model costs most, what a customer's agents spend per pull request.",
197+ "What customers used, across every workspace, sliced any way: by meter, repository and day. It answers the questions that come up in a sales call or a cost review: who uses the most sandbox time, and what a customer spends per pull request.",
199198 "Billing records every run and sandbox second with its cost and charge already. This puts it in front of the people who need it, without a query.",
200199 ],
201200 plans: [
218217 to: "/costs",
219218 icon: "costs",
220219 about: "Where g1t's money goes, with what it gave away kept apart; on Bill & pricing, Cloudflare's bill, drift and the price book.",
221− },
222− {
223− label: "Agents & models",
224− to: "/agents",
225− icon: "agents",
226− about: "Every model g1t can use, new ones to approve, and which model each tier and job uses by default.",
227220 },
228221 {
229222 label: "Abuse & fraud",
+0−4
11 import { env } from "cloudflare:workers";
22
33 import {
4− type ModelDiscoveryApi,
54 type StatusAdminApi,
65 accountsAdminClient,
76 billingAdminClient,
3029
3130 /** The status page's incidents (apps/status's `StatusAdmin` entrypoint). */
3231 export const statusAdmin = env.STATUS as unknown as StatusAdminApi;
33−
34−/** "Check for new models": the model proxy's `Discovery` entrypoint (services/models). */
35−export const modelDiscovery = env.MODELS as unknown as ModelDiscoveryApi;
3632
3733 /** STAFF_EMAILS, for suggesting staff in the incident roles. */
3834 export const staffEmails = (): string => env.STAFF_EMAILS ?? "";
+0−1
2020 route("velocity", "routes/velocity.tsx"),
2121 route("costs", "routes/costs.tsx"),
2222 route("costs/bill", "routes/costs-bill.tsx"),
23− route("agents", "routes/agents.tsx"),
2423 route("abuse", "routes/abuse.tsx"),
2524 route("invoices", "routes/invoices.tsx"),
2625 route("credits", "routes/credits.tsx"),
+0−575
1−import { Check, RefreshCw } from "lucide-react";
2−import { data, redirect } from "react-router";
3−
4−import type { AdminModels, CatalogueModel, DiscoveryResult, ModelStatus } from "@g1t/contracts";
5−
6−import type { Route } from "./+types/agents";
7−import { Badge, Button, ButtonLink, EmptyState, Field, Input, Notice, PageHeader, Section, Select, When } from "~/components/ui";
8−import { text } from "~/lib/forms";
9−import {
10− EFFORTS,
11− JOBS,
12− MAX_REASON,
13− MODEL_PURPOSES,
14− OVER_FIELDS,
15− PRICE_FIELDS,
16− TIER_LABELS,
17− type DefaultChange,
18− choicesFor,
19− defaultsByPurpose,
20− describeDefault,
21− impact,
22− parseApproval,
23− parseDefault,
24− parseReason,
25− perMillion,
26− pricesOf,
27− purposeLabel,
28− tokens,
29−} from "~/lib/models";
30−import { usd } from "~/lib/money";
31−import { admin, modelDiscovery } from "~/lib/services.server";
32−import { settle } from "~/lib/settle";
33−import { requireStaff } from "~/lib/staff";
34−
35−export const meta: Route.MetaFunction = () => [{ title: "Agents & models · sudo" }, { name: "robots", content: "noindex, nofollow" }];
36−
37−export async function loader({ request, context }: Route.LoaderArgs) {
38− requireStaff(context);
39− const url = new URL(request.url);
40− const models = await settle(admin.models());
41− const done = url.searchParams.get("done");
42− const subject = url.searchParams.get("subject") ?? "";
43− const message: Record<string, string> = {
44− approved: `${subject} is available now: defaults can use it, and the AI Gateway offers it.`,
45− retired: `${subject} is retired. Any default that chose it uses the next suitable model until you choose another.`,
46− restored: `${subject} is back where it stood.`,
47− default: `The default for ${subject} is saved. Runs pick it up within a minute.`,
48− };
49− return {
50− models: models.ok ? models.value : null,
51− error: models.ok ? null : models.error,
52− done: done && message[done] ? message[done] : null,
53− };
54−}
55−
56−type Review = { change: DefaultChange; before: string; after: string; impact: string | null };
57−
58−type ActionData =
59− | { kind: "error"; error: string; target: string; values?: Record<string, string> }
60− | { kind: "review"; review: Review }
61− | { kind: "checked"; results: DiscoveryResult[] | null; error: string | null };
62−
63−const back = (done: string, subject: string, anchor: string) =>
64− redirect(`/agents?done=${done}&subject=${encodeURIComponent(subject)}#${anchor}`);
65−
66−/**
67− * Checking for new models, approving, retiring and restoring them, and
68− * changing a default. Billing checks each again and records it in the
69− * audit log with the staff member and why; a default shows what it does
70− * to a typical run's cost before it is saved.
71− */
72−export async function action({ request, context }: Route.ActionArgs) {
73− const staff = requireStaff(context);
74− const form = await request.formData();
75− const failed = (error: string, target: string, values?: Record<string, string>) =>
76− data<ActionData>({ kind: "error", error, target, values }, { status: 422 });
77− const intent = text(form, "intent");
78−
79− if (intent === "check") {
80− const results = await settle(modelDiscovery.check(staff.email));
81− return { kind: "checked", results: results.ok ? results.value : null, error: results.ok ? null : results.error } satisfies ActionData;
82− }
83−
84− const catalogue = await admin.models().then((m) => m.catalogue);
85− if (intent === "approve" || intent === "retire" || intent === "restore") {
86− const id = text(form, "model");
87− const model = catalogue.find((m) => m.model === id);
88− if (!model) return failed(`${id} is not in the catalogue.`, `model-${id}`);
89− if (intent === "approve") {
90− const approval = parseApproval(form, model.kind ?? "chat");
91− if (!approval.ok) return failed(approval.error, `model-${id}`, Object.fromEntries([...form.entries()].map(([k, v]) => [k, String(v)])));
92− const { name, tierHint, prices, reason } = approval.value;
93− const result = await admin.decideModel(id, "approve", { name, tierHint: tierHint || "", prices }, reason, staff.email);
94− if (!result.ok) return failed(result.error.message, `model-${id}`);
95− throw back("approved", result.value.name, "catalogue");
96− }
97− const reason = parseReason(text(form, "reason"));
98− if (!reason.ok) return failed(reason.error, `model-${id}`);
99− const result = await admin.decideModel(id, intent, {}, reason.value, staff.email);
100− if (!result.ok) return failed(result.error.message, `model-${id}`);
101− throw back(intent === "retire" ? "retired" : "restored", result.value.name, "catalogue");
102− }
103−
104− if (intent === "default") {
105− const parsed = parseDefault(form, choicesFor(catalogue));
106− const purpose = text(form, "purpose");
107− if (!parsed.ok) return failed(parsed.error, `default-${purpose}`, { reason: text(form, "reason") });
108− const change = parsed.value;
109− if (text(form, "confirm") !== "yes") {
110− const models = await admin.models();
111− const current = defaultsByPurpose(models.defaults).get(change.purpose);
112− const resolved = models.resolved.models.find((m) => m.purpose === change.purpose);
113− return {
114− kind: "review",
115− review: {
116− change,
117− before: current ? describeDefault(current, catalogue) : "nothing",
118− after: describeDefault(change, catalogue),
119− impact: change.model ? impact(resolved, catalogue.find((m) => m.model === change.model), catalogue) : null,
120− },
121− } satisfies ActionData;
122− }
123− const result = await admin.setModelDefault(change.purpose, change, change.reason, staff.email);
124− if (!result.ok) return failed(result.error.message, `default-${change.purpose}`);
125− throw back("default", purposeLabel(change.purpose), "defaults");
126− }
127− return data<ActionData>({ kind: "error", error: "Unknown action.", target: "" }, { status: 400 });
128−}
129−
130−const STATUS: Record<ModelStatus, { label: string; tone: "mint" | "lavender" | "warn" | "danger" }> = {
131− available: { label: "Available", tone: "mint" },
132− new: { label: "New", tone: "lavender" },
133− deprecated: { label: "Deprecated", tone: "warn" },
134− retired: { label: "Retired", tone: "danger" },
135−};
136−
137−const PROVIDER: Record<string, string> = { anthropic: "Anthropic", "workers-ai": "Workers AI" };
138−
139−/** A typical run's cost, with the cents a fast model's run is measured in. */
140−function runCost(micros: number): string {
141− return micros > 0 ? usd(micros) : "—";
142−}
143−
144−export default function Agents({ loaderData, actionData }: Route.ComponentProps) {
145− const { models, error, done } = loaderData;
146− const result = actionData as ActionData | undefined;
147− const failure = result?.kind === "error" ? result : null;
148− return (
149− <main id="top" className="mx-auto max-w-6xl scroll-mt-20 px-4 py-8 sm:py-10">
150− <PageHeader
151− title="Agents & models"
152− description="Every model g1t can use, found by listing each provider daily; new ones wait here for their prices to be confirmed. Staff choose which model each tier and job uses. Customers never pick a model: Auto routes their work with these."
153− actions={
154− <form method="post" action="/agents#top">
155− <input type="hidden" name="intent" value="check" />
156− <Button type="submit" variant="lavender">
157− <RefreshCw size={14} />
158− Check for new models
159− </Button>
160− </form>
161− }
162− />
163− <div className="mt-6 space-y-3">
164− {done && <Notice tone="ok">{done}</Notice>}
165− {error && <Notice tone="error">Billing did not answer: {error}</Notice>}
166− {failure && !failure.target && <Notice tone="error">{failure.error}</Notice>}
167− {result?.kind === "checked" && <Checked results={result.results} error={result.error} />}
168− </div>
169− {result?.kind === "review" && <ReviewPanel review={result.review} />}
170− {models && <Page models={models} failure={failure} />}
171− </main>
172− );
173−}
174−
175−function Checked({ results, error }: { results: DiscoveryResult[] | null; error: string | null }) {
176− if (!results) return <Notice tone="error">The model proxy did not answer: {error}</Notice>;
177− return (
178− <Notice tone={results.some((r) => r.error) ? "warn" : "ok"}>
179− <ul className="space-y-1">
180− {results.map((r) => (
181− <li key={r.provider}>
182− <span className="font-medium text-fg">{PROVIDER[r.provider] ?? r.provider}</span>:{" "}
183− {r.error
184− ? `could not be listed (${r.error}). Nothing changed.`
185− : `${r.listed} listed; ${r.added.length ? `new: ${r.added.join(", ")}` : "nothing new"}${r.deprecated.length ? `; no longer listed: ${r.deprecated.join(", ")}` : ""}${r.restored.length ? `; listed again: ${r.restored.join(", ")}` : ""}.`}
186− </li>
187− ))}
188− </ul>
189− </Notice>
190− );
191−}
192−
193−function ReviewPanel({ review }: { review: Review }) {
194− const { change } = review;
195− return (
196− <Section id="review" className="mt-6 border-accent/40" title={`Change ${purposeLabel(change.purpose)}?`} description="Nothing is saved until you confirm.">
197− <dl className="grid gap-x-6 gap-y-2 text-sm sm:grid-cols-[max-content_minmax(0,1fr)]">
198− <dt className="text-muted">Now</dt>
199− <dd>{review.before}</dd>
200− <dt className="text-muted">After</dt>
201− <dd className="font-medium">{review.after}</dd>
202− {review.impact && (
203− <>
204− <dt className="text-muted">Cost</dt>
205− <dd>{review.impact}</dd>
206− </>
207− )}
208− <dt className="text-muted">Why</dt>
209− <dd className="text-fg-soft">{change.reason}</dd>
210− </dl>
211− <form method="post" action="/agents#defaults" className="mt-4 flex flex-wrap gap-2">
212− <input type="hidden" name="intent" value="default" />
213− <input type="hidden" name="confirm" value="yes" />
214− <input type="hidden" name="purpose" value={change.purpose} />
215− {change.model && <input type="hidden" name="model" value={change.model} />}
216− {change.tier && <input type="hidden" name="tier" value={change.tier} />}
217− <input type="hidden" name="effort" value={change.effort ?? ""} />
218− <input type="hidden" name="reason" value={change.reason} />
219− <Button type="submit" variant="lavender">
220− <Check size={14} />
221− Save
222− </Button>
223− <ButtonLink to="/agents#defaults" variant="quiet">
224− Cancel
225− </ButtonLink>
226− </form>
227− </Section>
228− );
229−}
230−
231−type Failure = Extract<ActionData, { kind: "error" }> | null;
232−
233−function Page({ models, failure }: { models: AdminModels; failure: Failure }) {
234− const waiting = models.catalogue.filter((m) => m.status === "new");
235− const t = models.typical;
236− return (
237− <>
238− <Defaults models={models} failure={failure} />
239− <Section
240− id="waiting"
241− className="mt-6"
242− title="New models"
243− description="Found by a check and not used for anything yet. Confirm the name and prices (from the provider's price page) to make one available; until then nothing routes to it, offers it or charges for it."
244− >
245− {waiting.length === 0 ? (
246− <EmptyState title="Nothing waiting">A model a provider starts listing appears here after the next check.</EmptyState>
247− ) : (
248− <ul className="space-y-4">
249− {waiting.map((model) => (
250− <Waiting key={model.model} model={model} failure={failure?.target === `model-${model.model}` ? failure : null} />
251− ))}
252− </ul>
253− )}
254− </Section>
255− <Section
256− id="catalogue"
257− className="mt-6"
258− title="Catalogue"
259− description={`Prices per million tokens. A typical run is ${t.requests} requests of ${t.input.toLocaleString("en-US")} input, ${t.output.toLocaleString("en-US")} output, ${t.cacheRead.toLocaleString("en-US")} cache-read and ${t.cacheWrite.toLocaleString("en-US")} cache-write tokens: an estimate for comparing models, never a charge.`}
260− >
261− <Catalogue catalogue={models.catalogue} failure={failure} />
262− </Section>
263− <Section id="checks" className="mt-6" title="Checks" description="Each provider is listed daily at 05:29 UTC, and whenever someone checks from here. Listing models is free; nothing calls a model.">
264− {models.checks.length === 0 ? (
265− <EmptyState title="No checks yet">Check for new models above, or wait for the daily check.</EmptyState>
266− ) : (
267− <ul className="divide-y divide-line text-sm">
268− {models.checks.map((check) => (
269− <li key={check.id} className="flex flex-col gap-1 py-2.5 first:pt-0 last:pb-0 sm:flex-row sm:items-baseline sm:gap-4">
270− <span className="w-28 shrink-0 font-medium">{PROVIDER[check.provider] ?? check.provider}</span>
271− <span className="w-44 shrink-0 text-muted">
272− <When at={check.checkedAt} time />
273− </span>
274− <span className="min-w-0 flex-1 text-fg-soft">
275− {check.error ? (
276− <span className="text-danger">Failed: {check.error}</span>
277− ) : (
278− <>
279− {check.listed} listed
280− {check.added.length > 0 && <>; new: {check.added.join(", ")}</>}
281− {check.deprecated.length > 0 && <>; gone: {check.deprecated.join(", ")}</>}
282− </>
283− )}
284− </span>
285− <span className="text-xs text-faint">{check.by === "schedule" ? "Daily check" : check.by}</span>
286− </li>
287− ))}
288− </ul>
289− )}
290− </Section>
291− </>
292− );
293−}
294−
295−function Defaults({ models, failure }: { models: AdminModels; failure: Failure }) {
296− const choices = choicesFor(models.catalogue);
297− const current = defaultsByPurpose(models.defaults);
298− return (
299− <Section
300− id="defaults"
301− className="mt-6"
302− title="Defaults"
303− description="What Auto runs each tier on, the harness's background model, the AI Gateway's first Claude, and where each kind of job starts and how hard it thinks. Runs read these within a minute; a model that is retired or no longer listed is never used, and the next suitable one runs instead."
304− >
305− <ul className="divide-y divide-line">
306− {MODEL_PURPOSES.map((info) => {
307− const row = current.get(info.purpose);
308− const resolved = models.resolved.models.find((m) => m.purpose === info.purpose);
309− const running = resolved?.model ? models.catalogue.find((m) => m.model === resolved.model!.model) : undefined;
310− const error = failure?.target === `default-${info.purpose}` ? failure : null;
311− return (
312− <li key={info.purpose} id={`default-${info.purpose}`} className="scroll-mt-20 py-4 first:pt-0 last:pb-0">
313− <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
314− <div>
315− <h3 className="text-sm font-semibold">{info.label}</h3>
316− <p className="text-xs text-muted">{info.about}</p>
317− </div>
318− <div className="text-right text-sm">
319− <span className="font-medium">{running?.name ?? resolved?.chosen ?? "Not set"}</span>
320− {running && running.typicalRunMicros > 0 && <span className="ml-2 text-xs text-muted">{runCost(running.typicalRunMicros)} a typical run</span>}
321− </div>
322− </div>
323− {resolved?.note && (
324− <div className="mt-2">
325− <Notice tone="warn">{resolved.note}</Notice>
326− </div>
327− )}
328− {row && (
329− <p className="mt-1 text-xs text-faint">
330− Set by {row.updatedBy} <When at={row.updatedAt} />
331− {row.reason ? `: ${row.reason}` : ""}
332− </p>
333− )}
334− <form method="post" action={`/agents#review`} className="mt-3 grid gap-2 sm:grid-cols-[minmax(0,14rem)_minmax(0,1fr)_auto] sm:items-end">
335− <input type="hidden" name="intent" value="default" />
336− <input type="hidden" name="purpose" value={info.purpose} />
337− <Select name="model" defaultValue={row?.model ?? ""} aria-label={`Model for ${info.label}`}>
338− {choices.map((m) => (
339− <option key={m.model} value={m.model}>
340− {m.name} · {runCost(m.typicalRunMicros)}
341− </option>
342− ))}
343− </Select>
344− <Input name="reason" required maxLength={MAX_REASON} placeholder="Why" aria-label={`Why change ${info.label}`} defaultValue={error?.values?.reason} />
345− <Button type="submit" variant="quiet">
346− Review
347− </Button>
348− </form>
349− {error && (
350− <div className="mt-2">
351− <Notice tone="error">{error.error}</Notice>
352− </div>
353− )}
354− </li>
355− );
356− })}
357− </ul>
358− <h3 className="mt-6 border-t border-line pt-4 text-sm font-semibold">Jobs</h3>
359− <p className="text-xs text-muted">
360− Where each kind of job starts. Failures, labels and the repository's own history still move it up or down. Effort applies on models that take
361− it; on one that does not, the harness's own.
362− </p>
363− <ul className="mt-3 divide-y divide-line">
364− {JOBS.map((job) => {
365− const purpose = `job_${job.kind}`;
366− const row = current.get(purpose);
367− const error = failure?.target === `default-${purpose}` ? failure : null;
368− const tiers = job.kind === "review" ? ["small", "large", "frontier", "change"] : ["small", "large", "frontier"];
369− return (
370− <li key={job.kind} id={`default-${purpose}`} className="scroll-mt-20 py-3 first:pt-0 last:pb-0">
371− <form method="post" action="/agents#review" className="grid gap-2 sm:grid-cols-[10rem_minmax(0,11rem)_minmax(0,9rem)_minmax(0,1fr)_auto] sm:items-center">
372− <input type="hidden" name="intent" value="default" />
373− <input type="hidden" name="purpose" value={purpose} />
374− <div>
375− <span className="text-sm font-medium">{job.label}</span>
376− {row && (
377− <span className="block text-xs text-faint">
378− {row.updatedBy} <When at={row.updatedAt} />
379− </span>
380− )}
381− </div>
382− <Select name="tier" defaultValue={row?.tier ?? "large"} aria-label={`Starting tier for ${job.label}`}>
383− {tiers.map((tier) => (
384− <option key={tier} value={tier}>
385− {TIER_LABELS[tier]}
386− </option>
387− ))}
388− </Select>
389− <Select name="effort" defaultValue={row?.effort ?? ""} aria-label={`Effort for ${job.label}`}>
390− <option value="">Harness's own</option>
391− {EFFORTS.map((effort) => (
392− <option key={effort} value={effort}>
393− {effort} effort
394− </option>
395− ))}
396− </Select>
397− <Input name="reason" required maxLength={MAX_REASON} placeholder="Why" aria-label={`Why change ${job.label}`} />
398− <Button type="submit" variant="quiet">
399− Review
400− </Button>
401− </form>
402− {error && (
403− <div className="mt-2">
404− <Notice tone="error">{error.error}</Notice>
405− </div>
406− )}
407− </li>
408− );
409− })}
410− </ul>
411− </Section>
412− );
413−}
414−
415−function Waiting({ model, failure }: { model: CatalogueModel; failure: Failure }) {
416− const prices = pricesOf(model);
417− const value = (name: string, fallback: string) => failure?.values?.[name] ?? fallback;
418− return (
419− <li id={`model-${model.model}`} className="scroll-mt-20 rounded-lg border border-line bg-bg p-4 sm:p-5">
420− <div className="flex flex-wrap items-center gap-2">
421− <span className="font-medium">{model.name}</span>
422− <code className="text-xs text-muted">{model.model}</code>
423− <Badge>{PROVIDER[model.provider] ?? model.provider}</Badge>
424− {!model.priced && <Badge tone="warn">No price known</Badge>}
425− </div>
426− <p className="mt-1 text-xs text-muted">
427− Found <When at={model.firstSeenAt} /> · {model.kind === "embeddings" ? "Embeddings" : "Chat"} · context {tokens(model.contextWindow)}
428− {model.capabilities.length > 0 && <> · {model.capabilities.join(", ")}</>}
429− {model.priced && " · prices filled from the provider; check them"}
430− </p>
431− <form method="post" action={`/agents#model-${model.model}`} className="mt-4 space-y-3">
432− <input type="hidden" name="intent" value="approve" />
433− <input type="hidden" name="model" value={model.model} />
434− <div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_12rem]">
435− <Field label="Name people see">
436− <Input name="name" required maxLength={120} defaultValue={value("name", model.name)} />
437− </Field>
438− <Field label="Suits the tier">
439− <Select name="tier_hint" defaultValue={value("tier_hint", model.tierHint)}>
440− <option value="">None</option>
441− <option value="small">Fast</option>
442− <option value="large">Standard</option>
443− <option value="frontier">Most capable</option>
444− </Select>
445− </Field>
446− </div>
447− <fieldset>
448− <legend className="mb-1.5 text-sm font-medium text-muted">Dollars per million tokens</legend>
449− <div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
450− {PRICE_FIELDS.map((field) => (
451− <Field key={field.name} label={field.label}>
452− <Input name={field.name} inputMode="decimal" className="font-mono" defaultValue={value(field.name, model.priced ? perMillion(prices[field.key]) : "")} />
453− </Field>
454− ))}
455− </div>
456− </fieldset>
457− <details className="rounded-md border border-line px-3 py-2" open={prices.threshold > 0}>
458− <summary className="cursor-pointer text-sm text-muted">Priced by prompt length</summary>
459− <div className="mt-3 space-y-3">
460− <Field label="Above this many prompt tokens" hint="The whole request is charged at the prices below once its prompt (input and cache tokens) is longer.">
461− <Input name="threshold" inputMode="numeric" className="font-mono" defaultValue={value("threshold", prices.threshold ? String(prices.threshold) : "")} />
462− </Field>
463− <div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
464− {OVER_FIELDS.map((field) => (
465− <Field key={field.name} label={field.label}>
466− <Input name={field.name} inputMode="decimal" className="font-mono" defaultValue={value(field.name, prices[field.key] ? perMillion(prices[field.key]) : "")} />
467− </Field>
468− ))}
469− </div>
470− </div>
471− </details>
472− <Field label="Why" hint="Kept with the model, and in the audit log.">
473− <Input name="reason" required maxLength={MAX_REASON} placeholder="e.g. Prices from the provider's price page, 2026-10-08." defaultValue={value("reason", "")} />
474− </Field>
475− {failure && <Notice tone="error">{failure.error}</Notice>}
476− <div className="flex justify-end">
477− <Button type="submit" variant="lavender">
478− <Check size={14} />
479− Approve
480− </Button>
481− </div>
482− </form>
483− <Decide model={model} intent="retire" label="Retire instead" />
484− </li>
485− );
486−}
487−
488−/** A retire or restore form, folded away until opened. */
489−function Decide({ model, intent, label }: { model: CatalogueModel; intent: "retire" | "restore"; label: string }) {
490− return (
491− <details className="mt-3">
492− <summary className="cursor-pointer text-xs text-muted hover:text-fg">{label}</summary>
493− <form method="post" action={`/agents#model-${model.model}`} className="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center">
494− <input type="hidden" name="intent" value={intent} />
495− <input type="hidden" name="model" value={model.model} />
496− <Input name="reason" required maxLength={MAX_REASON} placeholder="Why" aria-label={`Why ${intent} ${model.name}`} />
497− <Button type="submit" variant={intent === "retire" ? "danger" : "quiet"}>
498− {intent === "retire" ? "Retire" : "Restore"}
499− </Button>
500− </form>
501− </details>
502− );
503−}
504−
505−function Catalogue({ catalogue, failure }: { catalogue: CatalogueModel[]; failure: Failure }) {
506− const listed = catalogue.filter((m) => m.status !== "new");
507− return (
508− <div className="-mx-4 overflow-x-auto sm:-mx-5">
509− <table className="w-full min-w-[56rem] text-sm">
510− <thead>
511− <tr className="border-b border-line text-left text-xs text-muted">
512− <th className="px-4 py-2 font-medium sm:px-5">Model</th>
513− <th className="px-4 py-2 font-medium">Status</th>
514− <th className="px-4 py-2 font-medium">Suits</th>
515− <th className="px-4 py-2 text-right font-medium">Context</th>
516− <th className="px-4 py-2 text-right font-medium">In / out</th>
517− <th className="px-4 py-2 text-right font-medium">Cache read</th>
518− <th className="px-4 py-2 text-right font-medium">Typical run</th>
519− <th className="px-4 py-2 font-medium sm:pr-5">Listed</th>
520− </tr>
521− </thead>
522− <tbody>
523− {listed.map((model) => {
524− const status = STATUS[model.status] ?? STATUS.available;
525− const error = failure?.target === `model-${model.model}` ? failure : null;
526− return (
527− <tr key={model.model} id={`model-${model.model}`} className="scroll-mt-20 border-b border-line align-top last:border-0">
528− <td className="px-4 py-2.5 sm:px-5">
529− <span className="font-medium">{model.name}</span>
530− <code className="block text-xs text-muted">{model.model}</code>
531− {model.aliases.length > 0 && <span className="block text-xs text-faint">also {model.aliases.join(", ")}</span>}
532− <span className="block text-xs text-faint">{PROVIDER[model.provider] ?? model.provider}</span>
533− {error && <span className="mt-1 block text-xs text-danger">{error.error}</span>}
534− </td>
535− <td className="px-4 py-2.5">
536− <Badge tone={status.tone}>{status.label}</Badge>
537− {!model.priced && (
538− <span className="mt-1 block">
539− <Badge tone="warn">Unpriced</Badge>
540− </span>
541− )}
542− {model.status === "available" ? (
543− <Decide model={model} intent="retire" label="Retire" />
544− ) : (
545− <Decide model={model} intent="restore" label="Restore" />
546− )}
547− </td>
548− <td className="px-4 py-2.5 text-muted">{model.kind === "embeddings" ? `Embeddings${model.dimensions ? `, ${model.dimensions}` : ""}` : TIER_LABELS[model.tierHint] ?? "—"}</td>
549− <td className="px-4 py-2.5 text-right font-mono tabular-nums">{tokens(model.contextWindow)}</td>
550− <td className="px-4 py-2.5 text-right font-mono tabular-nums whitespace-nowrap">
551− ${perMillion(model.inputMicros)}
552− {model.kind !== "embeddings" && <> / ${perMillion(model.outputMicros)}</>}
553− {model.threshold ? <span className="block text-xs text-faint">over {tokens(model.threshold)}: ${perMillion(model.overInputMicros)} / ${perMillion(model.overOutputMicros)}</span> : null}
554− </td>
555− <td className="px-4 py-2.5 text-right font-mono tabular-nums">{model.kind === "embeddings" ? "—" : `$${perMillion(model.cacheReadMicros)}`}</td>
556− <td className="px-4 py-2.5 text-right font-mono tabular-nums">{runCost(model.typicalRunMicros)}</td>
557− <td className="px-4 py-2.5 text-xs text-muted sm:pr-5">
558− {model.missingSince ? (
559− <span className="text-warn">
560− Not since <When at={model.missingSince} />
561− </span>
562− ) : model.lastSeenAt ? (
563− <When at={model.lastSeenAt} />
564− ) : (
565− "Not checked yet"
566− )}
567− </td>
568− </tr>
569− );
570− })}
571− </tbody>
572− </table>
573− </div>
574− );
575−}
+4−5
2626 export default function Costs({ loaderData, actionData }: Route.ComponentProps) {
2727 const { range, report, error, done } = loaderData;
2828 const failed = actionData && "error" in actionData ? (actionData as CostsActionResult) : null;
29− const description = `What g1t cost to run (Cloudflare's bill and the model providers') against what workspaces paid, with what g1t gave away on purpose kept apart. Prices are cost plus ${MARKUP_PERCENT}%, a ${percentLabel(marginOnPrice(MARKUP_PERCENT))} margin: every margin here is a share of the price, not of the cost. The bill itself, drift and the price book are on Bill & pricing.`;
29+ const description = `What g1t cost to run (Cloudflare's bill) against what workspaces paid, with what g1t gave away on purpose kept apart. Prices are cost plus ${MARKUP_PERCENT}%, a ${percentLabel(marginOnPrice(MARKUP_PERCENT))} margin: every margin here is a share of the price, not of the cost. The bill itself, drift and the price book are on Bill & pricing.`;
3030 if (!report) {
3131 return (
3232 <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
229229 const spent = paid.totalMicros;
230230 const net = moneyIn - spent;
231231 const givenParts = givenBy(o);
232− const gateway = o.gatewayCostMicros ?? 0;
233232 const rows: { title: string; note: string; in: number | null; cost: number; result: number | null; tone?: "danger" | "warn" | "muted" }[] = [
234233 {
235234 title: "Usage sold",
298297 <Stat
299298 label="Who g1t paid"
300299 value={usd(paid.totalMicros, { cents: true })}
301− hint={`Cloudflare's usage ${usd(paid.cloudflareMicros, { cents: true })} and subscriptions ${usd(paid.subscriptionsMicros, { cents: true })}, model providers ${usd(paid.modelsMicros, { cents: true })}${gateway > 0 && Math.abs(gateway - paid.modelsMicros) >= 10_000 ? ` (AI Gateway priced them at ${usd(gateway, { cents: true })}; see Drift)` : ""}; the cost in All in`}
300+ hint={`Cloudflare's usage ${usd(paid.cloudflareMicros, { cents: true })} and subscriptions ${usd(paid.subscriptionsMicros, { cents: true })}${paid.modelsMicros > 0 ? `, model providers ${usd(paid.modelsMicros, { cents: true })} (from before agents were retired)` : ""}; the cost in All in`}
302301 />
303302 <Stat
304303 label="Proposals waiting"
393392 className="mt-6"
394393 id="spend"
395394 title="g1t's own spend"
396− description="What g1t pays for itself this calendar month, today included: accounts on a 100% discount, the trial and open-source pools, free workspaces' overruns, and usage charged without real money behind it. Each is what the work cost g1t, never its price, counted as each charge settled: the model providers' cost, and the price book's cost of sandboxes and builds. Two caps hold it: each 100%-discount account's monthly budget, and a daily breaker on all of it that pauses new hosted-model agent runs g1t would pay for."
395+ description="What g1t pays for itself this calendar month, today included: accounts on a 100% discount, the trial and open-source pools, free workspaces' overruns, and usage charged without real money behind it. Each is what the work cost g1t, never its price, counted as each charge settled: the price book's cost of sandboxes and builds. Two caps hold it: each 100%-discount account's monthly budget, and a daily breaker on all of it that pauses new work g1t would pay for."
397396 >
398397 <div className="grid gap-3 lg:grid-cols-2">
399398 <CapMeter
403402 hint={
404403 caps.tripped ? (
405404 <span className="text-danger">
406− Breaker open{caps.trippedAt ? <> since <When at={caps.trippedAt} time /></> : null}: new hosted-model runs g1t pays for wait until 00:00 UTC.
405+ Breaker open{caps.trippedAt ? <> since <When at={caps.trippedAt} time /></> : null}: new work g1t pays for waits until 00:00 UTC.
407406 </span>
408407 ) : caps.liftedBy ? (
409408 <>
+1−4
535535 ["Card check", e.trialVerified ? "Done" : "Not yet"],
536536 ["Trial left", usd(e.trialMicrosLeft)],
537537 ["First month", e.firstMonth ? "Yes" : "No"],
538− ["Agents at once", String(e.maxConcurrentAgents)],
539538 ["Longest run", e.maxRunMinutes ? `${e.maxRunMinutes} min` : "The guardrails'"],
540− ["Run cap", usd(e.runCapMicros)],
541− ["Issue cap", usd(e.issueCapMicros)],
542539 ["Ceiling", e.ceilingMicros >= 1e15 ? "None" : usd(e.ceilingMicros)],
543540 ["Not yet paid", usd(e.exposureMicros)],
544541 ["Held by reservations", usd(e.heldMicros ?? 0)],
589586 </div>
590587 ))}
591588 </dl>
592− <p className="mt-3 text-xs text-faint">Agents at once, the run and issue caps, and holds are set under Plan, pools and caps below.</p>
589+ <p className="mt-3 text-xs text-faint">Holds are set under Plan, pools and caps below.</p>
593590 </Section>
594591 );
595592 }
+0−2
88 EVENTS: ServiceBinding;
99 /** apps/status's `StatusAdmin` entrypoint: see `StatusAdminApi`. */
1010 STATUS: Fetcher;
11− /** services/models's `Discovery` entrypoint: see `ModelDiscoveryApi`. */
12− MODELS: Fetcher;
1311 ASSETS: Fetcher;
1412 ACCESS_TEAM_DOMAIN: string;
1513 ACCESS_AUD: string;
+1−5
2323 { "binding": "EVENTS", "service": "g1t-events" },
2424 // The status page's staff-only entrypoint: posting incidents to
2525 // status.g1t.sh (apps/status). Only bindings reach it.
26− { "binding": "STATUS", "service": "g1t-status", "entrypoint": "StatusAdmin" },
27− // "Check for new models" on Agents & models: the model proxy's
28− // `Discovery` entrypoint lists each provider's models now and records
29− // them with billing's catalogue. Only bindings reach it.
30− { "binding": "MODELS", "service": "g1t-models", "entrypoint": "Discovery" }
26+ { "binding": "STATUS", "service": "g1t-status", "entrypoint": "StatusAdmin" }
3127 ],
3228 "vars": {
3329 // The Zero Trust team domain, such as `g1t.cloudflareaccess.com`.
+0−160
179179 }
180180
181181 /*
182− * An agent's face (components/agent-face.tsx). Its colours are the face's
183− * own body colour (set on the element, a light-dark() pair) and these: the
184− * ink of its eyes and mouth, a second ink for what sits outside the head,
185− * the rim around its head, a highlight, the glow of a visor or a working
186− * face, and two accents for a bow and a spark. It moves with transforms and opacity only: a blink and a breath
187− * on its own beat (the delays are set per face from its seed), a pulse
188− * while it works, a bounce when it is happy, a z when it sleeps. The
189− * reduced-motion rule below stops every one of them, and the state's
190− * still pose (narrowed or closed eyes) is a plain transform, so it holds.
191− */
192−.agent-face {
193− --face-ink: #1b1b22;
194− /* What sits outside the head (antenna, hat, headphones, a z) reads on the page, not the body. */
195− --face-ink-out: light-dark(#1b1b22, #e9e9ee);
196− --face-rim: color-mix(in oklab, var(--face-body), #000 24%);
197− --face-light: #ffffff;
198− --face-glow: light-dark(#0d7a52, #86efc4);
199− --face-ribbon: light-dark(#6b52c8, #b6a8ff);
200− --face-spark: light-dark(#d4a90f, #f2dc72);
201− display: inline-block;
202− vertical-align: middle;
203−}
204−.agent-face .face-bob,
205−.agent-face .face-eyes,
206−.agent-face .face-pupils,
207−.agent-face .face-glow,
208−.agent-face .face-bulb,
209−.agent-face .face-spark,
210−.agent-face .face-z {
211− transform-box: fill-box;
212− transform-origin: center;
213−}
214−.agent-face .face-bob {
215− animation: face-bob 4.2s ease-in-out infinite;
216− animation-delay: var(--face-bob-delay, 0s);
217−}
218−.agent-face .face-eyes {
219− animation: face-blink var(--face-blink-period, 5s) ease-in-out infinite;
220− animation-delay: var(--face-blink-delay, 0s);
221−}
222−.agent-face .face-pupils {
223− transition: transform 0.25s ease-out;
224−}
225−/* A glance toward the pointer, on hover only. */
226−.agent-face:hover .face-pupils,
227−a:hover > .agent-face .face-pupils,
228−button:hover > .agent-face .face-pupils {
229− transform: translateX(2.5px);
230−}
231−.agent-face .face-glow {
232− opacity: 0;
233−}
234−.agent-face .face-spark {
235− animation: face-twinkle 2.8s ease-in-out infinite;
236− animation-delay: var(--face-bob-delay, 0s);
237−}
238−/* Working: eyes narrowed, no blink, a soft pulse around the head and in the bulb. */
239−.agent-face[data-state="working"] .face-eyes {
240− animation: none;
241− transform: scaleY(0.55);
242−}
243−.agent-face[data-state="working"] .face-glow {
244− animation: face-pulse 1.6s ease-in-out infinite;
245−}
246−.agent-face[data-state="working"] .face-bulb {
247− animation: face-bulb 1.6s ease-in-out infinite;
248−}
249−/* Asleep: eyes shut, a slower breath, a z drifting up. */
250−.agent-face[data-state="asleep"] .face-eyes {
251− animation: none;
252− transform: scaleY(0.1);
253−}
254−.agent-face[data-state="asleep"] .face-bob {
255− animation-duration: 6s;
256−}
257−.agent-face[data-state="asleep"] .face-z {
258− animation: face-zz 2.6s ease-in-out infinite;
259−}
260−.agent-face[data-state="asleep"] .face-z-2 {
261− animation-delay: 1.3s;
262−}
263−/* Happy: a bounce, twice, then still. */
264−.agent-face[data-state="happy"] .face-bob {
265− animation: face-happy 0.7s cubic-bezier(0.34, 1.56, 0.64, 1) 2;
266−}
267−@keyframes face-blink {
268− 0%,
269− 91%,
270− 100% {
271− transform: scaleY(1);
272− }
273− 94%,
274− 96% {
275− transform: scaleY(0.08);
276− }
277−}
278−@keyframes face-bob {
279− 0%,
280− 100% {
281− transform: translateY(0);
282− }
283− 50% {
284− transform: translateY(-1.6px);
285− }
286−}
287−@keyframes face-pulse {
288− 0%,
289− 100% {
290− opacity: 0.15;
291− }
292− 50% {
293− opacity: 0.55;
294− }
295−}
296−@keyframes face-bulb {
297− 0%,
298− 100% {
299− opacity: 0.6;
300− transform: scale(1);
301− }
302− 50% {
303− opacity: 1;
304− transform: scale(1.15);
305− }
306−}
307−@keyframes face-twinkle {
308− 0%,
309− 100% {
310− opacity: 0.75;
311− transform: scale(0.9) rotate(0deg);
312− }
313− 50% {
314− opacity: 1;
315− transform: scale(1.1) rotate(20deg);
316− }
317−}
318−@keyframes face-zz {
319− 0% {
320− opacity: 0;
321− transform: translate(0, 4px);
322− }
323− 30% {
324− opacity: 1;
325− }
326− 100% {
327− opacity: 0;
328− transform: translate(5px, -9px);
329− }
330−}
331−@keyframes face-happy {
332− 0%,
333− 100% {
334− transform: translateY(0) scale(1);
335− }
336− 40% {
337− transform: translateY(-6px) scale(1.06);
338− }
339−}
340−
341−/*
342182 * Appearance (lib/theme.ts): with no choice, or Auto, the page follows
343183 * the system; a choice of Light or Dark is data-theme on <html>.
344184 */
+0−196
1−import {
2− CircleCheck,
3− CircleDot,
4− CircleSlash,
5− GitMerge,
6− GitPullRequest,
7− MessageCircleQuestion,
8− MessageSquare,
9− Play,
10− Terminal,
11−} from "lucide-react";
12−import type { ReactNode } from "react";
13−import { Link } from "react-router";
14−
15−import type { AgentMessage, G1tEvent } from "@g1t/contracts";
16−
17−import { TimeAgo } from "./ui";
18−import { Avatar } from "./ui/avatar";
19−
20−/** Whether an actor is g1t itself: its agent or its machinery. */
21−const isG1t = (actor: string | null): actor is string => actor === "g1t";
22−
23−type Line = { icon: ReactNode; tone: string; actor: string | null; text: ReactNode; coordination?: boolean };
24−
25−/** One event as a sentence, or null for those not worth a line. */
26−function line(event: G1tEvent, base: string): Line | null {
27− const ref = (number: number) => (
28− <Link to={`${base}/issues/${number}`} prefetch="intent" className="font-medium text-fg hover:underline">
29− #{number}
30− </Link>
31− );
32− const actor = event.actor;
33− switch (event.type) {
34− case "issue.opened":
35− return {
36− icon: <CircleDot size={14} />,
37− tone: isG1t(actor) ? "text-accent" : "text-muted",
38− actor,
39− text: isG1t(actor) ? <>filed {ref(event.data.number)} for something it found</> : <>opened {ref(event.data.number)}</>,
40− coordination: isG1t(actor),
41− };
42− case "pull.opened":
43− return {
44− icon: <Play size={14} />,
45− tone: "text-accent",
46− actor: event.data.agent,
47− text: (
48− <>
49− started on {event.data.issue != null ? ref(event.data.issue) : "a change"} in {ref(event.data.number)}
50− </>
51− ),
52− };
53− case "pull.ready":
54− return { icon: <GitPullRequest size={14} />, tone: "text-info", actor, text: <>marked {ref(event.data.number)} ready for review</> };
55− case "checks.completed":
56− return {
57− icon: <Terminal size={14} />,
58− tone: event.data.status === "passed" ? "text-success" : "text-danger",
59− actor: null,
60− text: (
61− <>
62− checks {event.data.status} on {ref(event.data.number)}
63− </>
64− ),
65− };
66− case "review.completed":
67− return {
68− icon: event.data.verdict === "approve" ? <CircleCheck size={14} /> : <CircleSlash size={14} />,
69− tone: event.data.verdict === "approve" ? "text-success" : "text-warn",
70− actor: "g1t",
71− text: (
72− <>
73− {event.data.verdict === "approve" ? "approved" : "asked for changes on"} {ref(event.data.number)}
74− </>
75− ),
76− };
77− case "comment.created":
78− // One of the workspace's agents, as itself: named, never the person it acted for.
79− if (event.data.agent) {
80− const said = event.data.verdict === "approve" ? "approved" : event.data.verdict === "request_changes" ? "asked for changes on" : event.data.advisory ? "reviewed" : "commented on";
81− return {
82− icon: event.data.verdict === "approve" ? <CircleCheck size={14} /> : event.data.verdict ? <CircleSlash size={14} /> : <MessageSquare size={14} />,
83− tone: "text-muted",
84− actor: `${event.data.agent.displayName} (agent)`,
85− text: (
86− <>
87− {said} {ref(event.data.number)}
88− {event.data.advisory && " (advisory)"}
89− </>
90− ),
91− };
92− }
93− if (event.data.verdict) {
94− return {
95− icon: event.data.verdict === "approve" ? <CircleCheck size={14} /> : <CircleSlash size={14} />,
96− tone: event.data.verdict === "approve" ? "text-success" : "text-warn",
97− actor,
98− text: (
99− <>
100− {event.data.verdict === "approve" ? "approved" : "asked for changes on"} {ref(event.data.number)}
101− </>
102− ),
103− };
104− }
105− return {
106− icon: <MessageSquare size={14} />,
107− tone: isG1t(actor) ? "text-accent" : "text-muted",
108− actor,
109− text: <>commented on {ref(event.data.number)}</>,
110− coordination: isG1t(actor),
111− };
112− case "pull.merged":
113− return { icon: <GitMerge size={14} />, tone: "text-accent", actor, text: <>landed {ref(event.data.number)} on main</> };
114− case "agent.asked":
115− return { icon: <MessageCircleQuestion size={14} />, tone: "text-accent", actor, text: <>asked the agent on {ref(event.data.number)}, and g1t woke it to answer</> };
116− case "pull.merge_requested":
117− return { icon: <GitMerge size={14} />, tone: "text-muted", actor, text: <>is bringing {ref(event.data.number)} up to date</> };
118− default:
119− return null;
120− }
121−}
122−
123−/**
124− * What happened across an outcome's issues and pull requests, newest
125− * first. Agents talking to each other through the forge stand out.
126− */
127−export function Activity({ events, base }: { events: G1tEvent[]; base: string }) {
128− const lines = events.map((event) => ({ event, line: line(event, base) })).filter((item) => item.line != null);
129− if (lines.length === 0) return null;
130− return (
131− <ol className="space-y-0.5">
132− {lines.map(({ event, line }) => (
133− <li
134− key={event.id}
135− className={`flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm ${
136− line!.coordination ? "bg-accent/[0.07] ring-1 ring-accent/25" : ""
137− }`}
138− >
139− <span className={`shrink-0 ${line!.tone}`}>{line!.icon}</span>
140− {line!.actor && (
141− <span className="flex shrink-0 items-center gap-1.5 font-mono text-xs text-fg-soft">
142− <Avatar name={line!.actor} size={14} />
143− {line!.actor}
144− </span>
145− )}
146− <span className="min-w-0 grow truncate text-muted">{line!.text}</span>
147− <span className="shrink-0 text-xs text-faint">
148− <TimeAgo at={event.time} />
149− </span>
150− </li>
151− ))}
152− </ol>
153− );
154−}
155−
156−/**
157− * Questions and handoffs between the agents on an outcome's pull requests,
158− * each with where it stands: asked, read, answered or declined.
159− */
160−export function Exchanges({ exchanges, base }: { exchanges: AgentMessage[]; base: string }) {
161− if (exchanges.length === 0) return null;
162− const pull = (number: number) => (
163− <Link to={`${base}/pull/${number}`} prefetch="intent" className="font-mono text-fg hover:underline">
164− #{number}
165− </Link>
166− );
167− return (
168− <ul className="space-y-3">
169− {exchanges.map((exchange) => {
170− const state = exchange.answer
171− ? exchange.declined
172− ? { label: "Declined", tone: "text-warn ring-warn/40" }
173− : { label: exchange.kind === "handoff" ? "Taken on" : "Answered", tone: "text-success ring-success/40" }
174− : exchange.deliveredAt
175− ? { label: "Read", tone: "text-info ring-info/40" }
176− : { label: "Waiting to be read", tone: "text-faint ring-line" };
177− return (
178− <li key={exchange.id} className="rounded-2xl bg-accent/[0.05] p-4 ring-1 ring-accent/25">
179− <p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted">
180− <Avatar name="g1t" size={14} />
181− <span>
182− The agent on {exchange.fromNumber != null ? pull(exchange.fromNumber) : exchange.author}{" "}
183− {exchange.kind === "handoff" ? "handed work to" : "asked"} the agent on {pull(exchange.toNumber)}
184− </span>
185− <span className={`ml-auto rounded-full px-2 py-0.5 ring-1 ${state.tone}`}>{state.label}</span>
186− </p>
187− <p className="mt-2 text-sm">{exchange.body}</p>
188− {exchange.answer && (
189− <p className="mt-2 border-l-2 border-accent/40 pl-3 text-sm text-fg-soft">{exchange.answer}</p>
190− )}
191− </li>
192− );
193− })}
194− </ul>
195− );
196−}
+12−28
1−import type { AgentLook, AgentStatus } from "@g1t/contracts";
2−
3−import { type FaceState, faceStateOf } from "./agent-face";
41 import { Avatar } from "./ui/avatar";
52
6−/** What an agent's face is drawn from. */
3+/**
4+ * Who an agent's avatar is drawn for: g1t itself, or the author of an old
5+ * message from an agent that no longer exists. Extra fields older callers
6+ * pass (a face, a status) are accepted and not drawn.
7+ */
78 export type AgentLike = {
89 id?: string;
910 /** Its handle, or a member's name. */
1112 name?: string;
1213 avatar?: string | null;
1314 avatar_seed?: string | null;
14− /** Its chosen face; null or missing, the seed's. */
15− look?: AgentLook | null;
15+ look?: unknown;
1616 builtin?: boolean;
17− /** Its status, when the place it shows should show it on its face. */
18− status?: AgentStatus | null;
17+ status?: unknown;
1918 };
20−
21−/** The seed an agent's face grows from: its own, else its id, else its handle. */
22−export function seedOf(agent: AgentLike): string {
23− return agent.avatar_seed || agent.id || agent.handle || agent.name || "agent";
24−}
2519
2620 /**
27− * An agent's face wherever it appears: the shared Avatar with `agent` set
28− * (components/ui/avatar.tsx), so it is an uploaded picture, g1t's pixel 1,
29− * or its bot face, always with the agent marker. `state` says what the
30− * face is doing; left out, the agent's status decides, if it is known.
21+ * An agent's avatar wherever it appears: the shared Avatar with `agent`
22+ * set (components/ui/avatar.tsx), so it is g1t's pixel 1, an uploaded
23+ * picture, or its initial on a tile, always with the agent marker.
3124 */
32−export function AgentAvatar({ agent, size = 20, state, ring }: { agent: AgentLike; size?: number; state?: FaceState; ring?: string }) {
25+export function AgentAvatar({ agent, size = 20, ring }: { agent: AgentLike; size?: number; state?: unknown; ring?: string }) {
3326 const g1t = agent.builtin || agent.handle === "g1t" || agent.name === "g1t";
34− return (
35− <Avatar
36− name={agent.handle ?? agent.name ?? "agent"}
37− image={agent.avatar}
38− system={g1t}
39− size={size}
40− ring={ring}
41− agent={{ look: agent.look ?? null, seed: seedOf(agent), state: state ?? faceStateOf(agent.status) }}
42− />
43− );
27+ return <Avatar name={agent.handle ?? agent.name ?? "agent"} image={agent.avatar} system={g1t} size={size} ring={ring} agent />;
4428 }
+0−172
1−import { ArrowUpRight, LoaderCircle, Sparkles } from "lucide-react";
2−import { type ReactNode, useEffect, useRef, useState } from "react";
3−import { Link, useFetcher } from "react-router";
4−
5−import type { RepoPath } from "@g1t/contracts";
6−
7−import { cn } from "../lib/cn";
8−import type { NotStarted } from "../lib/delegate";
9−
10−import { Avatar } from "./ui/avatar";
11−import { Button } from "./ui/button";
12−import { CONTROL } from "./ui/input";
13−import { SelectField } from "./ui/select";
14−
15−/** What the composer's action said back: why nothing opened, or the issue that opened without its agent. */
16−export type ComposerResult = { error: string | null; notStarted: NotStarted | null } | null;
17−
18−/**
19− * "Put an agent on it": a compact composer that opens an issue in one of
20− * the viewer's projects and puts g1t on it, in one step. Posts to
21− * Mission control's action, which lands on the issue with the agent
22− * running. A native `<details>`, so it opens and posts without script.
23− */
24−export function AgentComposer({
25− repos,
26− open: openAtFirst,
27− result,
28− note,
29− action = "/?index",
30− children,
31−}: {
32− repos: RepoPath[];
33− /** Open from the start: asked for by the address, or a post came back. */
34− open: boolean;
35− /** What a post without script came back with. */
36− result: ComposerResult;
37− /** Said under the form, such as that agents need a model first. */
38− note?: ReactNode;
39− action?: string;
40− /** The button that opens it. */
41− children: ReactNode;
42−}) {
43− const fetcher = useFetcher<ComposerResult>();
44− const ref = useRef<HTMLDetailsElement>(null);
45− const [open, setOpen] = useState(openAtFirst);
46− const busy = fetcher.state !== "idle";
47− const said = fetcher.data ?? result;
48−
49− // Closed by Escape or by a click outside it, as a menu is.
50− useEffect(() => {
51− if (!open) return;
52− // A select's list, open inside it, takes its own Escape and clicks.
53− const onKey = (event: KeyboardEvent) => event.key === "Escape" && !event.defaultPrevented && setOpen(false);
54− const onClick = (event: MouseEvent) => {
55− if ((event.target as Element | null)?.closest?.("[data-radix-popper-content-wrapper]")) return;
56− if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false);
57− };
58− document.addEventListener("keydown", onKey);
59− document.addEventListener("mousedown", onClick);
60− return () => {
61− document.removeEventListener("keydown", onKey);
62− document.removeEventListener("mousedown", onClick);
63− };
64− }, [open]);
65−
66− return (
67− <details
68− ref={ref}
69− open={open}
70− onToggle={(event) => setOpen((event.currentTarget as HTMLDetailsElement).open)}
71− className="group/composer relative"
72− >
73− <summary className="list-none [&::-webkit-details-marker]:hidden">{children}</summary>
74− <div className="fixed inset-x-4 top-20 z-40 sm:absolute sm:inset-x-auto sm:top-full sm:left-0 sm:mt-2 sm:w-[28rem]">
75− <fetcher.Form
76− method="post"
77− action={action}
78− className="rounded-xl border border-line-strong bg-raised p-4 shadow-2xl shadow-black/50"
79− aria-label="Put an agent on it"
80− >
81− <input type="hidden" name="intent" value="delegate" />
82− <p className="flex items-center gap-2 text-sm font-semibold">
83− <Sparkles size={15} className="text-accent" />
84− Put an agent on it
85− </p>
86− <p className="mt-1 text-xs leading-5 text-muted">
87− Opens an issue and assigns g1t at once. It makes the change in a sandbox and sees it through checks
88− and review.
89− </p>
90−
91− <div className="mt-4 space-y-3">
92− <label className="block">
93− <span className="mb-1.5 block text-xs font-medium text-muted">Project</span>
94− <SelectField
95− name="repo"
96− required
97− defaultValue={repos[0] ? `${repos[0].namespace}/${repos[0].name}` : undefined}
98− placeholder="Choose a project"
99− options={repos.map((repo) => ({ value: `${repo.namespace}/${repo.name}`, label: repo.name }))}
100− />
101− </label>
102− <label className="block">
103− <span className="mb-1.5 block text-xs font-medium text-muted">Title</span>
104− <input
105− name="title"
106− required
107− maxLength={200}
108− autoComplete="off"
109− data-1p-ignore
110− placeholder="Retry failed webhook deliveries"
111− className={cn(CONTROL, "h-9")}
112− />
113− </label>
114− <label className="block">
115− <span className="mb-1.5 block text-xs font-medium text-muted">What you want done</span>
116− <textarea
117− name="body"
118− rows={4}
119− placeholder="In plain words: what is wrong or wanted, and anything the agent cannot see for itself."
120− className={cn(CONTROL, "min-h-24 resize-y leading-relaxed")}
121− />
122− </label>
123− <p className="text-xs text-faint">
124− Its pull request runs the project's workflows as its checks, and merges once the ones the default branch
125− requires pass. Say what done looks like in your own words, if it helps.
126− </p>
127− </div>
128−
129− {said?.error && <p className="mt-3 text-sm text-danger">{said.error}</p>}
130− {said?.notStarted && (
131− <div className="mt-3 rounded-lg border border-warn/30 bg-warn/[0.06] p-3 text-sm">
132− <p className="text-fg-soft">
133− Opened <Link to={said.notStarted.to} className="font-medium text-fg hover:underline">#{said.notStarted.number}</Link>, but
134− g1t did not start. {said.notStarted.message}
135− </p>
136− <p className="mt-2 flex flex-wrap gap-2">
137− {said.notStarted.fix && (
138− <Link
139− to={said.notStarted.fix.to}
140− className="inline-flex items-center gap-1 rounded-md bg-fg px-2.5 py-1 text-xs font-medium text-bg hover:bg-fg-hover"
141− >
142− {said.notStarted.fix.label} <ArrowUpRight size={12} />
143− </Link>
144− )}
145− <Link to={said.notStarted.to} className="inline-flex items-center rounded-md border border-line-strong px-2.5 py-1 text-xs font-medium text-fg/90 hover:bg-bg">
146− Open the issue
147− </Link>
148− </p>
149− </div>
150− )}
151− {note && <p className="mt-3 text-xs leading-5 text-muted">{note}</p>}
152−
153− <div className="mt-4 flex items-center justify-between gap-3">
154− <span className="flex min-w-0 items-center gap-1.5 text-xs text-faint">
155− <Avatar name="g1t" size={16} />
156− <span className="truncate">g1t · no model to choose</span>
157− </span>
158− <Button
159− type="submit"
160− disabled={busy || repos.length === 0}
161− variant="accent"
162− className="gap-1.5 font-semibold disabled:opacity-60"
163− >
164− {busy ? <LoaderCircle size={14} className="animate-spin" /> : <Sparkles size={14} />}
165− {busy ? "Starting…" : "Put an agent on it"}
166− </Button>
167− </div>
168− </fetcher.Form>
169− </div>
170− </details>
171− );
172−}
+0−343
Loading the diff…
+0−97
Loading the diff…
+0−379
Loading the diff…
+0−74
Loading the diff…
+0−148
Loading the diff…
+0−817
Loading the diff…
+0−546
Loading the diff…
+0−284
Loading the diff…
+0−34
Loading the diff…
+0−239
Loading the diff…
+0−851
Loading the diff…
+0−197
Loading the diff…
+0−140
Loading the diff…
+0−295
Loading the diff…
+0−298
Loading the diff…
+0−210
Loading the diff…
+0−207
Loading the diff…
+11−14
Loading the diff…
+6−330
Loading the diff…
+0−76
Loading the diff…
+4−106
Loading the diff…
+9−182
Loading the diff…
+7−62
Loading the diff…
+0−324
Loading the diff…
+14−33
Loading the diff…
+26−318
Loading the diff…
+40−284
Loading the diff…
+2−3
Loading the diff…
+1−1
Loading the diff…
+10−69
Loading the diff…
+18−76
Loading the diff…
+26−157
Loading the diff…
+3−6
Loading the diff…
+0−181
Loading the diff…
+80−2
Loading the diff…
+0−591
Loading the diff…
+0−186
Loading the diff…
+0−456
Loading the diff…
+1−1
Loading the diff…
+0−282
Loading the diff…
+3−3
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…

More files changed than one page lists: these are the first 300.