Skip to content
2,132 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
The docs folder is gone, and what it held lives where people read it: how a self-hosted g1t runs and how to deploy g1t to Cloudflare are pages on docs.g1t.sh under Run g1t yourself, and speed, rate limits and operating g1t.sh are sections of CONTRIBUTING.md; code that cited a file in docs/ now points to the page or section that covers it, or says what it means itself, and applied migrations and the runner images are left as they were.3 * messages. People and agents are members alike (docs.g1t.sh/guides/chat/).
Chat and workspace agents: channels, DMs and named agents you talk to4 *
5 * Reached through service bindings: `POST /rpc/<method>` with snake_case
6 * bodies (`chatClient` in @g1t/contracts), and `GET /live` for a channel's
7 * socket, which the site forwards after checking the session. Each channel
8 * has a room (src/room.ts) that delivers what happens in it live.
9 *
10 * Workspaces are kept by id, so renaming one changes nothing here; who is
11 * in a workspace comes from the viewer's memberships, as in every service.
12 */
13
14import {
15 CHAT_MAX_HOPS,
16 askerAccess,
17 CHAT_VIEWER_HEADER,
18 fail,
19 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.20 idFloor,
Chat and workspace agents: channels, DMs and named agents you talk to21 newId,
22 ok,
23 openD1,
24 parsePrincipalKey,
25 principalKey,
26 workspaceAgentsClient,
27 type AgentDelivery,
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)28 type AgentFoundMessage,
Merge branch 'worktree-agent-a1398e81ad1a64c5f'29 type AgentHandOff,
Chat and workspace agents: channels, DMs and named agents you talk to30 type AgentPostMessage,
31 type AskerAccess,
32 type Channel,
Chat controls, public profiles, shadcn selects, and no Docs tab in a project33 type ChannelChange,
34 type ChannelDetail,
Chat and workspace agents: channels, DMs and named agents you talk to35 type ChannelMember,
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)36 type ChatAudience,
Chat and workspace agents: channels, DMs and named agents you talk to37 type ChatLiveEvent,
38 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)39 type ChatReaction,
40 type CustomEmoji,
41 type EmojiFile,
42 type EmojiList,
43 type EmojiUpload,
Chat controls, public profiles, shadcn selects, and no Docs tab in a project44 type ChatSettings,
45 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.46 type ChatActivity,
Chat and workspace agents: channels, DMs and named agents you talk to47 type ChatSidebar,
48 type ChatSidebarEntry,
Merge branch 'worktree-agent-a1398e81ad1a64c5f'49 CONVERSATION_PEOPLE_SHOWN,
50 type ConversationForAgent,
51 type HandOffResult,
Chat and workspace agents: channels, DMs and named agents you talk to52 type Member,
53 type MemberProfile,
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar54 type CardActionResult,
Chat and workspace agents: channels, DMs and named agents you talk to55 type MessageCard,
56 type MessagePage,
57 type NewChannel,
58 type PostMessage,
59 type Principal,
60 type Result,
61 type ServiceBinding,
62 type User,
63 type Viewer,
64 type Workspace,
65 type WorkspaceAgent,
66} from "@g1t/contracts";
67
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.68import { 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)69import { audienceKind, isShared, likePattern, readableBy } from "./audience.ts";
Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how.70import { MAX_HOPS, addsOrchestrator, chainFor, deliveries, delivery, handOffPlace, handOffRefusal, personalAgentRefusal, type Chain, type Wake } from "./delivery.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)71import {
72 MAX_REACTIONS_PER_MESSAGE,
73 emojiImage,
74 emojiName,
75 fromBase64,
76 mayRemove,
77 mayUpload,
78 reactionEmoji,
79 roomForReaction,
80 tallyReactions,
81 type ReactionRow,
82 type ReactionTally,
83} from "./emoji.ts";
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar84import { cleanCard } from "./cards.ts";
Merge branch 'worktree-agent-a1398e81ad1a64c5f'85import { mentionedHandles, mentionsColumn, plainOutside } from "./mentions.ts";
Chat and workspace agents: channels, DMs and named agents you talk to86import { AGENT_TYPING_MS, historyOf, historySize, messageBody, meterDay, pageOf, pageSize } from "./messages.ts";
87import { GENERAL, MAX_DM_MEMBERS, channelName, dmKey, dmMembers } from "./names.ts";
88import { ROOM_MEMBER_HEADER, type ChannelRoom, type RoomMember } from "./room.ts";
Chat controls, public profiles, shadcn selects, and no Docs tab in a project89import { mayCreateChannel, mayManageChannel, permissionsFor, rowFor, settingsChange, settingsOf, type SettingsRow } from "./settings.ts";
Chat and workspace agents: channels, DMs and named agents you talk to90import { 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)91// Live notifications and counts (services/notify).
92import { notifyMessage, notifyMuted, notifyRead } from "./notify.ts";
Chat and workspace agents: channels, DMs and named agents you talk to93
94export { ChannelRoom } from "./room.ts";
95
96// The hop limit here is the one in the contract.
97const SAME_HOP_LIMIT: typeof CHAT_MAX_HOPS = MAX_HOPS;
98void SAME_HOP_LIMIT;
99
100type Env = {
101 DB: D1Database;
102 IDENTITY: ServiceBinding;
103 AGENTS: ServiceBinding;
104 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)105 /** The avatars namespace: custom emoji images, under `emoji/<sha256>`, which the usercontent origin serves. */
106 AVATARS: KVNamespace;
107 /** Live notifications and unread counts (services/notify); absent, nobody is told. */
108 NOTIFY?: ServiceBinding;
Chat and workspace agents: channels, DMs and named agents you talk to109};
110
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)111type EmojiRow = {
112 workspace_id: string;
113 name: string;
114 alias_of: string | null;
115 file: string;
116 content_type: CustomEmoji["content_type"];
117 bytes: number;
118 created_by: string;
119 created_at: string;
120 deleted_at: string | null;
121};
122
123/** The viewer's role in a workspace, or null when they are not in it. */
124function roleOf(viewer: Viewer, workspace: string): "owner" | "member" | null {
125 return viewer?.workspaces?.find((m) => m.slug === workspace.toLowerCase())?.role ?? null;
126}
127
128async function sha256(bytes: Uint8Array): Promise<string> {
129 const digest = await crypto.subtle.digest("SHA-256", bytes);
130 return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
131}
132
Chat and workspace agents: channels, DMs and named agents you talk to133type ChannelRow = {
134 id: string;
135 workspace_id: string;
136 kind: "channel" | "dm";
137 name: string | null;
138 topic: string | null;
139 private: number;
140 dm_key: string | null;
141 created_by: string;
142 created_at: string;
143 archived_at: string | null;
144 last_message_at: string | null;
145};
146
147type MemberRow = {
148 channel_id: string;
149 principal: string;
150 role: "owner" | "member";
151 starred: number;
152 muted: number;
153 last_read_id: string | null;
154 joined_at: string;
155};
156
157type MessageRow = {
158 id: string;
159 channel_id: string;
160 author: string;
161 kind: "text" | "card";
162 body: string;
163 card: string | null;
164 mentions: string;
165 thread_root: string | null;
166 reply_count: number;
167 last_reply_at: string | null;
168 created_at: string;
169 edited_at: string | null;
170 deleted_at: string | null;
171};
172
173/** What a method found out about the channel it was asked about. */
174type Place = { slug: string; workspace: Workspace; channel: ChannelRow; member: MemberRow | null };
175
176/** The most unread messages one sidebar reads to count; past it, counts are "at least". */
177const MAX_UNREAD_ROWS = 5_000;
178/** The longest channel topic. */
179const MAX_TOPIC = 250;
180/** How many others a direct message's sidebar entry shows. */
181const DM_FACES = 4;
182
183const now = () => new Date().toISOString();
184
185function isMember(viewer: Viewer, workspace: string): boolean {
186 return !!viewer?.workspaces?.some((m) => m.slug === workspace.toLowerCase());
187}
188
189function isOwner(viewer: Viewer, workspace: string): boolean {
190 return !!viewer?.workspaces?.some((m) => m.slug === workspace.toLowerCase() && m.role === "owner");
191}
192
193function userKey(viewer: User): string {
194 return principalKey({ kind: "user", id: viewer.id });
195}
196
197function toChannel(row: ChannelRow): Channel {
198 return {
199 id: row.id,
200 workspace_id: row.workspace_id,
201 kind: row.kind,
202 name: row.kind === "dm" ? null : row.name,
203 topic: row.topic,
204 private: row.kind === "dm" || !!row.private,
205 created_by: parsePrincipalKey(row.created_by) ?? { kind: "user", id: row.created_by },
206 created_at: row.created_at,
207 archived_at: row.archived_at,
208 last_message_at: row.last_message_at,
209 };
210}
211
212/** An asker handed back by the agents service, or null when what was sent is not one. */
213function cleanAsker(asker: unknown): AskerAccess | null {
214 if (!asker || typeof asker !== "object") return null;
215 const a = asker as Record<string, unknown>;
216 if (typeof a.username !== "string" || !["owner", "member", "outside"].includes(String(a.role))) return null;
217 return { username: a.username, role: a.role as AskerAccess["role"], can_write: a.can_write === true };
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>();
228 private readonly agents = new Map<string, WorkspaceAgent | null>();
Chat controls, public profiles, shadcn selects, and no Docs tab in a project229 private readonly settingsRows = new Map<string, Promise<SettingsRow | null>>();
Chat and workspace agents: channels, DMs and named agents you talk to230
231 /** `defer` runs work after the answer is sent: the request's waitUntil. */
232 constructor(
233 private readonly env: Env,
234 private readonly defer: (work: Promise<unknown>) => void = () => {},
235 ) {}
236
237 private get db() {
238 return this.env.DB;
239 }
240
241 // ── Who and where ───────────────────────────────────────────────────────
242
243 private workspace(slug: string): Promise<Workspace | null> {
244 const key = slug.toLowerCase();
245 let found = this.workspaces.get(key);
246 if (!found) {
247 found = identityClient(this.env.IDENTITY).getWorkspace(key);
248 this.workspaces.set(key, found);
249 }
250 return found;
251 }
252
253 /** The workspace's people by username, with their names and avatars; asked once per request. */
254 private members(slug: string, workspace: Workspace): Promise<Map<string, Member>> {
255 let found = this.people.get(workspace.id);
256 if (!found) {
257 // Asked as the workspace itself, so it works for agents' calls too.
258 const actor: User = {
259 id: workspace.id,
260 username: workspace.slug,
261 kind: "workspace",
262 verified: true,
263 workspaces: [{ slug: workspace.slug, role: "member" }],
264 };
265 found = identityClient(this.env.IDENTITY)
266 .listMembers(slug, actor)
267 .then((result) => new Map(result.ok ? result.value.map((m) => [m.username, m]) : []))
268 .catch((error) => {
269 console.error("chat could not list members of", slug, error);
270 return new Map<string, Member>();
271 });
272 this.people.set(workspace.id, found);
273 }
274 return found;
275 }
276
277 /** Agents by id; ones the agents service does not know are null. Asked once per request. */
278 private async agentsById(ids: string[]): Promise<Map<string, WorkspaceAgent | null>> {
279 const wanted = [...new Set(ids)].filter((id) => !this.agents.has(id));
280 if (wanted.length) {
281 let found: WorkspaceAgent[] = [];
282 try {
283 found = await workspaceAgentsClient(this.env.AGENTS).byIds(wanted);
284 } catch (error) {
285 console.error("chat could not resolve agents", error);
286 }
287 for (const id of wanted) this.agents.set(id, found.find((a) => a.id === id) ?? null);
288 }
289 return new Map(ids.map((id) => [id, this.agents.get(id) ?? null]));
290 }
291
292 /** An agent of this workspace that is not archived, or null. */
293 private async liveAgent(workspace: Workspace, id: string): Promise<WorkspaceAgent | null> {
294 const agent = (await this.agentsById([id])).get(id) ?? null;
295 return agent && agent.workspace_id === workspace.id && !agent.archived_at ? agent : null;
296 }
297
298 /** How each member key shows, for one workspace. */
299 private async profiles(slug: string, workspace: Workspace, keys: string[]): Promise<Map<string, MemberProfile>> {
300 const principals = [...new Set(keys)].map((key) => parsePrincipalKey(key)).filter((p): p is Principal => !!p);
301 const userIds = principals.filter((p) => p.kind === "user").map((p) => p.id);
302 const agentIds = principals.filter((p) => p.kind === "agent").map((p) => p.id);
303 const unnamed = userIds.filter((id) => !this.usernames.has(id));
304 const [named, people, agents] = await Promise.all([
305 unnamed.length ? identityClient(this.env.IDENTITY).usernames(unnamed).catch(() => ({}) as Record<string, string>) : ({} as Record<string, string>),
306 userIds.length ? this.members(slug, workspace) : new Map<string, Member>(),
307 this.agentsById(agentIds),
308 ]);
309 for (const [id, username] of Object.entries(named)) this.usernames.set(id, username);
310 const out = new Map<string, MemberProfile>();
311 for (const p of principals) {
312 if (p.kind === "user") {
313 const username = this.usernames.get(p.id) ?? null;
314 const person = username ? people.get(username) : undefined;
315 out.set(principalKey(p), {
316 ...p,
317 name: username ?? "ghost",
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar318 display_username: person?.display_username ?? null,
319 // Their display name, else their username as they wrote it
320 // (`memberName` reads this the same way).
321 display_name: person?.name?.trim() || person?.display_username || username || "Former member",
Chat and workspace agents: channels, DMs and named agents you talk to322 avatar: person?.avatar ?? null,
323 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)324 title: null,
325 avatar_seed: null,
Chat and workspace agents: channels, DMs and named agents you talk to326 });
327 } else {
328 const agent = agents.get(p.id) ?? null;
329 out.set(principalKey(p), {
330 ...p,
331 name: agent?.handle ?? p.id,
332 display_name: agent?.display_name ?? "Former agent",
333 avatar: agent?.avatar ?? null,
334 role: agent?.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: agent?.title || null,
336 avatar_seed: agent?.avatar_seed ?? null,
Agents have faces, and are never mistaken for people. Every agent wears a little bot face drawn from a look it owns, shape, colour, eyes, mouth, antenna, accessory and pattern, chosen in its builder and on its Profile tab with a live preview, Shuffle and a way back to the face its seed gives it; the face blinks on its own time, breathes, narrows its eyes while the agent works, shuts them asleep and bounces when it finishes, all of it still for anyone who asked for less motion. Wherever an agent shows, in chat, in a list, on a mention, on a review or a commit, its avatar carries an agent marker, and the people reading it are told so. In Chat, direct messages are two lists: People, and Agents, which also holds the agents you haven't talked to yet; a conversation with both a person and an agent in it is marked in the list, named in the conversation's header, spelled out by the composer and explained once the first time it opens. Agents keep their look in the agents service, which every service passes along. The chat and agents guides say so, and CONTRIBUTING makes the shared avatar the only way to draw an agent.337 look: agent?.look ?? null,
Chat and workspace agents: channels, DMs and named agents you talk to338 });
339 }
340 }
341 return out;
342 }
343
344 private async profile(slug: string, workspace: Workspace, key: string): Promise<MemberProfile> {
345 return (await this.profiles(slug, workspace, [key])).get(key)!;
346 }
347
348 /** Whether `principal` may be added to a conversation in this workspace. */
349 private async belongs(slug: string, workspace: Workspace, principal: Principal): Promise<boolean> {
350 if (principal.kind === "agent") return !!(await this.liveAgent(workspace, principal.id));
351 if (!this.usernames.has(principal.id)) {
352 const named = await identityClient(this.env.IDENTITY).usernames([principal.id]);
353 for (const [id, username] of Object.entries(named)) this.usernames.set(id, username);
354 }
355 const username = this.usernames.get(principal.id);
356 return !!username && (await this.members(slug, workspace)).has(username);
357 }
358
359 /**
360 * The viewer's workspace, checked: they must belong to it, as in every
361 * other service.
362 */
363 private async viewerWorkspace(slug: string, viewer: Viewer): Promise<Result<Workspace>> {
364 if (!viewer) return fail("unauthenticated", "Sign in to use chat.");
365 if (!slug || !isMember(viewer, slug)) return fail("forbidden", "Only members of a workspace can use its chat.");
366 const workspace = await this.workspace(slug);
367 return workspace ? ok(workspace) : fail("not_found", "No such workspace.");
368 }
369
370 /**
371 * A channel the viewer may read (`read`: any public one in their
372 * workspace, or one they are in) or write in (`member`: one they are in).
373 * A private channel or direct message they are not in is not found, so
374 * its existence does not leak.
375 */
376 private async place(
377 slug: string,
378 channelId: string,
379 viewer: Viewer,
380 need: "read" | "member",
381 ): Promise<Result<Place>> {
382 const found = await this.viewerWorkspace(slug, viewer);
383 if (!found.ok) return found;
384 const workspace = found.value;
385 const [channel, member] = await Promise.all([
386 this.db
387 .prepare("SELECT * FROM channels WHERE id = ? AND workspace_id = ?")
388 .bind(String(channelId ?? ""), workspace.id)
389 .first<ChannelRow>(),
390 this.db
391 .prepare("SELECT * FROM channel_members WHERE channel_id = ? AND principal = ?")
392 .bind(String(channelId ?? ""), userKey(viewer!))
393 .first<MemberRow>(),
394 ]);
395 if (!channel) return fail("not_found", "No such channel.");
396 const open = channel.kind === "channel" && !channel.private;
397 if (!member && !open) return fail("not_found", "No such channel.");
398 if (!member && need === "member") return fail("forbidden", `Join #${channel.name} first.`);
399 return ok({ slug: slug.toLowerCase(), workspace, channel, member });
400 }
401
402 private room(channelId: string) {
403 return this.env.ROOMS.get(this.env.ROOMS.idFromName(channelId));
404 }
405
406 /** Tells everyone looking at a channel, after the answer is sent. */
407 private broadcast(channelId: string, event: ChatLiveEvent, except: string | null = null): void {
408 this.defer(
409 this.room(channelId)
410 .broadcast(event, except)
411 .catch((error: unknown) => console.error("chat could not broadcast to", channelId, error)),
412 );
413 }
414
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)415 /**
416 * Messages as they go out, with their reactions. `me` (a member key) is
417 * the viewer an answer is for; null for what everyone in a room gets,
418 * where no reaction is anyone's own.
419 */
420 private async toMessages(slug: string, workspace: Workspace, rows: MessageRow[], me: string | null = null): Promise<ChatMessage[]> {
421 const reactions = await this.reactionsOf(rows.filter((r) => !r.deleted_at).map((r) => r.id), me);
422 const reactors = [...reactions.values()].flatMap((list) => list.flatMap((r) => r.by));
423 const profiles = await this.profiles(slug, workspace, [...rows.map((r) => r.author), ...reactors]);
Chat and workspace agents: channels, DMs and named agents you talk to424 return rows.map((row) => {
425 const gone = !!row.deleted_at;
426 return {
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)427 reactions: gone
428 ? []
429 : (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 to430 id: row.id,
431 channel_id: row.channel_id,
432 author: profiles.get(row.author)!,
433 kind: row.kind,
434 body: gone ? "" : row.body,
435 card: gone || !row.card ? null : (JSON.parse(row.card) as MessageCard),
436 thread_root: row.thread_root,
437 reply_count: row.reply_count,
438 last_reply_at: row.last_reply_at,
439 created_at: row.created_at,
440 edited_at: row.edited_at,
441 deleted_at: row.deleted_at,
442 };
443 });
444 }
445
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)446 /** Reactions on these messages, counted (src/emoji.ts). One read, by the reactions table's key. */
447 private async reactionsOf(ids: string[], me: string | null): Promise<Map<string, ReactionTally[]>> {
448 if (!ids.length) return new Map();
449 const rows = await this.db
450 .prepare(
451 "SELECT message_id, emoji, principal, created_at FROM reactions WHERE message_id IN (SELECT value FROM json_each(?))",
452 )
453 .bind(JSON.stringify(ids))
454 .all<ReactionRow>();
455 return tallyReactions(rows.results, me);
456 }
457
Chat and workspace agents: channels, DMs and named agents you talk to458 private async messageRow(channelId: string, id: string): Promise<MessageRow | null> {
459 return this.db.prepare("SELECT * FROM messages WHERE id = ? AND channel_id = ?").bind(String(id ?? ""), channelId).first<MessageRow>();
460 }
461
462 /** Sends a message as it now is to everyone looking at its channel. */
463 private rebroadcast(place: Place, id: string): void {
464 this.defer(
465 (async () => {
466 const row = await this.messageRow(place.channel.id, id);
467 if (!row) return;
468 const [message] = await this.toMessages(place.slug, place.workspace, [row]);
469 await this.room(place.channel.id).broadcast({ type: "message.updated", message });
470 })().catch((error) => console.error("chat could not rebroadcast", id, error)),
471 );
472 }
473
474 // ── The sidebar ─────────────────────────────────────────────────────────
475
476 /**
477 * Puts a person in the workspace's #general, once. A new workspace has
478 * no channels, so the first sidebar anyone in it asks for creates
479 * #general; and everyone who asks for the sidebar is put in it the first
480 * time, so a new workspace has somewhere to talk and a new member lands
481 * where everyone is. Someone who leaves it is not put back
482 * (`general_joined`). A private channel someone named `general` is never
483 * joined this way.
484 */
485 private async ensureGeneral(workspace: Workspace, me: string): Promise<void> {
486 const seen = await this.db
487 .prepare("SELECT 1 FROM general_joined WHERE workspace_id = ? AND principal = ?")
488 .bind(workspace.id, me)
489 .first();
490 if (seen) return;
491 const at = now();
492 await this.db
493 .prepare(
494 "INSERT OR IGNORE INTO channels (id, workspace_id, kind, name, topic, private, created_by, created_at) VALUES (?, ?, 'channel', ?, ?, 0, ?, ?)",
495 )
496 .bind(newId("chn"), workspace.id, GENERAL, "Anything and everything for the whole workspace.", me, at)
497 .run();
498 const general = await this.db
499 .prepare("SELECT * FROM channels WHERE workspace_id = ? AND name = ?")
500 .bind(workspace.id, GENERAL)
501 .first<ChannelRow>();
502 const statements = [
503 this.db
504 .prepare("INSERT OR IGNORE INTO general_joined (workspace_id, principal, joined_at) VALUES (?, ?, ?)")
505 .bind(workspace.id, me, at),
506 ];
Chat controls, public profiles, shadcn selects, and no Docs tab in a project507 // The workspace's default channels (#general unless its owners chose
508 // others): public and not archived only, whatever was kept.
509 const settings = await this.settings(workspace, general?.id ?? null);
510 const defaults = settings.default_channels.length
511 ? await this.db
512 .prepare(
513 "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(?))",
514 )
515 .bind(workspace.id, JSON.stringify(settings.default_channels))
516 .all<ChannelRow>()
517 : { results: [] as ChannelRow[] };
518 for (const channel of defaults.results) {
519 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 to520 }
521 await this.db.batch(statements);
522 }
523
524 /**
525 * Adds a member. Someone joining starts with everything already said
526 * read, so a long channel does not greet them with its whole history as
527 * unread.
528 */
529 private joinStatement(channelId: string, principal: string, role: "owner" | "member", at: string): D1PreparedStatement {
530 return this.db
531 .prepare(
532 "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)",
533 )
534 .bind(channelId, principal, role, at);
535 }
536
537 async sidebar(a: { workspace: string; viewer: Viewer }): Promise<Result<ChatSidebar>> {
538 const found = await this.viewerWorkspace(a.workspace, a.viewer);
539 if (!found.ok) return found;
540 const workspace = found.value;
541 const slug = a.workspace.toLowerCase();
542 const me = userKey(a.viewer!);
543 await this.ensureGeneral(workspace, me);
544
545 const [joined, unread, dmOthers, browsable] = await Promise.all([
546 this.db
547 .prepare(
548 `SELECT c.*, m.starred, m.muted, m.last_read_id
549 FROM channel_members m JOIN channels c ON c.id = m.channel_id
550 WHERE m.principal = ? AND c.workspace_id = ? AND c.archived_at IS NULL`,
551 )
552 .bind(me, workspace.id)
553 .all<ChannelRow & { starred: number; muted: number; last_read_id: string | null }>(),
554 this.db
555 .prepare(
556 `SELECT msg.channel_id, msg.id, msg.author, msg.mentions
557 FROM channel_members m
558 JOIN channels c ON c.id = m.channel_id
559 JOIN messages msg ON msg.channel_id = m.channel_id AND msg.id > COALESCE(m.last_read_id, '')
560 WHERE m.principal = ?1 AND c.workspace_id = ?2 AND c.archived_at IS NULL
561 AND msg.deleted_at IS NULL AND msg.author != ?1
562 LIMIT ${MAX_UNREAD_ROWS}`,
563 )
564 .bind(me, workspace.id)
565 .all<UnreadRow>(),
566 this.db
567 .prepare(
568 `SELECT o.channel_id, o.principal
569 FROM channel_members m
570 JOIN channels c ON c.id = m.channel_id AND c.kind = 'dm'
571 JOIN channel_members o ON o.channel_id = m.channel_id AND o.principal != m.principal
572 WHERE m.principal = ? AND c.workspace_id = ? AND c.archived_at IS NULL
573 ORDER BY o.joined_at, o.principal`,
574 )
575 .bind(me, workspace.id)
576 .all<{ channel_id: string; principal: string }>(),
577 this.db
578 .prepare(
579 `SELECT COUNT(*) AS n FROM channels c
580 WHERE c.workspace_id = ? AND c.kind = 'channel' AND c.private = 0 AND c.archived_at IS NULL
581 AND NOT EXISTS (SELECT 1 FROM channel_members m WHERE m.channel_id = c.id AND m.principal = ?)`,
582 )
583 .bind(workspace.id, me)
584 .first<{ n: number }>(),
585 ]);
586
587 const others = new Map<string, string[]>();
588 for (const row of dmOthers.results) others.set(row.channel_id, [...(others.get(row.channel_id) ?? []), row.principal]);
589 const profiles = await this.profiles(slug, workspace, [me, ...dmOthers.results.map((r) => r.principal)]);
590 const self = profiles.get(me) ?? null;
591 const counts = tally(
592 unread.results,
593 me,
594 self?.name ?? a.viewer!.username,
595 new Map(joined.results.map((row) => [row.id, row.last_read_id])),
596 );
597
598 const entries: ChatSidebarEntry[] = joined.results.map((row) => {
599 const faces = (others.get(row.id) ?? []).map((key) => profiles.get(key)!).filter(Boolean);
600 const count = counts.get(row.id) ?? { unread: 0, mentions: 0 };
601 return {
602 channel: toChannel(row),
603 title: row.kind === "dm" ? dmTitle(faces, self) : (row.name ?? ""),
604 others: row.kind === "dm" ? faces.slice(0, DM_FACES) : [],
605 starred: !!row.starred,
606 muted: !!row.muted,
607 unread: count.unread,
608 mentions: count.mentions,
609 };
610 });
Chat controls, public profiles, shadcn selects, and no Docs tab in a project611 const settings = await this.settings(workspace);
612 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 to613 }
614
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.615 /**
616 * What was said in `[from, until)` where the viewer can read: every
617 * public channel of the workspace, and the private channels and direct
618 * messages they are in (as `place` reads). Text messages and thread
619 * replies not deleted; never a card. Message ids sort by time, so each
620 * conversation's span is one range of `messages_by_channel`, read in one
621 * round trip for them all. Counts only, nothing of what was said.
622 */
623 async activity(a: { workspace: string; viewer: Viewer; from: string; until: string }): Promise<Result<ChatActivity>> {
624 const found = await this.viewerWorkspace(a.workspace, a.viewer);
625 if (!found.ok) return found;
626 const workspace = found.value;
627 const span = activitySpan(a.from, a.until);
628 if (!span) return fail("invalid", "from and until must be RFC 3339 times, from before until.");
629 const me = userKey(a.viewer!);
630 const rows = await this.db
631 .prepare(
632 `SELECT msg.channel_id, msg.author, COUNT(*) AS n
633 FROM channels c
634 JOIN messages msg ON msg.channel_id = c.id AND msg.id >= ?3 AND msg.id < ?4
635 WHERE c.workspace_id = ?1 AND c.archived_at IS NULL
636 AND (
637 (c.kind = 'channel' AND c.private = 0)
638 OR EXISTS (SELECT 1 FROM channel_members m WHERE m.channel_id = c.id AND m.principal = ?2)
639 )
640 AND msg.deleted_at IS NULL AND msg.kind = 'text'
641 GROUP BY msg.channel_id, msg.author`,
642 )
643 .bind(workspace.id, me, idFloor("msg", span.fromMs), idFloor("msg", span.untilMs))
644 .all<ActivityRow>();
645 return ok(chatActivity(rows.results, { from: span.from, until: span.until }));
646 }
647
Chat and workspace agents: channels, DMs and named agents you talk to648 // ── Channels ────────────────────────────────────────────────────────────
649
Chat controls, public profiles, shadcn selects, and no Docs tab in a project650 async channel(a: { workspace: string; channel_id: string; viewer: Viewer }): Promise<Result<ChannelDetail>> {
Chat and workspace agents: channels, DMs and named agents you talk to651 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
652 if (!found.ok) return found;
Chat controls, public profiles, shadcn selects, and no Docs tab in a project653 const { slug, workspace, channel, member } = found.value;
654 const [rows, settings] = await Promise.all([
655 this.db
656 .prepare("SELECT * FROM channel_members WHERE channel_id = ? ORDER BY joined_at, principal")
657 .bind(channel.id)
658 .all<MemberRow>(),
659 this.settings(workspace),
660 ]);
Chat and workspace agents: channels, DMs and named agents you talk to661 const profiles = await this.profiles(slug, workspace, rows.results.map((r) => r.principal));
662 return ok({
663 channel: toChannel(channel),
Chat controls, public profiles, shadcn selects, and no Docs tab in a project664 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 to665 members: rows.results.map((row) => ({
666 channel_id: row.channel_id,
667 member: profiles.get(row.principal)!,
668 role: row.role,
669 starred: !!row.starred,
670 muted: !!row.muted,
671 last_read_id: row.last_read_id,
672 joined_at: row.joined_at,
673 })),
674 });
675 }
676
677 /** 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 project678 async channelByName(a: { workspace: string; name: string; viewer: Viewer }): Promise<Result<ChannelDetail>> {
Chat and workspace agents: channels, DMs and named agents you talk to679 const found = await this.viewerWorkspace(a.workspace, a.viewer);
680 if (!found.ok) return found;
681 const named = channelName(a.name ?? "");
682 if (!named.ok) return fail("not_found", "No such channel.");
683 const row = await this.db
684 .prepare("SELECT id FROM channels WHERE workspace_id = ? AND name = ?")
685 .bind(found.value.id, named.name)
686 .first<{ id: string }>();
687 if (!row) return fail("not_found", "No such channel.");
688 // A private channel the viewer is not in stays not found there.
689 return this.channel({ workspace: a.workspace, channel_id: row.id, viewer: a.viewer });
690 }
691
Chat controls, public profiles, shadcn selects, and no Docs tab in a project692 /**
693 * Every public channel, and the private ones the viewer is in; a private
694 * one they are not in stays unseen. With `archived`, the archived ones.
695 */
696 async browse(a: { workspace: string; viewer: Viewer; archived?: boolean }): Promise<Result<Channel[]>> {
Chat and workspace agents: channels, DMs and named agents you talk to697 const found = await this.viewerWorkspace(a.workspace, a.viewer);
698 if (!found.ok) return found;
699 const rows = await this.db
700 .prepare(
Chat controls, public profiles, shadcn selects, and no Docs tab in a project701 `SELECT * FROM channels c
702 WHERE c.workspace_id = ?1 AND c.kind = 'channel'
703 AND (c.archived_at IS NULL) = (?3 = 0)
704 AND (c.private = 0 OR EXISTS (SELECT 1 FROM channel_members m WHERE m.channel_id = c.id AND m.principal = ?2))
705 ORDER BY c.name`,
Chat and workspace agents: channels, DMs and named agents you talk to706 )
Chat controls, public profiles, shadcn selects, and no Docs tab in a project707 .bind(found.value.id, userKey(a.viewer!), a.archived === true ? 1 : 0)
Chat and workspace agents: channels, DMs and named agents you talk to708 .all<ChannelRow>();
709 return ok(rows.results.map(toChannel));
710 }
711
712 async createChannel(a: { workspace: string; viewer: Viewer; input: NewChannel }): Promise<Result<Channel>> {
713 const found = await this.viewerWorkspace(a.workspace, a.viewer);
714 if (!found.ok) return found;
715 const workspace = found.value;
716 const named = channelName(a.input?.name ?? "");
717 if (!named.ok) return fail("invalid", named.message);
718 const topic = typeof a.input?.topic === "string" ? a.input.topic.trim() : "";
719 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 project720 const isPrivate = !!a.input?.private;
721 if (!mayCreateChannel(await this.settings(workspace), roleOf(a.viewer, a.workspace), isPrivate)) {
722 return fail("forbidden", `Only owners can create ${isPrivate ? "private" : "public"} channels in this workspace.`);
723 }
Chat and workspace agents: channels, DMs and named agents you talk to724 const taken = await this.db
725 .prepare("SELECT 1 FROM channels WHERE workspace_id = ? AND name = ?")
726 .bind(workspace.id, named.name)
727 .first();
728 if (taken) return fail("conflict", `#${named.name} already exists.`);
729 const me = userKey(a.viewer!);
730 const row: ChannelRow = {
731 id: newId("chn"),
732 workspace_id: workspace.id,
733 kind: "channel",
734 name: named.name,
735 topic: topic || null,
Chat controls, public profiles, shadcn selects, and no Docs tab in a project736 private: isPrivate ? 1 : 0,
Chat and workspace agents: channels, DMs and named agents you talk to737 dm_key: null,
738 created_by: me,
739 created_at: now(),
740 archived_at: null,
741 last_message_at: null,
742 };
743 try {
744 await this.db.batch([
745 this.db
746 .prepare(
747 "INSERT INTO channels (id, workspace_id, kind, name, topic, private, created_by, created_at) VALUES (?, ?, 'channel', ?, ?, ?, ?, ?)",
748 )
749 .bind(row.id, row.workspace_id, row.name, row.topic, row.private, me, row.created_at),
750 this.joinStatement(row.id, me, "owner", row.created_at),
751 ]);
752 } catch (error) {
753 if (String(error).includes("UNIQUE")) return fail("conflict", `#${named.name} already exists.`);
754 throw error;
755 }
756 return ok(toChannel(row));
757 }
758
759 async openDm(a: { workspace: string; viewer: Viewer; members: Principal[] }): Promise<Result<Channel>> {
760 const found = await this.viewerWorkspace(a.workspace, a.viewer);
761 if (!found.ok) return found;
762 const workspace = found.value;
763 const slug = a.workspace.toLowerCase();
764 const me = userKey(a.viewer!);
765 const asked = Array.isArray(a.members) ? a.members : [];
766 const principals: Principal[] = [];
767 for (const member of asked) {
768 const p = member && parsePrincipalKey(`${member.kind}:${member.id}`);
769 if (!p) return fail("invalid", "Each member is a person or an agent, by id.");
770 principals.push(p);
771 }
772 const members = dmMembers(me, principals.map(principalKey));
773 if (members.length > MAX_DM_MEMBERS) {
774 return fail("invalid", `A direct message has at most ${MAX_DM_MEMBERS} people and agents. Make a private channel instead.`);
775 }
776 const key = dmKey(members);
777 const existing = await this.db
778 .prepare("SELECT * FROM channels WHERE workspace_id = ? AND dm_key = ?")
779 .bind(workspace.id, key)
780 .first<ChannelRow>();
781 if (existing) return ok(toChannel(existing));
782
783 for (const member of members) {
784 if (member === me) continue;
785 const p = parsePrincipalKey(member)!;
786 if (!(await this.belongs(slug, workspace, p))) {
787 return fail("not_found", p.kind === "agent" ? "No such agent in this workspace." : "That person is not in this workspace.");
788 }
Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how.789 // A member's personal agent: only in the DM of the two of them.
790 if (p.kind === "agent") {
791 const agent = await this.liveAgent(workspace, p.id);
792 const refused = agent ? personalAgentRefusal(agent, { kind: "dm", members }) : null;
793 if (refused) return fail("forbidden", refused);
794 }
Chat and workspace agents: channels, DMs and named agents you talk to795 }
796 const at = now();
797 await this.db
798 .prepare(
799 "INSERT OR IGNORE INTO channels (id, workspace_id, kind, private, dm_key, created_by, created_at) VALUES (?, ?, 'dm', 1, ?, ?, ?)",
800 )
801 .bind(newId("chn"), workspace.id, key, me, at)
802 .run();
803 // Read back by key: if two people opened it at once, both get the one that won.
804 const channel = await this.db
805 .prepare("SELECT * FROM channels WHERE workspace_id = ? AND dm_key = ?")
806 .bind(workspace.id, key)
807 .first<ChannelRow>();
808 if (!channel) return fail("conflict", "The direct message could not be opened. Try again.");
809 await this.db.batch(members.map((member) => this.joinStatement(channel.id, member, "member", at)));
810 return ok(toChannel(channel));
811 }
812
813 async join(a: { workspace: string; channel_id: string; viewer: Viewer }): Promise<Result<null>> {
814 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
815 if (!found.ok) return found;
816 const { channel, member } = found.value;
817 if (member) return ok(null);
818 if (channel.archived_at) return fail("invalid", "This channel is archived.");
819 await this.joinStatement(channel.id, userKey(a.viewer!), "member", now()).run();
820 return ok(null);
821 }
822
823 async leave(a: { workspace: string; channel_id: string; viewer: Viewer }): Promise<Result<null>> {
824 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
825 if (!found.ok) return found;
826 const { channel, member } = found.value;
827 if (!member) return ok(null);
828 if (channel.kind === "dm") return fail("invalid", "A direct message can't be left. Mute it instead.");
829 const me = userKey(a.viewer!);
830 await this.db.prepare("DELETE FROM channel_members WHERE channel_id = ? AND principal = ?").bind(channel.id, me).run();
831 // Out of a private channel, they may no longer read it, live either.
832 if (channel.private) {
833 this.defer(this.room(channel.id).drop(me).catch((error: unknown) => console.error("chat could not drop", me, error)));
834 }
835 return ok(null);
836 }
837
838 async invite(a: { workspace: string; channel_id: string; viewer: Viewer; member: Principal }): Promise<Result<null>> {
839 const found = await this.place(a.workspace, a.channel_id, a.viewer, "member");
840 if (!found.ok) return found;
841 const { slug, workspace, channel } = found.value;
842 if (channel.kind === "dm") return fail("invalid", "People can't be added to a direct message. Start a new one with everyone in it.");
843 if (channel.archived_at) return fail("invalid", "This channel is archived.");
844 const p = a.member && parsePrincipalKey(`${a.member.kind}:${a.member.id}`);
845 if (!p) return fail("invalid", "Invite a person or an agent, by id.");
846 if (!(await this.belongs(slug, workspace, p))) {
847 return fail("not_found", p.kind === "agent" ? "No such agent in this workspace." : "That person is not in this workspace.");
848 }
Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how.849 if (p.kind === "agent") {
850 const agent = await this.liveAgent(workspace, p.id);
851 const refused = agent ? personalAgentRefusal(agent, { kind: "channel", members: null }) : null;
852 if (refused) return fail("forbidden", refused);
853 }
Chat and workspace agents: channels, DMs and named agents you talk to854 await this.joinStatement(channel.id, principalKey(p), "member", now()).run();
855 return ok(null);
856 }
857
858 async setPreferences(a: {
859 workspace: string;
860 channel_id: string;
861 viewer: Viewer;
862 prefs: { starred?: boolean; muted?: boolean };
863 }): Promise<Result<null>> {
864 const found = await this.place(a.workspace, a.channel_id, a.viewer, "member");
865 if (!found.ok) return found;
866 const starred = typeof a.prefs?.starred === "boolean" ? (a.prefs.starred ? 1 : 0) : null;
867 const muted = typeof a.prefs?.muted === "boolean" ? (a.prefs.muted ? 1 : 0) : null;
868 await this.db
869 .prepare(
870 "UPDATE channel_members SET starred = COALESCE(?, starred), muted = COALESCE(?, muted) WHERE channel_id = ? AND principal = ?",
871 )
872 .bind(starred, muted, found.value.channel.id, userKey(a.viewer!))
873 .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)874 // Notify: the badge counts the conversation again, or leaves it out, in every tab.
875 if (muted !== null) {
876 this.defer(
877 notifyMuted(this.env.NOTIFY, { slug: found.value.slug, channel_id: found.value.channel.id, user_id: a.viewer!.id, muted: !!muted }).catch((error) =>
878 console.error("chat could not notify a mute", error),
879 ),
880 );
881 }
Chat and workspace agents: channels, DMs and named agents you talk to882 return ok(null);
883 }
884
Chat controls, public profiles, shadcn selects, and no Docs tab in a project885 // ── What owners decide (src/settings.ts) ────────────────────────────────
886
887 /** The row of a workspace's chat settings, read once per request. */
888 private settingsRow(workspace: Workspace): Promise<SettingsRow | null> {
889 let found = this.settingsRows.get(workspace.id);
890 if (!found) {
891 found = this.db
892 .prepare("SELECT emoji_upload, public_channels, private_channels, manage_channels, default_channels FROM chat_settings WHERE workspace_id = ?")
893 .bind(workspace.id)
894 .first<SettingsRow>();
895 this.settingsRows.set(workspace.id, found);
896 }
897 return found;
898 }
899
900 /**
901 * A workspace's chat settings. Default channels never chosen are
902 * #general, looked up unless `generalId` is given.
903 */
904 private async settings(workspace: Workspace, generalId?: string | null): Promise<ChatSettings> {
905 const row = await this.settingsRow(workspace);
906 if (row?.default_channels != null || generalId !== undefined) return settingsOf(row, generalId ?? null);
907 const general = await this.db
908 .prepare("SELECT id FROM channels WHERE workspace_id = ? AND name = ? AND kind = 'channel'")
909 .bind(workspace.id, GENERAL)
910 .first<{ id: string }>();
911 return settingsOf(row, general?.id ?? null);
912 }
913
914 async chatSettings(a: { workspace: string; viewer: Viewer }): Promise<Result<ChatSettingsView>> {
915 const found = await this.viewerWorkspace(a.workspace, a.viewer);
916 if (!found.ok) return found;
917 const workspace = found.value;
918 const [settings, channels] = await Promise.all([
919 this.settings(workspace),
920 this.db
921 .prepare("SELECT * FROM channels WHERE workspace_id = ? AND kind = 'channel' AND private = 0 AND archived_at IS NULL ORDER BY name")
922 .bind(workspace.id)
923 .all<ChannelRow>(),
924 ]);
925 return ok({ settings, can: permissionsFor(settings, roleOf(a.viewer, a.workspace)), channels: channels.results.map(toChannel) });
926 }
927
928 async setChatSettings(a: { workspace: string; viewer: Viewer; change: Partial<ChatSettings> }): Promise<Result<ChatSettings>> {
929 const found = await this.viewerWorkspace(a.workspace, a.viewer);
930 if (!found.ok) return found;
931 const workspace = found.value;
932 if (roleOf(a.viewer, a.workspace) !== "owner") return fail("forbidden", "Only owners can change the workspace's chat settings.");
933 const checked = settingsChange(a.change);
934 if (!checked.ok) return fail("invalid", checked.message);
935 const change = checked.change;
936 if (change.default_channels) {
937 // Only the workspace's public channels that are not archived: a private
938 // one would put people somewhere they were never invited.
939 const rows = await this.db
940 .prepare(
941 "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(?))",
942 )
943 .bind(workspace.id, JSON.stringify(change.default_channels))
944 .all<{ id: string }>();
945 const open = new Set(rows.results.map((r) => r.id));
946 if (change.default_channels.some((id) => !open.has(id))) return fail("invalid", "Default channels must be public channels that are not archived.");
947 }
948 const next: ChatSettings = { ...(await this.settings(workspace)), ...change };
949 const row = rowFor(next);
950 await this.db
951 .prepare(
952 `INSERT INTO chat_settings (workspace_id, emoji_upload, public_channels, private_channels, manage_channels, default_channels)
953 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
954 ON CONFLICT (workspace_id) DO UPDATE SET emoji_upload = ?2, public_channels = ?3, private_channels = ?4, manage_channels = ?5, default_channels = ?6`,
955 )
956 .bind(workspace.id, row.emoji_upload, row.public_channels, row.private_channels, row.manage_channels, row.default_channels)
957 .run();
958 this.settingsRows.delete(workspace.id);
959 return ok(next);
960 }
961
962 /**
963 * Renames, archives or unarchives a channel, or changes its topic. The
964 * workspace's `manage_channels` setting says who may do the first three;
965 * any member may change the topic. #general stays #general, and stays.
966 */
967 async updateChannel(a: { workspace: string; channel_id: string; viewer: Viewer; change: ChannelChange }): Promise<Result<Channel>> {
968 // Archived or not, a channel is found the same way; a private one only by its members.
969 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
970 if (!found.ok) return found;
971 const { slug, workspace, channel, member } = found.value;
972 if (channel.kind === "dm") return fail("invalid", "A direct message has no name or topic to change.");
973 const change = a.change ?? {};
974 const next: ChannelRow = { ...channel };
975 if (change.name !== undefined || change.archived !== undefined) {
976 const settings = await this.settings(workspace);
977 if (!mayManageChannel(settings, roleOf(a.viewer, slug), member?.role ?? null)) {
978 return fail(
979 "forbidden",
980 settings.manage_channels === "owners"
981 ? "Only workspace owners can rename or archive channels here."
982 : "Only this channel's owners and workspace owners can rename or archive it.",
983 );
984 }
985 if (channel.name === GENERAL) return fail("invalid", "#general is where everyone is: it can't be renamed or archived.");
986 }
987 if (change.name !== undefined) {
988 const named = channelName(String(change.name ?? ""));
989 if (!named.ok) return fail("invalid", named.message);
990 if (named.name !== channel.name) {
991 const taken = await this.db
992 .prepare("SELECT 1 FROM channels WHERE workspace_id = ? AND name = ? AND id != ?")
993 .bind(workspace.id, named.name, channel.id)
994 .first();
995 if (taken || named.name === GENERAL) return fail("conflict", `#${named.name} already exists.`);
996 }
997 next.name = named.name;
998 }
999 if (change.topic !== undefined) {
1000 if (!member) return fail("forbidden", `Join #${channel.name} first.`);
1001 const topic = typeof change.topic === "string" ? change.topic.trim() : "";
1002 if (topic.length > MAX_TOPIC) return fail("invalid", `A topic is at most ${MAX_TOPIC} characters.`);
1003 next.topic = topic || null;
1004 }
1005 if (change.archived !== undefined) {
1006 next.archived_at = change.archived ? (channel.archived_at ?? now()) : null;
1007 } else if (channel.archived_at && (change.name !== undefined || change.topic !== undefined)) {
1008 return fail("invalid", "This channel is archived. Unarchive it first.");
1009 }
1010 try {
1011 await this.db
1012 .prepare("UPDATE channels SET name = ?, topic = ?, archived_at = ? WHERE id = ?")
1013 .bind(next.name, next.topic, next.archived_at, channel.id)
1014 .run();
1015 } catch (error) {
1016 if (String(error).includes("UNIQUE")) return fail("conflict", `#${next.name} already exists.`);
1017 throw error;
1018 }
1019 const updated = toChannel(next);
1020 this.broadcast(channel.id, { type: "channel.updated", channel: updated });
1021 return ok(updated);
1022 }
1023
Chat and workspace agents: channels, DMs and named agents you talk to1024 // ── Messages ────────────────────────────────────────────────────────────
1025
1026 /**
1027 * Newest first, a page at a time. With `thread_root`, that thread's
1028 * replies, and the message they reply to as the oldest once the page
1029 * reaches the start of the thread; without, the channel's top-level
1030 * messages. A deleted message stays only while replies hang off it.
1031 */
1032 async messages(a: {
1033 workspace: string;
1034 channel_id: string;
1035 viewer: Viewer;
1036 before?: string | null;
1037 after?: string | null;
1038 limit?: number | null;
1039 thread_root?: string | null;
1040 }): Promise<Result<MessagePage>> {
1041 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1042 if (!found.ok) return found;
1043 const { slug, workspace, channel } = found.value;
1044 const size = pageSize(a.limit);
1045 // Ids are lowercase letters, digits and `_`, so `~` sorts after every
1046 // one: the first page reads from the newest as a range of the index.
1047 const before = typeof a.before === "string" && a.before ? a.before : "~";
1048 const root = typeof a.thread_root === "string" && a.thread_root ? a.thread_root : null;
1049 if (typeof a.after === "string" && a.after) {
1050 // Catching up after a reconnect: what came after, oldest first. Deleted
1051 // ones too, so the client drops them; read one past the page to know
1052 // whether there is more.
1053 const newer = root
1054 ? await this.db
1055 .prepare("SELECT * FROM messages WHERE thread_root = ? AND channel_id = ? AND id > ? ORDER BY id LIMIT ?")
1056 .bind(root, channel.id, a.after, size + 1)
1057 .all<MessageRow>()
1058 : await this.db
1059 .prepare("SELECT * FROM messages WHERE channel_id = ? AND thread_root IS NULL AND id > ? ORDER BY id LIMIT ?")
1060 .bind(channel.id, a.after, size + 1)
1061 .all<MessageRow>();
1062 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)1063 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 to1064 }
1065 const rows = root
1066 ? await this.db
1067 .prepare(
1068 `SELECT * FROM messages WHERE thread_root = ?1 AND channel_id = ?2 AND id < ?3
1069 ORDER BY id DESC LIMIT ?4`,
1070 )
1071 .bind(root, channel.id, before, size + 1)
1072 .all<MessageRow>()
1073 : await this.db
1074 .prepare(
1075 `SELECT * FROM messages
1076 WHERE channel_id = ?1 AND thread_root IS NULL AND id < ?2
1077 AND (deleted_at IS NULL OR reply_count > 0)
1078 ORDER BY id DESC LIMIT ?3`,
1079 )
1080 .bind(channel.id, before, size + 1)
1081 .all<MessageRow>();
1082 const page = pageOf(rows.results, size);
1083 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 thread1084 // The message a thread is under: the oldest once the page reaches the
1085 // start, and, on the first page, as `root` however long the thread is,
1086 // so a session's card stays at the top of its thread.
1087 const firstPage = before === "~";
1088 const rootRow = root && (page.older === null || firstPage) ? await this.messageRow(channel.id, root) : null;
1089 if (rootRow && page.older === null) list = [...list, rootRow];
1090 const messages = await this.toMessages(slug, workspace, list, userKey(a.viewer!));
1091 if (!rootRow || !firstPage) return ok({ messages, older: page.older });
1092 const shown = messages.find((m) => m.id === rootRow.id) ?? (await this.toMessages(slug, workspace, [rootRow], userKey(a.viewer!)))[0] ?? null;
1093 return ok({ messages, older: page.older, root: shown });
Chat and workspace agents: channels, DMs and named agents you talk to1094 }
1095
1096 /**
1097 * Writes a message and everything that follows from it: the thread's
1098 * reply count, the channel's last activity, the author's own read mark,
1099 * the meter; then, after answering, tells the room and wakes the agents
Merge branch 'worktree-agent-a1398e81ad1a64c5f'1100 * it is for: those a person's message is for (src/delivery.ts), or, for
1101 * an agent's, only `wake` (a hand-off's colleague), never by mention.
Chat and workspace agents: channels, DMs and named agents you talk to1102 */
1103 private async write(
1104 place: Place,
1105 author: string,
1106 input: { body: string; card: MessageCard | null; thread_root: string | null },
1107 chain: Chain<AskerAccess>,
Merge branch 'worktree-agent-a1398e81ad1a64c5f'1108 wake: Wake[] = [],
Chat and workspace agents: channels, DMs and named agents you talk to1109 ): Promise<Result<ChatMessage>> {
1110 const { channel, workspace } = place;
1111 if (channel.archived_at) return fail("invalid", "This channel is archived.");
1112 let threadRoot: string | null = null;
1113 if (input.thread_root) {
1114 const root = await this.messageRow(channel.id, input.thread_root);
1115 if (!root || (root.deleted_at && !root.reply_count)) return fail("not_found", "No such message to reply to.");
1116 // A reply to a reply goes in the same thread.
1117 threadRoot = root.thread_root ?? root.id;
1118 }
1119 const at = now();
Merge branch 'worktree-agent-a1398e81ad1a64c5f'1120 // An agent's mention of someone who isn't here reaches nobody, so it reads as a plain name.
1121 const body = author.startsWith("agent:") ? await this.agentText(place, input.body) : input.body;
1122 const handles = mentionedHandles(body);
Chat and workspace agents: channels, DMs and named agents you talk to1123 const row: MessageRow = {
1124 id: newId("msg"),
1125 channel_id: channel.id,
1126 author,
1127 kind: input.card ? "card" : "text",
Merge branch 'worktree-agent-a1398e81ad1a64c5f'1128 body,
Chat and workspace agents: channels, DMs and named agents you talk to1129 card: input.card ? JSON.stringify(input.card) : null,
1130 mentions: mentionsColumn(handles),
1131 thread_root: threadRoot,
1132 reply_count: 0,
1133 last_reply_at: null,
1134 created_at: at,
1135 edited_at: null,
1136 deleted_at: null,
1137 };
1138 const statements = [
1139 this.db
1140 .prepare(
1141 "INSERT INTO messages (id, channel_id, author, kind, body, card, mentions, thread_root, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
1142 )
1143 .bind(row.id, row.channel_id, author, row.kind, row.body, row.card, row.mentions, threadRoot, at),
1144 this.db.prepare("UPDATE channels SET last_message_at = ? WHERE id = ?").bind(at, channel.id),
1145 // What you wrote, you have read.
1146 this.db
1147 .prepare("UPDATE channel_members SET last_read_id = ? WHERE channel_id = ? AND principal = ?")
1148 .bind(row.id, channel.id, author),
1149 this.db
1150 .prepare(
1151 `INSERT INTO chat_meter (workspace_id, day, messages, bytes) VALUES (?, ?, 1, ?)
1152 ON CONFLICT (workspace_id, day) DO UPDATE SET messages = messages + 1, bytes = bytes + excluded.bytes`,
1153 )
1154 .bind(workspace.id, meterDay(at), bytesOf(row.body) + bytesOf(row.card ?? "")),
1155 ];
1156 if (threadRoot) {
1157 statements.push(
1158 this.db
1159 .prepare("UPDATE messages SET reply_count = reply_count + 1, last_reply_at = ? WHERE id = ?")
1160 .bind(at, threadRoot),
1161 );
1162 }
1163 await this.db.batch(statements);
1164
1165 const [message] = await this.toMessages(place.slug, workspace, [row]);
1166 this.broadcast(channel.id, { type: "message.created", message });
1167 if (threadRoot) this.rebroadcast(place, threadRoot);
1168 this.defer(
Merge branch 'worktree-agent-a1398e81ad1a64c5f'1169 (wake.length ? this.deliverTo(place, row, chain, wake) : this.wake(place, row, handles, chain)).catch((error) =>
1170 console.error("chat could not hand", row.id, "to agents", error),
1171 ),
Chat and workspace agents: channels, DMs and named agents you talk to1172 );
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)1173 // Notify: counts for everyone in the conversation, a notification for those it is for.
1174 this.defer(
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread1175 notifyMessage(this.db, this.env.NOTIFY, (keys) => this.profiles(place.slug, workspace, keys), { slug: place.slug, channel, row, handles, asked_by: chain.asked_by }).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)1176 (error) => console.error("chat could not notify about", row.id, error),
1177 ),
1178 );
Chat and workspace agents: channels, DMs and named agents you talk to1179 return ok(message);
1180 }
1181
1182 /** Hands a new message to the agents it is for (src/delivery.ts). */
1183 private async wake(place: Place, row: MessageRow, handles: string[], chain: Chain<AskerAccess>): Promise<void> {
1184 const { channel, workspace } = place;
Merge branch 'worktree-agent-a1398e81ad1a64c5f'1185 // Only a person's message wakes anyone, and in a channel only by mention.
1186 if (!row.author.startsWith("user:") || chain.quiet) return;
Chat and workspace agents: channels, DMs and named agents you talk to1187 if (channel.kind === "channel" && !handles.length) return;
1188 const members = await this.db
1189 .prepare("SELECT principal FROM channel_members WHERE channel_id = ? AND principal LIKE 'agent:%'")
1190 .bind(channel.id)
1191 .all<{ principal: string }>();
1192 const ids = members.results.map((m) => m.principal.slice("agent:".length));
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)1193 let found = await this.agentsById(ids);
1194 const orchestratorIsMember = [...found.values()].some((agent) => !!agent?.builtin && agent.workspace_id === workspace.id && !agent.archived_at);
1195 if (addsOrchestrator({ channelKind: channel.kind, mentioned: handles, orchestratorIsMember })) {
1196 // Mentioning @g1t brings it in: every workspace has it, nobody invites it.
1197 const builtin = await workspaceAgentsClient(this.env.AGENTS)
1198 .builtin(place.slug, workspace.id)
1199 .catch((error: unknown) => {
1200 console.error("chat could not find @g1t for", place.slug, error);
1201 return null;
1202 });
1203 if (builtin?.ok) {
1204 await this.joinStatement(channel.id, `agent:${builtin.value.id}`, "member", now()).run();
1205 this.agents.set(builtin.value.id, builtin.value);
1206 ids.push(builtin.value.id);
1207 found = await this.agentsById(ids);
1208 }
1209 }
Chat and workspace agents: channels, DMs and named agents you talk to1210 if (!ids.length) return;
1211 const agents = [...found.values()].filter(
1212 (agent): agent is WorkspaceAgent => !!agent && agent.workspace_id === workspace.id && !agent.archived_at,
1213 );
1214 const wakes = deliveries({
1215 author: row.author,
1216 hops: chain.hops,
1217 channelKind: channel.kind,
1218 agents: agents.map((agent) => ({ id: agent.id, handle: agent.handle })),
1219 mentioned: handles,
1220 });
Merge branch 'worktree-agent-a1398e81ad1a64c5f'1221 await this.deliverTo(place, row, chain, wakes);
1222 }
1223
1224 /** Hands a message to these agents (src/delivery.ts `delivery`). */
1225 private async deliverTo(place: Place, row: { id: string; thread_root: string | null }, chain: Chain<AskerAccess>, wakes: Wake[]): Promise<void> {
1226 const { channel, workspace } = place;
Chat and workspace agents: channels, DMs and named agents you talk to1227 const client = workspaceAgentsClient(this.env.AGENTS);
1228 await Promise.all(
1229 wakes.map((wake) =>
1230 client
1231 .deliver(
1232 delivery(
1233 {
1234 workspace: place.slug,
1235 workspace_id: workspace.id,
1236 channel_id: channel.id,
1237 channel_kind: channel.kind,
1238 channel_name: channel.kind === "dm" ? null : channel.name,
1239 },
1240 wake,
1241 row,
1242 chain,
1243 ) satisfies AgentDelivery,
1244 )
1245 .then((result) => {
1246 if (!result.ok) console.error("agents refused delivery to", wake.agent_id, result.error.message);
1247 })
1248 .catch((error) => console.error("chat could not deliver to", wake.agent_id, error)),
1249 ),
1250 );
1251 }
1252
1253 async post(a: { workspace: string; channel_id: string; viewer: Viewer; message: PostMessage }): Promise<Result<ChatMessage>> {
1254 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1255 if (!found.ok) return found;
1256 const body = messageBody(a.message?.body);
1257 if (!body.ok) return fail("invalid", body.message);
1258 const me = userKey(a.viewer!);
1259 let place = found.value;
1260 if (!place.member) {
1261 // Saying something in a public channel joins it, as reading does not.
1262 if (place.channel.archived_at) return fail("invalid", "This channel is archived.");
1263 await this.joinStatement(place.channel.id, me, "member", now()).run();
1264 place = { ...place, member: { channel_id: place.channel.id, principal: me } as MemberRow };
1265 }
1266 return this.write(
1267 place,
1268 me,
1269 { body: body.body, card: null, thread_root: a.message?.thread_root ?? null },
Merge branch 'worktree-agent-a1398e81ad1a64c5f'1270 // A person's message starts a chain; a workflow job's token starts none.
1271 { hops: 0, asked_by: a.viewer!.id, asker: askerAccess(a.viewer!, a.workspace), chain: [], quiet: !!a.viewer!.token?.job },
Chat and workspace agents: channels, DMs and named agents you talk to1272 );
1273 }
1274
1275 async edit(a: { workspace: string; channel_id: string; viewer: Viewer; id: string; body: string }): Promise<Result<ChatMessage>> {
1276 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1277 if (!found.ok) return found;
1278 const place = found.value;
1279 const row = await this.messageRow(place.channel.id, a.id);
1280 if (!row || row.deleted_at) return fail("not_found", "No such message.");
1281 if (row.author !== userKey(a.viewer!)) return fail("forbidden", "Only its author can edit a message.");
1282 const body = messageBody(a.body, !!row.card);
1283 if (!body.ok) return fail("invalid", body.message);
1284 const at = now();
1285 const mentions = mentionsColumn(mentionedHandles(body.body));
1286 await this.db
1287 .prepare("UPDATE messages SET body = ?, mentions = ?, edited_at = ? WHERE id = ?")
1288 .bind(body.body, mentions, at, row.id)
1289 .run();
1290 const [message] = await this.toMessages(place.slug, place.workspace, [{ ...row, body: body.body, mentions, edited_at: at }]);
1291 this.broadcast(place.channel.id, { type: "message.updated", message });
1292 return ok(message);
1293 }
1294
1295 /** Deletes a message, keeping its place so its thread still hangs together. Its author or a workspace owner may. */
1296 async remove(a: { workspace: string; channel_id: string; viewer: Viewer; id: string }): Promise<Result<null>> {
1297 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1298 if (!found.ok) return found;
1299 const place = found.value;
1300 const row = await this.messageRow(place.channel.id, a.id);
1301 if (!row || row.deleted_at) return fail("not_found", "No such message.");
1302 if (row.author !== userKey(a.viewer!) && !isOwner(a.viewer, a.workspace)) {
1303 return fail("forbidden", "Only its author or a workspace owner can delete a message.");
1304 }
1305 const statements = [
1306 this.db
1307 .prepare("UPDATE messages SET deleted_at = ?, body = '', card = NULL, mentions = '' WHERE id = ?")
1308 .bind(now(), row.id),
1309 ];
1310 if (row.thread_root) {
1311 statements.push(
1312 this.db.prepare("UPDATE messages SET reply_count = MAX(reply_count - 1, 0) WHERE id = ?").bind(row.thread_root),
1313 );
1314 }
1315 await this.db.batch(statements);
1316 this.broadcast(place.channel.id, { type: "message.deleted", channel_id: place.channel.id, id: row.id });
1317 if (row.thread_root) this.rebroadcast(place, row.thread_root);
1318 return ok(null);
1319 }
1320
1321 async markRead(a: { workspace: string; channel_id: string; viewer: Viewer; id: string }): Promise<Result<null>> {
1322 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1323 if (!found.ok) return found;
1324 const { channel, member } = found.value;
1325 // Someone reading a public channel they have not joined keeps no read state.
1326 if (!member) return ok(null);
1327 const id = typeof a.id === "string" ? a.id : "";
1328 if (!id) return fail("invalid", "Say which message was read.");
1329 // Only forward: reading an old thread does not mark newer messages unread.
1330 const changed = await this.db
1331 .prepare(
1332 "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)",
1333 )
1334 .bind(id, channel.id, member.principal)
1335 .run();
1336 if (changed.meta.changes) {
1337 this.broadcast(channel.id, {
1338 type: "read",
1339 channel_id: channel.id,
1340 principal: { kind: "user", id: a.viewer!.id },
1341 last_read_id: id,
1342 });
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)1343 // Notify: the read drops the counts in every tab of theirs.
1344 this.defer(
1345 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(
1346 (error) => console.error("chat could not notify a read", error),
1347 ),
1348 );
Chat and workspace agents: channels, DMs and named agents you talk to1349 }
1350 return ok(null);
1351 }
1352
1353 // ── Agents ──────────────────────────────────────────────────────────────
1354
1355 /** The channel and agent for an agent's call: the agent must be of the workspace and in the channel. */
1356 private async agentPlace(slug: string, channelId: string, agentId: string): Promise<Result<{ place: Place; agent: WorkspaceAgent }>> {
1357 const workspace = await this.workspace(String(slug ?? ""));
1358 if (!workspace) return fail("not_found", "No such workspace.");
1359 const agent = await this.liveAgent(workspace, String(agentId ?? ""));
1360 if (!agent) return fail("not_found", "No such agent in this workspace.");
1361 const key = principalKey({ kind: "agent", id: agent.id });
1362 const [channel, member] = await Promise.all([
1363 this.db
1364 .prepare("SELECT * FROM channels WHERE id = ? AND workspace_id = ?")
1365 .bind(String(channelId ?? ""), workspace.id)
1366 .first<ChannelRow>(),
1367 this.db.prepare("SELECT * FROM channel_members WHERE channel_id = ? AND principal = ?").bind(String(channelId ?? ""), key).first<MemberRow>(),
1368 ]);
1369 if (!channel) return fail("not_found", "No such channel.");
1370 if (!member) return fail("forbidden", "The agent is not a member of this channel.");
1371 return ok({ place: { slug: slug.toLowerCase(), workspace, channel, member }, agent });
1372 }
1373
1374 async postAsAgent(a: { workspace: string; channel_id: string; agent_id: string; message: AgentPostMessage }): Promise<Result<ChatMessage>> {
1375 const found = await this.agentPlace(a.workspace, a.channel_id, a.agent_id);
1376 if (!found.ok) return found;
1377 const { place, agent } = found.value;
1378 const card = a.message?.card == null ? null : cleanCard(a.message.card);
1379 if (a.message?.card != null && !card) return fail("invalid", "A card needs a kind and a title.");
1380 const body = messageBody(a.message?.body ?? "", !!card);
1381 if (!body.ok) return fail("invalid", body.message);
1382 const hops = typeof a.message?.hops === "number" && a.message.hops >= 0 ? Math.floor(a.message.hops) : 0;
1383 const askedBy = typeof a.message?.asked_by === "string" && a.message.asked_by ? a.message.asked_by : agent.created_by;
1384 return this.write(
1385 place,
1386 principalKey({ kind: "agent", id: agent.id }),
1387 { body: body.body, card, thread_root: a.message?.thread_root ?? null },
1388 // The asker carries on from the delivery the agent is answering;
1389 // without one, agents it wakes treat the asker as unable to change code.
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)1390 { hops, asked_by: askedBy, asker: cleanAsker(a.message?.asker), chain: chainFor(a.message?.chain, agent.id) },
Chat and workspace agents: channels, DMs and named agents you talk to1391 );
1392 }
1393
Merge branch 'worktree-agent-a1398e81ad1a64c5f'1394 /** Everyone in a conversation, as member keys. */
1395 private async memberKeys(channelId: string): Promise<string[]> {
1396 const rows = await this.db.prepare("SELECT principal FROM channel_members WHERE channel_id = ?").bind(channelId).all<{ principal: string }>();
1397 return rows.results.map((row) => row.principal);
1398 }
1399
1400 /** An agent's text as it is kept: mentions of anyone not in the conversation lose their `@` (src/mentions.ts). */
1401 private async agentText(place: Place, body: string): Promise<string> {
1402 if (!body.includes("@")) return body;
1403 const profiles = await this.profiles(place.slug, place.workspace, await this.memberKeys(place.channel.id));
1404 return plainOutside(body, new Set([...profiles.values()].map((p) => p.name.toLowerCase())));
1405 }
1406
1407 /** What an agent is told about where it is answering, every turn. */
1408 async conversationForAgent(a: { workspace: string; channel_id: string; agent_id: string; asked_by?: string | null }): Promise<Result<ConversationForAgent>> {
1409 const found = await this.agentPlace(a.workspace, a.channel_id, a.agent_id);
1410 if (!found.ok) return found;
1411 const { place } = found.value;
1412 const rows = await this.db
1413 .prepare("SELECT principal FROM channel_members WHERE channel_id = ? ORDER BY joined_at, principal")
1414 .bind(place.channel.id)
1415 .all<{ principal: string }>();
1416 const keys = rows.results.map((row) => row.principal);
1417 const agents = keys.filter((key) => key.startsWith("agent:"));
1418 const people = keys.filter((key) => key.startsWith("user:"));
1419 // The person who asked first, then the earliest to join, up to the cap.
1420 const asker = typeof a.asked_by === "string" && a.asked_by ? `user:${a.asked_by}` : null;
1421 const shown = [...(asker && people.includes(asker) ? [asker] : []), ...people.filter((key) => key !== asker)].slice(0, CONVERSATION_PEOPLE_SHOWN);
1422 const profiles = await this.profiles(place.slug, place.workspace, [...agents, ...shown]);
1423 return ok({
1424 channel: toChannel(place.channel),
1425 members: [...agents, ...shown].map((key) => profiles.get(key)!).filter(Boolean),
1426 people: people.length,
1427 agents: agents.length,
1428 });
1429 }
1430
Chat and workspace agents: channels, DMs and named agents you talk to1431 /**
Merge branch 'worktree-agent-a1398e81ad1a64c5f'1432 * An agent hands work to a colleague agent for the person who asked
1433 * (`handOffAsAgent` in @g1t/contracts). The brief is the agent's message,
1434 * so everyone where it lands sees the work move; it wakes the colleague
1435 * and nobody else.
1436 */
1437 async handOffAsAgent(a: { workspace: string; channel_id: string; agent_id: string; hand_off: AgentHandOff }): Promise<Result<HandOffResult>> {
1438 const found = await this.agentPlace(a.workspace, a.channel_id, a.agent_id);
1439 if (!found.ok) return found;
1440 const { place, agent } = found.value;
1441 const input = a.hand_off ?? ({} as AgentHandOff);
1442 const brief = messageBody(input.brief);
1443 if (!brief.ok) return fail("invalid", brief.message);
1444 const colleague = await this.liveAgent(place.workspace, String(input.colleague_id ?? ""));
1445 if (!colleague) return fail("not_found", "No such agent in this workspace.");
Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how.1446 const personal = personalAgentRefusal(colleague, { kind: "hand_off", members: null });
1447 if (personal) return fail("invalid", personal);
Merge branch 'worktree-agent-a1398e81ad1a64c5f'1448 const given = typeof input.hops === "number" && input.hops >= 0 ? Math.floor(input.hops) : 0;
1449 const before = chainFor(input.chain, agent.id).slice(0, -1);
1450 const refused = handOffRefusal({ agent: agent.id, colleague, chain: before, hops: given });
1451 if (refused) return fail("invalid", refused);
1452 // Work is handed on for someone in this conversation, never for a stranger to it.
1453 const askedBy = typeof input.asked_by === "string" ? input.asked_by : "";
1454 const keys = await this.memberKeys(place.channel.id);
1455 const askerKey = principalKey({ kind: "user", id: askedBy });
1456 if (!askedBy || !keys.includes(askerKey)) return fail("forbidden", "Only the person who asked, in this conversation, can have work handed on.");
1457 const me = principalKey({ kind: "agent", id: agent.id });
1458 const them = principalKey({ kind: "agent", id: colleague.id });
1459 const chain: Chain<AskerAccess> = { hops: given + 1, asked_by: askedBy, asker: cleanAsker(input.asker), chain: [...before, agent.id] };
1460 const wake: Wake[] = [{ agent_id: colleague.id, hops: given + 1 }];
1461 const threadRoot = typeof input.thread_root === "string" && input.thread_root ? input.thread_root : null;
1462
1463 if (handOffPlace({ channelKind: place.channel.kind, members: keys.length, colleagueHere: keys.includes(them) }) === "here") {
1464 const posted = await this.write(place, me, { body: brief.body, card: null, thread_root: threadRoot }, chain, wake);
1465 return posted.ok ? ok({ where: "here", channel_id: place.channel.id, message_id: posted.value.id, opened: false }) : posted;
1466 }
1467
1468 // The group DM of the person, the agent and the colleague: the same three always get the same one.
1469 if (!(await this.belongs(place.slug, place.workspace, { kind: "user", id: askedBy }))) {
1470 return fail("forbidden", "Only members of the workspace can have work handed on to its agents.");
1471 }
1472 const members = dmMembers(askerKey, [me, them]);
1473 const key = dmKey(members);
1474 let dm = await this.db.prepare("SELECT * FROM channels WHERE workspace_id = ? AND dm_key = ?").bind(place.workspace.id, key).first<ChannelRow>();
1475 const opened = !dm;
1476 if (!dm) {
1477 const at = now();
1478 await this.db
1479 .prepare("INSERT OR IGNORE INTO channels (id, workspace_id, kind, private, dm_key, created_by, created_at) VALUES (?, ?, 'dm', 1, ?, ?, ?)")
1480 .bind(newId("chn"), place.workspace.id, key, askerKey, at)
1481 .run();
1482 dm = await this.db.prepare("SELECT * FROM channels WHERE workspace_id = ? AND dm_key = ?").bind(place.workspace.id, key).first<ChannelRow>();
1483 if (!dm) return fail("conflict", "The group message could not be opened. Try again.");
1484 await this.db.batch(members.map((member) => this.joinStatement(dm!.id, member, "member", at)));
1485 }
1486 const there: Place = { slug: place.slug, workspace: place.workspace, channel: dm, member: { channel_id: dm.id, principal: me } as MemberRow };
1487 const posted = await this.write(there, me, { body: brief.body, card: null, thread_root: null }, chain, wake);
1488 if (!posted.ok) return posted;
1489 // Where the work went, here, where it was asked for. It wakes nobody.
1490 const named = await this.profiles(place.slug, place.workspace, [askerKey]);
1491 const person = named.get(askerKey);
1492 await this.write(
1493 place,
1494 me,
1495 {
1496 body: "",
1497 card: {
1498 kind: "handoff",
1499 title: `${agent.display_name} handed this to ${colleague.display_name}`,
1500 detail: `${colleague.display_name} works on it in a group message with ${person ? person.display_name : "the person who asked"} and ${agent.display_name}.`,
1501 state: null,
1502 href: `/${place.slug}/-/chat/dm/${dm.id}`,
1503 },
1504 thread_root: threadRoot,
1505 },
1506 { ...chain, hops: given },
1507 ).catch((error: unknown) => console.error("chat could not post where a hand-off went", error));
1508 return ok({ where: "group_dm", channel_id: dm.id, message_id: posted.value.id, opened });
1509 }
1510
1511 /**
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar1512 * A person presses an action on a card. They must be able to read the
1513 * conversation; the card must offer the action; its owner (agents)
1514 * decides whether this person may, does it, and updates the card.
1515 */
1516 async cardAction(a: {
1517 workspace: string;
1518 channel_id: string;
1519 viewer: Viewer;
1520 message_id: string;
1521 action_id: string;
1522 input?: string | null;
1523 }): Promise<Result<CardActionResult>> {
1524 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1525 if (!found.ok) return found;
1526 const place = found.value;
1527 const row = await this.messageRow(place.channel.id, String(a.message_id ?? ""));
1528 if (!row || row.deleted_at || !row.card) return fail("not_found", "No such card.");
1529 const card = JSON.parse(row.card) as MessageCard;
1530 const action = card.actions?.find((x) => x.id === a.action_id);
1531 if (!action || action.href || card.owner !== "agents") return fail("invalid", "That card has no such action.");
1532 const input = typeof a.input === "string" ? a.input.slice(0, 4000) : null;
1533 if (action.input && !input?.trim()) return fail("invalid", `${action.input.label || "A value"} is needed.`);
1534 return workspaceAgentsClient(this.env.AGENTS).cardAction({
1535 workspace: place.slug,
1536 channel_id: place.channel.id,
1537 message_id: row.id,
1538 viewer: a.viewer!,
1539 card: { kind: card.kind, ref: card.ref ?? null },
1540 action_id: action.id,
1541 input,
1542 });
1543 }
1544
1545 /**
Chat controls, public profiles, shadcn selects, and no Docs tab in a project1546 * Changes a message the agent posted (a session's live card, say): its
1547 * body, its card, or both. Only the agent's own messages; it wakes
1548 * nobody, and everyone in the conversation sees it change.
1549 */
1550 async updateAsAgent(a: {
1551 workspace: string;
1552 channel_id: string;
1553 agent_id: string;
1554 id: string;
1555 change: { body?: string; card?: MessageCard | null };
1556 }): Promise<Result<ChatMessage>> {
1557 const found = await this.agentPlace(a.workspace, a.channel_id, a.agent_id);
1558 if (!found.ok) return found;
1559 const { place, agent } = found.value;
1560 const row = await this.messageRow(place.channel.id, String(a.id ?? ""));
1561 if (!row || row.deleted_at) return fail("not_found", "No such message.");
1562 if (row.author !== principalKey({ kind: "agent", id: agent.id })) return fail("forbidden", "An agent can change only its own messages.");
1563 const change = a.change ?? {};
1564 const card = change.card === undefined ? (row.card ? (JSON.parse(row.card) as MessageCard) : null) : change.card === null ? null : cleanCard(change.card);
1565 if (change.card && !card) return fail("invalid", "A card needs a kind and a title.");
Merge branch 'worktree-agent-a1398e81ad1a64c5f'1566 const body = messageBody(change.body === undefined ? row.body : await this.agentText(place, String(change.body ?? "")), !!card);
Chat controls, public profiles, shadcn selects, and no Docs tab in a project1567 if (!body.ok) return fail("invalid", body.message);
1568 const cardJson = card ? JSON.stringify(card) : null;
1569 const at = now();
1570 // A card's live state is not an edit a person made: no "edited" mark for it.
1571 const edited = change.body !== undefined && change.body !== row.body ? at : row.edited_at;
1572 await this.db
1573 .prepare("UPDATE messages SET body = ?, card = ?, kind = ?, edited_at = ? WHERE id = ?")
1574 .bind(body.body, cardJson, card ? "card" : "text", edited, row.id)
1575 .run();
1576 const [message] = await this.toMessages(place.slug, place.workspace, [{ ...row, body: body.body, card: cardJson, kind: card ? "card" : "text", edited_at: edited }]);
1577 this.broadcast(place.channel.id, { type: "message.updated", message });
1578 return ok(message);
1579 }
1580
1581 /**
Chat and workspace agents: channels, DMs and named agents you talk to1582 * What an agent reads before replying, oldest first: a thread (its root,
1583 * then its latest replies), or the channel's latest top-level messages.
1584 * Only where the agent is a member, so it reads only what was said where
1585 * it was invited. Deleted messages are left out, save a thread's root.
1586 */
1587 async historyForAgent(a: {
1588 workspace: string;
1589 channel_id: string;
1590 agent_id: string;
1591 thread_root?: string | null;
1592 limit?: number | null;
1593 }): Promise<Result<ChatMessage[]>> {
1594 const found = await this.agentPlace(a.workspace, a.channel_id, a.agent_id);
1595 if (!found.ok) return found;
1596 const { place } = found.value;
1597 const size = historySize(a.limit);
1598 if (typeof a.thread_root === "string" && a.thread_root) {
1599 const asked = await this.messageRow(place.channel.id, a.thread_root);
1600 if (!asked) return fail("not_found", "No such thread.");
1601 // Asked from a reply: its whole thread.
1602 const root = asked.thread_root ? await this.messageRow(place.channel.id, asked.thread_root) : asked;
1603 if (!root) return fail("not_found", "No such thread.");
1604 const replies = await this.db
1605 .prepare(
1606 "SELECT * FROM messages WHERE thread_root = ? AND channel_id = ? AND deleted_at IS NULL ORDER BY id DESC LIMIT ?",
1607 )
1608 .bind(root.id, place.channel.id, Math.max(0, size - 1))
1609 .all<MessageRow>();
1610 return ok(await this.toMessages(place.slug, place.workspace, historyOf(replies.results, root)));
1611 }
1612 const rows = await this.db
1613 .prepare(
1614 "SELECT * FROM messages WHERE channel_id = ? AND thread_root IS NULL AND id < '~' AND deleted_at IS NULL ORDER BY id DESC LIMIT ?",
1615 )
1616 .bind(place.channel.id, size)
1617 .all<MessageRow>();
1618 return ok(await this.toMessages(place.slug, place.workspace, historyOf(rows.results)));
1619 }
1620
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)1621 // ── What an agent may read (src/audience.ts) ───────────────────────────
1622
1623 /** The people in a conversation, by user id. */
1624 private async peopleIn(channelId: string): Promise<string[]> {
1625 const rows = await this.db
1626 .prepare("SELECT principal FROM channel_members WHERE channel_id = ? AND principal LIKE 'user:%'")
1627 .bind(channelId)
1628 .all<{ principal: string }>();
1629 return rows.results.map((row) => row.principal.slice("user:".length));
1630 }
1631
1632 /** A conversation of this workspace and who reads it, worked out here, never taken from a caller. */
1633 private async audienceOf(slug: string, channelId: string): Promise<Result<{ workspace: Workspace; channel: ChannelRow; audience: ChatAudience }>> {
1634 const workspace = await this.workspace(String(slug ?? "").toLowerCase());
1635 if (!workspace) return fail("not_found", "No such workspace.");
1636 const channel = await this.db
1637 .prepare("SELECT * FROM channels WHERE id = ? AND workspace_id = ?")
1638 .bind(String(channelId ?? ""), workspace.id)
1639 .first<ChannelRow>();
1640 if (!channel) return fail("not_found", "No such conversation.");
1641 const people = await this.peopleIn(channel.id);
1642 return ok({ workspace, channel, audience: { kind: audienceKind(channel), member_user_ids: people, member_count: people.length } });
1643 }
1644
1645 async audience(a: { workspace: string; channel_id: string }): Promise<Result<ChatAudience>> {
1646 const found = await this.audienceOf(a.workspace, a.channel_id);
1647 return found.ok ? ok(found.value.audience) : found;
1648 }
1649
1650 /**
1651 * The conversations of the workspace the audience of `channelId` may all
1652 * read: public channels, and the ones every person in it is in (a DM only
1653 * with exactly them). At most 500, most recently active first.
1654 */
1655 private async readableFor(workspace: Workspace, audience: ChatAudience): Promise<Map<string, ChannelRow>> {
1656 const people = audience.member_user_ids;
1657 const rows = isShared({ kind: audience.kind, user_ids: people }) || !people.length
1658 ? await this.db
1659 .prepare("SELECT * FROM channels WHERE workspace_id = ? AND kind = 'channel' AND private = 0 ORDER BY last_message_at DESC LIMIT 500")
1660 .bind(workspace.id)
1661 .all<ChannelRow>()
1662 : await this.db
1663 .prepare(
1664 `SELECT * FROM channels WHERE workspace_id = ?1 AND (
1665 (kind = 'channel' AND private = 0)
1666 OR id IN (SELECT channel_id FROM channel_members WHERE principal IN (${people.map((_, i) => `?${i + 2}`).join(", ")})
1667 GROUP BY channel_id HAVING COUNT(DISTINCT principal) = ${people.length})
1668 ) ORDER BY last_message_at DESC LIMIT 500`,
1669 )
1670 .bind(workspace.id, ...people.map((id) => `user:${id}`))
1671 .all<ChannelRow>();
1672 const out = new Map<string, ChannelRow>();
1673 for (const channel of rows.results) {
1674 // The query finds candidates; the rule decides, a DM's people included.
1675 const target = { kind: channel.kind, private: channel.private, user_ids: channel.kind === "channel" && !channel.private ? [] : await this.peopleIn(channel.id) };
1676 if (readableBy(target, { kind: audience.kind, user_ids: people })) out.set(channel.id, channel);
1677 }
1678 return out;
1679 }
1680
1681 private async found(slug: string, workspace: Workspace, channels: Map<string, ChannelRow>, rows: MessageRow[]): Promise<AgentFoundMessage[]> {
1682 const messages = await this.toMessages(slug, workspace, rows);
1683 return messages.map((message) => {
1684 const channel = channels.get(message.channel_id)!;
1685 return { channel_id: channel.id, channel: channel.kind === "dm" ? null : channel.name, message };
1686 });
1687 }
1688
1689 async searchForAgent(a: { workspace: string; channel_id: string; query: string; limit?: number | null }): Promise<Result<AgentFoundMessage[]>> {
1690 const found = await this.audienceOf(a.workspace, a.channel_id);
1691 if (!found.ok) return found;
1692 const pattern = likePattern(a.query);
1693 if (!pattern) return fail("invalid", "Search for at least two characters.");
1694 const { workspace, audience } = found.value;
1695 const channels = await this.readableFor(workspace, audience);
1696 if (!channels.size) return ok([]);
1697 const ids = [...channels.keys()];
1698 const limit = Math.min(20, Math.max(1, Math.floor(Number(a.limit) || 20)));
1699 const rows = await this.db
1700 .prepare(
1701 `SELECT * FROM messages WHERE channel_id IN (${ids.map(() => "?").join(", ")}) AND deleted_at IS NULL AND body LIKE ? ESCAPE '\\'
1702 ORDER BY id DESC LIMIT ?`,
1703 )
1704 .bind(...ids, pattern, limit)
1705 .all<MessageRow>();
1706 return ok(await this.found(a.workspace.toLowerCase(), workspace, channels, rows.results));
1707 }
1708
1709 async threadForAgent(a: { workspace: string; channel_id: string; target_channel_id: string; id: string }): Promise<Result<AgentFoundMessage[]>> {
1710 const found = await this.audienceOf(a.workspace, a.channel_id);
1711 if (!found.ok) return found;
1712 const { workspace, audience } = found.value;
1713 // The same answer for a conversation that is not there and one the audience may not read.
1714 const hidden = fail("not_found", "Not available in this conversation.");
1715 const target = await this.db
1716 .prepare("SELECT * FROM channels WHERE id = ? AND workspace_id = ?")
1717 .bind(String(a.target_channel_id ?? ""), workspace.id)
1718 .first<ChannelRow>();
1719 if (!target) return hidden;
1720 const people = target.kind === "channel" && !target.private ? [] : await this.peopleIn(target.id);
1721 if (!readableBy({ kind: target.kind, private: target.private, user_ids: people }, { kind: audience.kind, user_ids: audience.member_user_ids })) return hidden;
1722 const asked = await this.messageRow(target.id, String(a.id ?? ""));
1723 if (!asked) return hidden;
1724 const root = asked.thread_root ? await this.messageRow(target.id, asked.thread_root) : asked;
1725 if (!root) return hidden;
1726 const replies = await this.db
1727 .prepare("SELECT * FROM messages WHERE thread_root = ? AND channel_id = ? AND deleted_at IS NULL ORDER BY id DESC LIMIT 49")
1728 .bind(root.id, target.id)
1729 .all<MessageRow>();
1730 const rows = historyOf(replies.results, root).filter((row) => !row.deleted_at);
1731 return ok(await this.found(a.workspace.toLowerCase(), workspace, new Map([[target.id, target]]), rows));
1732 }
1733
Chat and workspace agents: channels, DMs and named agents you talk to1734 async agentTyping(a: { workspace: string; channel_id: string; agent_id: string }): Promise<Result<null>> {
1735 const found = await this.agentPlace(a.workspace, a.channel_id, a.agent_id);
1736 if (!found.ok) return found;
1737 const { place, agent } = found.value;
1738 const key = principalKey({ kind: "agent", id: agent.id });
1739 const member = await this.profile(place.slug, place.workspace, key);
1740 this.broadcast(place.channel.id, {
1741 type: "typing",
1742 channel_id: place.channel.id,
1743 member,
1744 until: new Date(Date.now() + AGENT_TYPING_MS).toISOString(),
1745 });
1746 return ok(null);
1747 }
1748
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)1749 // ── Reactions (src/emoji.ts) ────────────────────────────────────────────
1750
1751 /** One message's reactions now, as `me` sees them. */
1752 private async reactionsFor(place: Place, messageId: string, me: string): Promise<ChatReaction[]> {
1753 const list = (await this.reactionsOf([messageId], me)).get(messageId) ?? [];
1754 const profiles = await this.profiles(place.slug, place.workspace, list.flatMap((r) => r.by));
1755 return list.map((r) => ({ ...r, by: r.by.map((key) => profiles.get(key)!).filter(Boolean) }));
1756 }
1757
1758 /**
1759 * Adds or takes back `who`'s reaction. Each member reacts once with each
1760 * emoji, a message holds at most 50 different ones, and a workspace's own
1761 * emoji must exist to be used (taking one back never needs it to). The
1762 * room hears of each change.
1763 */
1764 private async reactTo(place: Place, who: string, messageId: unknown, input: unknown, remove: boolean): Promise<Result<ChatReaction[]>> {
1765 const parsed = reactionEmoji(input);
1766 if (!parsed.ok) return fail("invalid", parsed.message);
1767 const row = await this.messageRow(place.channel.id, String(messageId ?? ""));
1768 if (!row || row.deleted_at) return fail("not_found", "No such message.");
1769 let changed = 0;
1770 if (remove) {
1771 const done = await this.db
1772 .prepare("DELETE FROM reactions WHERE message_id = ? AND principal = ? AND emoji = ?")
1773 .bind(row.id, who, parsed.emoji)
1774 .run();
1775 changed = done.meta.changes;
1776 } else {
1777 if (parsed.custom) {
1778 const known = await this.db
1779 .prepare("SELECT 1 FROM custom_emoji WHERE workspace_id = ? AND name = ? AND deleted_at IS NULL")
1780 .bind(place.workspace.id, parsed.custom)
1781 .first();
1782 if (!known) return fail("not_found", `This workspace has no :${parsed.custom}: emoji.`);
1783 }
1784 const kinds = await this.db.prepare("SELECT DISTINCT emoji FROM reactions WHERE message_id = ?").bind(row.id).all<{ emoji: string }>();
1785 if (!roomForReaction(new Set(kinds.results.map((k) => k.emoji)), parsed.emoji)) {
1786 return fail("invalid", `A message can have at most ${MAX_REACTIONS_PER_MESSAGE} different reactions.`);
1787 }
1788 const done = await this.db
1789 .prepare("INSERT OR IGNORE INTO reactions (message_id, principal, emoji, created_at) VALUES (?, ?, ?, ?)")
1790 .bind(row.id, who, parsed.emoji, now())
1791 .run();
1792 changed = done.meta.changes;
1793 }
1794 if (changed) {
1795 const member = await this.profile(place.slug, place.workspace, who);
1796 this.broadcast(place.channel.id, {
1797 type: remove ? "reaction.removed" : "reaction.added",
1798 channel_id: place.channel.id,
1799 message_id: row.id,
1800 emoji: parsed.emoji,
1801 member,
1802 });
1803 }
1804 return ok(await this.reactionsFor(place, row.id, who));
1805 }
1806
1807 async react(a: { workspace: string; channel_id: string; viewer: Viewer; message_id: string; emoji: string }): Promise<Result<ChatReaction[]>> {
1808 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1809 if (!found.ok) return found;
1810 return this.reactTo(found.value, userKey(a.viewer!), a.message_id, a.emoji, false);
1811 }
1812
1813 async unreact(a: { workspace: string; channel_id: string; viewer: Viewer; message_id: string; emoji: string }): Promise<Result<ChatReaction[]>> {
1814 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1815 if (!found.ok) return found;
1816 return this.reactTo(found.value, userKey(a.viewer!), a.message_id, a.emoji, true);
1817 }
1818
1819 /** An agent's reaction counts like anyone's; it must be in the channel. */
1820 async reactAsAgent(a: {
1821 workspace: string;
1822 channel_id: string;
1823 agent_id: string;
1824 message_id: string;
1825 emoji: string;
1826 remove?: boolean;
1827 }): Promise<Result<ChatReaction[]>> {
1828 const found = await this.agentPlace(a.workspace, a.channel_id, a.agent_id);
1829 if (!found.ok) return found;
1830 const { place, agent } = found.value;
1831 return this.reactTo(place, principalKey({ kind: "agent", id: agent.id }), a.message_id, a.emoji, a.remove === true);
1832 }
1833
1834 // ── A workspace's own emoji (src/emoji.ts) ──────────────────────────────
1835
1836 private async emojiUpload(workspace: Workspace): Promise<EmojiUpload> {
Chat controls, public profiles, shadcn selects, and no Docs tab in a project1837 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)1838 }
1839
1840 private async toEmoji(slug: string, workspace: Workspace, rows: EmojiRow[]): Promise<CustomEmoji[]> {
1841 const profiles = await this.profiles(slug, workspace, rows.map((r) => r.created_by));
1842 return rows.map((row) => ({
1843 name: row.name,
1844 alias_of: row.alias_of,
1845 file: row.file,
1846 content_type: row.content_type,
1847 bytes: row.bytes,
1848 created_by: profiles.get(row.created_by)!,
1849 created_at: row.created_at,
1850 }));
1851 }
1852
1853 private liveEmoji(workspace: Workspace, name: string): Promise<EmojiRow | null> {
1854 return this.db
1855 .prepare("SELECT * FROM custom_emoji WHERE workspace_id = ? AND name = ? AND deleted_at IS NULL")
1856 .bind(workspace.id, name)
1857 .first<EmojiRow>();
1858 }
1859
1860 async listEmoji(a: { workspace: string; viewer: Viewer }): Promise<Result<EmojiList>> {
1861 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1862 if (!found.ok) return found;
1863 const workspace = found.value;
1864 const [rows, setting] = await Promise.all([
1865 this.db
1866 .prepare("SELECT * FROM custom_emoji WHERE workspace_id = ? AND deleted_at IS NULL ORDER BY name")
1867 .bind(workspace.id)
1868 .all<EmojiRow>(),
1869 this.emojiUpload(workspace),
1870 ]);
1871 const role = roleOf(a.viewer, a.workspace);
1872 return ok({
1873 emoji: await this.toEmoji(a.workspace.toLowerCase(), workspace, rows.results),
1874 emoji_upload: setting,
1875 can_upload: mayUpload(setting, role),
1876 can_manage: role === "owner",
1877 });
1878 }
1879
1880 /** Who may add one: checked against the workspace's setting. */
1881 private async mayAdd(workspace: Workspace, viewer: Viewer, slug: string): Promise<Result<null>> {
1882 const setting = await this.emojiUpload(workspace);
1883 if (!mayUpload(setting, roleOf(viewer, slug))) return fail("forbidden", "Only owners can add emoji in this workspace.");
1884 return ok(null);
1885 }
1886
1887 async addEmoji(a: { workspace: string; viewer: Viewer; name: string; file: EmojiFile }): Promise<Result<CustomEmoji>> {
1888 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1889 if (!found.ok) return found;
1890 const workspace = found.value;
1891 const allowed = await this.mayAdd(workspace, a.viewer, a.workspace);
1892 if (!allowed.ok) return allowed;
1893 const named = emojiName(a.name);
1894 if (!named.ok) return fail("invalid", named.message);
1895 if (await this.liveEmoji(workspace, named.name)) return fail("conflict", `:${named.name}: is already taken.`);
1896 const bytes = fromBase64(a.file?.data);
1897 if (!bytes) return fail("invalid", "Choose a PNG, GIF or WebP image of at most 256 KB.");
1898 const checked = emojiImage(bytes);
1899 if (!checked.ok) return fail("invalid", checked.message);
1900 const file = await sha256(bytes);
1901 // Kept by its hash, so the same image stored twice is one file; its
1902 // type is the one read from its bytes (usercontent serves only that).
1903 await this.env.AVATARS.put(`emoji/${file}`, bytes, { metadata: { contentType: checked.image.content_type } });
1904 const row: EmojiRow = {
1905 workspace_id: workspace.id,
1906 name: named.name,
1907 alias_of: null,
1908 file,
1909 content_type: checked.image.content_type,
1910 bytes: bytes.length,
1911 created_by: userKey(a.viewer!),
1912 created_at: now(),
1913 deleted_at: null,
1914 };
1915 const added = await this.insertEmoji(row);
1916 if (!added.ok) return added;
1917 const [emoji] = await this.toEmoji(a.workspace.toLowerCase(), workspace, [row]);
1918 return ok(emoji!);
1919 }
1920
1921 private async insertEmoji(row: EmojiRow): Promise<Result<null>> {
1922 try {
1923 await this.db
1924 .prepare(
1925 "INSERT INTO custom_emoji (workspace_id, name, alias_of, file, content_type, bytes, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
1926 )
1927 .bind(row.workspace_id, row.name, row.alias_of, row.file, row.content_type, row.bytes, row.created_by, row.created_at)
1928 .run();
1929 return ok(null);
1930 } catch (error) {
1931 if (String(error).includes("UNIQUE")) return fail("conflict", `:${row.name}: is already taken.`);
1932 throw error;
1933 }
1934 }
1935
1936 async aliasEmoji(a: { workspace: string; viewer: Viewer; name: string; target: string }): Promise<Result<CustomEmoji>> {
1937 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1938 if (!found.ok) return found;
1939 const workspace = found.value;
1940 const allowed = await this.mayAdd(workspace, a.viewer, a.workspace);
1941 if (!allowed.ok) return allowed;
1942 const named = emojiName(a.name);
1943 if (!named.ok) return fail("invalid", named.message);
1944 const targetName = String(a.target ?? "").trim().replace(/^:+|:+$/g, "").toLowerCase();
1945 let target = await this.liveEmoji(workspace, targetName);
1946 // An alias of an alias names the emoji itself, so removing one never strands another.
1947 if (target?.alias_of) target = await this.liveEmoji(workspace, target.alias_of);
1948 if (!target) return fail("not_found", `This workspace has no :${targetName}: emoji.`);
1949 if (await this.liveEmoji(workspace, named.name)) return fail("conflict", `:${named.name}: is already taken.`);
1950 const row: EmojiRow = { ...target, name: named.name, alias_of: target.name, created_by: userKey(a.viewer!), created_at: now(), deleted_at: null };
1951 const added = await this.insertEmoji(row);
1952 if (!added.ok) return added;
1953 const [emoji] = await this.toEmoji(a.workspace.toLowerCase(), workspace, [row]);
1954 return ok(emoji!);
1955 }
1956
1957 async removeEmoji(a: { workspace: string; viewer: Viewer; name: string }): Promise<Result<null>> {
1958 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1959 if (!found.ok) return found;
1960 const workspace = found.value;
1961 const name = String(a.name ?? "").trim().replace(/^:+|:+$/g, "").toLowerCase();
1962 const row = await this.liveEmoji(workspace, name);
1963 if (!row) return fail("not_found", `This workspace has no :${name}: emoji.`);
1964 if (!mayRemove(row.created_by, userKey(a.viewer!), roleOf(a.viewer, a.workspace))) {
1965 return fail("forbidden", "Only whoever added an emoji, or an owner, can remove it.");
1966 }
1967 // An emoji goes with its aliases; an alias goes alone.
1968 await this.db
1969 .prepare(
1970 "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))",
1971 )
1972 .bind(now(), workspace.id, row.name, row.alias_of ? 1 : 0)
1973 .run();
1974 // Its image goes once nothing live shows it, in any workspace.
1975 this.defer(
1976 (async () => {
1977 const used = await this.db.prepare("SELECT 1 FROM custom_emoji WHERE file = ? AND deleted_at IS NULL LIMIT 1").bind(row.file).first();
1978 if (!used) await this.env.AVATARS.delete(`emoji/${row.file}`);
1979 })().catch((error) => console.error("chat could not forget emoji", row.file, error)),
1980 );
1981 return ok(null);
1982 }
1983
1984 async setEmojiUpload(a: { workspace: string; viewer: Viewer; value: EmojiUpload }): Promise<Result<EmojiUpload>> {
1985 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1986 if (!found.ok) return found;
1987 if (roleOf(a.viewer, a.workspace) !== "owner") return fail("forbidden", "Only owners can change who adds emoji.");
1988 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 project1989 // The same setting as Settings → Chat (`setChatSettings`).
1990 const set = await this.setChatSettings({ workspace: a.workspace, viewer: a.viewer, change: { emoji_upload: a.value } });
1991 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)1992 }
1993
Chat and workspace agents: channels, DMs and named agents you talk to1994 // ── The live socket ─────────────────────────────────────────────────────
1995
1996 /**
1997 * `GET /live?workspace=<slug>&channel=<id>`, upgraded to a WebSocket. The
1998 * viewer comes in CHAT_VIEWER_HEADER, set by the site after checking the
1999 * session; trusted only because this Worker is reachable through service
2000 * bindings alone (`workers_dev` is off and it has no routes). Checked
2001 * like any read, then handed to the channel's room.
2002 */
2003 async live(request: Request): Promise<Response> {
2004 if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") {
2005 return new Response("Expected a WebSocket upgrade\n", { status: 426 });
2006 }
2007 let viewer: Viewer = null;
2008 try {
2009 viewer = JSON.parse(request.headers.get(CHAT_VIEWER_HEADER) ?? "null") as Viewer;
2010 } catch {
2011 viewer = null;
2012 }
2013 if (!viewer?.id) return new Response("Sign in to use chat\n", { status: 401 });
2014 const url = new URL(request.url);
2015 const channelId = url.searchParams.get("channel") ?? "";
2016 let slug = (url.searchParams.get("workspace") ?? "").toLowerCase();
2017 if (!slug) {
2018 // Not named: whichever of the viewer's workspaces holds the channel.
2019 const row = await this.db.prepare("SELECT workspace_id FROM channels WHERE id = ?").bind(channelId).first<{ workspace_id: string }>();
2020 if (row) {
2021 const theirs = await Promise.all((viewer.workspaces ?? []).map((m) => this.workspace(m.slug)));
2022 slug = theirs.find((w) => w?.id === row.workspace_id)?.slug.toLowerCase() ?? "";
2023 }
2024 }
2025 const found = await this.place(slug, channelId, viewer, "read");
2026 if (!found.ok) return new Response(`${found.error.message}\n`, { status: found.error.code === "forbidden" ? 403 : 404 });
2027 const { workspace, channel } = found.value;
2028 const who: RoomMember = { channel_id: channel.id, member: await this.profile(slug, workspace, userKey(viewer)) };
2029 const headers = new Headers(request.headers);
2030 headers.delete(CHAT_VIEWER_HEADER);
2031 headers.set(ROOM_MEMBER_HEADER, JSON.stringify(who));
2032 return this.room(channel.id).fetch(new Request(request.url, { method: "GET", headers }));
2033 }
2034}
2035
2036/** One RPC method's answer. */
2037async function answer(service: Chat, method: string, args: any): Promise<Response> {
2038 switch (method) {
2039 case "sidebar":
2040 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.2041 case "activity":
2042 return Response.json(await service.activity(args));
Chat and workspace agents: channels, DMs and named agents you talk to2043 case "channel":
2044 return Response.json(await service.channel(args));
2045 case "channel_by_name":
2046 return Response.json(await service.channelByName(args));
2047 case "browse":
2048 return Response.json(await service.browse(args));
2049 case "create_channel":
2050 return Response.json(await service.createChannel(args));
Chat controls, public profiles, shadcn selects, and no Docs tab in a project2051 case "update_channel":
2052 return Response.json(await service.updateChannel(args));
2053 case "chat_settings":
2054 return Response.json(await service.chatSettings(args));
2055 case "set_chat_settings":
2056 return Response.json(await service.setChatSettings(args));
Chat and workspace agents: channels, DMs and named agents you talk to2057 case "open_dm":
2058 return Response.json(await service.openDm(args));
2059 case "join":
2060 return Response.json(await service.join(args));
2061 case "leave":
2062 return Response.json(await service.leave(args));
2063 case "invite":
2064 return Response.json(await service.invite(args));
2065 case "messages":
2066 return Response.json(await service.messages(args));
2067 case "post":
2068 return Response.json(await service.post(args));
2069 case "edit":
2070 return Response.json(await service.edit(args));
2071 case "remove":
2072 return Response.json(await service.remove(args));
2073 case "mark_read":
2074 return Response.json(await service.markRead(args));
2075 case "set_preferences":
2076 return Response.json(await service.setPreferences(args));
2077 case "post_as_agent":
2078 return Response.json(await service.postAsAgent(args));
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar2079 case "card_action":
2080 return Response.json(await service.cardAction(args));
Chat controls, public profiles, shadcn selects, and no Docs tab in a project2081 case "update_as_agent":
2082 return Response.json(await service.updateAsAgent(args));
Chat and workspace agents: channels, DMs and named agents you talk to2083 case "agent_typing":
2084 return Response.json(await service.agentTyping(args));
2085 case "history_for_agent":
2086 return Response.json(await service.historyForAgent(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)2087 case "audience":
2088 return Response.json(await service.audience(args));
Merge branch 'worktree-agent-a1398e81ad1a64c5f'2089 case "conversation_for_agent":
2090 return Response.json(await service.conversationForAgent(args));
2091 case "hand_off_as_agent":
2092 return Response.json(await service.handOffAsAgent(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)2093 case "search_for_agent":
2094 return Response.json(await service.searchForAgent(args));
2095 case "thread_for_agent":
2096 return Response.json(await service.threadForAgent(args));
2097 case "react":
2098 return Response.json(await service.react(args));
2099 case "unreact":
2100 return Response.json(await service.unreact(args));
2101 case "react_as_agent":
2102 return Response.json(await service.reactAsAgent(args));
2103 case "list_emoji":
2104 return Response.json(await service.listEmoji(args));
2105 case "add_emoji":
2106 return Response.json(await service.addEmoji(args));
2107 case "alias_emoji":
2108 return Response.json(await service.aliasEmoji(args));
2109 case "remove_emoji":
2110 return Response.json(await service.removeEmoji(args));
2111 case "set_emoji_upload":
2112 return Response.json(await service.setEmojiUpload(args));
Chat and workspace agents: channels, DMs and named agents you talk to2113 default:
2114 return new Response("Unknown method\n", { status: 404 });
2115 }
2116}
2117
2118export default {
2119 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
2120 const url = new URL(request.url);
2121 if (request.method === "GET" && url.pathname === "/live") {
2122 return new Chat(env, (work) => ctx.waitUntil(work)).live(request);
2123 }
2124 const match = url.pathname.match(/^\/rpc\/([a-z_]+)$/);
2125 if (request.method !== "POST" || !match) return new Response("Not found\n", { status: 404 });
2126 // A replica near the caller when it asks for one (@g1t/contracts d1.ts).
2127 const opened = openD1(env.DB, request);
2128 const service = new Chat(Object.create(env, { DB: { value: opened.db } }) as Env, (work) => ctx.waitUntil(work));
2129 const args = (await request.json().catch(() => ({}))) as any;
2130 return opened.finish(await answer(service, match[1], args));
2131 },
2132} satisfies ExportedHandler<Env>;

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