@agent-team-foundation/first-tree-hub 0.12.6 → 0.12.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,10 @@
1
1
  import { O as withSpan, f as messageAttrs, s as createLogger } from "./observability-BAScT_5S-BcW9HgkG.mjs";
2
- import { $ as questionMessageContentSchema, M as extractMentions, O as defaultParticipantMode, Q as questionAnswerMessageContentSchema, _ as clientCapabilitiesSchema, a as AGENT_VISIBILITY, h as agentTypeSchema, i as AGENT_STATUSES, it as scanMentionTokens } from "./dist-xP6NpdMp.mjs";
2
+ import { N as extractMentions, a as AGENT_VISIBILITY, et as questionAnswerMessageContentSchema, g as agentTypeSchema, i as AGENT_STATUSES, k as defaultParticipantMode, o as CHAT_ENGAGEMENT_STATUSES, ot as scanMentionTokens, tt as questionMessageContentSchema, v as clientCapabilitiesSchema } from "./dist-CnjqakXS.mjs";
3
3
  import { a as ConflictError, i as ClientUserMismatchError, l as organizations, n as BadRequestError, o as ForbiddenError, s as NotFoundError, u as users } from "./errors-CF5evtJt-B0NTIVPt.mjs";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { and, desc, eq, inArray, isNotNull, lt, ne, or, sql } from "drizzle-orm";
6
6
  import { bigserial, boolean, index, integer, jsonb, pgTable, primaryKey, text, timestamp, unique } from "drizzle-orm/pg-core";
7
- //#region ../server/dist/client-B89AKi3Q.mjs
7
+ //#region ../server/dist/client-bR8nwHaV.mjs
8
8
  /**
9
9
  * Client connections. A client is a single SDK process (AgentRuntime) that may
10
10
  * host multiple agents. From the unified-user-token milestone on, a client is
@@ -58,6 +58,39 @@ const agents = pgTable("agents", {
58
58
  index("idx_agents_client").on(table.clientId),
59
59
  unique("uq_agents_org_name").on(table.organizationId, table.name)
60
60
  ]);
61
+ /**
62
+ * Unified membership table. Replaces the two-table split
63
+ * (chat_participants speakers ∪ chat_subscriptions watchers) — both
64
+ * collapse into a single row keyed by (chat_id, agent_id) with two
65
+ * orthogonal columns:
66
+ *
67
+ * - `role` ∈ owner / member (creator-vs-member, governs admin actions)
68
+ * - `access_mode` ∈ speaker / watcher (fan-out + mention candidacy)
69
+ *
70
+ * `(owner, speaker)`, `(member, speaker)`, `(member, watcher)` are the
71
+ * legal combinations. `(owner, watcher)` is structurally possible but
72
+ * never produced by v1 paths — the creator is always a speaker.
73
+ *
74
+ * Service-layer integrity (no FK / CHECK / trigger), matching the
75
+ * messages / inbox_entries / notifications convention. Chat hard-delete
76
+ * paths must explicitly DELETE rows here (service-level cascade) — the
77
+ * old DB-level `ON DELETE CASCADE` is intentionally not preserved.
78
+ *
79
+ * See proposals/chat-data-model-restructure.20260512.md §8.
80
+ */
81
+ const chatMembership = pgTable("chat_membership", {
82
+ chatId: text("chat_id").notNull(),
83
+ agentId: text("agent_id").notNull(),
84
+ role: text("role").notNull().default("member"),
85
+ accessMode: text("access_mode").notNull(),
86
+ mode: text("mode").notNull().default("full"),
87
+ source: text("source").notNull().default("manual"),
88
+ joinedAt: timestamp("joined_at", { withTimezone: true }).notNull().defaultNow()
89
+ }, (table) => [
90
+ primaryKey({ columns: [table.chatId, table.agentId] }),
91
+ index("idx_membership_agent").on(table.agentId),
92
+ index("idx_membership_chat_role").on(table.chatId, table.accessMode)
93
+ ]);
61
94
  /** Communication container. All messages between agents flow within a Chat. */
