Skip to content
1,789 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 * The chat service: a workspace's channels, direct messages, threads and
Chat, artifacts and notifications no longer serve g1t's own agents. Direct messages and conversation invites take people only, and a space or folio is shared with people and teams. Chat stops waking agents, drops the RPCs agents posted, read, searched and handed off through, and stops answering buttons on agent cards; old cards keep their links. An agent's old messages still show, under its saved name or as "A deleted agent". Artifacts drops the folio and docs RPCs agents read and edited pages with, and an old agent member of a space can still be removed. Notifications drop "an agent is waiting on you". A deployment no longer trusts a pull request with secrets just because g1t's agent opened it, and the sign-up card no longer promises a team of agents.3 * messages. Conversations are between people (docs.g1t.sh/guides/chat/);
4 * messages from agents of the past still show, under the name they had.
Chat and workspace agents: channels, DMs and named agents you talk to5 *
6 * Reached through service bindings: `POST /rpc/<method>` with snake_case
7 * bodies (`chatClient` in @g1t/contracts), and `GET /live` for a channel's
8 * socket, which the site forwards after checking the session. Each channel
9 * has a room (src/room.ts) that delivers what happens in it live.
10 *
11 * Workspaces are kept by id, so renaming one changes nothing here; who is
12 * in a workspace comes from the viewer's memberships, as in every service.
13 */
14
15import {
16 CHAT_VIEWER_HEADER,
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.17 G1T_KEY,
18 G1T_PRINCIPAL,
Chat and workspace agents: channels, DMs and named agents you talk to19 fail,
20 identityClient,
Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales.21 idFloor,
Chat and workspace agents: channels, DMs and named agents you talk to22 newId,
23 ok,
24 openD1,
25 parsePrincipalKey,
26 principalKey,
27 type Channel,
Chat controls, public profiles, shadcn selects, and no Docs tab in a project28 type ChannelChange,
29 type ChannelDetail,
Chat and workspace agents: channels, DMs and named agents you talk to30 type ChannelMember,
31 type ChatLiveEvent,
32 type ChatMessage,
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)33 type ChatReaction,
34 type CustomEmoji,
35 type EmojiFile,
36 type EmojiList,
37 type EmojiUpload,
Chat controls, public profiles, shadcn selects, and no Docs tab in a project38 type ChatSettings,
39 type ChatSettingsView,
Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales.40 type ChatActivity,
Chat and workspace agents: channels, DMs and named agents you talk to41 type ChatSidebar,
42 type ChatSidebarEntry,
43 type Member,
44 type MemberProfile,
45 type MessageCard,
46 type MessagePage,
47 type NewChannel,
48 type PostMessage,
49 type Principal,
50 type Result,
51 type ServiceBinding,
52 type User,
53 type Viewer,
54 type Workspace,
55} from "@g1t/contracts";
56
Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales.57import { type ActivityRow, activitySpan, chatActivity } from "./activity.ts";
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)58import {
59 MAX_REACTIONS_PER_MESSAGE,
60 emojiImage,
61 emojiName,
62 fromBase64,
63 mayRemove,
64 mayUpload,
65 reactionEmoji,
66 roomForReaction,
67 tallyReactions,
68 type ReactionRow,
69 type ReactionTally,
70} from "./emoji.ts";
Chat, artifacts and notifications no longer serve g1t's own agents. Direct messages and conversation invites take people only, and a space or folio is shared with people and teams. Chat stops waking agents, drops the RPCs agents posted, read, searched and handed off through, and stops answering buttons on agent cards; old cards keep their links. An agent's old messages still show, under its saved name or as "A deleted agent". Artifacts drops the folio and docs RPCs agents read and edited pages with, and an old agent member of a space can still be removed. Notifications drop "an agent is waiting on you". A deployment no longer trusts a pull request with secrets just because g1t's agent opened it, and the sign-up card no longer promises a team of agents.71import { shownCard } from "./cards.ts";
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.72// @g1t: the direct message every member has with g1t (src/g1t.ts, src/answer.ts).
73import {
74 G1T_PROFILE,
75 type MessageMeta,
76 type SaidRow,
77 conversation,
78 g1tDmPerson,
79 latestPage,
80 metaColumn,
81 pageFrom,
82 parseMeta,
83 shownAnswer,
84 startsAnswer,
85} from "./g1t.ts";
86import { internalAnswer } from "./internal.ts";
Chat, artifacts and notifications no longer serve g1t's own agents. Direct messages and conversation invites take people only, and a space or folio is shared with people and teams. Chat stops waking agents, drops the RPCs agents posted, read, searched and handed off through, and stops answering buttons on agent cards; old cards keep their links. An agent's old messages still show, under its saved name or as "A deleted agent". Artifacts drops the folio and docs RPCs agents read and edited pages with, and an old agent member of a space can still be removed. Notifications drop "an agent is waiting on you". A deployment no longer trusts a pull request with secrets just because g1t's agent opened it, and the sign-up card no longer promises a team of agents.87import { mentionedHandles, mentionsColumn } from "./mentions.ts";
88import { messageBody, meterDay, pageOf, pageSize } from "./messages.ts";
Chat and workspace agents: channels, DMs and named agents you talk to89import { GENERAL, MAX_DM_MEMBERS, channelName, dmKey, dmMembers } from "./names.ts";
90import { ROOM_MEMBER_HEADER, type ChannelRoom, type RoomMember } from "./room.ts";
Chat controls, public profiles, shadcn selects, and no Docs tab in a project91import { mayCreateChannel, mayManageChannel, permissionsFor, rowFor, settingsChange, settingsOf, type SettingsRow } from "./settings.ts";
Chat and workspace agents: channels, DMs and named agents you talk to92import { dmTitle, sidebarOrder, tally, type UnreadRow } from "./unread.ts";
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)93// Live notifications and counts (services/notify).
94import { notifyMessage, notifyMuted, notifyRead } from "./notify.ts";
Chat and workspace agents: channels, DMs and named agents you talk to95
96export { ChannelRoom } from "./room.ts";
97
98type Env = {
99 DB: D1Database;
100 IDENTITY: ServiceBinding;
101 ROOMS: DurableObjectNamespace<ChannelRoom>;
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)102 /** The avatars namespace: custom emoji images, under `emoji/<sha256>`, which the usercontent origin serves. */
103 AVATARS: KVNamespace;
104 /** Live notifications and unread counts (services/notify); absent, nobody is told. */
105 NOTIFY?: ServiceBinding;
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.106 /** Ask (services/integrations): @g1t's answers, which the channel's room asks for. */
107 INTEGRATIONS?: ServiceBinding;
Chat and workspace agents: channels, DMs and named agents you talk to108};
109
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)110type EmojiRow = {
111 workspace_id: string;
112 name: string;
113 alias_of: string | null;
114 file: string;
115 content_type: CustomEmoji["content_type"];
116 bytes: number;
117 created_by: string;
118 created_at: string;
119 deleted_at: string | null;
120};
121
122/** The viewer's role in a workspace, or null when they are not in it. */
123function roleOf(viewer: Viewer, workspace: string): "owner" | "member" | null {
124 return viewer?.workspaces?.find((m) => m.slug === workspace.toLowerCase())?.role ?? null;
125}
126
127async function sha256(bytes: Uint8Array): Promise<string> {
128 const digest = await crypto.subtle.digest("SHA-256", bytes);
129 return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
130}
131
Chat and workspace agents: channels, DMs and named agents you talk to132type ChannelRow = {
133 id: string;
134 workspace_id: string;
135 kind: "channel" | "dm";
136 name: string | null;
137 topic: string | null;
138 private: number;
139 dm_key: string | null;
140 created_by: string;
141 created_at: string;
142 archived_at: string | null;
143 last_message_at: string | null;
144};
145
146type MemberRow = {
147 channel_id: string;
148 principal: string;
149 role: "owner" | "member";
150 starred: number;
151 muted: number;
152 last_read_id: string | null;
153 joined_at: string;
154};
155
156type MessageRow = {
157 id: string;
158 channel_id: string;
159 author: string;
160 kind: "text" | "card";
161 body: string;
162 card: string | null;
163 mentions: string;
164 thread_root: string | null;
165 reply_count: number;
166 last_reply_at: string | null;
167 created_at: string;
168 edited_at: string | null;
169 deleted_at: string | null;
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.170 /** On @g1t's answers, questions asked from a page and new-conversation marks (src/g1t.ts `MessageMeta`). */
171 meta: string | null;
Chat and workspace agents: channels, DMs and named agents you talk to172};
173
Chat, artifacts and notifications no longer serve g1t's own agents. Direct messages and conversation invites take people only, and a space or folio is shared with people and teams. Chat stops waking agents, drops the RPCs agents posted, read, searched and handed off through, and stops answering buttons on agent cards; old cards keep their links. An agent's old messages still show, under its saved name or as "A deleted agent". Artifacts drops the folio and docs RPCs agents read and edited pages with, and an old agent member of a space can still be removed. Notifications drop "an agent is waiting on you". A deployment no longer trusts a pull request with secrets just because g1t's agent opened it, and the sign-up card no longer promises a team of agents.174/**
175 * An agent deleted for good, as `former_agents` keeps it (migration 0004).
176 * Workspaces no longer have agents; their old messages show under the name
177 * kept here, else as a deleted agent.
178 */
An archived agent can be found, brought back, or deleted for good. The Agents page ends with Archived, folded, where owners see every archived agent and members their own personal ones, each with when and by whom it was archived and what deleting it would take; Restore brings it back, under a new handle when a live agent has taken its old one, and Delete permanently asks for the handle to be typed and then removes its sessions, replies, memories, routines, versions, drafts, skills and its computer's home, leaving its messages in chat under its saved name, the workspace's spend totals and the audit log. In Chat an archived agent is deactivated the way people expect: never offered as someone to message or mention, never woken, a conversation with it kept in the list, dimmed and marked Archived, open to read with a note in place of the composer and a way to restore it for owners; after deletion the conversation still reads, its messages marked as a deleted agent's. The agents, chat and agent budgets guides say so.179type FormerAgentRow = { agent_id: string; workspace_id: string; handle: string; display_name: string; avatar_seed: string | null; look: string | null; deleted_at: string };
180
Chat and workspace agents: channels, DMs and named agents you talk to181/** What a method found out about the channel it was asked about. */
182type Place = { slug: string; workspace: Workspace; channel: ChannelRow; member: MemberRow | null };
183
184/** The most unread messages one sidebar reads to count; past it, counts are "at least". */
185const MAX_UNREAD_ROWS = 5_000;
186/** The longest channel topic. */
187const MAX_TOPIC = 250;
188/** How many others a direct message's sidebar entry shows. */
189const DM_FACES = 4;
190
191const now = () => new Date().toISOString();
192
193function isMember(viewer: Viewer, workspace: string): boolean {
194 return !!viewer?.workspaces?.some((m) => m.slug === workspace.toLowerCase());
195}
196
197function isOwner(viewer: Viewer, workspace: string): boolean {
198 return !!viewer?.workspaces?.some((m) => m.slug === workspace.toLowerCase() && m.role === "owner");
199}
200
201function userKey(viewer: User): string {
202 return principalKey({ kind: "user", id: viewer.id });
203}
204
205function toChannel(row: ChannelRow): Channel {
206 return {
207 id: row.id,
208 workspace_id: row.workspace_id,
209 kind: row.kind,
210 name: row.kind === "dm" ? null : row.name,
211 topic: row.topic,
212 private: row.kind === "dm" || !!row.private,
213 created_by: parsePrincipalKey(row.created_by) ?? { kind: "user", id: row.created_by },
214 created_at: row.created_at,
215 archived_at: row.archived_at,
216 last_message_at: row.last_message_at,
217 };
218}
219
220function bytesOf(text: string): number {
221 return new TextEncoder().encode(text).length;
222}
223
224class Chat {
225 private readonly workspaces = new Map<string, Promise<Workspace | null>>();
226 private readonly people = new Map<string, Promise<Map<string, Member>>>();
227 private readonly usernames = new Map<string, string>();
An archived agent can be found, brought back, or deleted for good. The Agents page ends with Archived, folded, where owners see every archived agent and members their own personal ones, each with when and by whom it was archived and what deleting it would take; Restore brings it back, under a new handle when a live agent has taken its old one, and Delete permanently asks for the handle to be typed and then removes its sessions, replies, memories, routines, versions, drafts, skills and its computer's home, leaving its messages in chat under its saved name, the workspace's spend totals and the audit log. In Chat an archived agent is deactivated the way people expect: never offered as someone to message or mention, never woken, a conversation with it kept in the list, dimmed and marked Archived, open to read with a note in place of the composer and a way to restore it for owners; after deletion the conversation still reads, its messages marked as a deleted agent's. The agents, chat and agent budgets guides say so.228 /** Agents deleted for good, by id, from `former_agents`; null for one chat never heard of. */
229 private readonly former = new Map<string, FormerAgentRow | null>();
Chat controls, public profiles, shadcn selects, and no Docs tab in a project230 private readonly settingsRows = new Map<string, Promise<SettingsRow | null>>();
Chat and workspace agents: channels, DMs and named agents you talk to231
232 /** `defer` runs work after the answer is sent: the request's waitUntil. */
233 constructor(
234 private readonly env: Env,
235 private readonly defer: (work: Promise<unknown>) => void = () => {},
236 ) {}
237
238 private get db() {
239 return this.env.DB;
240 }
241
242 // ── Who and where ───────────────────────────────────────────────────────
243
244 private workspace(slug: string): Promise<Workspace | null> {
245 const key = slug.toLowerCase();
246 let found = this.workspaces.get(key);
247 if (!found) {
248 found = identityClient(this.env.IDENTITY).getWorkspace(key);
249 this.workspaces.set(key, found);
250 }
251 return found;
252 }
253
254 /** The workspace's people by username, with their names and avatars; asked once per request. */
255 private members(slug: string, workspace: Workspace): Promise<Map<string, Member>> {
256 let found = this.people.get(workspace.id);
257 if (!found) {
Chat, artifacts and notifications no longer serve g1t's own agents. Direct messages and conversation invites take people only, and a space or folio is shared with people and teams. Chat stops waking agents, drops the RPCs agents posted, read, searched and handed off through, and stops answering buttons on agent cards; old cards keep their links. An agent's old messages still show, under its saved name or as "A deleted agent". Artifacts drops the folio and docs RPCs agents read and edited pages with, and an old agent member of a space can still be removed. Notifications drop "an agent is waiting on you". A deployment no longer trusts a pull request with secrets just because g1t's agent opened it, and the sign-up card no longer promises a team of agents.258 // Asked as the workspace itself.
Chat and workspace agents: channels, DMs and named agents you talk to259 const actor: User = {
260 id: workspace.id,
261 username: workspace.slug,
262 kind: "workspace",
263 verified: true,
264 workspaces: [{ slug: workspace.slug, role: "member" }],
265 };
266 found = identityClient(this.env.IDENTITY)
267 .listMembers(slug, actor)
268 .then((result) => new Map(result.ok ? result.value.map((m) => [m.username, m]) : []))
269 .catch((error) => {
270 console.error("chat could not list members of", slug, error);
271 return new Map<string, Member>();
272 });
273 this.people.set(workspace.id, found);
274 }
275 return found;
276 }
277
An archived agent can be found, brought back, or deleted for good. The Agents page ends with Archived, folded, where owners see every archived agent and members their own personal ones, each with when and by whom it was archived and what deleting it would take; Restore brings it back, under a new handle when a live agent has taken its old one, and Delete permanently asks for the handle to be typed and then removes its sessions, replies, memories, routines, versions, drafts, skills and its computer's home, leaving its messages in chat under its saved name, the workspace's spend totals and the audit log. In Chat an archived agent is deactivated the way people expect: never offered as someone to message or mention, never woken, a conversation with it kept in the list, dimmed and marked Archived, open to read with a note in place of the composer and a way to restore it for owners; after deletion the conversation still reads, its messages marked as a deleted agent's. The agents, chat and agent budgets guides say so.278 /**
Chat, artifacts and notifications no longer serve g1t's own agents. Direct messages and conversation invites take people only, and a space or folio is shared with people and teams. Chat stops waking agents, drops the RPCs agents posted, read, searched and handed off through, and stops answering buttons on agent cards; old cards keep their links. An agent's old messages still show, under its saved name or as "A deleted agent". Artifacts drops the folio and docs RPCs agents read and edited pages with, and an old agent member of a space can still be removed. Notifications drop "an agent is waiting on you". A deployment no longer trusts a pull request with secrets just because g1t's agent opened it, and the sign-up card no longer promises a team of agents.279 * Agents of the past, by id, as `former_agents` remembers them: the
280 * name and face their messages still show under. Once per request.
An archived agent can be found, brought back, or deleted for good. The Agents page ends with Archived, folded, where owners see every archived agent and members their own personal ones, each with when and by whom it was archived and what deleting it would take; Restore brings it back, under a new handle when a live agent has taken its old one, and Delete permanently asks for the handle to be typed and then removes its sessions, replies, memories, routines, versions, drafts, skills and its computer's home, leaving its messages in chat under its saved name, the workspace's spend totals and the audit log. In Chat an archived agent is deactivated the way people expect: never offered as someone to message or mention, never woken, a conversation with it kept in the list, dimmed and marked Archived, open to read with a note in place of the composer and a way to restore it for owners; after deletion the conversation still reads, its messages marked as a deleted agent's. The agents, chat and agent budgets guides say so.281 */
282 private async formerAgents(ids: string[]): Promise<Map<string, FormerAgentRow>> {
283 const wanted = [...new Set(ids)].filter((id) => !this.former.has(id));
284 if (wanted.length) {
285 const rows = await this.db
286 .prepare(`SELECT * FROM former_agents WHERE agent_id IN (${wanted.map(() => "?").join(", ")})`)
287 .bind(...wanted)
288 .all<FormerAgentRow>()
289 .catch((error: unknown) => {
290 console.error("chat could not read former agents", error);
291 return { results: [] as FormerAgentRow[] };
292 });
293 for (const id of wanted) this.former.set(id, rows.results.find((row) => row.agent_id === id) ?? null);
294 }
295 const out = new Map<string, FormerAgentRow>();
296 for (const id of ids) {
297 const row = this.former.get(id);
298 if (row) out.set(id, row);
299 }
300 return out;
301 }
302
303 /**
Chat, artifacts and notifications no longer serve g1t's own agents. Direct messages and conversation invites take people only, and a space or folio is shared with people and teams. Chat stops waking agents, drops the RPCs agents posted, read, searched and handed off through, and stops answering buttons on agent cards; old cards keep their links. An agent's old messages still show, under its saved name or as "A deleted agent". Artifacts drops the folio and docs RPCs agents read and edited pages with, and an old agent member of a space can still be removed. Notifications drop "an agent is waiting on you". A deployment no longer trusts a pull request with secrets just because g1t's agent opened it, and the sign-up card no longer promises a team of agents.304 * How each member key shows, for one workspace. An agent of the past
305 * shows under the name it had, else as a deleted agent, marked `deleted`.
An archived agent can be found, brought back, or deleted for good. The Agents page ends with Archived, folded, where owners see every archived agent and members their own personal ones, each with when and by whom it was archived and what deleting it would take; Restore brings it back, under a new handle when a live agent has taken its old one, and Delete permanently asks for the handle to be typed and then removes its sessions, replies, memories, routines, versions, drafts, skills and its computer's home, leaving its messages in chat under its saved name, the workspace's spend totals and the audit log. In Chat an archived agent is deactivated the way people expect: never offered as someone to message or mention, never woken, a conversation with it kept in the list, dimmed and marked Archived, open to read with a note in place of the composer and a way to restore it for owners; after deletion the conversation still reads, its messages marked as a deleted agent's. The agents, chat and agent budgets guides say so.306 */
Chat and workspace agents: channels, DMs and named agents you talk to307 private async profiles(slug: string, workspace: Workspace, keys: string[]): Promise<Map<string, MemberProfile>> {
308 const principals = [...new Set(keys)].map((key) => parsePrincipalKey(key)).filter((p): p is Principal => !!p);
309 const userIds = principals.filter((p) => p.kind === "user").map((p) => p.id);
310 const agentIds = principals.filter((p) => p.kind === "agent").map((p) => p.id);
311 const unnamed = userIds.filter((id) => !this.usernames.has(id));
Chat, artifacts and notifications no longer serve g1t's own agents. Direct messages and conversation invites take people only, and a space or folio is shared with people and teams. Chat stops waking agents, drops the RPCs agents posted, read, searched and handed off through, and stops answering buttons on agent cards; old cards keep their links. An agent's old messages still show, under its saved name or as "A deleted agent". Artifacts drops the folio and docs RPCs agents read and edited pages with, and an old agent member of a space can still be removed. Notifications drop "an agent is waiting on you". A deployment no longer trusts a pull request with secrets just because g1t's agent opened it, and the sign-up card no longer promises a team of agents.312 const [named, people, former] = await Promise.all([
Chat and workspace agents: channels, DMs and named agents you talk to313 unnamed.length ? identityClient(this.env.IDENTITY).usernames(unnamed).catch(() => ({}) as Record<string, string>) : ({} as Record<string, string>),
314 userIds.length ? this.members(slug, workspace) : new Map<string, Member>(),
Chat, artifacts and notifications no longer serve g1t's own agents. Direct messages and conversation invites take people only, and a space or folio is shared with people and teams. Chat stops waking agents, drops the RPCs agents posted, read, searched and handed off through, and stops answering buttons on agent cards; old cards keep their links. An agent's old messages still show, under its saved name or as "A deleted agent". Artifacts drops the folio and docs RPCs agents read and edited pages with, and an old agent member of a space can still be removed. Notifications drop "an agent is waiting on you". A deployment no longer trusts a pull request with secrets just because g1t's agent opened it, and the sign-up card no longer promises a team of agents.315 agentIds.length ? this.formerAgents(agentIds) : new Map<string, FormerAgentRow>(),
Chat and workspace agents: channels, DMs and named agents you talk to316 ]);
317 for (const [id, username] of Object.entries(named)) this.usernames.set(id, username);
318 const out = new Map<string, MemberProfile>();
319 for (const p of principals) {
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.320 if (p.kind === "system") {
321 // @g1t: the one system member there is.
322 out.set(principalKey(p), G1T_PROFILE);
323 } else if (p.kind === "user") {
Chat and workspace agents: channels, DMs and named agents you talk to324 const username = this.usernames.get(p.id) ?? null;
325 const person = username ? people.get(username) : undefined;
326 out.set(principalKey(p), {
327 ...p,
328 name: username ?? "ghost",
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar329 display_username: person?.display_username ?? null,
330 // Their display name, else their username as they wrote it
331 // (`memberName` reads this the same way).
332 display_name: person?.name?.trim() || person?.display_username || username || "Former member",
Chat and workspace agents: channels, DMs and named agents you talk to333 avatar: person?.avatar ?? null,
334 role: 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)335 title: null,
336 avatar_seed: null,
Chat and workspace agents: channels, DMs and named agents you talk to337 });
338 } else {
Chat, artifacts and notifications no longer serve g1t's own agents. Direct messages and conversation invites take people only, and a space or folio is shared with people and teams. Chat stops waking agents, drops the RPCs agents posted, read, searched and handed off through, and stops answering buttons on agent cards; old cards keep their links. An agent's old messages still show, under its saved name or as "A deleted agent". Artifacts drops the folio and docs RPCs agents read and edited pages with, and an old agent member of a space can still be removed. Notifications drop "an agent is waiting on you". A deployment no longer trusts a pull request with secrets just because g1t's agent opened it, and the sign-up card no longer promises a team of agents.339 const was = former.get(p.id) ?? null;
Chat and workspace agents: channels, DMs and named agents you talk to340 out.set(principalKey(p), {
341 ...p,
Chat, artifacts and notifications no longer serve g1t's own agents. Direct messages and conversation invites take people only, and a space or folio is shared with people and teams. Chat stops waking agents, drops the RPCs agents posted, read, searched and handed off through, and stops answering buttons on agent cards; old cards keep their links. An agent's old messages still show, under its saved name or as "A deleted agent". Artifacts drops the folio and docs RPCs agents read and edited pages with, and an old agent member of a space can still be removed. Notifications drop "an agent is waiting on you". A deployment no longer trusts a pull request with secrets just because g1t's agent opened it, and the sign-up card no longer promises a team of agents.342 name: was?.handle ?? p.id,
343 // Never blank: an old agent's messages show under the name it had, else as a deleted agent.
344 display_name: was?.display_name ?? "A deleted agent",
345 avatar: null,
346 role: null,
347 title: null,
348 avatar_seed: was?.avatar_seed ?? null,
349 archived_at: null,
350 archived_by: null,
351 deleted: true,
Chat and workspace agents: channels, DMs and named agents you talk to352 });
353 }
354 }
355 return out;
356 }
357
358 private async profile(slug: string, workspace: Workspace, key: string): Promise<MemberProfile> {
359 return (await this.profiles(slug, workspace, [key])).get(key)!;
360 }
361
Chat, artifacts and notifications no longer serve g1t's own agents. Direct messages and conversation invites take people only, and a space or folio is shared with people and teams. Chat stops waking agents, drops the RPCs agents posted, read, searched and handed off through, and stops answering buttons on agent cards; old cards keep their links. An agent's old messages still show, under its saved name or as "A deleted agent". Artifacts drops the folio and docs RPCs agents read and edited pages with, and an old agent member of a space can still be removed. Notifications drop "an agent is waiting on you". A deployment no longer trusts a pull request with secrets just because g1t's agent opened it, and the sign-up card no longer promises a team of agents.362 /** Whether `principal` may be added to a conversation in this workspace: people of it only. */
Chat and workspace agents: channels, DMs and named agents you talk to363 private async belongs(slug: string, workspace: Workspace, principal: Principal): Promise<boolean> {
Chat, artifacts and notifications no longer serve g1t's own agents. Direct messages and conversation invites take people only, and a space or folio is shared with people and teams. Chat stops waking agents, drops the RPCs agents posted, read, searched and handed off through, and stops answering buttons on agent cards; old cards keep their links. An agent's old messages still show, under its saved name or as "A deleted agent". Artifacts drops the folio and docs RPCs agents read and edited pages with, and an old agent member of a space can still be removed. Notifications drop "an agent is waiting on you". A deployment no longer trusts a pull request with secrets just because g1t's agent opened it, and the sign-up card no longer promises a team of agents.364 if (principal.kind !== "user") return false;
Chat and workspace agents: channels, DMs and named agents you talk to365 if (!this.usernames.has(principal.id)) {
366 const named = await identityClient(this.env.IDENTITY).usernames([principal.id]);
367 for (const [id, username] of Object.entries(named)) this.usernames.set(id, username);
368 }
369 const username = this.usernames.get(principal.id);
370 return !!username && (await this.members(slug, workspace)).has(username);
371 }
372
373 /**
374 * The viewer's workspace, checked: they must belong to it, as in every
375 * other service.
376 */
377 private async viewerWorkspace(slug: string, viewer: Viewer): Promise<Result<Workspace>> {
378 if (!viewer) return fail("unauthenticated", "Sign in to use chat.");
379 if (!slug || !isMember(viewer, slug)) return fail("forbidden", "Only members of a workspace can use its chat.");
380 const workspace = await this.workspace(slug);
381 return workspace ? ok(workspace) : fail("not_found", "No such workspace.");
382 }
383
384 /**
385 * A channel the viewer may read (`read`: any public one in their
386 * workspace, or one they are in) or write in (`member`: one they are in).
387 * A private channel or direct message they are not in is not found, so
388 * its existence does not leak.
389 */
390 private async place(
391 slug: string,
392 channelId: string,
393 viewer: Viewer,
394 need: "read" | "member",
395 ): Promise<Result<Place>> {
396 const found = await this.viewerWorkspace(slug, viewer);
397 if (!found.ok) return found;
398 const workspace = found.value;
399 const [channel, member] = await Promise.all([
400 this.db
401 .prepare("SELECT * FROM channels WHERE id = ? AND workspace_id = ?")
402 .bind(String(channelId ?? ""), workspace.id)
403 .first<ChannelRow>(),
404 this.db
405 .prepare("SELECT * FROM channel_members WHERE channel_id = ? AND principal = ?")
406 .bind(String(channelId ?? ""), userKey(viewer!))
407 .first<MemberRow>(),
408 ]);
409 if (!channel) return fail("not_found", "No such channel.");
410 const open = channel.kind === "channel" && !channel.private;
411 if (!member && !open) return fail("not_found", "No such channel.");
412 if (!member && need === "member") return fail("forbidden", `Join #${channel.name} first.`);
413 return ok({ slug: slug.toLowerCase(), workspace, channel, member });
414 }
415
416 private room(channelId: string) {
417 return this.env.ROOMS.get(this.env.ROOMS.idFromName(channelId));
418 }
419
420 /** Tells everyone looking at a channel, after the answer is sent. */
421 private broadcast(channelId: string, event: ChatLiveEvent, except: string | null = null): void {
422 this.defer(
423 this.room(channelId)
424 .broadcast(event, except)
425 .catch((error: unknown) => console.error("chat could not broadcast to", channelId, error)),
426 );
427 }
428
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)429 /**
430 * Messages as they go out, with their reactions. `me` (a member key) is
431 * the viewer an answer is for; null for what everyone in a room gets,
432 * where no reaction is anyone's own.
433 */
434 private async toMessages(slug: string, workspace: Workspace, rows: MessageRow[], me: string | null = null): Promise<ChatMessage[]> {
435 const reactions = await this.reactionsOf(rows.filter((r) => !r.deleted_at).map((r) => r.id), me);
436 const reactors = [...reactions.values()].flatMap((list) => list.flatMap((r) => r.by));
437 const profiles = await this.profiles(slug, workspace, [...rows.map((r) => r.author), ...reactors]);
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.438 const at = Date.now();
Chat and workspace agents: channels, DMs and named agents you talk to439 return rows.map((row) => {
440 const gone = !!row.deleted_at;
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.441 // @g1t's answers, questions asked from a page, new-conversation marks (src/g1t.ts).
442 const meta = gone ? {} : parseMeta(row.meta);
443 const extra: Pick<ChatMessage, "answer" | "page" | "reset"> = {};
444 if (meta.answer) extra.answer = shownAnswer(meta.answer, row.created_at, at);
445 if (meta.page) extra.page = meta.page;
446 if (meta.reset) extra.reset = true;
Chat and workspace agents: channels, DMs and named agents you talk to447 return {
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.448 ...extra,
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)449 reactions: gone
450 ? []
451 : (reactions.get(row.id) ?? []).map((r) => ({ ...r, by: r.by.map((key) => profiles.get(key)!).filter(Boolean) })),
Chat and workspace agents: channels, DMs and named agents you talk to452 id: row.id,
453 channel_id: row.channel_id,
454 author: profiles.get(row.author)!,
455 kind: row.kind,
456 body: gone ? "" : row.body,
Chat, artifacts and notifications no longer serve g1t's own agents. Direct messages and conversation invites take people only, and a space or folio is shared with people and teams. Chat stops waking agents, drops the RPCs agents posted, read, searched and handed off through, and stops answering buttons on agent cards; old cards keep their links. An agent's old messages still show, under its saved name or as "A deleted agent". Artifacts drops the folio and docs RPCs agents read and edited pages with, and an old agent member of a space can still be removed. Notifications drop "an agent is waiting on you". A deployment no longer trusts a pull request with secrets just because g1t's agent opened it, and the sign-up card no longer promises a team of agents.457 card: gone || !row.card ? null : shownCard(JSON.parse(row.card) as MessageCard),
Chat and workspace agents: channels, DMs and named agents you talk to458 thread_root: row.thread_root,
459 reply_count: row.reply_count,
460 last_reply_at: row.last_reply_at,
461 created_at: row.created_at,
462 edited_at: row.edited_at,
463 deleted_at: row.deleted_at,
464 };
465 });
466 }
467
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)468 /** Reactions on these messages, counted (src/emoji.ts). One read, by the reactions table's key. */
469 private async reactionsOf(ids: string[], me: string | null): Promise<Map<string, ReactionTally[]>> {
470 if (!ids.length) return new Map();
471 const rows = await this.db
472 .prepare(
473 "SELECT message_id, emoji, principal, created_at FROM reactions WHERE message_id IN (SELECT value FROM json_each(?))",
474 )
475 .bind(JSON.stringify(ids))
476 .all<ReactionRow>();
477 return tallyReactions(rows.results, me);
478 }
479
Chat and workspace agents: channels, DMs and named agents you talk to480 private async messageRow(channelId: string, id: string): Promise<MessageRow | null> {
481 return this.db.prepare("SELECT * FROM messages WHERE id = ? AND channel_id = ?").bind(String(id ?? ""), channelId).first<MessageRow>();
482 }
483
484 /** Sends a message as it now is to everyone looking at its channel. */
485 private rebroadcast(place: Place, id: string): void {
486 this.defer(
487 (async () => {
488 const row = await this.messageRow(place.channel.id, id);
489 if (!row) return;
490 const [message] = await this.toMessages(place.slug, place.workspace, [row]);
491 await this.room(place.channel.id).broadcast({ type: "message.updated", message });
492 })().catch((error) => console.error("chat could not rebroadcast", id, error)),
493 );
494 }
495
496 // ── The sidebar ─────────────────────────────────────────────────────────
497
498 /**
499 * Puts a person in the workspace's #general, once. A new workspace has
500 * no channels, so the first sidebar anyone in it asks for creates
501 * #general; and everyone who asks for the sidebar is put in it the first
502 * time, so a new workspace has somewhere to talk and a new member lands
503 * where everyone is. Someone who leaves it is not put back
504 * (`general_joined`). A private channel someone named `general` is never
505 * joined this way.
506 */
507 private async ensureGeneral(workspace: Workspace, me: string): Promise<void> {
508 const seen = await this.db
509 .prepare("SELECT 1 FROM general_joined WHERE workspace_id = ? AND principal = ?")
510 .bind(workspace.id, me)
511 .first();
512 if (seen) return;
513 const at = now();
514 await this.db
515 .prepare(
516 "INSERT OR IGNORE INTO channels (id, workspace_id, kind, name, topic, private, created_by, created_at) VALUES (?, ?, 'channel', ?, ?, 0, ?, ?)",
517 )
518 .bind(newId("chn"), workspace.id, GENERAL, "Anything and everything for the whole workspace.", me, at)
519 .run();
520 const general = await this.db
521 .prepare("SELECT * FROM channels WHERE workspace_id = ? AND name = ?")
522 .bind(workspace.id, GENERAL)
523 .first<ChannelRow>();
524 const statements = [
525 this.db
526 .prepare("INSERT OR IGNORE INTO general_joined (workspace_id, principal, joined_at) VALUES (?, ?, ?)")
527 .bind(workspace.id, me, at),
528 ];
Chat controls, public profiles, shadcn selects, and no Docs tab in a project529 // The workspace's default channels (#general unless its owners chose
530 // others): public and not archived only, whatever was kept.
531 const settings = await this.settings(workspace, general?.id ?? null);
532 const defaults = settings.default_channels.length
533 ? await this.db
534 .prepare(
535 "SELECT * FROM channels WHERE workspace_id = ? AND kind = 'channel' AND private = 0 AND archived_at IS NULL AND id IN (SELECT value FROM json_each(?))",
536 )
537 .bind(workspace.id, JSON.stringify(settings.default_channels))
538 .all<ChannelRow>()
539 : { results: [] as ChannelRow[] };
540 for (const channel of defaults.results) {
541 statements.push(this.joinStatement(channel.id, me, channel.created_by === me ? "owner" : "member", at));
Chat and workspace agents: channels, DMs and named agents you talk to542 }
543 await this.db.batch(statements);
544 }
545
546 /**
547 * Adds a member. Someone joining starts with everything already said
548 * read, so a long channel does not greet them with its whole history as
549 * unread.
550 */
551 private joinStatement(channelId: string, principal: string, role: "owner" | "member", at: string): D1PreparedStatement {
552 return this.db
553 .prepare(
554 "INSERT OR IGNORE INTO channel_members (channel_id, principal, role, last_read_id, joined_at) VALUES (?1, ?2, ?3, (SELECT MAX(id) FROM messages WHERE channel_id = ?1), ?4)",
555 )
556 .bind(channelId, principal, role, at);
557 }
558
559 async sidebar(a: { workspace: string; viewer: Viewer }): Promise<Result<ChatSidebar>> {
560 const found = await this.viewerWorkspace(a.workspace, a.viewer);
561 if (!found.ok) return found;
562 const workspace = found.value;
563 const slug = a.workspace.toLowerCase();
564 const me = userKey(a.viewer!);
565 await this.ensureGeneral(workspace, me);
566
567 const [joined, unread, dmOthers, browsable] = await Promise.all([
568 this.db
569 .prepare(
570 `SELECT c.*, m.starred, m.muted, m.last_read_id
571 FROM channel_members m JOIN channels c ON c.id = m.channel_id
572 WHERE m.principal = ? AND c.workspace_id = ? AND c.archived_at IS NULL`,
573 )
574 .bind(me, workspace.id)
575 .all<ChannelRow & { starred: number; muted: number; last_read_id: string | null }>(),
576 this.db
577 .prepare(
578 `SELECT msg.channel_id, msg.id, msg.author, msg.mentions
579 FROM channel_members m
580 JOIN channels c ON c.id = m.channel_id
581 JOIN messages msg ON msg.channel_id = m.channel_id AND msg.id > COALESCE(m.last_read_id, '')
582 WHERE m.principal = ?1 AND c.workspace_id = ?2 AND c.archived_at IS NULL
583 AND msg.deleted_at IS NULL AND msg.author != ?1
584 LIMIT ${MAX_UNREAD_ROWS}`,
585 )
586 .bind(me, workspace.id)
587 .all<UnreadRow>(),
588 this.db
589 .prepare(
590 `SELECT o.channel_id, o.principal
591 FROM channel_members m
592 JOIN channels c ON c.id = m.channel_id AND c.kind = 'dm'
593 JOIN channel_members o ON o.channel_id = m.channel_id AND o.principal != m.principal
594 WHERE m.principal = ? AND c.workspace_id = ? AND c.archived_at IS NULL
595 ORDER BY o.joined_at, o.principal`,
596 )
597 .bind(me, workspace.id)
598 .all<{ channel_id: string; principal: string }>(),
599 this.db
600 .prepare(
601 `SELECT COUNT(*) AS n FROM channels c
602 WHERE c.workspace_id = ? AND c.kind = 'channel' AND c.private = 0 AND c.archived_at IS NULL
603 AND NOT EXISTS (SELECT 1 FROM channel_members m WHERE m.channel_id = c.id AND m.principal = ?)`,
604 )
605 .bind(workspace.id, me)
606 .first<{ n: number }>(),
607 ]);
608
609 const others = new Map<string, string[]>();
610 for (const row of dmOthers.results) others.set(row.channel_id, [...(others.get(row.channel_id) ?? []), row.principal]);
611 const profiles = await this.profiles(slug, workspace, [me, ...dmOthers.results.map((r) => r.principal)]);
612 const self = profiles.get(me) ?? null;
613 const counts = tally(
614 unread.results,
615 me,
616 self?.name ?? a.viewer!.username,
617 new Map(joined.results.map((row) => [row.id, row.last_read_id])),
618 );
619
620 const entries: ChatSidebarEntry[] = joined.results.map((row) => {
621 const faces = (others.get(row.id) ?? []).map((key) => profiles.get(key)!).filter(Boolean);
622 const count = counts.get(row.id) ?? { unread: 0, mentions: 0 };
623 return {
624 channel: toChannel(row),
625 title: row.kind === "dm" ? dmTitle(faces, self) : (row.name ?? ""),
626 others: row.kind === "dm" ? faces.slice(0, DM_FACES) : [],
627 starred: !!row.starred,
628 muted: !!row.muted,
629 unread: count.unread,
630 mentions: count.mentions,
631 };
632 });
Chat controls, public profiles, shadcn selects, and no Docs tab in a project633 const settings = await this.settings(workspace);
634 return ok({ entries: sidebarOrder(entries), browsable: browsable?.n ?? 0, can: permissionsFor(settings, roleOf(a.viewer, slug)) });
Chat and workspace agents: channels, DMs and named agents you talk to635 }
636
Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales.637 /**
638 * What was said in `[from, until)` where the viewer can read: every
639 * public channel of the workspace, and the private channels and direct
640 * messages they are in (as `place` reads). Text messages and thread
641 * replies not deleted; never a card. Message ids sort by time, so each
642 * conversation's span is one range of `messages_by_channel`, read in one
643 * round trip for them all. Counts only, nothing of what was said.
644 */
645 async activity(a: { workspace: string; viewer: Viewer; from: string; until: string }): Promise<Result<ChatActivity>> {
646 const found = await this.viewerWorkspace(a.workspace, a.viewer);
647 if (!found.ok) return found;
648 const workspace = found.value;
649 const span = activitySpan(a.from, a.until);
650 if (!span) return fail("invalid", "from and until must be RFC 3339 times, from before until.");
651 const me = userKey(a.viewer!);
652 const rows = await this.db
653 .prepare(
654 `SELECT msg.channel_id, msg.author, COUNT(*) AS n
655 FROM channels c
656 JOIN messages msg ON msg.channel_id = c.id AND msg.id >= ?3 AND msg.id < ?4
657 WHERE c.workspace_id = ?1 AND c.archived_at IS NULL
658 AND (
659 (c.kind = 'channel' AND c.private = 0)
660 OR EXISTS (SELECT 1 FROM channel_members m WHERE m.channel_id = c.id AND m.principal = ?2)
661 )
662 AND msg.deleted_at IS NULL AND msg.kind = 'text'
663 GROUP BY msg.channel_id, msg.author`,
664 )
665 .bind(workspace.id, me, idFloor("msg", span.fromMs), idFloor("msg", span.untilMs))
666 .all<ActivityRow>();
667 return ok(chatActivity(rows.results, { from: span.from, until: span.until }));
668 }
669
Chat and workspace agents: channels, DMs and named agents you talk to670 // ── Channels ────────────────────────────────────────────────────────────
671
Chat controls, public profiles, shadcn selects, and no Docs tab in a project672 async channel(a: { workspace: string; channel_id: string; viewer: Viewer }): Promise<Result<ChannelDetail>> {
Chat and workspace agents: channels, DMs and named agents you talk to673 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
674 if (!found.ok) return found;
Chat controls, public profiles, shadcn selects, and no Docs tab in a project675 const { slug, workspace, channel, member } = found.value;
676 const [rows, settings] = await Promise.all([
677 this.db
678 .prepare("SELECT * FROM channel_members WHERE channel_id = ? ORDER BY joined_at, principal")
679 .bind(channel.id)
680 .all<MemberRow>(),
681 this.settings(workspace),
682 ]);
Chat and workspace agents: channels, DMs and named agents you talk to683 const profiles = await this.profiles(slug, workspace, rows.results.map((r) => r.principal));
684 return ok({
685 channel: toChannel(channel),
Chat controls, public profiles, shadcn selects, and no Docs tab in a project686 can_manage: channel.kind === "channel" && mayManageChannel(settings, roleOf(a.viewer, slug), member?.role ?? null),
Chat and workspace agents: channels, DMs and named agents you talk to687 members: rows.results.map((row) => ({
688 channel_id: row.channel_id,
689 member: profiles.get(row.principal)!,
690 role: row.role,
691 starred: !!row.starred,
692 muted: !!row.muted,
693 last_read_id: row.last_read_id,
694 joined_at: row.joined_at,
695 })),
696 });
697 }
698
699 /** A channel by its name, as the site's URLs name them; read like `channel`. */
Chat controls, public profiles, shadcn selects, and no Docs tab in a project700 async channelByName(a: { workspace: string; name: string; viewer: Viewer }): Promise<Result<ChannelDetail>> {
Chat and workspace agents: channels, DMs and named agents you talk to701 const found = await this.viewerWorkspace(a.workspace, a.viewer);
702 if (!found.ok) return found;
703 const named = channelName(a.name ?? "");
704 if (!named.ok) return fail("not_found", "No such channel.");
705 const row = await this.db
706 .prepare("SELECT id FROM channels WHERE workspace_id = ? AND name = ?")
707 .bind(found.value.id, named.name)
708 .first<{ id: string }>();
709 if (!row) return fail("not_found", "No such channel.");
710 // A private channel the viewer is not in stays not found there.
711 return this.channel({ workspace: a.workspace, channel_id: row.id, viewer: a.viewer });
712 }
713
Chat controls, public profiles, shadcn selects, and no Docs tab in a project714 /**
715 * Every public channel, and the private ones the viewer is in; a private
716 * one they are not in stays unseen. With `archived`, the archived ones.
717 */
718 async browse(a: { workspace: string; viewer: Viewer; archived?: boolean }): Promise<Result<Channel[]>> {
Chat and workspace agents: channels, DMs and named agents you talk to719 const found = await this.viewerWorkspace(a.workspace, a.viewer);
720 if (!found.ok) return found;
721 const rows = await this.db
722 .prepare(
Chat controls, public profiles, shadcn selects, and no Docs tab in a project723 `SELECT * FROM channels c
724 WHERE c.workspace_id = ?1 AND c.kind = 'channel'
725 AND (c.archived_at IS NULL) = (?3 = 0)
726 AND (c.private = 0 OR EXISTS (SELECT 1 FROM channel_members m WHERE m.channel_id = c.id AND m.principal = ?2))
727 ORDER BY c.name`,
Chat and workspace agents: channels, DMs and named agents you talk to728 )
Chat controls, public profiles, shadcn selects, and no Docs tab in a project729 .bind(found.value.id, userKey(a.viewer!), a.archived === true ? 1 : 0)
Chat and workspace agents: channels, DMs and named agents you talk to730 .all<ChannelRow>();
731 return ok(rows.results.map(toChannel));
732 }
733
734 async createChannel(a: { workspace: string; viewer: Viewer; input: NewChannel }): Promise<Result<Channel>> {
735 const found = await this.viewerWorkspace(a.workspace, a.viewer);
736 if (!found.ok) return found;
737 const workspace = found.value;
738 const named = channelName(a.input?.name ?? "");
739 if (!named.ok) return fail("invalid", named.message);
740 const topic = typeof a.input?.topic === "string" ? a.input.topic.trim() : "";
741 if (topic.length > MAX_TOPIC) return fail("invalid", `A topic is at most ${MAX_TOPIC} characters.`);
Chat controls, public profiles, shadcn selects, and no Docs tab in a project742 const isPrivate = !!a.input?.private;
743 if (!mayCreateChannel(await this.settings(workspace), roleOf(a.viewer, a.workspace), isPrivate)) {
744 return fail("forbidden", `Only owners can create ${isPrivate ? "private" : "public"} channels in this workspace.`);
745 }
Chat and workspace agents: channels, DMs and named agents you talk to746 const taken = await this.db
747 .prepare("SELECT 1 FROM channels WHERE workspace_id = ? AND name = ?")
748 .bind(workspace.id, named.name)
749 .first();
750 if (taken) return fail("conflict", `#${named.name} already exists.`);
751 const me = userKey(a.viewer!);
752 const row: ChannelRow = {
753 id: newId("chn"),
754 workspace_id: workspace.id,
755 kind: "channel",
756 name: named.name,
757 topic: topic || null,
Chat controls, public profiles, shadcn selects, and no Docs tab in a project758 private: isPrivate ? 1 : 0,
Chat and workspace agents: channels, DMs and named agents you talk to759 dm_key: null,
760 created_by: me,
761 created_at: now(),
762 archived_at: null,
763 last_message_at: null,
764 };
765 try {
766 await this.db.batch([
767 this.db
768 .prepare(
769 "INSERT INTO channels (id, workspace_id, kind, name, topic, private, created_by, created_at) VALUES (?, ?, 'channel', ?, ?, ?, ?, ?)",
770 )
771 .bind(row.id, row.workspace_id, row.name, row.topic, row.private, me, row.created_at),
772 this.joinStatement(row.id, me, "owner", row.created_at),
773 ]);
774 } catch (error) {
775 if (String(error).includes("UNIQUE")) return fail("conflict", `#${named.name} already exists.`);
776 throw error;
777 }
778 return ok(toChannel(row));
779 }
780
781 async openDm(a: { workspace: string; viewer: Viewer; members: Principal[] }): Promise<Result<Channel>> {
782 const found = await this.viewerWorkspace(a.workspace, a.viewer);
783 if (!found.ok) return found;
784 const workspace = found.value;
785 const slug = a.workspace.toLowerCase();
786 const me = userKey(a.viewer!);
787 const asked = Array.isArray(a.members) ? a.members : [];
788 const principals: Principal[] = [];
789 for (const member of asked) {
790 const p = member && parsePrincipalKey(`${member.kind}:${member.id}`);
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.791 // A person of the workspace, or @g1t.
792 if (!p || (p.kind !== "user" && principalKey(p) !== G1T_KEY)) return fail("invalid", "Each member is a person of the workspace, by id.");
Chat and workspace agents: channels, DMs and named agents you talk to793 principals.push(p);
794 }
795 const members = dmMembers(me, principals.map(principalKey));
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.796 if (members.includes(G1T_KEY) && !g1tDmPerson(members)) {
797 return fail("invalid", "A direct message with g1t is just you and g1t.");
798 }
Chat and workspace agents: channels, DMs and named agents you talk to799 if (members.length > MAX_DM_MEMBERS) {
Chat, artifacts and notifications no longer serve g1t's own agents. Direct messages and conversation invites take people only, and a space or folio is shared with people and teams. Chat stops waking agents, drops the RPCs agents posted, read, searched and handed off through, and stops answering buttons on agent cards; old cards keep their links. An agent's old messages still show, under its saved name or as "A deleted agent". Artifacts drops the folio and docs RPCs agents read and edited pages with, and an old agent member of a space can still be removed. Notifications drop "an agent is waiting on you". A deployment no longer trusts a pull request with secrets just because g1t's agent opened it, and the sign-up card no longer promises a team of agents.800 return fail("invalid", `A direct message has at most ${MAX_DM_MEMBERS} people. Make a private channel instead.`);
Chat and workspace agents: channels, DMs and named agents you talk to801 }
802 const key = dmKey(members);
803 const existing = await this.db
804 .prepare("SELECT * FROM channels WHERE workspace_id = ? AND dm_key = ?")
805 .bind(workspace.id, key)
806 .first<ChannelRow>();
807 if (existing) return ok(toChannel(existing));
808
809 for (const member of members) {
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.810 // g1t is in every workspace.
811 if (member === me || member === G1T_KEY) continue;
Chat and workspace agents: channels, DMs and named agents you talk to812 const p = parsePrincipalKey(member)!;
813 if (!(await this.belongs(slug, workspace, p))) {
Chat, artifacts and notifications no longer serve g1t's own agents. Direct messages and conversation invites take people only, and a space or folio is shared with people and teams. Chat stops waking agents, drops the RPCs agents posted, read, searched and handed off through, and stops answering buttons on agent cards; old cards keep their links. An agent's old messages still show, under its saved name or as "A deleted agent". Artifacts drops the folio and docs RPCs agents read and edited pages with, and an old agent member of a space can still be removed. Notifications drop "an agent is waiting on you". A deployment no longer trusts a pull request with secrets just because g1t's agent opened it, and the sign-up card no longer promises a team of agents.814 return fail("not_found", "That person is not in this workspace.");
Chat and workspace agents: channels, DMs and named agents you talk to815 }
816 }
817 const at = now();
818 await this.db
819 .prepare(
820 "INSERT OR IGNORE INTO channels (id, workspace_id, kind, private, dm_key, created_by, created_at) VALUES (?, ?, 'dm', 1, ?, ?, ?)",
821 )
822 .bind(newId("chn"), workspace.id, key, me, at)
823 .run();
824 // Read back by key: if two people opened it at once, both get the one that won.
825 const channel = await this.db
826 .prepare("SELECT * FROM channels WHERE workspace_id = ? AND dm_key = ?")
827 .bind(workspace.id, key)
828 .first<ChannelRow>();
829 if (!channel) return fail("conflict", "The direct message could not be opened. Try again.");
830 await this.db.batch(members.map((member) => this.joinStatement(channel.id, member, "member", at)));
831 return ok(toChannel(channel));
832 }
833
834 async join(a: { workspace: string; channel_id: string; viewer: Viewer }): Promise<Result<null>> {
835 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
836 if (!found.ok) return found;
837 const { channel, member } = found.value;
838 if (member) return ok(null);
839 if (channel.archived_at) return fail("invalid", "This channel is archived.");
840 await this.joinStatement(channel.id, userKey(a.viewer!), "member", now()).run();
841 return ok(null);
842 }
843
844 async leave(a: { workspace: string; channel_id: string; viewer: Viewer }): Promise<Result<null>> {
845 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
846 if (!found.ok) return found;
847 const { channel, member } = found.value;
848 if (!member) return ok(null);
849 if (channel.kind === "dm") return fail("invalid", "A direct message can't be left. Mute it instead.");
850 const me = userKey(a.viewer!);
851 await this.db.prepare("DELETE FROM channel_members WHERE channel_id = ? AND principal = ?").bind(channel.id, me).run();
852 // Out of a private channel, they may no longer read it, live either.
853 if (channel.private) {
854 this.defer(this.room(channel.id).drop(me).catch((error: unknown) => console.error("chat could not drop", me, error)));
855 }
856 return ok(null);
857 }
858
859 async invite(a: { workspace: string; channel_id: string; viewer: Viewer; member: Principal }): Promise<Result<null>> {
860 const found = await this.place(a.workspace, a.channel_id, a.viewer, "member");
861 if (!found.ok) return found;
862 const { slug, workspace, channel } = found.value;
863 if (channel.kind === "dm") return fail("invalid", "People can't be added to a direct message. Start a new one with everyone in it.");
864 if (channel.archived_at) return fail("invalid", "This channel is archived.");
865 const p = a.member && parsePrincipalKey(`${a.member.kind}:${a.member.id}`);
Chat, artifacts and notifications no longer serve g1t's own agents. Direct messages and conversation invites take people only, and a space or folio is shared with people and teams. Chat stops waking agents, drops the RPCs agents posted, read, searched and handed off through, and stops answering buttons on agent cards; old cards keep their links. An agent's old messages still show, under its saved name or as "A deleted agent". Artifacts drops the folio and docs RPCs agents read and edited pages with, and an old agent member of a space can still be removed. Notifications drop "an agent is waiting on you". A deployment no longer trusts a pull request with secrets just because g1t's agent opened it, and the sign-up card no longer promises a team of agents.866 if (!p || p.kind !== "user") return fail("invalid", "Invite a person of the workspace, by id.");
867 if (!(await this.belongs(slug, workspace, p))) return fail("not_found", "That person is not in this workspace.");
Chat and workspace agents: channels, DMs and named agents you talk to868 await this.joinStatement(channel.id, principalKey(p), "member", now()).run();
869 return ok(null);
870 }
871
872 async setPreferences(a: {
873 workspace: string;
874 channel_id: string;
875 viewer: Viewer;
876 prefs: { starred?: boolean; muted?: boolean };
877 }): Promise<Result<null>> {
878 const found = await this.place(a.workspace, a.channel_id, a.viewer, "member");
879 if (!found.ok) return found;
880 const starred = typeof a.prefs?.starred === "boolean" ? (a.prefs.starred ? 1 : 0) : null;
881 const muted = typeof a.prefs?.muted === "boolean" ? (a.prefs.muted ? 1 : 0) : null;
882 await this.db
883 .prepare(
884 "UPDATE channel_members SET starred = COALESCE(?, starred), muted = COALESCE(?, muted) WHERE channel_id = ? AND principal = ?",
885 )
886 .bind(starred, muted, found.value.channel.id, userKey(a.viewer!))
887 .run();
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)888 // Notify: the badge counts the conversation again, or leaves it out, in every tab.
889 if (muted !== null) {
890 this.defer(
891 notifyMuted(this.env.NOTIFY, { slug: found.value.slug, channel_id: found.value.channel.id, user_id: a.viewer!.id, muted: !!muted }).catch((error) =>
892 console.error("chat could not notify a mute", error),
893 ),
894 );
895 }
Chat and workspace agents: channels, DMs and named agents you talk to896 return ok(null);
897 }
898
Chat controls, public profiles, shadcn selects, and no Docs tab in a project899 // ── What owners decide (src/settings.ts) ────────────────────────────────
900
901 /** The row of a workspace's chat settings, read once per request. */
902 private settingsRow(workspace: Workspace): Promise<SettingsRow | null> {
903 let found = this.settingsRows.get(workspace.id);
904 if (!found) {
905 found = this.db
906 .prepare("SELECT emoji_upload, public_channels, private_channels, manage_channels, default_channels FROM chat_settings WHERE workspace_id = ?")
907 .bind(workspace.id)
908 .first<SettingsRow>();
909 this.settingsRows.set(workspace.id, found);
910 }
911 return found;
912 }
913
914 /**
915 * A workspace's chat settings. Default channels never chosen are
916 * #general, looked up unless `generalId` is given.
917 */
918 private async settings(workspace: Workspace, generalId?: string | null): Promise<ChatSettings> {
919 const row = await this.settingsRow(workspace);
920 if (row?.default_channels != null || generalId !== undefined) return settingsOf(row, generalId ?? null);
921 const general = await this.db
922 .prepare("SELECT id FROM channels WHERE workspace_id = ? AND name = ? AND kind = 'channel'")
923 .bind(workspace.id, GENERAL)
924 .first<{ id: string }>();
925 return settingsOf(row, general?.id ?? null);
926 }
927
928 async chatSettings(a: { workspace: string; viewer: Viewer }): Promise<Result<ChatSettingsView>> {
929 const found = await this.viewerWorkspace(a.workspace, a.viewer);
930 if (!found.ok) return found;
931 const workspace = found.value;
932 const [settings, channels] = await Promise.all([
933 this.settings(workspace),
934 this.db
935 .prepare("SELECT * FROM channels WHERE workspace_id = ? AND kind = 'channel' AND private = 0 AND archived_at IS NULL ORDER BY name")
936 .bind(workspace.id)
937 .all<ChannelRow>(),
938 ]);
939 return ok({ settings, can: permissionsFor(settings, roleOf(a.viewer, a.workspace)), channels: channels.results.map(toChannel) });
940 }
941
942 async setChatSettings(a: { workspace: string; viewer: Viewer; change: Partial<ChatSettings> }): Promise<Result<ChatSettings>> {
943 const found = await this.viewerWorkspace(a.workspace, a.viewer);
944 if (!found.ok) return found;
945 const workspace = found.value;
946 if (roleOf(a.viewer, a.workspace) !== "owner") return fail("forbidden", "Only owners can change the workspace's chat settings.");
947 const checked = settingsChange(a.change);
948 if (!checked.ok) return fail("invalid", checked.message);
949 const change = checked.change;
950 if (change.default_channels) {
951 // Only the workspace's public channels that are not archived: a private
952 // one would put people somewhere they were never invited.
953 const rows = await this.db
954 .prepare(
955 "SELECT id FROM channels WHERE workspace_id = ? AND kind = 'channel' AND private = 0 AND archived_at IS NULL AND id IN (SELECT value FROM json_each(?))",
956 )
957 .bind(workspace.id, JSON.stringify(change.default_channels))
958 .all<{ id: string }>();
959 const open = new Set(rows.results.map((r) => r.id));
960 if (change.default_channels.some((id) => !open.has(id))) return fail("invalid", "Default channels must be public channels that are not archived.");
961 }
962 const next: ChatSettings = { ...(await this.settings(workspace)), ...change };
963 const row = rowFor(next);
964 await this.db
965 .prepare(
966 `INSERT INTO chat_settings (workspace_id, emoji_upload, public_channels, private_channels, manage_channels, default_channels)
967 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
968 ON CONFLICT (workspace_id) DO UPDATE SET emoji_upload = ?2, public_channels = ?3, private_channels = ?4, manage_channels = ?5, default_channels = ?6`,
969 )
970 .bind(workspace.id, row.emoji_upload, row.public_channels, row.private_channels, row.manage_channels, row.default_channels)
971 .run();
972 this.settingsRows.delete(workspace.id);
973 return ok(next);
974 }
975
976 /**
977 * Renames, archives or unarchives a channel, or changes its topic. The
978 * workspace's `manage_channels` setting says who may do the first three;
979 * any member may change the topic. #general stays #general, and stays.
980 */
981 async updateChannel(a: { workspace: string; channel_id: string; viewer: Viewer; change: ChannelChange }): Promise<Result<Channel>> {
982 // Archived or not, a channel is found the same way; a private one only by its members.
983 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
984 if (!found.ok) return found;
985 const { slug, workspace, channel, member } = found.value;
986 if (channel.kind === "dm") return fail("invalid", "A direct message has no name or topic to change.");
987 const change = a.change ?? {};
988 const next: ChannelRow = { ...channel };
989 if (change.name !== undefined || change.archived !== undefined) {
990 const settings = await this.settings(workspace);
991 if (!mayManageChannel(settings, roleOf(a.viewer, slug), member?.role ?? null)) {
992 return fail(
993 "forbidden",
994 settings.manage_channels === "owners"
995 ? "Only workspace owners can rename or archive channels here."
996 : "Only this channel's owners and workspace owners can rename or archive it.",
997 );
998 }
999 if (channel.name === GENERAL) return fail("invalid", "#general is where everyone is: it can't be renamed or archived.");
1000 }
1001 if (change.name !== undefined) {
1002 const named = channelName(String(change.name ?? ""));
1003 if (!named.ok) return fail("invalid", named.message);
1004 if (named.name !== channel.name) {
1005 const taken = await this.db
1006 .prepare("SELECT 1 FROM channels WHERE workspace_id = ? AND name = ? AND id != ?")
1007 .bind(workspace.id, named.name, channel.id)
1008 .first();
1009 if (taken || named.name === GENERAL) return fail("conflict", `#${named.name} already exists.`);
1010 }
1011 next.name = named.name;
1012 }
1013 if (change.topic !== undefined) {
1014 if (!member) return fail("forbidden", `Join #${channel.name} first.`);
1015 const topic = typeof change.topic === "string" ? change.topic.trim() : "";
1016 if (topic.length > MAX_TOPIC) return fail("invalid", `A topic is at most ${MAX_TOPIC} characters.`);
1017 next.topic = topic || null;
1018 }
1019 if (change.archived !== undefined) {
1020 next.archived_at = change.archived ? (channel.archived_at ?? now()) : null;
1021 } else if (channel.archived_at && (change.name !== undefined || change.topic !== undefined)) {
1022 return fail("invalid", "This channel is archived. Unarchive it first.");
1023 }
1024 try {
1025 await this.db
1026 .prepare("UPDATE channels SET name = ?, topic = ?, archived_at = ? WHERE id = ?")
1027 .bind(next.name, next.topic, next.archived_at, channel.id)
1028 .run();
1029 } catch (error) {
1030 if (String(error).includes("UNIQUE")) return fail("conflict", `#${next.name} already exists.`);
1031 throw error;
1032 }
1033 const updated = toChannel(next);
1034 this.broadcast(channel.id, { type: "channel.updated", channel: updated });
1035 return ok(updated);
1036 }
1037
Chat and workspace agents: channels, DMs and named agents you talk to1038 // ── Messages ────────────────────────────────────────────────────────────
1039
1040 /**
1041 * Newest first, a page at a time. With `thread_root`, that thread's
1042 * replies, and the message they reply to as the oldest once the page
1043 * reaches the start of the thread; without, the channel's top-level
1044 * messages. A deleted message stays only while replies hang off it.
1045 */
1046 async messages(a: {
1047 workspace: string;
1048 channel_id: string;
1049 viewer: Viewer;
1050 before?: string | null;
1051 after?: string | null;
1052 limit?: number | null;
1053 thread_root?: string | null;
1054 }): Promise<Result<MessagePage>> {
1055 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1056 if (!found.ok) return found;
1057 const { slug, workspace, channel } = found.value;
1058 const size = pageSize(a.limit);
1059 // Ids are lowercase letters, digits and `_`, so `~` sorts after every
1060 // one: the first page reads from the newest as a range of the index.
1061 const before = typeof a.before === "string" && a.before ? a.before : "~";
1062 const root = typeof a.thread_root === "string" && a.thread_root ? a.thread_root : null;
1063 if (typeof a.after === "string" && a.after) {
1064 // Catching up after a reconnect: what came after, oldest first. Deleted
1065 // ones too, so the client drops them; read one past the page to know
1066 // whether there is more.
1067 const newer = root
1068 ? await this.db
1069 .prepare("SELECT * FROM messages WHERE thread_root = ? AND channel_id = ? AND id > ? ORDER BY id LIMIT ?")
1070 .bind(root, channel.id, a.after, size + 1)
1071 .all<MessageRow>()
1072 : await this.db
1073 .prepare("SELECT * FROM messages WHERE channel_id = ? AND thread_root IS NULL AND id > ? ORDER BY id LIMIT ?")
1074 .bind(channel.id, a.after, size + 1)
1075 .all<MessageRow>();
1076 const page = pageOf(newer.results, size);
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)1077 return ok({ messages: await this.toMessages(slug, workspace, page.rows, userKey(a.viewer!)), older: null, newer: page.older });
Chat and workspace agents: channels, DMs and named agents you talk to1078 }
1079 const rows = root
1080 ? await this.db
1081 .prepare(
1082 `SELECT * FROM messages WHERE thread_root = ?1 AND channel_id = ?2 AND id < ?3
1083 ORDER BY id DESC LIMIT ?4`,
1084 )
1085 .bind(root, channel.id, before, size + 1)
1086 .all<MessageRow>()
1087 : await this.db
1088 .prepare(
1089 `SELECT * FROM messages
1090 WHERE channel_id = ?1 AND thread_root IS NULL AND id < ?2
1091 AND (deleted_at IS NULL OR reply_count > 0)
1092 ORDER BY id DESC LIMIT ?3`,
1093 )
1094 .bind(channel.id, before, size + 1)
1095 .all<MessageRow>();
1096 const page = pageOf(rows.results, size);
1097 let list = page.rows;
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread1098 // The message a thread is under: the oldest once the page reaches the
1099 // start, and, on the first page, as `root` however long the thread is,
Chat, artifacts and notifications no longer serve g1t's own agents. Direct messages and conversation invites take people only, and a space or folio is shared with people and teams. Chat stops waking agents, drops the RPCs agents posted, read, searched and handed off through, and stops answering buttons on agent cards; old cards keep their links. An agent's old messages still show, under its saved name or as "A deleted agent". Artifacts drops the folio and docs RPCs agents read and edited pages with, and an old agent member of a space can still be removed. Notifications drop "an agent is waiting on you". A deployment no longer trusts a pull request with secrets just because g1t's agent opened it, and the sign-up card no longer promises a team of agents.1100 // so what the thread replies to stays at the top of it.
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread1101 const firstPage = before === "~";
1102 const rootRow = root && (page.older === null || firstPage) ? await this.messageRow(channel.id, root) : null;
1103 if (rootRow && page.older === null) list = [...list, rootRow];
1104 const messages = await this.toMessages(slug, workspace, list, userKey(a.viewer!));
1105 if (!rootRow || !firstPage) return ok({ messages, older: page.older });
1106 const shown = messages.find((m) => m.id === rootRow.id) ?? (await this.toMessages(slug, workspace, [rootRow], userKey(a.viewer!)))[0] ?? null;
1107 return ok({ messages, older: page.older, root: shown });
Chat and workspace agents: channels, DMs and named agents you talk to1108 }
1109
1110 /**
1111 * Writes a message and everything that follows from it: the thread's
1112 * reply count, the channel's last activity, the author's own read mark,
Chat, artifacts and notifications no longer serve g1t's own agents. Direct messages and conversation invites take people only, and a space or folio is shared with people and teams. Chat stops waking agents, drops the RPCs agents posted, read, searched and handed off through, and stops answering buttons on agent cards; old cards keep their links. An agent's old messages still show, under its saved name or as "A deleted agent". Artifacts drops the folio and docs RPCs agents read and edited pages with, and an old agent member of a space can still be removed. Notifications drop "an agent is waiting on you". A deployment no longer trusts a pull request with secrets just because g1t's agent opened it, and the sign-up card no longer promises a team of agents.1113 * the meter; then, after answering, tells the room and notifies the
1114 * people it is for.
Chat and workspace agents: channels, DMs and named agents you talk to1115 */
1116 private async write(
1117 place: Place,
1118 author: string,
1119 input: { body: string; card: MessageCard | null; thread_root: string | null },
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.1120 options: { meta?: MessageMeta; notify?: boolean } = {},
Chat and workspace agents: channels, DMs and named agents you talk to1121 ): Promise<Result<ChatMessage>> {
1122 const { channel, workspace } = place;
1123 if (channel.archived_at) return fail("invalid", "This channel is archived.");
1124 let threadRoot: string | null = null;
1125 if (input.thread_root) {
1126 const root = await this.messageRow(channel.id, input.thread_root);
1127 if (!root || (root.deleted_at && !root.reply_count)) return fail("not_found", "No such message to reply to.");
1128 // A reply to a reply goes in the same thread.
1129 threadRoot = root.thread_root ?? root.id;
1130 }
1131 const at = now();
Chat, artifacts and notifications no longer serve g1t's own agents. Direct messages and conversation invites take people only, and a space or folio is shared with people and teams. Chat stops waking agents, drops the RPCs agents posted, read, searched and handed off through, and stops answering buttons on agent cards; old cards keep their links. An agent's old messages still show, under its saved name or as "A deleted agent". Artifacts drops the folio and docs RPCs agents read and edited pages with, and an old agent member of a space can still be removed. Notifications drop "an agent is waiting on you". A deployment no longer trusts a pull request with secrets just because g1t's agent opened it, and the sign-up card no longer promises a team of agents.1132 const body = input.body;
Merge branch 'worktree-agent-a1398e81ad1a64c5f'1133 const handles = mentionedHandles(body);
Chat and workspace agents: channels, DMs and named agents you talk to1134 const row: MessageRow = {
1135 id: newId("msg"),
1136 channel_id: channel.id,
1137 author,
1138 kind: input.card ? "card" : "text",
Merge branch 'worktree-agent-a1398e81ad1a64c5f'1139 body,
Chat and workspace agents: channels, DMs and named agents you talk to1140 card: input.card ? JSON.stringify(input.card) : null,
1141 mentions: mentionsColumn(handles),
1142 thread_root: threadRoot,
1143 reply_count: 0,
1144 last_reply_at: null,
1145 created_at: at,
1146 edited_at: null,
1147 deleted_at: null,
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.1148 meta: metaColumn(options.meta ?? {}),
Chat and workspace agents: channels, DMs and named agents you talk to1149 };
1150 const statements = [
1151 this.db
1152 .prepare(
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.1153 "INSERT INTO messages (id, channel_id, author, kind, body, card, mentions, thread_root, created_at, meta) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
Chat and workspace agents: channels, DMs and named agents you talk to1154 )
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.1155 .bind(row.id, row.channel_id, author, row.kind, row.body, row.card, row.mentions, threadRoot, at, row.meta),
Chat and workspace agents: channels, DMs and named agents you talk to1156 this.db.prepare("UPDATE channels SET last_message_at = ? WHERE id = ?").bind(at, channel.id),
1157 // What you wrote, you have read.
1158 this.db
1159 .prepare("UPDATE channel_members SET last_read_id = ? WHERE channel_id = ? AND principal = ?")
1160 .bind(row.id, channel.id, author),
1161 this.db
1162 .prepare(
1163 `INSERT INTO chat_meter (workspace_id, day, messages, bytes) VALUES (?, ?, 1, ?)
1164 ON CONFLICT (workspace_id, day) DO UPDATE SET messages = messages + 1, bytes = bytes + excluded.bytes`,
1165 )
1166 .bind(workspace.id, meterDay(at), bytesOf(row.body) + bytesOf(row.card ?? "")),
1167 ];
1168 if (threadRoot) {
1169 statements.push(
1170 this.db
1171 .prepare("UPDATE messages SET reply_count = reply_count + 1, last_reply_at = ? WHERE id = ?")
1172 .bind(at, threadRoot),
1173 );
1174 }
1175 await this.db.batch(statements);
1176
1177 const [message] = await this.toMessages(place.slug, workspace, [row]);
1178 this.broadcast(channel.id, { type: "message.created", message });
1179 if (threadRoot) this.rebroadcast(place, threadRoot);
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.1180 // An answer from @g1t notifies when it ends (src/room.ts), not while it is empty.
1181 if (options.notify === false) return ok(message);
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)1182 // Notify: counts for everyone in the conversation, a notification for those it is for.
1183 this.defer(
Chat, artifacts and notifications no longer serve g1t's own agents. Direct messages and conversation invites take people only, and a space or folio is shared with people and teams. Chat stops waking agents, drops the RPCs agents posted, read, searched and handed off through, and stops answering buttons on agent cards; old cards keep their links. An agent's old messages still show, under its saved name or as "A deleted agent". Artifacts drops the folio and docs RPCs agents read and edited pages with, and an old agent member of a space can still be removed. Notifications drop "an agent is waiting on you". A deployment no longer trusts a pull request with secrets just because g1t's agent opened it, and the sign-up card no longer promises a team of agents.1184 notifyMessage(this.db, this.env.NOTIFY, (keys) => this.profiles(place.slug, workspace, keys), { slug: place.slug, channel, row, handles }).catch(
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)1185 (error) => console.error("chat could not notify about", row.id, error),
1186 ),
1187 );
Chat and workspace agents: channels, DMs and named agents you talk to1188 return ok(message);
1189 }
1190
1191 async post(a: { workspace: string; channel_id: string; viewer: Viewer; message: PostMessage }): Promise<Result<ChatMessage>> {
1192 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1193 if (!found.ok) return found;
1194 const body = messageBody(a.message?.body);
1195 if (!body.ok) return fail("invalid", body.message);
1196 const me = userKey(a.viewer!);
1197 let place = found.value;
1198 if (!place.member) {
1199 // Saying something in a public channel joins it, as reading does not.
1200 if (place.channel.archived_at) return fail("invalid", "This channel is archived.");
1201 await this.joinStatement(place.channel.id, me, "member", now()).run();
1202 place = { ...place, member: { channel_id: place.channel.id, principal: me } as MemberRow };
1203 }
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.1204 const input = { body: body.body, card: null, thread_root: a.message?.thread_root ?? null };
1205 const members = place.channel.kind === "dm" ? await this.memberKeys(place.channel.id) : [];
1206 if (!startsAnswer({ channelKind: place.channel.kind, members, author: me, threadRoot: input.thread_root, card: false, body: input.body })) {
1207 return this.write(place, me, input);
1208 }
1209 // A question for @g1t: kept with the page it was asked from, then answered.
1210 const page = pageFrom(a.message?.page);
1211 const written = await this.write(place, me, input, { meta: page ? { page } : {} });
1212 if (written.ok) this.defer(this.startAnswer(place, a.viewer!).catch((error) => console.error("g1t could not start an answer in", place.channel.id, error)));
1213 return written;
1214 }
1215
1216 private async memberKeys(channelId: string): Promise<string[]> {
1217 const rows = await this.db.prepare("SELECT principal FROM channel_members WHERE channel_id = ?").bind(channelId).all<{ principal: string }>();
1218 return rows.results.map((row) => row.principal);
Chat and workspace agents: channels, DMs and named agents you talk to1219 }
1220
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.1221 // ── @g1t (src/g1t.ts, src/answer.ts) ────────────────────────────────────
1222
1223 /**
1224 * Starts @g1t's answer to what was last said in its direct message: an
1225 * empty message from g1t, `thinking`, which the channel's room writes the
1226 * answer into as it streams (src/room.ts `startAnswer`).
1227 */
1228 private async startAnswer(place: Place, viewer: User): Promise<void> {
1229 const recent = await this.db
1230 .prepare("SELECT id, author, kind, body, meta, deleted_at FROM messages WHERE channel_id = ? AND thread_root IS NULL ORDER BY id DESC LIMIT 120")
1231 .bind(place.channel.id)
1232 .all<SaidRow>();
1233 const rows = recent.results.reverse();
1234 const messages = conversation(rows);
1235 if (!messages.length) return;
1236 const written = await this.write(place, G1T_KEY, { body: "", card: null, thread_root: null }, { meta: { answer: { state: "thinking" } }, notify: false });
1237 if (!written.ok) return;
1238 const { channel } = place;
1239 await this.room(channel.id).startAnswer({
1240 slug: place.slug,
1241 channel: { id: channel.id, kind: channel.kind, name: channel.name },
1242 actor: viewer,
1243 messages,
1244 page: latestPage(rows) ?? { path: `/${place.slug}/-/chat/dm/${channel.id}` },
1245 message: written.value,
1246 });
1247 }
1248
1249 /** The viewer's direct message with @g1t, checked: theirs, and with g1t. */
1250 private async g1tPlace(slug: string, channelId: string, viewer: Viewer): Promise<Result<Place>> {
1251 const found = await this.place(slug, channelId, viewer, "member");
1252 if (!found.ok) return found;
1253 if (found.value.channel.kind !== "dm" || g1tDmPerson(await this.memberKeys(found.value.channel.id)) !== userKey(viewer!)) {
1254 return fail("invalid", "That is not a direct message with g1t.");
1255 }
1256 return found;
1257 }
1258
1259 async g1tDm(a: { workspace: string; viewer: Viewer }): Promise<Result<Channel>> {
1260 return this.openDm({ workspace: a.workspace, viewer: a.viewer, members: [G1T_PRINCIPAL] });
1261 }
1262
1263 /**
1264 * "New conversation": stops an answer still coming, and leaves a mark
1265 * after which g1t reads only what is said next. Everything before stays
1266 * to read.
1267 */
1268 async g1tReset(a: { workspace: string; channel_id: string; viewer: Viewer }): Promise<Result<ChatMessage>> {
1269 const found = await this.g1tPlace(a.workspace, a.channel_id, a.viewer);
1270 if (!found.ok) return found;
1271 await this.room(found.value.channel.id).stopAnswer();
1272 const written = await this.write(found.value, G1T_KEY, { body: "New conversation", card: null, thread_root: null }, { meta: { reset: true }, notify: false });
1273 if (written.ok) {
1274 // Their own mark is read.
1275 await this.db
1276 .prepare("UPDATE channel_members SET last_read_id = ? WHERE channel_id = ? AND principal = ?")
1277 .bind(written.value.id, found.value.channel.id, userKey(a.viewer!))
1278 .run();
1279 }
1280 return written;
1281 }
1282
1283 /** Answers the latest question again: g1t's last answer goes, and a new one is written. */
1284 async g1tRetry(a: { workspace: string; channel_id: string; viewer: Viewer }): Promise<Result<null>> {
1285 const found = await this.g1tPlace(a.workspace, a.channel_id, a.viewer);
1286 if (!found.ok) return found;
1287 const place = found.value;
1288 const last = await this.db
1289 .prepare("SELECT * FROM messages WHERE channel_id = ? AND thread_root IS NULL AND deleted_at IS NULL ORDER BY id DESC LIMIT 1")
1290 .bind(place.channel.id)
1291 .first<MessageRow>();
1292 if (!last || (last.author === G1T_KEY && parseMeta(last.meta).reset)) return fail("invalid", "There is no question to answer again.");
1293 if (last.author === G1T_KEY) {
1294 await this.room(place.channel.id).stopAnswer();
1295 await this.db.prepare("UPDATE messages SET deleted_at = ?, body = '', meta = NULL WHERE id = ?").bind(now(), last.id).run();
1296 this.broadcast(place.channel.id, { type: "message.deleted", channel_id: place.channel.id, id: last.id });
1297 }
1298 await this.startAnswer(place, a.viewer!);
1299 return ok(null);
1300 }
1301
1302 async g1tStop(a: { workspace: string; channel_id: string; viewer: Viewer }): Promise<Result<null>> {
1303 const found = await this.g1tPlace(a.workspace, a.channel_id, a.viewer);
1304 if (!found.ok) return found;
1305 await this.room(found.value.channel.id).stopAnswer();
1306 return ok(null);
1307 }
1308
1309 /**
1310 * For g1t's own services (`POST /internal/g1t_post`): says something as
1311 * @g1t in a direct message with it. Never reached from the site.
1312 */
1313 async internalG1tPost(a: { workspace: string; channel_id: string; body: string }): Promise<Result<ChatMessage>> {
1314 const found = await this.internalPlace(a.workspace, a.channel_id);
1315 if (!found.ok) return found;
1316 const body = messageBody(a.body);
1317 if (!body.ok) return fail("invalid", body.message);
1318 return this.write(found.value, G1T_KEY, { body: body.body, card: null, thread_root: null });
1319 }
1320
1321 /** `POST /internal/g1t_edit`: changes one of @g1t's messages in a direct message with it. */
1322 async internalG1tEdit(a: { workspace: string; channel_id: string; id: string; body: string }): Promise<Result<ChatMessage>> {
1323 const found = await this.internalPlace(a.workspace, a.channel_id);
1324 if (!found.ok) return found;
1325 const place = found.value;
1326 const row = await this.messageRow(place.channel.id, a.id);
1327 if (!row || row.deleted_at || row.author !== G1T_KEY) return fail("not_found", "No such message from g1t.");
1328 const body = messageBody(a.body);
1329 if (!body.ok) return fail("invalid", body.message);
1330 const at = now();
1331 await this.db.prepare("UPDATE messages SET body = ?, edited_at = ? WHERE id = ?").bind(body.body, at, row.id).run();
1332 const [message] = await this.toMessages(place.slug, place.workspace, [{ ...row, body: body.body, edited_at: at }]);
1333 this.broadcast(place.channel.id, { type: "message.updated", message });
1334 return ok(message);
1335 }
1336
1337 /** A direct message with @g1t, by the workspace's slug and the channel's id, for g1t's own services. */
1338 private async internalPlace(slug: string, channelId: string): Promise<Result<Place>> {
1339 const workspace = await this.workspace(String(slug ?? ""));
1340 if (!workspace) return fail("not_found", "No such workspace.");
1341 const channel = await this.db
1342 .prepare("SELECT * FROM channels WHERE id = ? AND workspace_id = ?")
1343 .bind(String(channelId ?? ""), workspace.id)
1344 .first<ChannelRow>();
1345 if (!channel || channel.kind !== "dm" || !g1tDmPerson(await this.memberKeys(channel.id))) return fail("not_found", "No such direct message with g1t.");
1346 return ok({ slug: workspace.slug.toLowerCase(), workspace, channel, member: null });
1347 }
1348
Chat and workspace agents: channels, DMs and named agents you talk to1349 async edit(a: { workspace: string; channel_id: string; viewer: Viewer; id: string; body: string }): Promise<Result<ChatMessage>> {
1350 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1351 if (!found.ok) return found;
1352 const place = found.value;
1353 const row = await this.messageRow(place.channel.id, a.id);
1354 if (!row || row.deleted_at) return fail("not_found", "No such message.");
1355 if (row.author !== userKey(a.viewer!)) return fail("forbidden", "Only its author can edit a message.");
1356 const body = messageBody(a.body, !!row.card);
1357 if (!body.ok) return fail("invalid", body.message);
1358 const at = now();
1359 const mentions = mentionsColumn(mentionedHandles(body.body));
1360 await this.db
1361 .prepare("UPDATE messages SET body = ?, mentions = ?, edited_at = ? WHERE id = ?")
1362 .bind(body.body, mentions, at, row.id)
1363 .run();
1364 const [message] = await this.toMessages(place.slug, place.workspace, [{ ...row, body: body.body, mentions, edited_at: at }]);
1365 this.broadcast(place.channel.id, { type: "message.updated", message });
1366 return ok(message);
1367 }
1368
1369 /** Deletes a message, keeping its place so its thread still hangs together. Its author or a workspace owner may. */
1370 async remove(a: { workspace: string; channel_id: string; viewer: Viewer; id: string }): Promise<Result<null>> {
1371 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1372 if (!found.ok) return found;
1373 const place = found.value;
1374 const row = await this.messageRow(place.channel.id, a.id);
1375 if (!row || row.deleted_at) return fail("not_found", "No such message.");
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.1376 const me = userKey(a.viewer!);
1377 // In your direct message with @g1t, g1t's messages are yours to delete too.
1378 const g1tsInMine = row.author === G1T_KEY && place.channel.kind === "dm" && g1tDmPerson(await this.memberKeys(place.channel.id)) === me;
1379 if (row.author !== me && !isOwner(a.viewer, a.workspace) && !g1tsInMine) {
Chat and workspace agents: channels, DMs and named agents you talk to1380 return fail("forbidden", "Only its author or a workspace owner can delete a message.");
1381 }
1382 const statements = [
1383 this.db
1384 .prepare("UPDATE messages SET deleted_at = ?, body = '', card = NULL, mentions = '' WHERE id = ?")
1385 .bind(now(), row.id),
1386 ];
1387 if (row.thread_root) {
1388 statements.push(
1389 this.db.prepare("UPDATE messages SET reply_count = MAX(reply_count - 1, 0) WHERE id = ?").bind(row.thread_root),
1390 );
1391 }
1392 await this.db.batch(statements);
1393 this.broadcast(place.channel.id, { type: "message.deleted", channel_id: place.channel.id, id: row.id });
1394 if (row.thread_root) this.rebroadcast(place, row.thread_root);
1395 return ok(null);
1396 }
1397
1398 async markRead(a: { workspace: string; channel_id: string; viewer: Viewer; id: string }): Promise<Result<null>> {
1399 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1400 if (!found.ok) return found;
1401 const { channel, member } = found.value;
1402 // Someone reading a public channel they have not joined keeps no read state.
1403 if (!member) return ok(null);
1404 const id = typeof a.id === "string" ? a.id : "";
1405 if (!id) return fail("invalid", "Say which message was read.");
1406 // Only forward: reading an old thread does not mark newer messages unread.
1407 const changed = await this.db
1408 .prepare(
1409 "UPDATE channel_members SET last_read_id = ?1 WHERE channel_id = ?2 AND principal = ?3 AND (last_read_id IS NULL OR last_read_id < ?1)",
1410 )
1411 .bind(id, channel.id, member.principal)
1412 .run();
1413 if (changed.meta.changes) {
1414 this.broadcast(channel.id, {
1415 type: "read",
1416 channel_id: channel.id,
1417 principal: { kind: "user", id: a.viewer!.id },
1418 last_read_id: id,
1419 });
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)1420 // Notify: the read drops the counts in every tab of theirs.
1421 this.defer(
1422 notifyRead(this.db, this.env.NOTIFY, { slug: found.value.slug, channel_id: channel.id, user_id: a.viewer!.id, username: a.viewer!.username, last_read_id: id }).catch(
1423 (error) => console.error("chat could not notify a read", error),
1424 ),
1425 );
1426 }
Chat and workspace agents: channels, DMs and named agents you talk to1427 return ok(null);
1428 }
1429
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)1430 // ── Reactions (src/emoji.ts) ────────────────────────────────────────────
1431
1432 /** One message's reactions now, as `me` sees them. */
1433 private async reactionsFor(place: Place, messageId: string, me: string): Promise<ChatReaction[]> {
1434 const list = (await this.reactionsOf([messageId], me)).get(messageId) ?? [];
1435 const profiles = await this.profiles(place.slug, place.workspace, list.flatMap((r) => r.by));
1436 return list.map((r) => ({ ...r, by: r.by.map((key) => profiles.get(key)!).filter(Boolean) }));
1437 }
1438
1439 /**
1440 * Adds or takes back `who`'s reaction. Each member reacts once with each
1441 * emoji, a message holds at most 50 different ones, and a workspace's own
1442 * emoji must exist to be used (taking one back never needs it to). The
1443 * room hears of each change.
1444 */
1445 private async reactTo(place: Place, who: string, messageId: unknown, input: unknown, remove: boolean): Promise<Result<ChatReaction[]>> {
1446 const parsed = reactionEmoji(input);
1447 if (!parsed.ok) return fail("invalid", parsed.message);
1448 const row = await this.messageRow(place.channel.id, String(messageId ?? ""));
1449 if (!row || row.deleted_at) return fail("not_found", "No such message.");
1450 let changed = 0;
1451 if (remove) {
1452 const done = await this.db
1453 .prepare("DELETE FROM reactions WHERE message_id = ? AND principal = ? AND emoji = ?")
1454 .bind(row.id, who, parsed.emoji)
1455 .run();
1456 changed = done.meta.changes;
1457 } else {
1458 if (parsed.custom) {
1459 const known = await this.db
1460 .prepare("SELECT 1 FROM custom_emoji WHERE workspace_id = ? AND name = ? AND deleted_at IS NULL")
1461 .bind(place.workspace.id, parsed.custom)
1462 .first();
1463 if (!known) return fail("not_found", `This workspace has no :${parsed.custom}: emoji.`);
1464 }
1465 const kinds = await this.db.prepare("SELECT DISTINCT emoji FROM reactions WHERE message_id = ?").bind(row.id).all<{ emoji: string }>();
1466 if (!roomForReaction(new Set(kinds.results.map((k) => k.emoji)), parsed.emoji)) {
1467 return fail("invalid", `A message can have at most ${MAX_REACTIONS_PER_MESSAGE} different reactions.`);
1468 }
1469 const done = await this.db
1470 .prepare("INSERT OR IGNORE INTO reactions (message_id, principal, emoji, created_at) VALUES (?, ?, ?, ?)")
1471 .bind(row.id, who, parsed.emoji, now())
1472 .run();
1473 changed = done.meta.changes;
1474 }
1475 if (changed) {
1476 const member = await this.profile(place.slug, place.workspace, who);
1477 this.broadcast(place.channel.id, {
1478 type: remove ? "reaction.removed" : "reaction.added",
1479 channel_id: place.channel.id,
1480 message_id: row.id,
1481 emoji: parsed.emoji,
1482 member,
1483 });
1484 }
1485 return ok(await this.reactionsFor(place, row.id, who));
1486 }
1487
1488 async react(a: { workspace: string; channel_id: string; viewer: Viewer; message_id: string; emoji: string }): Promise<Result<ChatReaction[]>> {
1489 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1490 if (!found.ok) return found;
1491 return this.reactTo(found.value, userKey(a.viewer!), a.message_id, a.emoji, false);
1492 }
1493
1494 async unreact(a: { workspace: string; channel_id: string; viewer: Viewer; message_id: string; emoji: string }): Promise<Result<ChatReaction[]>> {
1495 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1496 if (!found.ok) return found;
1497 return this.reactTo(found.value, userKey(a.viewer!), a.message_id, a.emoji, true);
1498 }
1499
1500 // ── A workspace's own emoji (src/emoji.ts) ──────────────────────────────
1501
1502 private async emojiUpload(workspace: Workspace): Promise<EmojiUpload> {
Chat controls, public profiles, shadcn selects, and no Docs tab in a project1503 return settingsOf(await this.settingsRow(workspace)).emoji_upload;
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)1504 }
1505
1506 private async toEmoji(slug: string, workspace: Workspace, rows: EmojiRow[]): Promise<CustomEmoji[]> {
1507 const profiles = await this.profiles(slug, workspace, rows.map((r) => r.created_by));
1508 return rows.map((row) => ({
1509 name: row.name,
1510 alias_of: row.alias_of,
1511 file: row.file,
1512 content_type: row.content_type,
1513 bytes: row.bytes,
1514 created_by: profiles.get(row.created_by)!,
1515 created_at: row.created_at,
1516 }));
1517 }
1518
1519 private liveEmoji(workspace: Workspace, name: string): Promise<EmojiRow | null> {
1520 return this.db
1521 .prepare("SELECT * FROM custom_emoji WHERE workspace_id = ? AND name = ? AND deleted_at IS NULL")
1522 .bind(workspace.id, name)
1523 .first<EmojiRow>();
1524 }
1525
1526 async listEmoji(a: { workspace: string; viewer: Viewer }): Promise<Result<EmojiList>> {
1527 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1528 if (!found.ok) return found;
1529 const workspace = found.value;
1530 const [rows, setting] = await Promise.all([
1531 this.db
1532 .prepare("SELECT * FROM custom_emoji WHERE workspace_id = ? AND deleted_at IS NULL ORDER BY name")
1533 .bind(workspace.id)
1534 .all<EmojiRow>(),
1535 this.emojiUpload(workspace),
1536 ]);
1537 const role = roleOf(a.viewer, a.workspace);
1538 return ok({
1539 emoji: await this.toEmoji(a.workspace.toLowerCase(), workspace, rows.results),
1540 emoji_upload: setting,
1541 can_upload: mayUpload(setting, role),
1542 can_manage: role === "owner",
1543 });
1544 }
1545
1546 /** Who may add one: checked against the workspace's setting. */
1547 private async mayAdd(workspace: Workspace, viewer: Viewer, slug: string): Promise<Result<null>> {
1548 const setting = await this.emojiUpload(workspace);
1549 if (!mayUpload(setting, roleOf(viewer, slug))) return fail("forbidden", "Only owners can add emoji in this workspace.");
1550 return ok(null);
1551 }
1552
1553 async addEmoji(a: { workspace: string; viewer: Viewer; name: string; file: EmojiFile }): Promise<Result<CustomEmoji>> {
1554 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1555 if (!found.ok) return found;
1556 const workspace = found.value;
1557 const allowed = await this.mayAdd(workspace, a.viewer, a.workspace);
1558 if (!allowed.ok) return allowed;
1559 const named = emojiName(a.name);
1560 if (!named.ok) return fail("invalid", named.message);
1561 if (await this.liveEmoji(workspace, named.name)) return fail("conflict", `:${named.name}: is already taken.`);
1562 const bytes = fromBase64(a.file?.data);
1563 if (!bytes) return fail("invalid", "Choose a PNG, GIF or WebP image of at most 256 KB.");
1564 const checked = emojiImage(bytes);
1565 if (!checked.ok) return fail("invalid", checked.message);
1566 const file = await sha256(bytes);
1567 // Kept by its hash, so the same image stored twice is one file; its
1568 // type is the one read from its bytes (usercontent serves only that).
1569 await this.env.AVATARS.put(`emoji/${file}`, bytes, { metadata: { contentType: checked.image.content_type } });
1570 const row: EmojiRow = {
1571 workspace_id: workspace.id,
1572 name: named.name,
1573 alias_of: null,
1574 file,
1575 content_type: checked.image.content_type,
1576 bytes: bytes.length,
1577 created_by: userKey(a.viewer!),
1578 created_at: now(),
1579 deleted_at: null,
1580 };
1581 const added = await this.insertEmoji(row);
1582 if (!added.ok) return added;
1583 const [emoji] = await this.toEmoji(a.workspace.toLowerCase(), workspace, [row]);
1584 return ok(emoji!);
1585 }
1586
1587 private async insertEmoji(row: EmojiRow): Promise<Result<null>> {
1588 try {
1589 await this.db
1590 .prepare(
1591 "INSERT INTO custom_emoji (workspace_id, name, alias_of, file, content_type, bytes, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
1592 )
1593 .bind(row.workspace_id, row.name, row.alias_of, row.file, row.content_type, row.bytes, row.created_by, row.created_at)
1594 .run();
1595 return ok(null);
1596 } catch (error) {
1597 if (String(error).includes("UNIQUE")) return fail("conflict", `:${row.name}: is already taken.`);
1598 throw error;
1599 }
1600 }
1601
1602 async aliasEmoji(a: { workspace: string; viewer: Viewer; name: string; target: string }): Promise<Result<CustomEmoji>> {
1603 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1604 if (!found.ok) return found;
1605 const workspace = found.value;
1606 const allowed = await this.mayAdd(workspace, a.viewer, a.workspace);
1607 if (!allowed.ok) return allowed;
1608 const named = emojiName(a.name);
1609 if (!named.ok) return fail("invalid", named.message);
1610 const targetName = String(a.target ?? "").trim().replace(/^:+|:+$/g, "").toLowerCase();
1611 let target = await this.liveEmoji(workspace, targetName);
1612 // An alias of an alias names the emoji itself, so removing one never strands another.
1613 if (target?.alias_of) target = await this.liveEmoji(workspace, target.alias_of);
1614 if (!target) return fail("not_found", `This workspace has no :${targetName}: emoji.`);
1615 if (await this.liveEmoji(workspace, named.name)) return fail("conflict", `:${named.name}: is already taken.`);
1616 const row: EmojiRow = { ...target, name: named.name, alias_of: target.name, created_by: userKey(a.viewer!), created_at: now(), deleted_at: null };
1617 const added = await this.insertEmoji(row);
1618 if (!added.ok) return added;
1619 const [emoji] = await this.toEmoji(a.workspace.toLowerCase(), workspace, [row]);
1620 return ok(emoji!);
1621 }
1622
1623 async removeEmoji(a: { workspace: string; viewer: Viewer; name: string }): Promise<Result<null>> {
1624 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1625 if (!found.ok) return found;
1626 const workspace = found.value;
1627 const name = String(a.name ?? "").trim().replace(/^:+|:+$/g, "").toLowerCase();
1628 const row = await this.liveEmoji(workspace, name);
1629 if (!row) return fail("not_found", `This workspace has no :${name}: emoji.`);
1630 if (!mayRemove(row.created_by, userKey(a.viewer!), roleOf(a.viewer, a.workspace))) {
1631 return fail("forbidden", "Only whoever added an emoji, or an owner, can remove it.");
1632 }
1633 // An emoji goes with its aliases; an alias goes alone.
1634 await this.db
1635 .prepare(
1636 "UPDATE custom_emoji SET deleted_at = ?1 WHERE workspace_id = ?2 AND deleted_at IS NULL AND (name = ?3 OR (?4 = 0 AND alias_of = ?3))",
1637 )
1638 .bind(now(), workspace.id, row.name, row.alias_of ? 1 : 0)
1639 .run();
1640 // Its image goes once nothing live shows it, in any workspace.
1641 this.defer(
1642 (async () => {
1643 const used = await this.db.prepare("SELECT 1 FROM custom_emoji WHERE file = ? AND deleted_at IS NULL LIMIT 1").bind(row.file).first();
1644 if (!used) await this.env.AVATARS.delete(`emoji/${row.file}`);
1645 })().catch((error) => console.error("chat could not forget emoji", row.file, error)),
1646 );
1647 return ok(null);
1648 }
1649
1650 async setEmojiUpload(a: { workspace: string; viewer: Viewer; value: EmojiUpload }): Promise<Result<EmojiUpload>> {
1651 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1652 if (!found.ok) return found;
1653 if (roleOf(a.viewer, a.workspace) !== "owner") return fail("forbidden", "Only owners can change who adds emoji.");
1654 if (a.value !== "members" && a.value !== "admins") return fail("invalid", "Choose members or owners.");
Chat controls, public profiles, shadcn selects, and no Docs tab in a project1655 // The same setting as Settings → Chat (`setChatSettings`).
1656 const set = await this.setChatSettings({ workspace: a.workspace, viewer: a.viewer, change: { emoji_upload: a.value } });
1657 return set.ok ? ok(set.value.emoji_upload) : set;
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)1658 }
1659
Chat and workspace agents: channels, DMs and named agents you talk to1660 // ── The live socket ─────────────────────────────────────────────────────
1661
1662 /**
1663 * `GET /live?workspace=<slug>&channel=<id>`, upgraded to a WebSocket. The
1664 * viewer comes in CHAT_VIEWER_HEADER, set by the site after checking the
1665 * session; trusted only because this Worker is reachable through service
1666 * bindings alone (`workers_dev` is off and it has no routes). Checked
1667 * like any read, then handed to the channel's room.
1668 */
1669 async live(request: Request): Promise<Response> {
1670 if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") {
1671 return new Response("Expected a WebSocket upgrade\n", { status: 426 });
1672 }
1673 let viewer: Viewer = null;
1674 try {
1675 viewer = JSON.parse(request.headers.get(CHAT_VIEWER_HEADER) ?? "null") as Viewer;
1676 } catch {
1677 viewer = null;
1678 }
1679 if (!viewer?.id) return new Response("Sign in to use chat\n", { status: 401 });
1680 const url = new URL(request.url);
1681 const channelId = url.searchParams.get("channel") ?? "";
1682 let slug = (url.searchParams.get("workspace") ?? "").toLowerCase();
1683 if (!slug) {
1684 // Not named: whichever of the viewer's workspaces holds the channel.
1685 const row = await this.db.prepare("SELECT workspace_id FROM channels WHERE id = ?").bind(channelId).first<{ workspace_id: string }>();
1686 if (row) {
1687 const theirs = await Promise.all((viewer.workspaces ?? []).map((m) => this.workspace(m.slug)));
1688 slug = theirs.find((w) => w?.id === row.workspace_id)?.slug.toLowerCase() ?? "";
1689 }
1690 }
1691 const found = await this.place(slug, channelId, viewer, "read");
1692 if (!found.ok) return new Response(`${found.error.message}\n`, { status: found.error.code === "forbidden" ? 403 : 404 });
1693 const { workspace, channel } = found.value;
1694 const who: RoomMember = { channel_id: channel.id, member: await this.profile(slug, workspace, userKey(viewer)) };
1695 const headers = new Headers(request.headers);
1696 headers.delete(CHAT_VIEWER_HEADER);
1697 headers.set(ROOM_MEMBER_HEADER, JSON.stringify(who));
1698 return this.room(channel.id).fetch(new Request(request.url, { method: "GET", headers }));
1699 }
1700}
1701
1702/** One RPC method's answer. */
1703async function answer(service: Chat, method: string, args: any): Promise<Response> {
1704 switch (method) {
1705 case "sidebar":
1706 return Response.json(await service.sidebar(args));
Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales.1707 case "activity":
1708 return Response.json(await service.activity(args));
Chat and workspace agents: channels, DMs and named agents you talk to1709 case "channel":
1710 return Response.json(await service.channel(args));
1711 case "channel_by_name":
1712 return Response.json(await service.channelByName(args));
1713 case "browse":
1714 return Response.json(await service.browse(args));
1715 case "create_channel":
1716 return Response.json(await service.createChannel(args));
Chat controls, public profiles, shadcn selects, and no Docs tab in a project1717 case "update_channel":
1718 return Response.json(await service.updateChannel(args));
1719 case "chat_settings":
1720 return Response.json(await service.chatSettings(args));
1721 case "set_chat_settings":
1722 return Response.json(await service.setChatSettings(args));
Chat and workspace agents: channels, DMs and named agents you talk to1723 case "open_dm":
1724 return Response.json(await service.openDm(args));
1725 case "join":
1726 return Response.json(await service.join(args));
1727 case "leave":
1728 return Response.json(await service.leave(args));
1729 case "invite":
1730 return Response.json(await service.invite(args));
1731 case "messages":
1732 return Response.json(await service.messages(args));
1733 case "post":
1734 return Response.json(await service.post(args));
1735 case "edit":
1736 return Response.json(await service.edit(args));
1737 case "remove":
1738 return Response.json(await service.remove(args));
1739 case "mark_read":
1740 return Response.json(await service.markRead(args));
1741 case "set_preferences":
1742 return Response.json(await service.setPreferences(args));
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)1743 case "react":
1744 return Response.json(await service.react(args));
1745 case "unreact":
1746 return Response.json(await service.unreact(args));
1747 case "list_emoji":
1748 return Response.json(await service.listEmoji(args));
1749 case "add_emoji":
1750 return Response.json(await service.addEmoji(args));
1751 case "alias_emoji":
1752 return Response.json(await service.aliasEmoji(args));
1753 case "remove_emoji":
1754 return Response.json(await service.removeEmoji(args));
1755 case "set_emoji_upload":
1756 return Response.json(await service.setEmojiUpload(args));
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.1757 case "g1t_dm":
1758 return Response.json(await service.g1tDm(args));
1759 case "g1t_reset":
1760 return Response.json(await service.g1tReset(args));
1761 case "g1t_retry":
1762 return Response.json(await service.g1tRetry(args));
1763 case "g1t_stop":
1764 return Response.json(await service.g1tStop(args));
Chat and workspace agents: channels, DMs and named agents you talk to1765 default:
1766 return new Response("Unknown method\n", { status: 404 });
1767 }
1768}
1769
1770export default {
1771 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
1772 const url = new URL(request.url);
1773 if (request.method === "GET" && url.pathname === "/live") {
1774 return new Chat(env, (work) => ctx.waitUntil(work)).live(request);
1775 }
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.1776 const internal = url.pathname.match(/^\/internal\/([a-z0-9_]+)$/);
1777 if (request.method === "POST" && internal) {
1778 // Only g1t's own services (src/internal.ts).
1779 return internalAnswer(new Chat(env, (work) => ctx.waitUntil(work)), request, internal[1]!);
1780 }
1781 const match = url.pathname.match(/^\/rpc\/([a-z0-9_]+)$/);
Chat and workspace agents: channels, DMs and named agents you talk to1782 if (request.method !== "POST" || !match) return new Response("Not found\n", { status: 404 });
1783 // A replica near the caller when it asks for one (@g1t/contracts d1.ts).
1784 const opened = openD1(env.DB, request);
1785 const service = new Chat(Object.create(env, { DB: { value: opened.db } }) as Env, (work) => ctx.waitUntil(work));
1786 const args = (await request.json().catch(() => ({}))) as any;
1787 return opened.finish(await answer(service, match[1], args));
1788 },
1789} satisfies ExportedHandler<Env>;

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