Skip to content
228 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,
6 * another chat app the workspace connected later (docs/WORKSPACE.md,
7 * "Working from another chat app"). Only g1t's adapter exists.
8 */
Merge branch 'worktree-agent-a1398e81ad1a64c5f'9import type { AgentDelivery, ChatMessage, ConversationForAgent, MessageCard, ServiceBinding } from "@g1t/contracts";
Chat and workspace agents: channels, DMs and named agents you talk to10
11// By path, not the package: it imports only types, so the adapter is tested under Node.
12import { CHAT_MAX_HOPS, chatClient } from "../../../packages/contracts/src/chat.ts";
13
14/** One message, as the reply loop reads it, whatever the surface. */
15export type SurfaceMessage = {
16 id: string;
17 author: { kind: "user" | "agent"; id: string; name: string; display_name: string };
18 body: string;
19 /** A card's one-line summary, when the message is a card. */
20 card: string | null;
21 created_at: string;
22};
23
Merge branch 'worktree-agent-a1398e81ad1a64c5f'24/** One member of a conversation, as an agent is told about it. */
25export type ConversationMember = {
26 kind: "user" | "agent";
27 id: string;
28 /** What `@` mentions: a person's username, an agent's handle. */
29 name: string;
30 display_name: string;
31 /** An agent's title, or its role when it has none; null for a person. */
32 title: string | null;
33};
34
35/**
36 * Where an agent is answering and who is in it (docs/WORKSPACE.md, "Where
37 * you are"): only these members read what it says there.
38 */
39export type Conversation = {
40 kind: "dm" | "group_dm" | "private_channel" | "public_channel";
41 /** A channel's name; null for a direct message. */
42 name: string | null;
43 /** Every agent, then people up to a cap. */
44 members: ConversationMember[];
45 people: number;
46 agents: number;
47};
48
49/** What handing work to a colleague did. */
50export type HandedOff = { ok: true; where: "here" | "group_dm"; opened: boolean } | { ok: false; message: string };
51
Chat and workspace agents: channels, DMs and named agents you talk to52export interface Surface {
53 /** The latest `limit` messages of the conversation (the thread, or the DM or channel), oldest first. */
54 history(limit: number): Promise<SurfaceMessage[]>;
55 /** Shows the agent typing. Never throws: it is a courtesy. */
56 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)57 /**
58 * Posts as the agent, in the conversation it was asked in, optionally as
59 * a card (a consult, say). Returns the new message's id.
60 */
61 post(body: string, card?: MessageCard | null): Promise<string>;
62 /**
63 * Reacts 👀 to the message that woke the agent, so people see it is on
64 * it. Never throws, and skipped where it adds nothing (see `reactsIn`).
65 */
66 acknowledge(): Promise<void>;
67 /**
68 * Once the reply has posted: 👀 becomes ✅ when it answered (`done`), or
69 * is taken away when it posted a notice or an apology (`withdrawn`).
70 * Never throws.
71 */
72 settle(outcome: "done" | "withdrawn"): Promise<void>;
Merge branch 'worktree-agent-a1398e81ad1a64c5f'73 /** The conversation and who is in it; null when it can't be read. Never throws. */
74 conversation(): Promise<Conversation | null>;
75 /**
76 * Hands work to a colleague agent for the person who asked: posted here
77 * when the colleague is in this channel or group DM, otherwise in the
78 * group DM of that person, this agent and the colleague, with a card
79 * here saying where it went. Wakes the colleague and nobody else.
80 */
81 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)82}
83
Merge branch 'worktree-agent-a1398e81ad1a64c5f'84/** A conversation as the chat service describes it, as the reply loop reads it. */
85export function conversationFrom(value: ConversationForAgent): Conversation {
86 const { channel } = value;
87 const kind =
88 channel.kind === "dm" ? (value.people + value.agents > 2 ? "group_dm" : "dm") : channel.private ? "private_channel" : "public_channel";
89 return {
90 kind,
91 name: channel.kind === "dm" ? null : channel.name,
92 members: value.members.map((m) => ({
93 kind: m.kind,
94 id: m.id,
95 name: m.name,
96 display_name: m.display_name,
97 title: m.kind === "agent" ? m.title || m.role || null : null,
98 })),
99 people: value.people,
100 agents: value.agents,
101 };
102}
103
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)104export const SEEN = "👀";
105export const DONE = "✅";
106
107/**
108 * Whether an agent reacts in a conversation: in channels and in direct
109 * messages with more than one person. In a DM with one person, the typing
110 * indicator says enough.
111 */
112export function reactsIn(kind: "channel" | "dm", people: number): boolean {
113 return kind === "channel" || people > 1;
Chat and workspace agents: channels, DMs and named agents you talk to114}
115
116/** A g1t chat message as the loop reads it. */
117export function fromChat(message: ChatMessage): SurfaceMessage {
118 const card = message.card
119 ? [message.card.kind, message.card.title, message.card.detail, message.card.state].filter(Boolean).join(" · ")
120 : null;
121 return {
122 id: message.id,
123 author: {
124 kind: message.author.kind,
125 id: message.author.id,
126 name: message.author.name,
127 display_name: message.author.display_name,
128 },
129 body: message.deleted_at ? "" : message.body,
130 card,
131 created_at: message.created_at,
132 };
133}
134
135/**
136 * g1t's own chat, over the chat service. Replies stay in the thread they
Merge branch 'worktree-agent-a1398e81ad1a64c5f'137 * were asked in, and carry the delivery's hops, `asked_by` and `asker` on.
138 * A reply wakes nobody, @mentions or not; a hand-off wakes its colleague
139 * one hop further along the same person's request, with that person's
140 * access.
Chat and workspace agents: channels, DMs and named agents you talk to141 */
142export function g1tSurface(chat: ServiceBinding, delivery: AgentDelivery): Surface {
143 const client = chatClient(chat);
144 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)145 // Whether 👀 went on, so `settle` knows what to take back.
146 let acknowledged: Promise<boolean> = Promise.resolve(false);
Chat and workspace agents: channels, DMs and named agents you talk to147 return {
148 async history(limit) {
149 const page = await client.historyForAgent(workspace, channel, agent, { thread_root: delivery.thread_root, limit });
150 if (!page.ok) throw new Error(`reading the conversation failed: ${page.error.message}`);
151 return page.value.filter((message) => !message.deleted_at).map(fromChat);
152 },
153 async typing() {
154 await client.agentTyping(workspace, channel, agent).catch(() => undefined);
155 },
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)156 async acknowledge() {
157 acknowledged = (async () => {
158 try {
159 // A first message's hello answers nothing; a DM's people decide the rest.
160 if (delivery.message_id.startsWith("hello:")) return false;
161 if (delivery.channel_kind === "dm") {
162 const audience = await client.audience(workspace, channel);
163 if (!audience.ok || !reactsIn("dm", audience.value.member_count)) return false;
164 }
165 const reacted = await client.reactAsAgent(workspace, channel, agent, delivery.message_id, SEEN);
166 return reacted.ok;
167 } catch {
168 return false;
169 }
170 })();
171 await acknowledged;
172 },
173 async settle(outcome) {
174 try {
175 if (!(await acknowledged)) return;
176 await client.reactAsAgent(workspace, channel, agent, delivery.message_id, SEEN, true);
177 if (outcome === "done") await client.reactAsAgent(workspace, channel, agent, delivery.message_id, DONE);
178 } catch {
179 // A reaction is a courtesy: the reply stands without it.
180 }
181 },
182 async post(body, card = null) {
Chat and workspace agents: channels, DMs and named agents you talk to183 const posted = await client.postAsAgent(workspace, channel, agent, {
184 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)185 card,
Chat and workspace agents: channels, DMs and named agents you talk to186 thread_root: delivery.thread_root,
187 hops: Math.min(delivery.hops, CHAT_MAX_HOPS),
188 asked_by: delivery.asked_by,
189 // Agents this reply wakes act for the same person, with their access.
190 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)191 // Chat adds this agent, and never hands the post back to the one that sent it the work.
192 chain: delivery.chain ?? [],
Chat and workspace agents: channels, DMs and named agents you talk to193 });
194 if (!posted.ok) throw new Error(`posting the reply failed: ${posted.error.message}`);
195 return posted.value.id;
196 },
Merge branch 'worktree-agent-a1398e81ad1a64c5f'197 async conversation() {
198 try {
199 const found = await client.conversationForAgent(workspace, channel, agent, delivery.asked_by);
200 return found.ok ? conversationFrom(found.value) : null;
201 } catch {
202 return null;
203 }
204 },
205 async handOff(colleagueId, brief) {
206 const done = await client.handOffAsAgent(workspace, channel, agent, {
207 colleague_id: colleagueId,
208 brief,
209 thread_root: delivery.thread_root,
210 hops: Math.min(delivery.hops, CHAT_MAX_HOPS),
211 asked_by: delivery.asked_by,
212 // The colleague works for the same person, with their access.
213 asker: delivery.asker ?? null,
214 chain: delivery.chain ?? [],
215 });
216 return done.ok ? { ok: true, where: done.value.where, opened: done.value.opened } : { ok: false, message: done.error.message };
217 },
Chat and workspace agents: channels, DMs and named agents you talk to218 };
219}
220
221/** The surface a delivery came from. Every delivery is g1t's today. */
222export function surfaceFor(chat: ServiceBinding, delivery: AgentDelivery): Surface {
223 switch (delivery.surface ?? "g1t") {
224 case "g1t":
225 default:
226 return g1tSurface(chat, delivery);
227 }
228}

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