Skip to content
315 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.

Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar1/**
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.2 * Agents' cards in chat that people act on in place
3 * (docs.g1t.sh/guides/chat/, "Cards you can act on"): a session's card
4 * (message it, stop it, approve more), and an issue an agent drafted (file
5 * it, discard it). Chat checks the person can read the conversation and
6 * that the card offers the action; this decides whether they may, acts as
7 * them, and updates the card for everyone.
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar8 *
9 * The rules, decided here in code:
10 * - **Stop, message:** anyone who can read the conversation the session
11 * reports in, as they could from its page.
12 * - **Approve more:** the workspace's owners only, to a cap above what it
13 * has spent.
14 * - **File issue:** whoever presses it files it as themselves, and only if
15 * they can read the repository; the agent files nothing.
16 * - **Discard:** whoever asked for it, or an owner.
Each agent has an Abilities tab: g1t's built-ins, always on within the asker's access; its computer, coming; each connected integration's actions one row each, read, import, comment or resolve, with a level for each, alone, alone when the person asked for it, ask first or never, and whose connection it runs on; and MCP servers an owner adds. Reading is alone, writing inside g1t follows today's choices, anything that leaves g1t asks first, and production deploys can't go above ask. The agents service enforces every level: ask first posts a card to allow or deny and parks a session until it's answered, a refusal names its rule in the transcript and the audit log, and a missing ability posts a request to owners. The agent abilities guide says how.17 * - **Allow, deny** (an Ask-first card, abilities.ts): whoever the agent
18 * acts for, or an owner. Allowing runs the call as the person who
19 * pressed it, and the agent hears the result; a session goes on.
20 * - **Ask the owners** (a Request card): anyone; an integration becomes a
21 * Marketplace install request, an ability a notification to the owners.
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar22 */
23import {
24 type AgentCardAction,
25 type CardActionResult,
26 type MessageCard,
27 type Result,
28 type User,
29 chatClient,
30 fail,
Each agent has an Abilities tab: g1t's built-ins, always on within the asker's access; its computer, coming; each connected integration's actions one row each, read, import, comment or resolve, with a level for each, alone, alone when the person asked for it, ask first or never, and whose connection it runs on; and MCP servers an owner adds. Reading is alone, writing inside g1t follows today's choices, anything that leaves g1t asks first, and production deploys can't go above ask. The agents service enforces every level: ask first posts a card to allow or deny and parks a session until it's answered, a refusal names its rule in the transcript and the audit log, and a missing ability posts a request to owners. The agent abilities guide says how.31 identityClient,
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar32 newId,
33 ok,
34 reposClient,
35 workClient,
36} from "@g1t/contracts";
37
Each agent has an Abilities tab: g1t's built-ins, always on within the asker's access; its computer, coming; each connected integration's actions one row each, read, import, comment or resolve, with a level for each, alone, alone when the person asked for it, ask first or never, and whose connection it runs on; and MCP servers an owner adds. Reading is alone, writing inside g1t follows today's choices, anything that leaves g1t asks first, and production deploys can't go above ask. The agents service enforces every level: ask first posts a card to allow or deny and parks a session until it's answered, a refusal names its rule in the transcript and the audit log, and a missing ability posts a request to owners. The agent abilities guide says how.38import { type AbilityRequestRow, abilitiesPath, recordDecision, runAllowed, tellOwnersOfRequest } from "./abilities.ts";
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar39import { canManage } from "./access.ts";
Each agent has an Abilities tab: g1t's built-ins, always on within the asker's access; its computer, coming; each connected integration's actions one row each, read, import, comment or resolve, with a level for each, alone, alone when the person asked for it, ask first or never, and whose connection it runs on; and MCP servers an owner adds. Reading is alone, writing inside g1t follows today's choices, anything that leaves g1t asks first, and production deploys can't go above ask. The agents service enforces every level: ask first posts a card to allow or deny and parks a session until it's answered, a refusal names its rule in the transcript and the audit log, and a missing ability posts a request to owners. The agent abilities guide says how.40import { abilityCard, draftCard, parseMoney, requestCard } from "./card-views.ts";
41import { findAbility, resolveAbilities } from "../../../packages/contracts/src/abilities.ts";
42import { CONNECTORS } from "../../../packages/contracts/src/connectors.ts";
43import { findListing, openRequest } from "./installs.ts";
44import { type Row, definitionOf, isPersonal, periods, selectAgents } from "./store.ts";
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar45
46export { draftCard, parseMoney } from "./card-views.ts";
47import { dollars } from "./money.ts";
Each agent has an Abilities tab: g1t's built-ins, always on within the asker's access; its computer, coming; each connected integration's actions one row each, read, import, comment or resolve, with a level for each, alone, alone when the person asked for it, ask first or never, and whose connection it runs on; and MCP servers an owner adds. Reading is alone, writing inside g1t follows today's choices, anything that leaves g1t asks first, and production deploys can't go above ask. The agents service enforces every level: ask first posts a card to allow or deny and parks a session until it's answered, a refusal names its rule in the transcript and the audit log, and a missing ability posts a request to owners. The agent abilities guide says how.48import { type SessionEnv, approve, pushInbox, resumeAfterDecision, sessionRow, steer, stop } from "./sessions.ts";
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar49
50export type DraftRow = {
51 id: string;
52 agent_id: string;
53 workspace_id: string;
54 workspace: string;
55 channel_id: string;
56 message_id: string | null;
57 session_id: string | null;
58 repo_id: string;
59 repo: string;
60 title: string;
61 body: string;
62 labels: string;
63 asked_by: string | null;
64 status: string;
65 filed_by: string | null;
66 number: number | null;
67 created_at: string;
68 updated_at: string;
69};
70
71/** Records a draft and posts its card with `post`. Returns the draft's id. */
72export async function postDraft(
73 env: SessionEnv,
74 input: { agent_id: string; workspace_id: string; workspace: string; channel_id: string; session_id: string | null; repo_id: string; repo: string; title: string; body: string; labels: string[]; asked_by: string | null },
75 post: (card: MessageCard) => Promise<string | null>,
76): Promise<string | null> {
77 const id = newId("drf");
78 const now = new Date().toISOString();
79 const row: DraftRow = { ...input, id, labels: JSON.stringify(input.labels), message_id: null, status: "draft", filed_by: null, number: null, created_at: now, updated_at: now };
80 await env.DB.prepare(
81 `INSERT INTO agent_drafts (id, agent_id, workspace_id, workspace, channel_id, session_id, repo_id, repo, title, body, labels, asked_by, status, created_at, updated_at)
82 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?)`,
83 )
84 .bind(id, row.agent_id, row.workspace_id, row.workspace, row.channel_id, row.session_id, row.repo_id, row.repo, row.title, row.body, row.labels, row.asked_by, now, now)
85 .run();
86 const messageId = await post(draftCard(row));
87 if (!messageId) return null;
88 await env.DB.prepare("UPDATE agent_drafts SET message_id = ? WHERE id = ?").bind(messageId, id).run();
89 return id;
90}
91
92const done = (message: string | null): Result<CardActionResult> => ok({ ok: true, message });
93const no = (message: string): Result<CardActionResult> => ok({ ok: false, message });
94
95/** A person pressed an action on one of agents' cards. */
96export async function cardAction(env: SessionEnv, a: AgentCardAction): Promise<Result<CardActionResult>> {
97 const viewer = a?.viewer as User | undefined;
98 if (!viewer || !a.card?.ref) return fail("invalid", "No such card.");
99 if (a.card.kind === "session") return sessionAction(env, a, viewer);
100 if (a.card.kind === "draft_issue") return draftAction(env, a, viewer);
Each agent has an Abilities tab: g1t's built-ins, always on within the asker's access; its computer, coming; each connected integration's actions one row each, read, import, comment or resolve, with a level for each, alone, alone when the person asked for it, ask first or never, and whose connection it runs on; and MCP servers an owner adds. Reading is alone, writing inside g1t follows today's choices, anything that leaves g1t asks first, and production deploys can't go above ask. The agents service enforces every level: ask first posts a card to allow or deny and parks a session until it's answered, a refusal names its rule in the transcript and the audit log, and a missing ability posts a request to owners. The agent abilities guide says how.101 if (a.card.kind === "ability") return abilityAction(env, a, viewer);
102 if (a.card.kind === "request") return requestAction(env, a, viewer);
103 return fail("invalid", "That card has no such action.");
104}
105
106/** The agent a card belongs to, by id. */
107async function agentById(db: D1Database, id: string): Promise<Row | null> {
108 return db
109 .prepare(selectAgents("a.id = ?3"))
110 .bind(...periods(new Date()), id)
111 .first<Row>();
112}
113
114/** Allow or deny an Ask-first call (abilities.ts): whoever the agent acts for, or an owner. */
115async function abilityAction(env: SessionEnv, a: AgentCardAction, viewer: User): Promise<Result<CardActionResult>> {
116 const db = env.DB;
117 const request = await db.prepare("SELECT * FROM agent_ability_requests WHERE id = ?").bind(a.card.ref).first<AbilityRequestRow>();
118 if (!request || request.workspace !== a.workspace.toLowerCase() || request.channel_id !== a.channel_id) return fail("not_found", "No such request.");
119 if (a.action_id !== "allow" && a.action_id !== "deny") return fail("invalid", "That card has no such action.");
120 if (request.status !== "pending") return no(request.status === "denied" ? "It was denied." : "It was already answered.");
121 if (request.asked_by !== viewer.id && !canManage(viewer, a.workspace)) return no("Only whoever the agent is working for, or an owner, can answer this.");
122 const agent = await agentById(db, request.agent_id);
123 if (!agent) return fail("not_found", "The agent is gone.");
124 const definition = definitionOf(agent);
125 const sections = resolveAbilities({ connectors: CONNECTORS, abilities: definition.abilities, autonomy: definition.autonomy, connected: [request.ability.split(":")[1] ?? ""], personal: isPersonal(agent) });
126 const found = findAbility(sections, request.ability);
127 const about = {
128 agent: agent.display_name,
129 asker: await usernameOf(env, request.asked_by),
130 rule: found ? `${found.source.name}: ${found.ability.label}` : request.ability,
131 level: found?.ability.level ?? ("ask" as const),
132 note: null,
133 body: bodyOf(request.input),
134 };
135 const now = new Date().toISOString();
136 const update = async (row: AbilityRequestRow) => {
137 if (!row.message_id) return;
138 await chatClient(env.CHAT)
139 .updateAsAgent(row.workspace, row.channel_id, row.agent_id, row.message_id, { card: abilityCard(row, about) })
140 .catch((error: unknown) => console.error("agents: an ability card was not updated", row.id, String(error)));
141 };
142 // Claimed first, so two presses never run it twice.
143 const claimed = await db.prepare("UPDATE agent_ability_requests SET status = ?, decided_by = ?, decided_at = ?, updated_at = ? WHERE id = ? AND status = 'pending'").bind(a.action_id === "allow" ? "running" : "denied", viewer.username, now, now, request.id).run();
144 if (!claimed.meta.changes) return no("Someone got there first.");
145 recordDecision(env, { by: viewer, agent, workspace: a.workspace, request, allowed: a.action_id === "allow" });
146 if (a.action_id === "deny") {
147 const denied = { ...request, status: "denied", decided_by: viewer.username, decided_at: now };
148 await update(denied);
149 await resumeAfterDecision(env, request, `@${viewer.username} denied: ${request.summary}. Don't try another way; say so.`);
150 return done("Denied. It won't be done.");
151 }
152 const ran = await runAllowed(env, request, agent, definition, viewer);
153 const status = ran.ok ? "allowed" : "failed";
154 await db.prepare("UPDATE agent_ability_requests SET status = ?, result = ?, updated_at = ? WHERE id = ?").bind(status, ran.message.slice(0, 20_000), new Date().toISOString(), request.id).run();
155 const fresh = { ...request, status, decided_by: viewer.username, decided_at: now, result: ran.message.slice(0, 300) };
156 await update(fresh);
157 await resumeAfterDecision(env, request, ran.ok ? `@${viewer.username} allowed "${request.summary}", and it ran:\n${ran.message}` : `@${viewer.username} allowed "${request.summary}", but it didn't work: ${ran.message}`);
158 return ran.ok ? done(`Allowed. ${ran.message.slice(0, 200)}`) : no(`Allowed, but it didn't work: ${ran.message.slice(0, 300)}`);
159}
160
161/** The text a call would write, for the card. */
162function bodyOf(input: string): string | null {
163 try {
164 const args = JSON.parse(input) as { text?: unknown };
165 return typeof args.text === "string" && args.text.trim() ? args.text.trim().slice(0, 900) : null;
166 } catch {
167 return null;
168 }
169}
170
171async function usernameOf(env: SessionEnv, id: string | null): Promise<string | null> {
172 if (!id) return null;
173 const [user] = await identityClient(env.IDENTITY)
174 .usersForAudience([id])
175 .catch(() => [] as User[]);
176 return user?.username ?? null;
177}
178
179/**
180 * Ask the owners, from a Request card: an integration becomes a Marketplace
181 * install request (the same one the Marketplace opens); an ability, a
182 * notification to the owners with a link to the agent's Abilities tab.
183 */
184async function requestAction(env: SessionEnv, a: AgentCardAction, viewer: User): Promise<Result<CardActionResult>> {
185 if (a.action_id !== "ask") return fail("invalid", "That card has no such action.");
186 const ref = a.card.ref ?? "";
187 const [kind, agentId, ...rest] = ref.split(":");
188 const agent = agentId ? await agentById(env.DB, agentId) : null;
189 if (!agent) return fail("not_found", "No such card.");
190 const slug = a.workspace.toLowerCase();
191 const update = async (card: MessageCard) => {
192 await chatClient(env.CHAT)
193 .updateAsAgent(slug, a.channel_id, agent.id, a.message_id, { card })
194 .catch((error: unknown) => console.error("agents: a request card was not updated", a.message_id, String(error)));
195 };
196 const who = { id: agent.id, handle: agent.handle, display_name: agent.display_name };
197 if (kind === "connector") {
198 const connector = CONNECTORS.find((c) => c.id === rest[0]);
199 if (!connector) return fail("not_found", "No such integration.");
200 if (canManage(viewer, slug)) return no(`You're an owner: connect ${connector.name} from the Marketplace.`);
201 const listing = findListing(`integration:${connector.id}`);
202 if (!listing) return no(`${connector.name} can't be connected to a workspace yet.`);
203 const why = `${agent.display_name} needs it for @${viewer.username}.`;
204 const opened = await openRequest(env.DB, agent.workspace_id, newId("ins"), listing, viewer, why);
205 if (!opened.ok && opened.error.code !== "conflict") return no(opened.error.message);
206 if (opened.ok) {
207 await tellOwnersOfRequest(env, { workspace: slug, by: viewer, title: `@${viewer.username} asks you to add ${connector.name}`, body: why, href: `/${slug}/-/marketplace/requests`, id: opened.value.id });
208 }
209 await update(requestCard({ agent: who, workspace: slug, connector: { id: connector.id, name: connector.name, available: true }, ability: null, why, status: "asked", by: viewer.username }));
210 return done(opened.ok ? "Asked. The owners have your request." : "You'd already asked; the owners have it.");
211 }
212 if (kind === "ability") {
213 const abilityId = rest.join(":");
214 const definition = definitionOf(agent);
215 const sections = resolveAbilities({ connectors: CONNECTORS, abilities: definition.abilities, autonomy: definition.autonomy, connected: CONNECTORS.map((c) => c.id), personal: isPersonal(agent) });
216 const found = findAbility(sections, abilityId);
217 const label = found ? `${found.source.name}: ${found.ability.label}` : abilityId;
218 if (canManage(viewer, slug)) return no(`You're an owner: allow it on ${agent.display_name}'s Abilities tab.`);
219 const why = `${agent.display_name} needs it for @${viewer.username}.`;
220 await tellOwnersOfRequest(env, { workspace: slug, by: viewer, title: `@${viewer.username} asks you to let ${agent.display_name} ${found ? found.ability.label.toLowerCase() : "do more"}`, body: `${label}. ${why}`, href: abilitiesPath(slug, agent.handle), id: `${agent.id}:${abilityId}:${viewer.id}` });
221 await update(requestCard({ agent: who, workspace: slug, connector: null, ability: { id: abilityId, label }, why, status: "asked", by: viewer.username }));
222 return done("Asked. The owners have been told.");
223 }
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar224 return fail("invalid", "That card has no such action.");
225}
226
227async function sessionAction(env: SessionEnv, a: AgentCardAction, viewer: User): Promise<Result<CardActionResult>> {
228 const row = await sessionRow(env.DB, a.card.ref!);
229 // The card is in the conversation the session reports in; chat checked the person reads it.
230 if (!row || row.workspace !== a.workspace.toLowerCase() || row.channel_id !== a.channel_id) return fail("not_found", "No such session.");
231 const live = ["queued", "working", "waiting", "needs_approval"].includes(row.status);
232 switch (a.action_id) {
233 case "stop":
234 if (!live) return no("It already ended.");
235 await stop(env, row, viewer.username);
236 return done("Stopped.");
237 case "steer": {
238 const body = (a.input ?? "").trim();
239 if (!body) return no("Say something to it.");
240 await steer(env, row, viewer.username, body);
241 return done(live ? "It'll read that at its next step." : "It's picking up again with that.");
242 }
243 case "approve": {
244 if (!canManage(viewer, a.workspace)) return no("Only the workspace's owners can approve more spend.");
245 if (row.status !== "needs_approval") return no("It isn't waiting for approval.");
246 const cap = parseMoney(a.input);
247 if (!cap || cap <= row.charged_micros) return no(`The new cap must be more than the ${dollars(row.charged_micros)} it has spent.`);
248 await approve(env, row, viewer.username, cap);
249 return done(`Approved up to ${dollars(cap)}. It's going on.`);
250 }
251 default:
252 return fail("invalid", "That card has no such action.");
253 }
254}
255
256async function draftAction(env: SessionEnv, a: AgentCardAction, viewer: User): Promise<Result<CardActionResult>> {
257 const db = env.DB;
258 const draft = await db.prepare("SELECT * FROM agent_drafts WHERE id = ?").bind(a.card.ref).first<DraftRow>();
259 if (!draft || draft.workspace !== a.workspace.toLowerCase() || draft.channel_id !== a.channel_id) return fail("not_found", "No such draft.");
260 const update = async (row: DraftRow) => {
261 if (!row.message_id) return;
262 await chatClient(env.CHAT)
263 .updateAsAgent(row.workspace, row.channel_id, row.agent_id, row.message_id, { card: draftCard(row) })
264 .catch((error: unknown) => console.error("agents: a draft's card was not updated", row.id, String(error)));
265 };
266 if (draft.status !== "draft") return no(draft.status === "filed" ? `It's already filed as #${draft.number}.` : "It was discarded.");
267 const now = new Date().toISOString();
268 if (a.action_id === "discard") {
269 if (draft.asked_by !== viewer.id && !canManage(viewer, a.workspace)) return no("Only whoever asked for it, or an owner, can discard it.");
270 const claimed = await db.prepare("UPDATE agent_drafts SET status = 'discarded', updated_at = ? WHERE id = ? AND status = 'draft'").bind(now, draft.id).run();
271 if (!claimed.meta.changes) return no("Someone got there first.");
272 await update({ ...draft, status: "discarded" });
273 return done("Discarded.");
274 }
275 if (a.action_id !== "file") return fail("invalid", "That card has no such action.");
276 // Filed as the person who pressed it, only where they can read.
277 const [repo] = await reposClient(env.REPOS)
278 .readable([draft.repo_id], viewer)
279 .catch(() => []);
280 if (!repo) return no("You can't file in that repository.");
281 // Claimed first, so two presses never file it twice.
282 const claimed = await db.prepare("UPDATE agent_drafts SET status = 'filing', updated_at = ? WHERE id = ? AND status = 'draft'").bind(now, draft.id).run();
283 if (!claimed.meta.changes) return no("Someone got there first.");
284 const agent = await db.prepare("SELECT handle, display_name FROM agents WHERE id = ?").bind(draft.agent_id).first<{ handle: string; display_name: string }>();
285 const footer = agent ? `\n\n---\n_Drafted by ${agent.display_name} (@${agent.handle}), filed by @${viewer.username}._` : "";
286 let labels: string[] = [];
287 try {
288 labels = JSON.parse(draft.labels || "[]");
289 } catch {
290 labels = [];
291 }
292 const opened = await workClient(env.WORK).openIssue(viewer, { namespace: repo.namespace, name: repo.name }, { title: draft.title, body: `${draft.body}${footer}`, labels });
293 if (!opened.ok) {
294 await db.prepare("UPDATE agent_drafts SET status = 'draft', updated_at = ? WHERE id = ?").bind(new Date().toISOString(), draft.id).run();
295 return no(`It couldn't be filed: ${opened.error.message}`);
296 }
297 const filed: DraftRow = { ...draft, status: "filed", filed_by: viewer.username, number: opened.value.number };
298 await db.prepare("UPDATE agent_drafts SET status = 'filed', filed_by = ?, number = ?, updated_at = ? WHERE id = ?").bind(viewer.username, filed.number, new Date().toISOString(), draft.id).run();
299 if (draft.session_id) {
300 const row = await sessionRow(db, draft.session_id);
301 if (row) {
302 const outputs = (() => {
303 try {
304 return JSON.parse(row.outputs || "[]") as unknown[];
305 } catch {
306 return [];
307 }
308 })();
309 outputs.push({ kind: "issue", repo: draft.repo, number: filed.number, title: draft.title });
310 await db.prepare("UPDATE agent_sessions SET outputs = ? WHERE id = ?").bind(JSON.stringify(outputs.slice(-50)), row.id).run();
311 }
312 }
313 await update(filed);
314 return done(`Filed ${draft.repo}#${filed.number}.`);
315}