62
95
  const chats = pgTable("chats", {
63
96
  id: text("id").primaryKey(),
@@ -72,39 +105,6 @@ const chats = pgTable("chats", {
72
105
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
73
106
  updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
74
107
  }, (table) => [index("idx_chats_org_last_message").on(table.organizationId, desc(table.lastMessageAt))]);
75
- /** Speaking participants of a chat (M:N). Watchers live in chat_subscriptions. */
76
- const chatParticipants = pgTable("chat_participants", {
77
- chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
78
- agentId: text("agent_id").notNull().references(() => agents.uuid),
79
- role: text("role").notNull().default("member"),
80
- mode: text("mode").notNull().default("full"),
81
- lastReadAt: timestamp("last_read_at", { withTimezone: true }),
82
- unreadMentionCount: integer("unread_mention_count").notNull().default(0),
83
- joinedAt: timestamp("joined_at", { withTimezone: true }).notNull().defaultNow()
84
- }, (table) => [primaryKey({ columns: [table.chatId, table.agentId] }), index("idx_participants_agent").on(table.agentId)]);
85
- /**
86
- * Non-speaking observers ("watchers"). Used by the chat-first workspace so a
87
- * user can supervise chats their managed agents participate in without
88
- * accidentally being part of fan-out.
89
- *
90
- * Invariants:
91
- * 1. (chat_id, agent_id) is mutually exclusive with chat_participants.
92
- * 2. Rows here NEVER produce inbox_entries (fan-out exclusivity).
93
- * 3. Mention candidate resolution NEVER includes these rows.
94
- * 4. State transitions (join/leave) carry last_read_at + counter; lifecycle
95
- * recomputes default to NULL/0 and MUST NOT run on the join/leave path.
96
- *
97
- * See docs/chat-first-workspace-product-design.md "Data Model" + "State
98
- * Transitions" for the full contract.
99
- */
100
- const chatSubscriptions = pgTable("chat_subscriptions", {
101
- chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
102
- agentId: text("agent_id").notNull().references(() => agents.uuid),
103
- kind: text("kind").notNull().default("watching"),
104
- lastReadAt: timestamp("last_read_at", { withTimezone: true }),
105
- unreadMentionCount: integer("unread_mention_count").notNull().default(0),
106
- createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
107
- }, (table) => [primaryKey({ columns: [table.chatId, table.agentId] }), index("idx_chat_subscriptions_agent").on(table.agentId)]);
108
108
  /** Organization membership. Links a user to an org with a role and a 1:1 human agent. */
109
109
  const members = pgTable("members", {
110
110
  id: text("id").primaryKey(),
@@ -121,10 +121,10 @@ const members = pgTable("members", {
121
121
  ]);
122
122
  /**
123
123
  * Process-local cache for the per-chat realtime push audience
124
- * (`chat_participants chat_subscriptions`, keyed by human agent
125
- * uuid). Sits in front of the admin WS dispatch so a chat with N
126
- * messages/sec doesn't issue N audience-resolution queries; one query
127
- * + cache hit per chat per TTL window.
124
+ * (every row in `chat_membership` for the chat speakers + watchers,
125
+ * keyed by human agent uuid). Sits in front of the admin WS dispatch
126
+ * so a chat with N messages/sec doesn't issue N audience-resolution
127
+ * queries; one query + cache hit per chat per TTL window.
128
128
  *
129
129
  * The cache exposes both a populator (`getCachedAudience`) and an
130
130
  * invalidator (`invalidateChatAudience`). Participant-mutation paths
@@ -156,9 +156,7 @@ async function getCachedAudience(db, chatId) {
156
156
  if (cached && cached.expiresAt > now) return cached.audience;
157
157
  try {
158
158
  const rows = await db.execute(sql`
159
- SELECT agent_id FROM chat_participants WHERE chat_id = ${chatId}
160
- UNION
161
- SELECT agent_id FROM chat_subscriptions WHERE chat_id = ${chatId}
159
+ SELECT agent_id FROM chat_membership WHERE chat_id = ${chatId}
162
160
  `);
163
161
  const audience = new Set(rows.map((r) => r.agent_id));
164
162
  cache.set(chatId, {
@@ -185,20 +183,25 @@ function invalidateChatAudience(chatId) {
185
183
  cache.delete(chatId);
186
184
  }
187
185
  /**
188
- * Single source of truth for writing `chat_participants`.
186
+ * Single source of truth for writing speaker rows into `chat_membership`.
187
+ *
188
+ * **This is the ONLY place in the codebase that may INSERT speaker rows
189
+ * (access_mode = 'speaker') into `chat_membership`.** Do not call
190
+ * `tx.insert(chatMembership)` with `accessMode: 'speaker'` from anywhere
191
+ * else. The original bug (docs/chat-participant-mode-fix-design.md §1.1)
192
+ * was caused by mode-derivation logic scattered across ten insert sites,
193
+ * several of which violated `group + non-human ⇒ mention_only`.
194
+ * Re-introducing a second writer reopens that hole — please don't.
189
195
  *
190
- * **This is the ONLY place in the codebase that may `INSERT` into the
191
- * `chat_participants` table.** Do not call `tx.insert(chatParticipants)`
192
- * or `db.insert(chatParticipants)` from anywhere else. The original bug
193
- * (docs/chat-participant-mode-fix-design.md §1.1) was caused by ten
194
- * scattered insert sites each re-deriving the `mode` rule, several of
195
- * which violated `group + non-human ⇒ mention_only`. Re-introducing a
196
- * second writer reopens that hole — please don't.
196
+ * Watcher rows (access_mode = 'watcher') are written from
197
+ * `services/watcher.ts::recomputeChatWatchers` via raw SQL; they don't
198
+ * go through this service because the mode rule is `full` by construction
199
+ * for watchers (they receive but don't fan out).
197
200
  *
198
- * Test fixtures under `src/__tests__/` that deliberately seed
199
- * pathological rows (e.g. cross-org pollution tests) may bypass this
200
- * rule; they are setting up "what bad data looks like" rather than
201
- * exercising the production write path.
201
+ * Test fixtures under `src/__tests__/` that deliberately seed pathological
202
+ * rows (e.g. cross-org pollution tests) may bypass this rule; they are
203
+ * setting up "what bad data looks like" rather than exercising the
204
+ * production write path.
202
205
  *
203
206
  * All callers that need to add a participant — `createChat`, `addParticipant`,
204
207
  * `ensureParticipant`, `joinChat`, `createMeChat`, `addMeChatParticipants`,
@@ -211,13 +214,20 @@ function invalidateChatAudience(chatId) {
211
214
  *
212
215
  * `changeChatType` complements it on the type-flip path: when a `direct`
213
216
  * chat is being upgraded to `group` by the very next participant insert, the
214
- * existing non-human rows must be re-graded to `mention_only`. Callers that
215
- * trigger an upgrade are expected to invoke `changeChatType` BEFORE
217
+ * existing non-human speakers must be re-graded to `mention_only`. Callers
218
+ * that trigger an upgrade are expected to invoke `changeChatType` BEFORE
216
219
  * `addChatParticipants`, inside the same transaction, so the new row picks
217
220
  * up the post-upgrade `chats.type` and existing rows get re-graded together.
221
+ *
222
+ * Read state (`last_read_at` / `unread_mention_count`) is no longer carried
223
+ * here: per the chat-data-model-restructure (proposal §8), it lives in a
224
+ * structurally separate `chat_user_state` table whose rows survive
225
+ * access_mode transitions untouched. A watcher → speaker promotion just
226
+ * UPDATEs `chat_membership.access_mode`; the `chat_user_state` row (if any)
227
+ * is unaffected — no state-carry transaction needed.
218
228
  */
219
229
  /**
220
- * Insert participant rows whose `mode` is derived from `(chats.type, agents.type)`.
230
+ * Insert speaker rows whose `mode` is derived from `(chats.type, agents.type)`.
221
231
  *
222
232
  * Reads:
223
233
  * - `chats.type` for the target chat (NotFoundError on missing)
@@ -225,16 +235,16 @@ function invalidateChatAudience(chatId) {
225
235
  *
226
236
  * Mode derivation:
227
237
  * - for each row, `peerAgentTypes` is the type of every OTHER participant
228
- * being inserted in the same call PLUS every EXISTING participant of
238
+ * being inserted in the same call PLUS every EXISTING speaker of
229
239
  * the chat. This matters only for `direct` chats; the helper ignores
230
240
  * it for `group` / `thread`.
231
241
  *
232
242
  * Writes one INSERT (multi-row) per call.
233
243
  *
234
244
  * No watcher / audience-cache side effects — the caller owns those, since
235
- * different entrypoints have different surrounding work (state-carry, watcher
236
- * recompute, audience invalidation). Keeping this module side-effect-free
237
- * makes it testable from any tx context.
245
+ * different entrypoints have different surrounding work (watcher recompute,
246
+ * audience invalidation). Keeping this module side-effect-free makes it
247
+ * testable from any tx context.
238
248
  */
239
249
  async function addChatParticipants(tx, chatId, participants, options = {}) {
240
250
  if (participants.length === 0) return;
@@ -266,35 +276,51 @@ async function addChatParticipants(tx, chatId, participants, options = {}) {
266
276
  chatId,
267
277
  agentId: spec.agentId,
268
278
  role: spec.role ?? "member",
279
+ accessMode: "speaker",
269
280
  mode: defaultParticipantMode(chatType, agentType, peerTypesForRow),
270
- lastReadAt: spec.carriedReadState?.lastReadAt ?? null,
271
- unreadMentionCount: spec.carriedReadState?.unreadMentionCount ?? 0
281
+ source: "manual"
272
282
  };
273
283
  });
274
- const insert = tx.insert(chatParticipants).values(rows);
275
- if (options.onConflictDoNothing) await insert.onConflictDoNothing({ target: [chatParticipants.chatId, chatParticipants.agentId] });
284
+ const insert = tx.insert(chatMembership).values(rows);
285
+ if (options.upgradeWatcherToSpeaker) await insert.onConflictDoUpdate({
286
+ target: [chatMembership.chatId, chatMembership.agentId],
287
+ set: {
288
+ accessMode: "speaker",
289
+ mode: sqlExcluded("mode"),
290
+ source: "manual"
291
+ }
292
+ });
293
+ else if (options.onConflictDoNothing) await insert.onConflictDoNothing({ target: [chatMembership.chatId, chatMembership.agentId] });
276
294
  else await insert;
277
295
  }
296
+ /**
297
+ * Drizzle helper: reference `excluded.<col>` in an UPSERT's UPDATE clause.
298
+ * Returned as untyped SQL because Drizzle's type system doesn't model the
299
+ * `excluded` pseudo-row, and we only use it for two simple text columns
300
+ * here. Centralised so callers don't have to import `sql` just for this.
301
+ */
302
+ function sqlExcluded(column) {
303
+ return sql.raw(`excluded.${column}`);
304
+ }
278
305
  async function loadExistingAgentTypes(tx, chatId, excludeAgentIds) {
279
306
  return (await tx.select({
280
307
  type: agents.type,
281
- agentId: chatParticipants.agentId
282
- }).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(eq(chatParticipants.chatId, chatId))).filter((r) => !excludeAgentIds.has(r.agentId)).map((r) => r.type);
308
+ agentId: chatMembership.agentId
309
+ }).from(chatMembership).innerJoin(agents, eq(chatMembership.agentId, agents.uuid)).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")))).filter((r) => !excludeAgentIds.has(r.agentId)).map((r) => r.type);
283
310
  }
