Skip to content
388 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

The plan says what g1t is for without measuring it against other products1/**
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.2 * Invites, as the site shows them: links to an invite, and how each
3 * invitation reads in a list.
The plan says what g1t is for without measuring it against other products4 * No Workers or React imports, so it can be tested under Node.
5 */
6
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.7/** What the sign-up buttons say: Sign up. Anyone may register. */
Sign-up buttons say Sign up everywhere; only the sign-up page says registration takes an invite8export function signUpCopy(): { primary: string; secondary: string | null } {
9 return { primary: "Sign up", secondary: null };
Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038)10}
11
The plan says what g1t is for without measuring it against other products12/** The address an invite link has. */
13export function inviteLink(code: string, origin = "https://g1t.sh"): string {
14 return `${origin.replace(/\/+$/, "")}/invite/${code}`;
15}
16
17/**
18 * An invite code from however someone pasted it: the code, a whole
19 * invite link, or a /register?invite= address. Identity reads it again;
20 * this only tidies what is put back into a form.
21 */
22export function cleanCode(raw: string | null | undefined): string {
23 let text = (raw ?? "").trim();
24 const param = /[?&]invite=([^&#\s]+)/i.exec(text);
25 if (param) text = decodeURIComponent(param[1]!);
26 else if (/^https?:\/\//i.test(text)) text = text.replace(/[?#].*$/, "").split("/").filter(Boolean).pop() ?? "";
27 return text.replace(/\s+/g, "").slice(0, 80);
28}
29
Merge invite emails that confirm the address: the emailed link carries a proof only that email has, so signing up from it needs no code; shared links and typed codes still confirm30/**
31 * The `proof` an invite email's link carries, tidied: hex, or null for
32 * anything else. Identity decides whether it is the invite's own; this only
33 * keeps junk out of what is passed on and put back into a form.
34 */
35export function cleanProof(raw: string | null | undefined): string | null {
36 const text = (raw ?? "").trim().toLowerCase();
37 return /^[0-9a-f]{16,128}$/.test(text) ? text : null;
38}
39
40/** An invite's page, keeping the email's proof when there is one. */
41export function invitePath(code: string, proof?: string | null): string {
42 const path = `/invite/${encodeURIComponent(code)}`;
43 return proof ? `${path}?proof=${encodeURIComponent(proof)}` : path;
44}
45
46type Proven = {
47 /** The bound address in full, or null for an invite to anyone with the code. */
48 address: string | null;
49 emailProven: boolean;
50 workspace: { name: string } | null;
51 repository: { name: string } | null;
52};
53
54/**
55 * What signing up on an invite's page says about the email address. Opened
56 * from the invite's own email (`emailProven`), the address is confirmed
57 * already, so there is no code to enter; otherwise the address is confirmed
58 * after sign-up, as it always is.
59 */
60export function inviteSignUpCopy(invite: Proven): {
61 /** Under "Create your account". */
62 intro: string;
63 /** Under the email field. */
64 hint: string;
65 /** Said plainly above the form when the address is confirmed already; null otherwise. */
66 confirmed: string | null;
67} {
68 const proven = invite.emailProven && invite.address !== null;
69 const when = proven ? "as soon as you create it" : "as soon as you confirm your email";
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)70 // A workspace is never joined without saying yes: the new account accepts its invitation.
Merge invite emails that confirm the address: the emailed link carries a proof only that email has, so signing up from it needs no code; shared links and typed codes still confirm71 const intro = invite.workspace
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)72 ? `You can join ${invite.workspace.name} ${when}: accept the invitation then.`
Merge invite emails that confirm the address: the emailed link carries a proof only that email has, so signing up from it needs no code; shared links and typed codes still confirm73 : invite.repository
74 ? `You get ${invite.repository.name} ${when}.`
75 : "It takes a minute.";
76 if (proven) {
77 return {
78 intro,
79 hint: "Your invite was sent here, and you opened it from that email, so this address is confirmed already.",
80 confirmed: `${invite.address} is confirmed: you came here from the invite we emailed to it, so there is no code to enter after you sign up.`,
81 };
82 }
83 return {
84 intro,
85 hint: invite.address
86 ? "Your invite was sent here. We email it a code to confirm it before you start."
87 : "We email it a code to confirm it before you start.",
88 confirmed: null,
89 };
90}
91
The plan says what g1t is for without measuring it against other products92type Listed = {
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)93 status: "pending" | "awaiting_confirmation" | "awaiting_answer" | "redeemed" | "declined" | "expired" | "revoked";
The plan says what g1t is for without measuring it against other products94 redeemedBy: string | null;
95 email: string | null;
96 workspace: string | null;
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)97 /** The account a workspace invitation is for, by username. */
98 invitee?: string | null;
The plan says what g1t is for without measuring it against other products99};
100
101/** How an invite's state reads in a list. */
102export function inviteState(invite: Listed): { label: string; tone: "pending" | "done" | "dead" } {
103 switch (invite.status) {
104 case "pending":
105 return { label: "Pending", tone: "pending" };
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)106 case "awaiting_confirmation":
107 // The account is made; it joins once it confirms its address.
108 return {
109 label: invite.redeemedBy ? `@${invite.redeemedBy} is confirming their email` : "Confirming their email",
110 tone: "pending",
111 };
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)112 case "awaiting_answer": {
113 // The account is made and confirmed; the workspace waits for its yes or no.
114 const who = invite.redeemedBy ?? invite.invitee;
115 return { label: who ? `Waiting for @${who} to accept` : "Waiting for an answer", tone: "pending" };
116 }
The plan says what g1t is for without measuring it against other products117 case "redeemed":
118 return { label: invite.redeemedBy ? `Joined as @${invite.redeemedBy}` : "Used", tone: "done" };
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)119 case "declined":
120 return { label: invite.invitee ? `@${invite.invitee} declined` : "Declined", tone: "dead" };
The plan says what g1t is for without measuring it against other products121 case "expired":
122 return { label: "Expired", tone: "dead" };
123 case "revoked":
124 return { label: "Revoked", tone: "dead" };
125 }
126}
127
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)128type Previewed = {
129 kind: "account" | "workspace";
130 invitedBy: { username: string } | null;
131 workspace: { name: string } | null;
132 repository: { name: string; role: string } | null;
133 hasAccount: boolean;
134};
135
136/**
137 * What an invite's page (/invite/:code) says it is, so nobody mistakes one
138 * kind for the other: the headline, in three parts with the place between
139 * (shown in bold), and the line under it. "@syntaqx invited you to g1t" is
140 * an account and no workspace; "@syntaqx invited you to join Flagon, Inc.
141 * on g1t" is a workspace invitation, which also makes the account of
142 * someone who has none.
143 */
144export function invitePageCopy(
145 invite: Previewed,
146 signedIn: boolean,
147): { before: string; place: string | null; after: string; about: string } {
148 const from = invite.invitedBy ? `@${invite.invitedBy.username}` : "The g1t team";
149 const signingUp = !signedIn && !invite.hasAccount && invite.kind === "account";
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.150 const g1t = "g1t is where a team and the coding agents it already uses ship code";
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)151 if (invite.workspace) {
152 const name = invite.workspace.name;
153 return {
154 before: `${from} invited you to join `,
155 place: name,
156 after: " on g1t",
157 about: `This is an invitation to join ${name}, which you accept or decline. ${
158 signingUp
159 ? `You do not have a g1t account yet, so it also lets you make one: make it below, then join ${name}.`
160 : `Accepting joins you to ${name}.`
161 }`,
162 };
163 }
164 if (invite.repository) {
165 return {
166 before: `${from} invited you to collaborate on `,
167 place: invite.repository.name,
168 after: "",
169 about: `${g1t}. ${signingUp ? "Make your account below and you get" : "Accepting gives you"} the ${invite.repository.role} role on ${invite.repository.name}.`,
170 };
171 }
172 return {
173 before: `${from} invited you to g1t`,
174 place: null,
175 after: "",
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.176 about: `${g1t}: repositories, pull requests and checks that hold, a merge queue and deployments, with chat for the team. This invite lets you make an account. It does not add you to anyone's workspace: your account starts with a workspace of its own.`,
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)177 };
178}
179
Workspace is the workspace's settings, in one place. Its sidebar is grouped, General, Access, Money, Compute, Code, Agents, Chat, Artifacts, Security and Integrations, every page one click away with no settings inside settings, the groups folding and the owner-only pages hidden from members; what is not here yet is marked Soon with a hint. Members is a list with search and filters for role, two-factor and team, and Invite opens a dialog: who, by username, name or email, their role and a note that goes into the invitation; nothing is filled in inline any more. Invitations is its own page with All, Pending, Accepted, Declined, Expired and Revoked filters that say how many, and each row's menu can copy the link, send it again or revoke it; identity learned to send an invitation again and to carry the note. Permissions gathers every rule about who may do what, by part: Code's base permission and member privileges, who creates teams, who creates channels, and, marked Soon with what applies today, forking private repositories, adding agents to conversations, messaging agents directly, creating spaces and default sharing, creating workspace agents and raising budgets, deploying to production and publishing packages. General lost what moved. The permissions guide is new, and the workspaces, people and teams, access, chat, teams, authentication and billing guides say where things are now.180/** What inviting someone came to (lib/member-invitations.server.ts): who was invited, or why not. */
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.181export type InviteResult = { invited: string } | { error: string };
Workspace is the workspace's settings, in one place. Its sidebar is grouped, General, Access, Money, Compute, Code, Agents, Chat, Artifacts, Security and Integrations, every page one click away with no settings inside settings, the groups folding and the owner-only pages hidden from members; what is not here yet is marked Soon with a hint. Members is a list with search and filters for role, two-factor and team, and Invite opens a dialog: who, by username, name or email, their role and a note that goes into the invitation; nothing is filled in inline any more. Invitations is its own page with All, Pending, Accepted, Declined, Expired and Revoked filters that say how many, and each row's menu can copy the link, send it again or revoke it; identity learned to send an invitation again and to carry the note. Permissions gathers every rule about who may do what, by part: Code's base permission and member privileges, who creates teams, who creates channels, and, marked Soon with what applies today, forking private repositories, adding agents to conversations, messaging agents directly, creating spaces and default sharing, creating workspace agents and raising budgets, deploying to production and publishing packages. General lost what moved. The permissions guide is new, and the workspaces, people and teams, access, chat, teams, authentication and billing guides say where things are now.182
183/** What revoking or resending an invitation came to. */
184export type InvitationResult = { done: "revoked" | "resent"; id: string } | { error: string; id: string };
185
186/**
187 * The Invitations page's filters: every invitation, or the ones in one
188 * state. Pending gathers the three states an invitation waits in (to be
189 * used, for the new account to confirm its address, for the person's
190 * answer); Accepted is the ones that joined. g1t does not record an email
191 * that could not be delivered, so an invitation nobody used shows as
192 * Expired once its 30 days are up; there is no Failed filter until it does.
193 */
194export type InvitationFilter = "all" | "pending" | "accepted" | "declined" | "expired" | "revoked";
195
196export const INVITATION_FILTERS: readonly { key: InvitationFilter; label: string }[] = [
197 { key: "all", label: "All" },
198 { key: "pending", label: "Pending" },
199 { key: "accepted", label: "Accepted" },
200 { key: "declined", label: "Declined" },
201 { key: "expired", label: "Expired" },
202 { key: "revoked", label: "Revoked" },
203];
204
205/** The filter an invitation's state falls under. */
206export function invitationFilterOf(status: Listed["status"]): Exclude<InvitationFilter, "all"> {
207 switch (status) {
208 case "pending":
209 case "awaiting_confirmation":
210 case "awaiting_answer":
211 return "pending";
212 case "redeemed":
213 return "accepted";
214 case "declined":
215 return "declined";
216 case "expired":
217 return "expired";
218 case "revoked":
219 return "revoked";
220 }
221}
222
223/** The filter `?state=` names; All for anything else. */
224export function invitationFilter(raw: string | null | undefined): InvitationFilter {
225 return INVITATION_FILTERS.some((filter) => filter.key === raw) ? (raw as InvitationFilter) : "all";
226}
227
228/** The invitations under `filter`. */
229export function filterInvitations<T extends Pick<Listed, "status">>(invites: readonly T[], filter: InvitationFilter): T[] {
230 if (filter === "all") return [...invites];
231 return invites.filter((invite) => invitationFilterOf(invite.status) === filter);
232}
233
234/** How many invitations each filter holds, for the counts on the chips. */
235export function invitationCounts(invites: readonly Pick<Listed, "status">[]): Record<InvitationFilter, number> {
236 const counts: Record<InvitationFilter, number> = { all: invites.length, pending: 0, accepted: 0, declined: 0, expired: 0, revoked: 0 };
237 for (const invite of invites) counts[invitationFilterOf(invite.status)] += 1;
238 return counts;
239}
240
241/** The tone of an invitation's status badge, from how its state reads. */
242export function invitationTone(status: Listed["status"]): "neutral" | "success" | "warn" | "danger" {
243 switch (invitationFilterOf(status)) {
244 case "pending":
245 return "warn";
246 case "accepted":
247 return "success";
248 case "declined":
249 case "revoked":
250 return "danger";
251 default:
252 return "neutral";
253 }
254}
255
The plan says what g1t is for without measuring it against other products256/** Who an invite is for, in a list. */
257export function inviteFor(invite: Listed): string {
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)258 return invite.email ?? (invite.invitee ? `@${invite.invitee}` : "Anyone with the link");
259}
260
261/**
262 * Which of the two invites a listed one is, in words: an invite to g1t
263 * (an account, and no workspace), or an invitation to join a workspace.
264 */
265export function inviteKind(invite: { workspace: string | null }): { kind: "g1t" | "workspace"; label: string } {
266 return invite.workspace
267 ? { kind: "workspace", label: `Invite to join ${invite.workspace}` }
268 : { kind: "g1t", label: "Invite to g1t" };
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)269}
270
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.271/** The words for a workspace's People page, the invitation to join it. */
272export function workspaceInviteCopy(name: string): { heading: string; hint: string } {
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)273 return {
274 heading: `Invite to ${name}`,
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.275 hint: `Search people on g1t by username or name, or enter an email address. They get an invitation to join ${name}, in their notifications and by email, and join only if they accept. If they do not have a g1t account yet, they sign up from the invitation first.`,
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)276 };
277}
278
279/**
Workspace is the workspace's settings, in one place. Its sidebar is grouped, General, Access, Money, Compute, Code, Agents, Chat, Artifacts, Security and Integrations, every page one click away with no settings inside settings, the groups folding and the owner-only pages hidden from members; what is not here yet is marked Soon with a hint. Members is a list with search and filters for role, two-factor and team, and Invite opens a dialog: who, by username, name or email, their role and a note that goes into the invitation; nothing is filled in inline any more. Invitations is its own page with All, Pending, Accepted, Declined, Expired and Revoked filters that say how many, and each row's menu can copy the link, send it again or revoke it; identity learned to send an invitation again and to carry the note. Permissions gathers every rule about who may do what, by part: Code's base permission and member privileges, who creates teams, who creates channels, and, marked Soon with what applies today, forking private repositories, adding agents to conversations, messaging agents directly, creating spaces and default sharing, creating workspace agents and raising budgets, deploying to production and publishing packages. General lost what moved. The permissions guide is new, and the workspaces, people and teams, access, chat, teams, authentication and billing guides say where things are now.280 * The Members page's filters, from its query: the role to show (`role=`:
281 * owners or members), whether two-factor authentication is on (`2fa=`: on
282 * or off; owners only see it), a team by slug (`team=`) and words to
283 * search by (`q=`, matched against the username and the name). Anything
284 * else is every member.
285 */
286export type MemberFilters = { role: "all" | "owner" | "member"; twoFactor: "all" | "on" | "off"; team: string | null; query: string };
287
288export function memberFilters(search: URLSearchParams | string): MemberFilters {
289 const params = typeof search === "string" ? new URLSearchParams(search) : search;
290 const role = params.get("role");
291 const twoFactor = params.get("2fa");
292 return {
293 role: role === "owner" || role === "member" ? role : "all",
294 twoFactor: twoFactor === "on" || twoFactor === "off" ? twoFactor : "all",
295 team: params.get("team")?.trim().toLowerCase() || null,
296 query: params.get("q")?.trim() ?? "",
297 };
298}
299
300type Listable = {
301 username: string;
302 name?: string | null;
303 role: "owner" | "member";
304 two_factor?: boolean | null;
305};
306
307/** The members `filters` keep, in the order given. `teams` is each member's teams, by slug. */
308export function filterMembers<T extends Listable>(members: readonly T[], filters: MemberFilters, teams: Record<string, { slug: string }[]> = {}): T[] {
309 const words = filters.query.toLowerCase().replace(/^@+/, "");
310 return members.filter((member) => {
311 if (filters.role !== "all" && member.role !== filters.role) return false;
312 if (filters.twoFactor !== "all" && member.two_factor !== (filters.twoFactor === "on")) return false;
313 if (filters.team && !(teams[member.username] ?? []).some((team) => team.slug.toLowerCase() === filters.team)) return false;
314 if (words && !member.username.toLowerCase().includes(words) && !(member.name ?? "").toLowerCase().includes(words)) return false;
315 return true;
316 });
The plan says what g1t is for without measuring it against other products317}
318
319/**
320 * Whether a sign-up or access form was filled in by a bot: the hidden
321 * `website` field people never see, or a form sent back faster than a
322 * person types.
323 */
324export function looksAutomated(form: { get(name: string): unknown }, now = Date.now()): boolean {
325 const trap = form.get("website");
326 if (typeof trap === "string" && trap.trim() !== "") return true;
327 const started = Number(form.get("started"));
328 return Number.isFinite(started) && started > 0 && now - started < 1500;
329}
330
331/**
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers332 * A username to offer someone signing up with `email`: its local part,
333 * lowercased, with anything a username cannot hold made single hyphens,
334 * up to 39. They can type it in any case they like. Empty when nothing
335 * usable is left. Identity checks it is free.
The plan says what g1t is for without measuring it against other products336 */
337export function suggestUsername(email: string | null | undefined): string {
338 const local = (email ?? "").split("@")[0]?.split("+")[0] ?? "";
339 return local
340 .toLowerCase()
341 .replace(/[^a-z0-9]+/g, "-")
342 .replace(/^-+|-+$/g, "")
343 .slice(0, 39)
344 .replace(/-+$/g, "");
345}
346
347type Lands = { workspace: { slug: string } | null; repository: { name: string } | null };
348
349/**
350 * Where using an invite lands: the workspace it joins, the repository it
351 * gives access to, or nowhere in particular.
352 */
353export function landingFor(invite: Lands): string | null {
354 if (invite.workspace) return invite.workspace.slug.toLowerCase();
355 if (invite.repository) return invite.repository.name.toLowerCase();
356 return null;
357}
358
359/** What someone who just joined is welcomed into, for one page view. */
360export const WELCOME_COOKIE = "g1t_welcome";
361
362const TARGET = /^[a-z0-9][a-z0-9._-]*(\/[a-z0-9._-]+)?$/;
363
364/** The `Set-Cookie` value that welcomes the next view of `target` (a slug or `workspace/repo`). */
365export function welcomeCookie(target: string, secure: boolean): string {
366 return `${WELCOME_COOKIE}=${encodeURIComponent(target.toLowerCase())}; Path=/; Max-Age=300; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`;
367}
368
369/** The `Set-Cookie` value that ends the welcome, once it has been shown. */
370export function clearWelcome(secure: boolean): string {
371 return `${WELCOME_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`;
372}
373
374/** Whether the request's cookies welcome someone into `target`. */
375export function welcomes(cookieHeader: string | null, target: string): boolean {
376 for (const part of (cookieHeader ?? "").split(";")) {
377 const [key, ...rest] = part.trim().split("=");
378 if (key !== WELCOME_COOKIE) continue;
379 let value: string;
380 try {
381 value = decodeURIComponent(rest.join("="));
382 } catch {
383 return false;
384 }
385 return TARGET.test(value) && value === target.toLowerCase();
386 }
387 return false;
388}

This file's history is long; its oldest lines are credited to the oldest commit read.