Skip to content
259 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 {
@g1t welcomes you when you join a workspace, and that is the one introduction any agent makes. Identity now says when a person becomes a member, workspace.member_joined, by invitation, by an invite code, or by creating the workspace, and webhooks can hear it; the agents service hears it too, and for anyone who joined, never the creator, the workspace's @g1t opens your direct message and says in its own voice who it is, what to ask it, how agents answer, and where things are, once per person per workspace and charged to @g1t like any reply, with a fixed welcome when no model can be used. The agents, workspaces, authentication and webhooks guides say so.190 // A welcome (welcome.ts, `welcome:` ids) answers no message, so there is nothing to react to; a DM's people decide the rest.
191 if (delivery.message_id.startsWith("welcome:")) return false;
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)192 if (delivery.channel_kind === "dm") {
193 const audience = await client.audience(workspace, channel);
194 if (!audience.ok || !reactsIn("dm", audience.value.member_count)) return false;
195 }
196 const reacted = await client.reactAsAgent(workspace, channel, agent, delivery.message_id, SEEN);
197 return reacted.ok;
198 } catch {
199 return false;
200 }
201 })();
202 await acknowledged;
203 },
204 async settle(outcome) {
205 try {
206 if (!(await acknowledged)) return;
207 await client.reactAsAgent(workspace, channel, agent, delivery.message_id, SEEN, true);
208 if (outcome === "done") await client.reactAsAgent(workspace, channel, agent, delivery.message_id, DONE);
209 } catch {
210 // A reaction is a courtesy: the reply stands without it.
211 }
212 },
213 async post(body, card = null) {
Chat and workspace agents: channels, DMs and named agents you talk to214 const posted = await client.postAsAgent(workspace, channel, agent, {
215 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)216 card,
Chat and workspace agents: channels, DMs and named agents you talk to217 thread_root: delivery.thread_root,
218 hops: Math.min(delivery.hops, CHAT_MAX_HOPS),
219 asked_by: delivery.asked_by,
220 // Agents this reply wakes act for the same person, with their access.
221 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)222 // Chat adds this agent, and never hands the post back to the one that sent it the work.
223 chain: delivery.chain ?? [],
Chat and workspace agents: channels, DMs and named agents you talk to224 });
225 if (!posted.ok) throw new Error(`posting the reply failed: ${posted.error.message}`);
226 return posted.value.id;
227 },
Merge branch 'worktree-agent-a1398e81ad1a64c5f'228 async conversation() {
229 try {
230 const found = await client.conversationForAgent(workspace, channel, agent, delivery.asked_by);
231 return found.ok ? conversationFrom(found.value) : null;
232 } catch {
233 return null;
234 }
235 },
236 async handOff(colleagueId, brief) {
237 const done = await client.handOffAsAgent(workspace, channel, agent, {
238 colleague_id: colleagueId,
239 brief,
240 thread_root: delivery.thread_root,
241 hops: Math.min(delivery.hops, CHAT_MAX_HOPS),
242 asked_by: delivery.asked_by,
243 // The colleague works for the same person, with their access.
244 asker: delivery.asker ?? null,
245 chain: delivery.chain ?? [],
246 });
247 return done.ok ? { ok: true, where: done.value.where, opened: done.value.opened } : { ok: false, message: done.error.message };
248 },
Chat and workspace agents: channels, DMs and named agents you talk to249 };
250}
251
252/** The surface a delivery came from. Every delivery is g1t's today. */
253export function surfaceFor(chat: ServiceBinding, delivery: AgentDelivery): Surface {
254 switch (delivery.surface ?? "g1t") {
255 case "g1t":
256 default:
257 return g1tSurface(chat, delivery);
258 }
259}

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