284
311
  /**
285
312
  * Upgrade `chats.type` from `direct` → `group` AND re-grade every existing
286
- * non-human participant to `mention_only`. Idempotent: if `chat.type` is
313
+ * non-human speaker to `mention_only`. Idempotent: if `chat.type` is
287
314
  * already `group` (or any non-`direct` value), no-op.
288
315
  *
289
- * Callers that are about to insert a 3rd participant on a `direct` chat
316
+ * Callers that are about to insert a 3rd speaker on a `direct` chat
290
317
  * invoke this BEFORE `addChatParticipants` so the new row picks up the
291
318
  * post-upgrade `chats.type` and the existing rows are re-graded in the
292
319
  * same transaction.
293
320
  *
294
- * Note: this is the replacement for `services/chat.ts`'s
295
- * `maybeUpgradeDirectToGroup` (the one in `services/watcher.ts` is
296
- * removed). Keep the rename: `changeChatType` is more precise about the
297
- * primary mutation; `maybe…ToGroup` overstated the conditional gate.
321
+ * Re-grade is gated on `access_mode = 'speaker'` — watcher rows already
322
+ * have `mode = 'full'` by construction (recompute writes that literal)
323
+ * and don't participate in fan-out, so they need no touching.
298
324
  */
299
325
  async function changeChatType(tx, chatId, newType) {
300
326
  const [chat] = await tx.select({ type: chats.type }).from(chats).where(eq(chats.id, chatId)).limit(1);
@@ -305,9 +331,9 @@ async function changeChatType(tx, chatId, newType) {
305
331
  type: newType,
306
332
  updatedAt: /* @__PURE__ */ new Date()
307
333
  }).where(eq(chats.id, chatId));
308
- const ids = (await tx.select({ agentId: chatParticipants.agentId }).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(and(eq(chatParticipants.chatId, chatId), ne(agents.type, "human")))).map((r) => r.agentId);
334
+ const ids = (await tx.select({ agentId: chatMembership.agentId }).from(chatMembership).innerJoin(agents, eq(chatMembership.agentId, agents.uuid)).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker"), ne(agents.type, "human")))).map((r) => r.agentId);
309
335
  if (ids.length === 0) return;
310
- await tx.update(chatParticipants).set({ mode: "mention_only" }).where(and(eq(chatParticipants.chatId, chatId), inArray(chatParticipants.agentId, ids)));
336
+ await tx.update(chatMembership).set({ mode: "mention_only" }).where(and(eq(chatMembership.chatId, chatId), inArray(chatMembership.agentId, ids), eq(chatMembership.accessMode, "speaker")));
311
337
  }
312
338
  /**
313
339
  * Heuristic for whether an insert about to happen would push the chat past
@@ -315,182 +341,187 @@ async function changeChatType(tx, chatId, newType) {
315
341
  * to call `changeChatType` before `addChatParticipants` without re-deriving
316
342
  * the rule locally.
317
343
  */
318
- function wouldUpgradeToGroup(currentParticipantCount, newParticipantCount) {
319
- return currentParticipantCount + newParticipantCount >= 3;
344
+ function wouldUpgradeToGroup(currentSpeakerCount, newSpeakerCount) {
345
+ return currentSpeakerCount + newSpeakerCount >= 3;
320
346
  }
321
347
  /**
322
- * Chat-first workspace — watcher subscription helpers.
348
+ * Chat-first workspace — membership lifecycle helpers.
323
349
  *
324
- * Watchers (rows in `chat_subscriptions`) are non-speaking observers. A
325
- * member who manages an agent that participates in a chat but whose own
326
- * human agent is not a speaker there sees the chat in their workspace
327
- * via a watcher row.
350
+ * After the chat data model restructure (see
351
+ * proposals/chat-data-model-restructure.20260512.md §8), "watcher" is
352
+ * just an `access_mode` value on `chat_membership`, not a separate
353
+ * table. Speaker watcher transitions are a single-table UPDATE;
354
+ * read state lives in `chat_user_state` and is structurally isolated
355
+ * from access_mode changes — there is no state-carry path anymore.
328
356
  *
329
357
  * Two distinct kinds of operation live here:
330
358
  *
331
- * 1. Set rebuilds (`recompute*`). Idempotent set-based recomputations
332
- * driven by lifecycle events (chat created, participant added/removed,
333
- * member status flipped, etc.). These DEFAULT new rows to NULL/0 read
334
- * state.
359
+ * 1. Set rebuilds (`recompute*`). Idempotent set-based
360
+ * recomputations driven by lifecycle events (chat created,
361
+ * participant added/removed, member status flipped, agent
362
+ * rebind, etc.). Strict invariant: ONLY INSERT or DELETE rows
363
+ * where access_mode = 'watcher'. NEVER UPDATE any row with
364
+ * access_mode = 'speaker' — the user's own join/leave decision
365
+ * must not be overwritten by ops paths.
335
366
  *
336
- * 2. State-carry transitions (`joinAsParticipant`, `leaveAsParticipant`).
337
- * Move a single (chat, agent) pair between `chat_participants` and
338
- * `chat_subscriptions` while preserving `last_read_at` and
339
- * `unread_mention_count`. NEVER call recompute on this path or you'll
340
- * lose read state.
367
+ * 2. Speaker ↔ watcher transitions (`joinAsParticipant`,
368
+ * `leaveAsParticipant`). Single-table UPDATE on
369
+ * `chat_membership.access_mode`; `chat_user_state` rows for
370
+ * the (chat, agent) pair are not touched. Per §11.4 default,
371
+ * a fully-detached user keeps their `chat_user_state` row
372
+ * (read state remembered for re-add).
341
373
  *
342
- * See docs/chat-first-workspace-product-design.md "State Transitions" and
343
- * "Risk Constraints".
374
+ * File name preserved across the refactor for diff readability; may
375
+ * be renamed in a follow-up. Public function names preserved too —
376
+ * `recomputeChatWatchers` still describes what it does (recomputes
377
+ * the watcher rows), so the rename to `recomputeChatMembership`
378
+ * would obscure rather than clarify.
344
379
  */
345
380
  /**
346
381
  * Recompute watcher rows for ONE chat. For every active member who:
347
382
  * - manages a non-human agent that speaks in the chat, AND
348
383
  * - whose own human agent is NOT a speaker in the chat
349
- * an `(chat_id, member.agent_id)` watcher row is upserted (NULL read state).
384
+ * a `(chat_id, member.agent_id)` watcher row is upserted.
385
+ *
386
+ * Strict invariant: only writes rows with access_mode = 'watcher';
387
+ * never updates or deletes any access_mode = 'speaker' row. The
388
+ * ON CONFLICT DO NOTHING clause guarantees that if a (chat, agent)
389
+ * row already exists as a speaker (the manager joined as a real
390
+ * participant themselves), we leave it alone.
350
391
  *
351
- * Watchers whose anchoring condition no longer holds (manager left, the
352
- * managed agent was removed from the chat, the manager joined as a speaker
353
- * themselves) are deleted.
392
+ * Watchers whose anchoring condition no longer holds (manager left,
393
+ * the managed agent was removed from the chat, the manager joined as
394
+ * a speaker themselves) are deleted — also gated on access_mode =
395
+ * 'watcher'.
354
396
  *
355
397
  * Idempotent: safe to call multiple times for the same chat.
356
398
  */
357
399
  async function recomputeChatWatchers(db, chatId) {
358
400
  await db.execute(sql`
359
- INSERT INTO chat_subscriptions
360
- (chat_id, agent_id, kind, last_read_at, unread_mention_count, created_at)
361
- SELECT DISTINCT cp.chat_id, m.agent_id, 'watching', NULL::timestamp with time zone, 0, now()
362
- FROM chat_participants cp
363
- JOIN agents a ON a.uuid = cp.agent_id
401
+ INSERT INTO chat_membership
402
+ (chat_id, agent_id, role, access_mode, mode, source, joined_at)
403
+ SELECT DISTINCT cm.chat_id, m.agent_id, 'member', 'watcher', 'full', 'auto_manager', now()
404
+ FROM chat_membership cm
405
+ JOIN agents a ON a.uuid = cm.agent_id
364
406
  JOIN members m ON m.id = a.manager_id
365
- WHERE cp.chat_id = ${chatId}
366
- AND m.status = 'active'
367
- AND a.type <> 'human'
407
+ WHERE cm.chat_id = ${chatId}
408
+ AND cm.access_mode = 'speaker'
409
+ AND m.status = 'active'
410
+ AND a.type <> 'human'
368
411
  AND NOT EXISTS (
369
- SELECT 1 FROM chat_participants cp2
370
- WHERE cp2.chat_id = cp.chat_id
371
- AND cp2.agent_id = m.agent_id
412
+ SELECT 1 FROM chat_membership cm2
413
+ WHERE cm2.chat_id = cm.chat_id
414
+ AND cm2.agent_id = m.agent_id
372
415
  )
373
416
  ON CONFLICT (chat_id, agent_id) DO NOTHING
374
417
  `);
375
418
  await db.execute(sql`
376
- DELETE FROM chat_subscriptions cs
377
- WHERE cs.chat_id = ${chatId}
419
+ DELETE FROM chat_membership cm
420
+ WHERE cm.chat_id = ${chatId}
421
+ AND cm.access_mode = 'watcher'
378
422
  AND NOT EXISTS (
379
423
  SELECT 1
380
- FROM chat_participants cp
381
- JOIN agents a ON a.uuid = cp.agent_id
424
+ FROM chat_membership speakers
425
+ JOIN agents a ON a.uuid = speakers.agent_id
382
426
  JOIN members m ON m.id = a.manager_id
383
- WHERE cp.chat_id = cs.chat_id
384
- AND m.agent_id = cs.agent_id
385
- AND m.status = 'active'
386
- AND a.type <> 'human'
387
- AND NOT EXISTS (
388
- SELECT 1 FROM chat_participants cp2
389
- WHERE cp2.chat_id = cp.chat_id
390
- AND cp2.agent_id = m.agent_id
391
- )
427
+ WHERE speakers.chat_id = cm.chat_id
428
+ AND speakers.access_mode = 'speaker'
429
+ AND m.agent_id = cm.agent_id
430
+ AND m.status = 'active'
431
+ AND a.type <> 'human'
392
432
  )
393
433
  `);
394
434
  }
395
435
  /**
396
- * Recompute watcher rows touching ONE agent across all chats it speaks in.
397
- * Used after `rebindAgent` (manager change) so the new manager picks up
398
- * watcher rows and the old manager's are dropped.
436
+ * Recompute watcher rows touching ONE agent across all chats it
437
+ * speaks in. Used after `rebindAgent` (manager change) so the new
438
+ * manager picks up watcher rows and the old manager's are dropped.
399
439
  */
400
440
  async function recomputeWatchersForAgent(db, agentId) {
401
- const chatRows = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(eq(chatParticipants.agentId, agentId));
441
+ const chatRows = await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(eq(chatMembership.agentId, agentId), eq(chatMembership.accessMode, "speaker")));
402
442
  for (const { chatId } of chatRows) await recomputeChatWatchers(db, chatId);
403
443
  }
