Skip to content
258 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 * Where a conversation happens, as the reply loop sees it: read what was
3 * said, show that the agent is typing, post its answer. The loop knows
4 * nothing else about the chat it is in, so the same agent (its definition,
5 * budget and replies) answers wherever it is reached: g1t's own chat today,
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.6 * another chat app the workspace connected later. Only g1t's adapter exists.
Chat and workspace agents: channels, DMs and named agents you talk to7 */
Merge branch 'worktree-agent-a1398e81ad1a64c5f'8import type { AgentDelivery, ChatMessage, ConversationForAgent, MessageCard, ServiceBinding } from "@g1t/contracts";
Chat and workspace agents: channels, DMs and named agents you talk to9
10// By path, not the package: it imports only types, so the adapter is tested under Node.
11import { CHAT_MAX_HOPS, chatClient } from "../../../packages/contracts/src/chat.ts";
12
13/** One message, as the reply loop reads it, whatever the surface. */
14export type SurfaceMessage = {
15 id: string;
16 author: { kind: "user" | "agent"; id: string; name: string; display_name: string };
17 body: string;
18 /** A card's one-line summary, when the message is a card. */
19 card: string | null;
20 created_at: string;
21};
22
Merge branch 'worktree-agent-a1398e81ad1a64c5f'23/** One member of a conversation, as an agent is told about it. */
24export type ConversationMember = {
25 kind: "user" | "agent";
26 id: string;
27 /** What `@` mentions: a person's username, an agent's handle. */
28 name: string;
29 display_name: string;
30 /** An agent's title, or its role when it has none; null for a person. */
31 title: string | null;
32};
33
34/**
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.35 * Where an agent is answering and who is in it (docs.g1t.sh/guides/agents/,
36 * "Who is in the conversation"): only these members read what it says there.
Merge branch 'worktree-agent-a1398e81ad1a64c5f'37 */
38export type Conversation = {
39 kind: "dm" | "group_dm" | "private_channel" | "public_channel";
40 /** A channel's name; null for a direct message. */
41 name: string | null;
42 /** Every agent, then people up to a cap. */
43 members: ConversationMember[];
44 people: number;
45 agents: number;
46};
47
48/** What handing work to a colleague did. */
49export type HandedOff = { ok: true; where: "here" | "group_dm"; opened: boolean } | { ok: false; message: string };
50
Chat and workspace agents: channels, DMs and named agents you talk to51export interface Surface {
52 /** The latest `limit` messages of the conversation (the thread, or the DM or channel), oldest first. */
53 history(limit: number): Promise<SurfaceMessage[]>;
54 /** Shows the agent typing. Never throws: it is a courtesy. */
55 typing(): Promise<void>;
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)56 /**
57 * Posts as the agent, in the conversation it was asked in, optionally as
58 * a card (a consult, say). Returns the new message's id.
59 */
60 post(body: string, card?: MessageCard | null): Promise<string>;
61 /**
62 * Reacts 👀 to the message that woke the agent, so people see it is on
63 * it. Never throws, and skipped where it adds nothing (see `reactsIn`).
64 */
65 acknowledge(): Promise<void>;
66 /**
67 * Once the reply has posted: 👀 becomes ✅ when it answered (`done`), or
68 * is taken away when it posted a notice or an apology (`withdrawn`).
69 * Never throws.
70 */
71 settle(outcome: "done" | "withdrawn"): Promise<void>;
Merge branch 'worktree-agent-a1398e81ad1a64c5f'72 /** The conversation and who is in it; null when it can't be read. Never throws. */
73 conversation(): Promise<Conversation | null>;
74 /**
75 * Hands work to a colleague agent for the person who asked: posted here
76 * when the colleague is in this channel or group DM, otherwise in the
77 * group DM of that person, this agent and the colleague, with a card
78 * here saying where it went. Wakes the colleague and nobody else.
79 */
80 handOff(colleagueId: string, brief: string): Promise<HandedOff>;
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)81}
82
Merge branch 'worktree-agent-a1398e81ad1a64c5f'83/** A conversation as the chat service describes it, as the reply loop reads it. */
84export function conversationFrom(value: ConversationForAgent): Conversation {
85 const { channel } = value;
86 const kind =
87 channel.kind === "dm" ? (value.people + value.agents > 2 ? "group_dm" : "dm") : channel.private ? "private_channel" : "public_channel";
88 return {
89 kind,
90 name: channel.kind === "dm" ? null : channel.name,
91 members: value.members.map((m) => ({
92 kind: m.kind,
93 id: m.id,
94 name: m.name,
95 display_name: m.display_name,
96 title: m.kind === "agent" ? m.title || m.role || null : null,
97 })),
98 people: value.people,
99 agents: value.agents,
100 };
101}
102
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)103export const SEEN = "👀";
104export const DONE = "✅";
105
106/**
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.107 * How often an agent still working says so again. Chat shows each call
108 * for `AGENT_TYPING_MS` (8 s, services/chat/src/messages.ts), so this stays
109 * under it and the indicator never flickers off between beats.
110 */
111export const TYPING_HEARTBEAT_MS = 5_000;
112
113/**
114 * Keeps the agent shown as typing while it works: `typing()` now and every
115 * `everyMs` until the function it returns is called. A reply that reads
116 * code, consults a colleague or starts a session takes a minute or more,
117 * and one call alone would fade after seconds while the agent is still on
118 * it. Never throws: a failed beat is simply the next one's turn.
119 */
120export function keepTyping(surface: Pick<Surface, "typing">, everyMs = TYPING_HEARTBEAT_MS): () => void {
121 let on = true;
122 const beat = () => {
123 if (!on) return;
124 try {
125 void surface.typing().catch(() => undefined);
126 } catch {
127 // A courtesy only.
128 }
129 };
130 beat();
131 const timer = setInterval(beat, everyMs);
132 return () => {
133 on = false;
134 clearInterval(timer);
135 };
136}
137
138/**
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)139 * Whether an agent reacts in a conversation: in channels and in direct
140 * messages with more than one person. In a DM with one person, the typing
141 * indicator says enough.
142 */
143export function reactsIn(kind: "channel" | "dm", people: number): boolean {
144 return kind === "channel" || people > 1;
Chat and workspace agents: channels, DMs and named agents you talk to145}
146
147/** A g1t chat message as the loop reads it. */
148export function fromChat(message: ChatMessage): SurfaceMessage {
149 const card = message.card
150 ? [message.card.kind, message.card.title, message.card.detail, message.card.state].filter(Boolean).join(" · ")
151 : null;
152 return {
153 id: message.id,
154 author: {
155 kind: message.author.kind,
156 id: message.author.id,
157 name: message.author.name,
158 display_name: message.author.display_name,
159 },
160 body: message.deleted_at ? "" : message.body,
161 card,
162 created_at: message.created_at,
163 };
164}
165
166/**
167 * g1t's own chat, over the chat service. Replies stay in the thread they
Merge branch 'worktree-agent-a1398e81ad1a64c5f'168 * were asked in, and carry the delivery's hops, `asked_by` and `asker` on.
169 * A reply wakes nobody, @mentions or not; a hand-off wakes its colleague
170 * one hop further along the same person's request, with that person's
171 * access.
Chat and workspace agents: channels, DMs and named agents you talk to172 */
173export function g1tSurface(chat: ServiceBinding, delivery: AgentDelivery): Surface {
174 const client = chatClient(chat);
175 const { workspace, channel_id: channel, agent_id: agent } = delivery;
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)176 // Whether 👀 went on, so `settle` knows what to take back.
177 let acknowledged: Promise<boolean> = Promise.resolve(false);
Chat and workspace agents: channels, DMs and named agents you talk to178 return {
179 async history(limit) {
180 const page = await client.historyForAgent(workspace, channel, agent, { thread_root: delivery.thread_root, limit });
181 if (!page.ok) throw new Error(`reading the conversation failed: ${page.error.message}`);
182 return page.value.filter((message) => !message.deleted_at).map(fromChat);
183 },
184 async typing() {
185 await client.agentTyping(workspace, channel, agent).catch(() => undefined);
186 },
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)187 async acknowledge() {
188 acknowledged = (async () => {
189 try {
A new agent says nothing until you do. Creating one still opens your direct message with it, but no introduction is written there, nothing is charged to its budget and nothing waits in Chat; the desk's special first message, its fixed fallback and the prompt that asked for it are gone. The agents guide says so.190 // A DM's people decide.
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)191 if (delivery.channel_kind === "dm") {
192 const audience = await client.audience(workspace, channel);
193 if (!audience.ok || !reactsIn("dm", audience.value.member_count)) return false;
194 }
195 const reacted = await client.reactAsAgent(workspace, channel, agent, delivery.message_id, SEEN);
196 return reacted.ok;
197 } catch {
198 return false;
199 }
200 })();
201 await acknowledged;
202 },
203 async settle(outcome) {
204 try {
205 if (!(await acknowledged)) return;
206 await client.reactAsAgent(workspace, channel, agent, delivery.message_id, SEEN, true);
207 if (outcome === "done") await client.reactAsAgent(workspace, channel, agent, delivery.message_id, DONE);
208 } catch {
209 // A reaction is a courtesy: the reply stands without it.
210 }
211 },
212 async post(body, card = null) {
Chat and workspace agents: channels, DMs and named agents you talk to213 const posted = await client.postAsAgent(workspace, channel, agent, {
214 body,
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)215 card,
Chat and workspace agents: channels, DMs and named agents you talk to216 thread_root: delivery.thread_root,
217 hops: Math.min(delivery.hops, CHAT_MAX_HOPS),
218 asked_by: delivery.asked_by,
219 // Agents this reply wakes act for the same person, with their access.
220 asker: delivery.asker ?? null,
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)221 // Chat adds this agent, and never hands the post back to the one that sent it the work.
222 chain: delivery.chain ?? [],
Chat and workspace agents: channels, DMs and named agents you talk to223 });
224 if (!posted.ok) throw new Error(`posting the reply failed: ${posted.error.message}`);
225 return posted.value.id;
226 },
Merge branch 'worktree-agent-a1398e81ad1a64c5f'227 async conversation() {
228 try {
229 const found = await client.conversationForAgent(workspace, channel, agent, delivery.asked_by);
230 return found.ok ? conversationFrom(found.value) : null;
231 } catch {
232 return null;
233 }
234 },
235 async handOff(colleagueId, brief) {
236 const done = await client.handOffAsAgent(workspace, channel, agent, {
237 colleague_id: colleagueId,
238 brief,
239 thread_root: delivery.thread_root,
240 hops: Math.min(delivery.hops, CHAT_MAX_HOPS),
241 asked_by: delivery.asked_by,
242 // The colleague works for the same person, with their access.
243 asker: delivery.asker ?? null,
244 chain: delivery.chain ?? [],
245 });
246 return done.ok ? { ok: true, where: done.value.where, opened: done.value.opened } : { ok: false, message: done.error.message };
247 },
Chat and workspace agents: channels, DMs and named agents you talk to248 };
249}
250
251/** The surface a delivery came from. Every delivery is g1t's today. */
252export function surfaceFor(chat: ServiceBinding, delivery: AgentDelivery): Surface {
253 switch (delivery.surface ?? "g1t") {
254 case "g1t":
255 default:
256 return g1tSurface(chat, delivery);
257 }
258}

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