Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
218 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.

Chat and workspace agents: channels, DMs and named agents you talk to1/**
2 * Which agents a new message is handed to, and how many agent-to-agent
3 * hops along it is. Pure, so it is tested apart from the service.
4 *
The docs folder is gone, and what it held lives where people read it: how a self-hosted g1t runs and how to deploy g1t to Cloudflare are pages on docs.g1t.sh under Run g1t yourself, and speed, rate limits and operating g1t.sh are sections of CONTRIBUTING.md; code that cited a file in docs/ now points to the page or section that covers it, or says what it means itself, and applied migrations and the runner images are left as they were.5 * The rules (docs.g1t.sh/guides/agents/, "Talk to an agent"):
Merge branch 'worktree-agent-a1398e81ad1a64c5f'6 * - A person's message in a channel wakes the agent members it @mentions.
In a group message with several agents, a message that names no agent goes to the one you are talking with: the agents that answered you last, else the ones you last mentioned, else, before anyone has been addressed, all of them; a message that mentions agents still goes only to those, and a conversation with one agent is as it was. Each agent is also told, when several are here, who you are talking with, so one that is woken alongside answers only what is clearly for it and does not redo a colleague's work. And an agent shows as typing for as long as it works, not for eight seconds: its desk says so every five seconds until the answer is posted, and after ten seconds the line reads that it is still working on it. The chat and agents guides say so.7 * In a direct message it wakes the agents in it that it @mentions; one
8 * that mentions none wakes the only agent there, or, with several, the
9 * agent the person is talking with (`addressedAgents`), or all of them
10 * when the conversation doesn't say. That starts a chain at hop 0.
Merge branch 'worktree-agent-a1398e81ad1a64c5f'11 * - An agent's message wakes nobody, @mentions or not. An agent gets a
12 * colleague working only by handing off (`handOffPlace`), which wakes
13 * that one colleague, one hop further along the chain it was answering.
14 * No loops, and no agent summoned because its name came up.
Chat and workspace agents: channels, DMs and named agents you talk to15 * - A chain stops after `MAX_HOPS` hops, and asks a person instead.
16 */
17
18/** The most agent-to-agent hops one person's request may start. Same as CHAT_MAX_HOPS in @g1t/contracts. */
19export const MAX_HOPS = 6;
20
21export type AgentMember = { id: string; handle: string };
22
23export type Wake = { agent_id: string; hops: number };
24
25/**
26 * Who a chain was started by, carried along every hop of it: the person's
27 * id and what they may do. A person's message starts one with their own
28 * access (`askerAccess` in @g1t/contracts); an agent's message carries on
29 * the one it was answering, so an agent woken three hops along never does
30 * more than the person who asked could.
31 */
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)32export type Chain<A> = {
33 hops: number;
34 asked_by: string;
35 asker: A | null;
36 /** The agents that handled the request so far, by id, oldest first: for an agent's message, ending with its author. */
37 chain: string[];
Merge branch 'worktree-agent-a1398e81ad1a64c5f'38 /**
39 * Set for a message written with a workflow job's token (`G1T_TOKEN`):
40 * it wakes no agent, or a workflow that posts on a failing check could
41 * start one whose push runs the workflow again, without end.
42 */
43 quiet?: boolean;
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)44};
45
46/** The most agent ids a chain carries: the hop limit's worth, and some. */
47const MAX_CHAIN = 16;
48
49/**
50 * An agent's post's chain: the one it was answering, as the agents service
51 * passed it back, with the agent itself added. Anything that is not a list
52 * of ids is no chain.
53 */
54export function chainFor(given: unknown, author: string): string[] {
55 const before = Array.isArray(given) ? given.filter((id): id is string => typeof id === "string" && !!id).slice(-MAX_CHAIN) : [];
56 return [...before, author];
57}
Chat and workspace agents: channels, DMs and named agents you talk to58
59/** What the agents service is handed for one wake (AgentDelivery), on g1t's own chat. */
60export function delivery<P extends object, A>(
61 place: P,
62 wake: Wake,
63 message: { id: string; thread_root: string | null },
64 chain: Chain<A>,
65) {
66 return {
67 ...place,
68 agent_id: wake.agent_id,
69 message_id: message.id,
70 thread_root: message.thread_root,
71 asked_by: chain.asked_by,
72 asker: chain.asker,
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)73 chain: chain.chain,
Chat and workspace agents: channels, DMs and named agents you talk to74 hops: wake.hops,
75 surface: "g1t" as const,
76 };
77}
78
In a group message with several agents, a message that names no agent goes to the one you are talking with: the agents that answered you last, else the ones you last mentioned, else, before anyone has been addressed, all of them; a message that mentions agents still goes only to those, and a conversation with one agent is as it was. Each agent is also told, when several are here, who you are talking with, so one that is woken alongside answers only what is clearly for it and does not redo a colleague's work. And an agent shows as typing for as long as it works, not for eight seconds: its desk says so every five seconds until the answer is posted, and after ten seconds the line reads that it is still working on it. The chat and agents guides say so.79/** One earlier message of the conversation, as much of it as addressing needs. */
80export type RecentMessage = {
81 /** Who wrote it: `user:<id>` or `agent:<id>`. */
82 author: string;
83 /** Handles it @mentions, lowercased. */
84 mentioned: string[];
85};
86
87/** How many earlier messages decide who an unaddressed message is for. */
88export const ADDRESSING_HISTORY = 30;
89
90/**
91 * In a direct message with several agents, which of them a message from
92 * `author` that mentions none of them is for: the agent the person is
93 * talking with, read from the conversation so far (`recent`, oldest
94 * first, the message itself left out). Walking back from the latest
95 * message:
96 *
97 * 1. The agents whose messages come before any person's are the last
98 * exchange: whoever answered last. The message is for them.
99 * 2. With no answer yet, the person's own latest message that @mentions
100 * agents of this conversation says who they addressed: it is for those.
101 * Their messages that mention none are passed over on the way.
102 *
103 * So "@mike make a PDF", Mike's answer, then "now make a fake one" is for
104 * Mike alone, however many agents are in the conversation, and so is a
105 * second message sent before Mike answers; after a hand-off to a
106 * colleague here, the colleague's answer makes the next message theirs.
107 * Other people's mentions decide nothing, and agents no longer in the
108 * conversation count for nothing. When nothing in `recent` decides, every
109 * agent gets it, as every agent got the first message.
110 */
111export function addressedAgents(agents: AgentMember[], author: string, recent: RecentMessage[]): AgentMember[] {
112 if (agents.length < 2) return agents;
113 const byId = new Map(agents.map((agent) => [`agent:${agent.id}`, agent]));
114 const byHandle = new Map(agents.map((agent) => [agent.handle.toLowerCase(), agent]));
115 const exchange: AgentMember[] = [];
116 for (let i = recent.length - 1; i >= 0; i--) {
117 const message = recent[i];
118 const agent = byId.get(message.author);
119 if (agent) {
120 if (!exchange.includes(agent)) exchange.push(agent);
121 continue;
122 }
123 if (!message.author.startsWith("user:")) continue;
124 // A person's message: the agents that answered since are the exchange.
125 if (exchange.length) break;
126 if (message.author !== author) continue;
127 const named = message.mentioned.map((handle) => byHandle.get(handle.toLowerCase())).filter((found): found is AgentMember => !!found);
128 if (named.length) return agents.filter((agent) => named.includes(agent));
129 }
130 if (exchange.length) return agents.filter((agent) => exchange.includes(agent));
131 return agents;
132}
133
Chat and workspace agents: channels, DMs and named agents you talk to134export function deliveries(input: {
135 /** Who wrote it: `user:<id>` or `agent:<id>`. */
136 author: string;
137 /** For an agent's message: the hops of the delivery it answers. */
138 hops: number;
139 channelKind: "channel" | "dm";
140 /** The channel's agent members (archived ones left out). */
141 agents: AgentMember[];
142 /** Handles the message @mentions, lowercased. */
143 mentioned: string[];
In a group message with several agents, a message that names no agent goes to the one you are talking with: the agents that answered you last, else the ones you last mentioned, else, before anyone has been addressed, all of them; a message that mentions agents still goes only to those, and a conversation with one agent is as it was. Each agent is also told, when several are here, who you are talking with, so one that is woken alongside answers only what is clearly for it and does not redo a colleague's work. And an agent shows as typing for as long as it works, not for eight seconds: its desk says so every five seconds until the answer is posted, and after ten seconds the line reads that it is still working on it. The chat and agents guides say so.144 /**
145 * In a direct message with several agents: the conversation before this
146 * message, oldest first (`addressedAgents`). Absent or empty, an
147 * unaddressed message goes to every agent in it.
148 */
149 recent?: RecentMessage[];
Chat and workspace agents: channels, DMs and named agents you talk to150}): Wake[] {
Merge branch 'worktree-agent-a1398e81ad1a64c5f'151 // Only a person's message wakes anyone; agents reach each other by hand-off.
152 if (!input.author.startsWith("user:")) return [];
Chat and workspace agents: channels, DMs and named agents you talk to153 const mentioned = new Set(input.mentioned.map((h) => h.toLowerCase()));
Merge branch 'worktree-agent-a1398e81ad1a64c5f'154 const named = input.agents.filter((agent) => mentioned.has(agent.handle.toLowerCase()));
In a group message with several agents, a message that names no agent goes to the one you are talking with: the agents that answered you last, else the ones you last mentioned, else, before anyone has been addressed, all of them; a message that mentions agents still goes only to those, and a conversation with one agent is as it was. Each agent is also told, when several are here, who you are talking with, so one that is woken alongside answers only what is clearly for it and does not redo a colleague's work. And an agent shows as typing for as long as it works, not for eight seconds: its desk says so every five seconds until the answer is posted, and after ten seconds the line reads that it is still working on it. The chat and agents guides say so.155 const woken = input.channelKind === "dm" && !named.length ? addressedAgents(input.agents, input.author, input.recent ?? []) : named;
Merge branch 'worktree-agent-a1398e81ad1a64c5f'156 return woken.map((agent) => ({ agent_id: agent.id, hops: 0 }));
157}
158
159/**
160 * Where a hand-off's brief goes: here, when the colleague is already a
161 * member of this channel or group direct message (everyone here sees the
162 * work move); otherwise a group direct message of the person who asked,
163 * the agent and the colleague, so the colleague reads only what it was
164 * handed and works for that person, with their access.
165 */
166export function handOffPlace(input: { channelKind: "channel" | "dm"; members: number; colleagueHere: boolean }): "here" | "group_dm" {
167 const shared = input.channelKind === "channel" || input.members > 2;
168 return input.colleagueHere && shared ? "here" : "group_dm";
169}
170
171/**
172 * Why an agent may not hand work to `colleague`, or null when it may. The
173 * colleague is already of the workspace and not archived; this decides the
174 * rest of the rails: not itself, never @g1t (no agent puts g1t to work),
175 * never an agent already on this request (no ping-pong), and within the
176 * hop limit.
177 */
178export function handOffRefusal(input: { agent: string; colleague: { id: string; builtin?: boolean }; chain: string[]; hops: number }): string | null {
179 if (input.colleague.id === input.agent) return "An agent can't hand work to itself.";
180 if (input.colleague.builtin) return "An agent can't hand work to @g1t. The person can ask @g1t themselves.";
181 if (input.chain.includes(input.colleague.id)) return "That agent has already handled this request: no handing work back.";
182 if (Math.max(0, Math.floor(input.hops || 0)) + 1 > MAX_HOPS) return "This request has been passed along too many times. Ask a person to step in.";
183 return null;
Chat and workspace agents: channels, DMs and named agents you talk to184}
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)185
186/** The built-in orchestrator's handle. Same as BUILTIN_AGENT_HANDLE in @g1t/contracts. */
187export const ORCHESTRATOR = "g1t";
188
189/**
190 * Whether a message brings @g1t into the conversation: every workspace has
191 * it, so mentioning it in a channel adds it as a member the first time,
192 * with no invite. A direct message is made with its members and never
193 * gains one; to talk to @g1t alone, open a DM with it.
194 */
195export function addsOrchestrator(input: { channelKind: "channel" | "dm"; mentioned: string[]; orchestratorIsMember: boolean }): boolean {
196 if (input.channelKind !== "channel" || input.orchestratorIsMember) return false;
197 return input.mentioned.some((handle) => handle.toLowerCase() === ORCHESTRATOR);
198}
Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how.199
200/**
201 * Where a member's personal agent may be (docs.g1t.sh/guides/agents/,
202 * "Personal agents"): only in the direct message of the two of them. Not
203 * in a channel, not in a group direct message, never handed work by
204 * another agent. `members` are the conversation's principal keys
205 * (`user:<id>`, `agent:<id>`); null when it is a channel. Null: allowed;
206 * otherwise why not, as people are told.
207 */
208export function personalAgentRefusal(
209 agent: { id: string; handle: string; scope?: string | null; personal_owner_id?: string | null },
210 place: { kind: "channel" | "dm" | "hand_off"; members: string[] | null },
211): string | null {
212 if (agent.scope !== "personal") return null;
213 const mine = `user:${agent.personal_owner_id ?? ""}`;
214 const refusal = `@${agent.handle} is someone's personal agent: only the person it belongs to talks to it, in a direct message of the two of them.`;
215 if (place.kind !== "dm" || !place.members) return refusal;
216 const others = place.members.filter((key) => key !== `agent:${agent.id}`);
217 return others.length === 1 && others[0] === mine ? null : refusal;
218}

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