404
444
  /**
405
- * Recompute watcher rows touching ONE member across all chats. Triggered
406
- * when the member's status flips active ↔ left.
445
+ * Recompute watcher rows touching ONE member across all chats.
446
+ * Triggered when the member's status flips active ↔ left.
407
447
  */
408
448
  async function recomputeWatchersForMember(db, memberId) {
409
- const rows = await db.selectDistinct({ chatId: chatParticipants.chatId }).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(and(eq(agents.managerId, memberId), ne(agents.type, "human")));
449
+ const rows = await db.selectDistinct({ chatId: chatMembership.chatId }).from(chatMembership).innerJoin(agents, eq(chatMembership.agentId, agents.uuid)).where(and(eq(chatMembership.accessMode, "speaker"), eq(agents.managerId, memberId), ne(agents.type, "human")));
410
450
  for (const { chatId } of rows) await recomputeChatWatchers(db, chatId);
411
451
  }
412
452
  /**
413
- * Watcher → speaking participant. State-carry transaction.
453
+ * Watcher → speaker (or fresh speaker insert).
414
454
  *
415
- * 1. DELETE the watcher row (returning read state).
416
- * 2. If a participant row already exists, no-op (idempotent).
417
- * 3. Otherwise, run the direct group upgrade rule against the *current*
418
- * participant set, then INSERT the participant row carrying read state.
455
+ * 1. SELECT the existing chat_membership row for the (chat, agent) pair.
456
+ * 2. If already a speaker no-op (idempotent).
457
+ * 3. If a watcher row → run the direct→group upgrade rule, then
458
+ * UPDATE access_mode to 'speaker'.
459
+ * 4. If no row → run the direct→group upgrade rule, then INSERT a
460
+ * fresh speaker row.
419
461
  *
420
- * If `requireWatcherOrVisible` is true, refuse when the user has neither a
421
- * watcher row nor admin-derived visibility used to keep the public
422
- * `/me/chats/:chatId/join` endpoint honest. Pre-check happens in the
423
- * route layer where we have the full member scope.
462
+ * Caller is expected to have verified the user is authorised to join
463
+ * (admin override OR an existing watcher row); this helper does not
464
+ * gate on visibility.
424
465
  */
425
466
  async function joinAsParticipant(db, chatId, humanAgentId) {
426
467
  return db.transaction(async (tx) => {
427
- const [carriedRow] = await tx.delete(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, humanAgentId))).returning({
428
- lastReadAt: chatSubscriptions.lastReadAt,
429
- unreadMentionCount: chatSubscriptions.unreadMentionCount
430
- });
431
- const [existing] = await tx.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, humanAgentId))).limit(1);
432
- if (existing) return {
468
+ const [existing] = await tx.select({ accessMode: chatMembership.accessMode }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, humanAgentId))).limit(1);
469
+ if (existing?.accessMode === "speaker") return {
433
470
  chatId,
434
471
  inserted: false,
435
- carried: carriedRow ?? null
472
+ carried: null
436
473
  };
437
- if (wouldUpgradeToGroup((await tx.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId))).length, 1)) await changeChatType(tx, chatId, "group");
474
+ if (wouldUpgradeToGroup((await tx.select({ agentId: chatMembership.agentId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")))).length, 1)) await changeChatType(tx, chatId, "group");
438
475
  await addChatParticipants(tx, chatId, [{
439
476
  agentId: humanAgentId,
440
- role: "member",
441
- carriedReadState: carriedRow ? {
442
- lastReadAt: carriedRow.lastReadAt,
443
- unreadMentionCount: carriedRow.unreadMentionCount
444
- } : void 0
445
- }], { assertHuman: true });
477
+ role: "member"
478
+ }], {
479
+ assertHuman: true,
480
+ upgradeWatcherToSpeaker: true
481
+ });
446
482
  return {
447
483
  chatId,
448
- inserted: true,
449
- carried: carriedRow ?? null
484
+ inserted: !existing,
485
+ carried: null
450
486
  };
451
487
  });
452
488
  }
453
489
  /**
454
- * Speaking participant → watcher (or fully detach).
490
+ * Speaker → watcher (or fully detach).
455
491
  *
456
- * 1. DELETE the participant row (returning read state).
457
- * 2. Test "still visible": is the user still the manager of an agent that
458
- * remains a participant in this chat? If yes, INSERT a watcher row
459
- * carrying read state. If no, drop entirely.
460
- *
461
- * Caller must validate that the user actually has a participant row to
462
- * leave (returns `NotFoundError` if not).
492
+ * 1. SELECT the existing speaker row; 404 if not present.
493
+ * 2. Test "still visible": does the user still manage a non-human
494
+ * agent that remains a speaker in this chat?
495
+ * - If yes UPDATE access_mode to 'watcher'.
496
+ * - If no → DELETE the chat_membership row entirely.
497
+ * 3. `chat_user_state` row (if any) is preserved either way per
498
+ * §11.4 default read state is remembered for re-add.
463
499
  */
464
500
  async function leaveAsParticipant(db, chatId, humanAgentId) {
465
501
  return db.transaction(async (tx) => {
466
- const [carried] = await tx.delete(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, humanAgentId))).returning({
467
- lastReadAt: chatParticipants.lastReadAt,
468
- unreadMentionCount: chatParticipants.unreadMentionCount
469
- });
470
- if (!carried) throw new NotFoundError("Not a participant of this chat");
471
- const [stillVisibleRow] = await tx.execute(sql`
502
+ const [existing] = await tx.select({ accessMode: chatMembership.accessMode }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, humanAgentId))).limit(1);
503
+ if (!existing || existing.accessMode !== "speaker") throw new NotFoundError("Not a participant of this chat");
504
+ const result = await tx.execute(sql`
472
505
  SELECT EXISTS (
473
506
  SELECT 1
474
- FROM chat_participants cp
475
- JOIN agents a ON a.uuid = cp.agent_id
507
+ FROM chat_membership cm
508
+ JOIN agents a ON a.uuid = cm.agent_id
476
509
  JOIN members m ON m.id = a.manager_id
477
- WHERE cp.chat_id = ${chatId}
510
+ WHERE cm.chat_id = ${chatId}
511
+ AND cm.access_mode = 'speaker'
478
512
  AND m.agent_id = ${humanAgentId}
479
513
  AND m.status = 'active'
480
514
  AND a.type <> 'human'
481
515
  ) AS visible
482
516
  `);
483
- if (!Boolean(stillVisibleRow?.visible)) return {
484
- chatId,
485
- membershipKind: null
486
- };
487
- await tx.insert(chatSubscriptions).values({
488
- chatId,
489
- agentId: humanAgentId,
490
- kind: "watching",
491
- lastReadAt: carried.lastReadAt,
492
- unreadMentionCount: carried.unreadMentionCount
493
- }).onConflictDoNothing();
517
+ if (!Boolean(result[0]?.visible)) {
518
+ await tx.delete(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, humanAgentId)));
519
+ return {
520
+ chatId,
521
+ membershipKind: null
522
+ };
523
+ }
524
+ await tx.update(chatMembership).set({ accessMode: "watcher" }).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, humanAgentId)));
494
525
  return {
495
526
  chatId,
496
527
  membershipKind: "watching"
@@ -498,24 +529,24 @@ async function leaveAsParticipant(db, chatId, humanAgentId) {
498
529
  });
499
530
  }
500
531
  /**
501
- * Resolve the membership row of the human agent for the given chat. Returns
502
- * one of: 'participant', 'watching', or null.
532
+ * Resolve the membership row of the human agent for the given chat.
533
+ * Returns one of: 'participant' (speaker), 'watching' (watcher),
534
+ * or null (no row).
503
535
  *
504
- * Used by `/me/chats/:chatId/join` to refuse a join when the user has
505
- * neither a watcher row nor a participant row, and isn't otherwise
506
- * authorised (admin in the chat's org).
536
+ * Used by `/me/chats/:chatId/join` to refuse a join when the user
537
+ * has neither a watcher row nor a participant row, and isn't
538
+ * otherwise authorised (admin in the chat's org).
507
539
  */
508
540
  async function resolveChatMembership(db, chatId, humanAgentId) {
509
- const [participant] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, humanAgentId))).limit(1);
510
- if (participant) return "participant";
511
- const [sub] = await db.select({ chatId: chatSubscriptions.chatId }).from(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, humanAgentId))).limit(1);
512
- if (sub) return "watching";
513
- return null;
541
+ const [row] = await db.select({ accessMode: chatMembership.accessMode }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, humanAgentId))).limit(1);
542
+ if (!row) return null;
543
+ return row.accessMode === "speaker" ? "participant" : "watching";
514
544
  }
515
545
  /**
516
- * Used by `/me/chats/:chatId/join`. Throw 409 if already a speaker (no work
517
- * to do) and 403 if no watcher row and no admin override. Admin override is
518
- * resolved at the route layer; this helper only reports the watcher state.
546
+ * Used by `/me/chats/:chatId/join`. Throw 409 if already a speaker
547
+ * (no work to do) and 403 if no row at all (admin override is
548
+ * resolved at the route layer; this helper only reports the membership
549
+ * state).
519
550
  */
520
551
  function ensureCanJoin(membership) {
521
552
  if (membership === "participant") throw new ConflictError("Already a participant in this chat");
@@ -551,7 +582,7 @@ async function createChat(db, creatorId, data) {
551
582
  role: agentId === creatorId ? "owner" : "member"
552
583
  })));
553
584
  await recomputeChatWatchers(tx, chatId);
554
- const participants = await tx.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
585
+ const participants = await tx.select().from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
555
586
  if (!chat) throw new Error("Unexpected: INSERT RETURNING produced no row");
556
587
  return {
557
588
  ...chat,
@@ -566,14 +597,14 @@ async function getChat(db, chatId) {
566
597
  }
567
598
  async function getChatDetail(db, chatId) {
568
599
  const chat = await getChat(db, chatId);
569
- const participants = await db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
600
+ const participants = await db.select().from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
570
601
  return {
571
602
  ...chat,
572
603
  participants
573
604
  };
574
605
  }
575
606
  async function listChats(db, agentId, limit, cursor) {
576
- const chatIds = (await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(eq(chatParticipants.agentId, agentId))).map((r) => r.chatId);
607
+ const chatIds = (await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(eq(chatMembership.agentId, agentId), eq(chatMembership.accessMode, "speaker")))).map((r) => r.chatId);
577
608
  if (chatIds.length === 0) return {
578
609
  items: [],
579
610
  nextCursor: null
@@ -595,17 +626,17 @@ async function listChats(db, agentId, limit, cursor) {
595
626
  */
596
627
  async function listChatParticipantsWithNames(db, chatId) {
597
628
  return await db.select({
598
- agentId: chatParticipants.agentId,
599
- role: chatParticipants.role,
600
- mode: chatParticipants.mode,
601
- joinedAt: chatParticipants.joinedAt,
629
+ agentId: chatMembership.agentId,
630
+ role: chatMembership.role,
631
+ mode: chatMembership.mode,
632
+ joinedAt: chatMembership.joinedAt,
602
633
  name: agents.name,
603
634
  displayName: agents.displayName,
604
635
  type: agents.type
605
- }).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(eq(chatParticipants.chatId, chatId));
636
+ }).from(chatMembership).innerJoin(agents, eq(chatMembership.agentId, agents.uuid)).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
606
637
  }
607
638
  async function assertParticipant(db, chatId, agentId) {
608
- const [row] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, agentId))).limit(1);
639
+ const [row] = await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, agentId), eq(chatMembership.accessMode, "speaker"))).limit(1);
609
640
  if (!row) throw new ForbiddenError("Not a participant of this chat");
610
641
  }
611
642
  /**
@@ -614,17 +645,16 @@ async function assertParticipant(db, chatId, agentId) {
614
645
  * caller's current chat (see `sendToAgent`'s current-chat routing branch).
615
646
  */
616
647
  async function isParticipant(db, chatId, agentId) {
617
- const [row] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, agentId))).limit(1);
648
+ const [row] = await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, agentId), eq(chatMembership.accessMode, "speaker"))).limit(1);
618
649
  return Boolean(row);
619
650
  }
620
- /** Ensure an agent is a participant of a chat. Silently adds them if not already. */
651
+ /** Ensure an agent is a speaker of a chat. Silently adds them if not already. */
621
652
  async function ensureParticipant(db, chatId, agentId) {
622
- const [existing] = await db.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, agentId))).limit(1);
623
- if (existing) return;
653
+ const [existing] = await db.select({ accessMode: chatMembership.accessMode }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, agentId))).limit(1);
654
+ if (existing?.accessMode === "speaker") return;
624
655
  await db.transaction(async (tx) => {
625
- if (wouldUpgradeToGroup((await tx.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId))).length, 1)) await changeChatType(tx, chatId, "group");
626
- await tx.delete(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, agentId)));
627
- await addChatParticipants(tx, chatId, [{ agentId }], { onConflictDoNothing: true });
656
+ if (wouldUpgradeToGroup((await tx.select({ agentId: chatMembership.agentId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")))).length, 1)) await changeChatType(tx, chatId, "group");
657
+ await addChatParticipants(tx, chatId, [{ agentId }], { upgradeWatcherToSpeaker: true });
628
658
  await recomputeChatWatchers(tx, chatId);
629
659
  });
630
660
  invalidateChatAudience(chatId);
@@ -638,25 +668,24 @@ async function addParticipant(db, chatId, requesterId, data) {
638
668
  }).from(agents).where(eq(agents.uuid, data.agentId)).limit(1);
639
669
  if (!targetAgent) throw new NotFoundError(`Agent "${data.agentId}" not found`);
640
670
  if (targetAgent.organizationId !== chat.organizationId) throw new BadRequestError("Cannot add agent from different organization");
641
- const [existing] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, data.agentId))).limit(1);
642
- if (existing) throw new ConflictError(`Agent "${data.agentId}" is already a participant`);
671
+ const [existing] = await db.select({ accessMode: chatMembership.accessMode }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, data.agentId))).limit(1);
672
+ if (existing?.accessMode === "speaker") throw new ConflictError(`Agent "${data.agentId}" is already a participant`);
643
673
  await db.transaction(async (tx) => {
644
- if (wouldUpgradeToGroup((await tx.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId))).length, 1)) await changeChatType(tx, chatId, "group");
645
- await tx.delete(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, data.agentId)));
646
- await addChatParticipants(tx, chatId, [{ agentId: data.agentId }]);
674
+ if (wouldUpgradeToGroup((await tx.select({ agentId: chatMembership.agentId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")))).length, 1)) await changeChatType(tx, chatId, "group");
675
+ await addChatParticipants(tx, chatId, [{ agentId: data.agentId }], { upgradeWatcherToSpeaker: true });
647
676
  await recomputeChatWatchers(tx, chatId);
648
677
  });
649
678
  invalidateChatAudience(chatId);
650
- return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
679
+ return db.select().from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
651
680
  }
652
681
  async function removeParticipant(db, chatId, requesterId, targetAgentId) {
653
682
  await assertParticipant(db, chatId, requesterId);
654
683
  if (requesterId === targetAgentId) throw new BadRequestError("Cannot remove yourself from a chat");
655
- const [removed] = await db.delete(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, targetAgentId))).returning();
684
+ const [removed] = await db.delete(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, targetAgentId), eq(chatMembership.accessMode, "speaker"))).returning();
656
685
  if (!removed) throw new NotFoundError(`Agent "${targetAgentId}" is not a participant of this chat`);
657
686
  await recomputeChatWatchers(db, chatId);
658
687
  invalidateChatAudience(chatId);
659
- return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
688
+ return db.select().from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
660
689
  }
661
690
  /**
662
691
  * List chats visible to a member, grouped by agent.
@@ -685,11 +714,11 @@ async function listChatsForMember(db, memberId, humanAgentId) {
685
714
  const agentIds = [...agentMap.keys()];
686
715
  if (agentIds.length === 0) return [];
687
716
  const participations = await db.select({
688
- chatId: chatParticipants.chatId,
689
- agentId: chatParticipants.agentId,
690
- role: chatParticipants.role,
691
- mode: chatParticipants.mode
692
- }).from(chatParticipants).where(inArray(chatParticipants.agentId, agentIds));
717
+ chatId: chatMembership.chatId,
718
+ agentId: chatMembership.agentId,
719
+ role: chatMembership.role,
720
+ mode: chatMembership.mode
721
+ }).from(chatMembership).where(and(inArray(chatMembership.agentId, agentIds), eq(chatMembership.accessMode, "speaker")));
693
722
  if (participations.length === 0) return [];
694
723
  const chatIds = [...new Set(participations.map((p) => p.chatId))];
695
724
  const agentChatMap = /* @__PURE__ */ new Map();
@@ -705,7 +734,7 @@ async function listChatsForMember(db, memberId, humanAgentId) {
705
734
  metadata: chats.metadata,
706
735
  createdAt: chats.createdAt,
707
736
  updatedAt: chats.updatedAt,
708
- participantCount: sql`(SELECT count(*)::int FROM chat_participants WHERE chat_id = ${chats.id})`
737
+ participantCount: sql`(SELECT count(*)::int FROM chat_membership WHERE chat_id = ${chats.id} AND access_mode = 'speaker')`
709
738
  }).from(chats).where(inArray(chats.id, chatIds)).orderBy(desc(chats.updatedAt));
710
739
  const chatMap = new Map(chatRows.map((c) => [c.id, c]));
711
740
  const humanParticipantChatIds = new Set(participations.filter((p) => p.agentId === humanAgentId).map((p) => p.chatId));
@@ -740,30 +769,25 @@ async function listChatsForMember(db, memberId, humanAgentId) {
740
769
  */
741
770
  async function joinChat(db, chatId, memberId, humanAgentId) {
742
771
  const chat = await getChat(db, chatId);
743
- const participantAgentIds = (await db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId))).map((p) => p.agentId);
772
+ const participantAgentIds = (await db.select().from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")))).map((p) => p.agentId);
744
773
  if (participantAgentIds.length === 0) throw new NotFoundError("Chat has no participants");
745
774
  if (participantAgentIds.includes(humanAgentId)) throw new ConflictError("Already a participant in this chat");
746
775
  if ((await db.select({ uuid: agents.uuid }).from(agents).where(and(inArray(agents.uuid, participantAgentIds), eq(agents.managerId, memberId)))).length === 0) throw new ForbiddenError("You can only join chats where you manage at least one participant");
747
776
  const [humanAgent] = await db.select({ organizationId: agents.organizationId }).from(agents).where(eq(agents.uuid, humanAgentId)).limit(1);
748
777
  if (!humanAgent || humanAgent.organizationId !== chat.organizationId) throw new BadRequestError("Agent does not belong to the same organization as the chat");
749
778
  await db.transaction(async (tx) => {
750
- const [carriedRow] = await tx.delete(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, humanAgentId))).returning({
751
- lastReadAt: chatSubscriptions.lastReadAt,
752
- unreadMentionCount: chatSubscriptions.unreadMentionCount
753
- });
754
779
  if (wouldUpgradeToGroup(participantAgentIds.length, 1)) await changeChatType(tx, chatId, "group");
755
780
  await addChatParticipants(tx, chatId, [{
756
781
  agentId: humanAgentId,
757
- role: "member",
758
- carriedReadState: carriedRow ? {
759
- lastReadAt: carriedRow.lastReadAt,
760
- unreadMentionCount: carriedRow.unreadMentionCount
761
- } : void 0
762
- }], { assertHuman: true });
782
+ role: "member"
783
+ }], {
784
+ assertHuman: true,
785
+ upgradeWatcherToSpeaker: true
786
+ });
763
787
  await recomputeChatWatchers(tx, chatId);
764
788
  });
765
789
  invalidateChatAudience(chatId);
766
- return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
790
+ return db.select().from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
767
791
  }
768
792
  /**
769
793
  * Manager leaves a chat. Removes their human agent from participants.
@@ -779,7 +803,7 @@ async function joinChat(db, chatId, memberId, humanAgentId) {
779
803
  async function leaveChat(db, chatId, humanAgentId) {
780
804
  await leaveAsParticipant(db, chatId, humanAgentId);
781
805
  invalidateChatAudience(chatId);
782
- return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
806
+ return db.select().from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
783
807
  }
784
808
  async function findOrCreateDirectChat(db, agentAId, agentBId) {
785
809
  const ends = await db.select({
@@ -793,10 +817,7 @@ async function findOrCreateDirectChat(db, agentAId, agentBId) {
793
817
  if (!agentB) throw new NotFoundError(`Agent "${agentBId}" not found`);
794
818
  if (agentA.organizationId !== agentB.organizationId) throw new BadRequestError(`Cannot create direct chat across organizations: agent "${agentAId}" (org "${agentA.organizationId}") vs agent "${agentBId}" (org "${agentB.organizationId}")`);
795
819
  const orgId = agentA.organizationId;
796
- const aChats = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(eq(chatParticipants.agentId, agentAId));
797
- const bChats = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(eq(chatParticipants.agentId, agentBId));
798
- const bChatIds = new Set(bChats.map((r) => r.chatId));
799
- const commonChatIds = aChats.map((r) => r.chatId).filter((id) => bChatIds.has(id));
820
+ const commonChatIds = (await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(inArray(chatMembership.agentId, [agentAId, agentBId]), eq(chatMembership.accessMode, "speaker"))).groupBy(chatMembership.chatId).having(sql`COUNT(DISTINCT ${chatMembership.agentId}) = 2`)).map((r) => r.chatId);
800
821
  if (commonChatIds.length > 0) {
801
822
  const directChats = await db.select().from(chats).where(and(inArray(chats.id, commonChatIds), eq(chats.type, "direct"), eq(chats.organizationId, orgId))).orderBy(chats.createdAt, chats.id).limit(1);
802
823
  if (directChats.length > 0 && directChats[0]) return directChats[0];
@@ -1156,7 +1177,8 @@ async function listAgentsWithRuntime(db, scope) {
1156
1177
  activeSessions: agentPresence.activeSessions,
1157
1178
  totalSessions: agentPresence.totalSessions,
1158
1179
  runtimeUpdatedAt: agentPresence.runtimeUpdatedAt,
1159
- type: agents.type
1180
+ type: agents.type,
1181
+ managerId: agents.managerId
1160
1182
  }).from(agentPresence).innerJoin(agents, eq(agentPresence.agentId, agents.uuid)).where(and(isNotNull(agentPresence.runtimeState), agentVisibilityCondition(scope.organizationId, scope.memberId)));
1161
1183
  }
1162
1184
  /**
@@ -1164,17 +1186,21 @@ async function listAgentsWithRuntime(db, scope) {
1164
1186
  *
1165
1187
  * The single sanctioned extension point on the message hot path. Called
1166
1188
  * from `services/message.ts` AFTER existing fan-out completes, inside the
1167
- * same transaction. Three responsibilities:
1168
- *
1169
- * 1. Mention propagation: increment `unread_mention_count` for mentioned
1170
- * speaking participants AND for watcher rows whose managed agent was
1171
- * mentioned. Sender row is excluded.
1189
+ * same transaction. Four responsibilities:
1172
1190
  *
1173
- * 2. Chats projection: roll forward `chats.last_message_at`,
1191
+ * 1. Chats projection: roll forward `chats.last_message_at`,
1174
1192
  * `chats.last_message_preview`. Powers the conversation list cursor +
1175
1193
  * sort + preview.
1176
1194
  *
1177
- * 3. Realtime kick: fire-and-forget `pg_notify('chat_message_events', …)`
1195
+ * 2. Engagement auto-revive: flip `chat_user_state.engagement_status`
1196
+ * from `archived` → `active` for everyone watching this chat. `deleted`
1197
+ * rows are sticky and intentionally untouched.
1198
+ *
1199
+ * 3. Mention propagation: increment `unread_mention_count` for mentioned
1200
+ * speaking participants AND for watcher rows whose managed agent was
1201
+ * mentioned. Sender row is excluded.
1202
+ *
1203
+ * 4. Realtime kick: fire-and-forget `pg_notify('chat_message_events', …)`
1178
1204
  * so admin WS sockets can translate it into a `chat:message` frame.
1179
1205
  * Failure is swallowed — durable persistence is the correctness path.
1180
1206
  *
@@ -1182,11 +1208,13 @@ async function listAgentsWithRuntime(db, scope) {
1182
1208
  * "Risk Constraints"):
1183
1209
  * - This module appends ONLY. Never edits existing fan-out / inbox /
1184
1210
  * mention-extraction code.
1185
- * - Watchers (chat_subscriptions) are NEVER added to inbox_entries here.
1186
- * Their counters are bumped purely as a per-user red-dot signal.
1187
- * - Mention candidate set is `chat_participants` only; watchers are not
1188
- * direct `@`-mention targets.
1211
+ * - Watcher rows (chat_membership with access_mode='watcher') are
1212
+ * NEVER added to inbox_entries here. Their counters in
1213
+ * chat_user_state are bumped purely as a per-user red-dot signal.
1214
+ * - Mention candidate set is `chat_membership` speakers only;
1215
+ * watchers are not direct `@`-mention targets.
1189
1216
  */
1217
+ const { ACTIVE, ARCHIVED } = CHAT_ENGAGEMENT_STATUSES;
1190
1218
  let dispatcher = null;
1191
1219
  function registerChatMessageDispatcher(fn) {
1192
1220
  dispatcher = fn;
@@ -1219,27 +1247,38 @@ async function applyAfterFanOut(tx, input) {
1219
1247
  lastMessageAt: ts,
1220
1248
  lastMessagePreview: previewClipped
1221
1249
  }).where(eq(chats.id, chatId));
1250
+ await tx.execute(sql`
1251
+ UPDATE chat_user_state
1252
+ SET engagement_status = ${ACTIVE}
1253
+ WHERE chat_id = ${chatId}
1254
+ AND engagement_status = ${ARCHIVED}
1255
+ `);
1222
1256
  if (mentionedAgentIds.length === 0) return;
1223
- await tx.update(chatParticipants).set({ unreadMentionCount: sql`${chatParticipants.unreadMentionCount} + 1` }).where(and(eq(chatParticipants.chatId, chatId), inArray(chatParticipants.agentId, mentionedAgentIds), ne(chatParticipants.agentId, senderId)));
1224
- const managerHumanAgentIds = (await tx.execute(sql`
1225
- SELECT DISTINCT m.agent_id AS human_agent_id
1226
- FROM agents a
1227
- JOIN members m ON m.id = a.manager_id
1228
- WHERE a.uuid IN ${makeUuidList(mentionedAgentIds)}
1229
- AND a.type <> 'human'
1230
- AND m.status = 'active'
1231
- `)).map((r) => r.human_agent_id);
1232
- if (managerHumanAgentIds.length === 0) return;
1233
- await tx.update(chatSubscriptions).set({ unreadMentionCount: sql`${chatSubscriptions.unreadMentionCount} + 1` }).where(and(eq(chatSubscriptions.chatId, chatId), inArray(chatSubscriptions.agentId, managerHumanAgentIds)));
1234
- }
1235
- /**
1236
- * Build a parenthesised, comma-separated list of bound parameters: `(?, ?, ?)`.
1237
- * Used in raw SQL where drizzle's `inArray` can't be directly applied (e.g.
1238
- * inside a hand-rolled SELECT). Always called with a non-empty list — the
1239
- * caller short-circuits the empty case.
1240
- */
1241
- function makeUuidList(ids) {
1242
- return sql`(${sql.join(ids.map((id) => sql`${id}`), sql`, `)})`;
1257
+ const mentionedList = sql.join(mentionedAgentIds.map((id) => sql`${id}`), sql`, `);
1258
+ await tx.execute(sql`
1259
+ INSERT INTO chat_user_state (chat_id, agent_id, unread_mention_count)
1260
+ SELECT chat_id, agent_id, 1
1261
+ FROM (
1262
+ SELECT cm.chat_id, cm.agent_id
1263
+ FROM chat_membership cm
1264
+ WHERE cm.chat_id = ${chatId}
1265
+ AND cm.access_mode = 'speaker'
1266
+ AND cm.agent_id IN (${mentionedList})
1267
+ AND cm.agent_id <> ${senderId}
1268
+ UNION
1269
+ SELECT cm.chat_id, cm.agent_id
1270
+ FROM chat_membership cm
1271
+ JOIN members m ON m.agent_id = cm.agent_id
1272
+ JOIN agents a ON a.manager_id = m.id
1273
+ WHERE cm.chat_id = ${chatId}
1274
+ AND cm.access_mode = 'watcher'
1275
+ AND a.uuid IN (${mentionedList})
1276
+ AND a.type <> 'human'
1277
+ AND m.status = 'active'
1278
+ ) targets
1279
+ ON CONFLICT (chat_id, agent_id)
1280
+ DO UPDATE SET unread_mention_count = chat_user_state.unread_mention_count + 1
1281
+ `);
1243
1282
  }
1244
1283
  /**
1245
1284
  * Server-side lifecycle tracker for `format=question` messages.
@@ -1635,11 +1674,11 @@ async function sendMessageInner(db, chatId, senderId, data, options) {
1635
1674
  const txResult = await db.transaction(async (tx) => {
1636
1675
  const [participants, [chatRow], [senderRow]] = await Promise.all([
1637
1676
  tx.select({
1638
- agentId: chatParticipants.agentId,
1677
+ agentId: chatMembership.agentId,
1639
1678
  inboxId: agents.inboxId,
1640
- mode: chatParticipants.mode,
1679
+ mode: chatMembership.mode,
1641
1680
  name: agents.name
1642
- }).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(eq(chatParticipants.chatId, chatId)),
1681
+ }).from(chatMembership).innerJoin(agents, eq(chatMembership.agentId, agents.uuid)).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker"))),
1643
1682
  tx.select({ type: chats.type }).from(chats).where(eq(chats.id, chatId)).limit(1),
1644
1683
  tx.select({
1645
1684
  inboxId: agents.inboxId,
@@ -2128,4 +2167,4 @@ async function cleanupStaleClients(db, staleSeconds = 60) {
2128
2167
  return result.length;
2129
2168
  }
2130
2169
  //#endregion
2131
- export { messages as $, getOnlineCount as A, listActiveAgentsPinnedToClient as B, ensureCanJoin as C, getCachedAudience as D, getActivityOverview as E, invalidateChatAudience as F, listChatsForMember as G, listAgentsWithRuntime as H, joinAsParticipant as I, listMessages as J, listClients as K, joinChat as L, heartbeatClient as M, heartbeatInstance as N, getChatDetail as O, inboxEntries as P, members as Q, leaveAsParticipant as R, editMessage as S, findOrCreateDirectChat as T, listChatParticipantsWithNames as U, listAgentsManagedByUser as V, listChats as W, markStaleAgents as X, listMyPinnedAgents as Y, markSupersededByChat as Z, clients as _, touchAgent as _t, agentVisibilityCondition as a, registerChatMessageDispatcher as at, deriveAuthState as b, upsertSessionState as bt, assertParticipant as c, resetActivity as ct, chatParticipants as d, sendMessage as dt, notifyRecipients as et, chatSubscriptions as f, sendToAgent as ft, cleanupStalePresence as g, submitAnswer as gt, cleanupStaleClients as h, setRuntimeState as ht, agentPresence as i, recomputeWatchersForMember as it, getPresence as j, getClient as k, bindAgent as l, resolveChatMembership as lt, claimClient as m, setOffline as mt, addParticipant as n, recomputeChatWatchers as nt, agents as o, registerClient as ot, chats as p, serverInstances as pt, listClientsForOrgAdmin as q, agentChatSessions as r, recomputeWatchersForAgent as rt, assertClientOwner as s, removeParticipant as st, addChatParticipants as t, pendingQuestions as tt, changeChatType as u, retireClient as ut, createChat as v, unbindAgent as vt, ensureParticipant as w, disconnectClient as x, createNotifier as y, updateClientCapabilities as yt, leaveChat as z };
2170
+ export { notifyRecipients as $, getPresence as A, listAgentsManagedByUser as B, ensureParticipant as C, getChatDetail as D, getCachedAudience as E, joinAsParticipant as F, listClients as G, listChatParticipantsWithNames as H, joinChat as I, listMyPinnedAgents as J, listClientsForOrgAdmin as K, leaveAsParticipant as L, heartbeatInstance as M, inboxEntries as N, getClient as O, invalidateChatAudience as P, messages as Q, leaveChat as R, ensureCanJoin as S, getActivityOverview as T, listChats as U, listAgentsWithRuntime as V, listChatsForMember as W, markSupersededByChat as X, markStaleAgents as Y, members as Z, createChat as _, unbindAgent as _t, agentVisibilityCondition as a, registerClient as at, disconnectClient as b, assertParticipant as c, resolveChatMembership as ct, chatMembership as d, sendToAgent as dt, pendingQuestions as et, chats as f, serverInstances as ft, clients as g, touchAgent as gt, cleanupStalePresence as h, submitAnswer as ht, agentPresence as i, registerChatMessageDispatcher as it, heartbeatClient as j, getOnlineCount as k, bindAgent as l, retireClient as lt, cleanupStaleClients as m, setRuntimeState as mt, addParticipant as n, recomputeWatchersForAgent as nt, agents as o, removeParticipant as ot, claimClient as p, setOffline as pt, listMessages as q, agentChatSessions as r, recomputeWatchersForMember as rt, assertClientOwner as s, resetActivity as st, addChatParticipants as t, recomputeChatWatchers as tt, changeChatType as u, sendMessage as ut, createNotifier as v, updateClientCapabilities as vt, findOrCreateDirectChat as w, editMessage as x, deriveAuthState as y, upsertSessionState as yt, listActiveAgentsPinnedToClient as z };