@agent-team-foundation/first-tree-hub 0.10.15 → 0.11.0

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,7 +1,7 @@
1
1
  import { m as __toESM } from "./esm-CYu4tXXn.mjs";
2
2
  import { _ as withSpan, a as endWsConnectionSpan, b as require_pino, c as messageAttrs, d as rootLogger$1, g as startWsConnectionSpan, i as currentTraceId, n as applyLoggerConfig, o as getFastifyOtelPlugin, p as setWsConnectionAttrs, r as createLogger$1, t as adapterAttrs, u as observabilityPlugin, v as withWsMessageSpan, y as FIRST_TREE_HUB_ATTR } from "./observability-DPyf745N-BSc8QNcR.mjs";
3
3
  import { C as resetConfigMeta, E as setConfigValue, S as resetConfig, T as serverConfigSchema, b as migrateLegacyHome, c as resolveServerUrl, d as DEFAULT_CONFIG_DIR, f as DEFAULT_DATA_DIR$1, g as collectMissingPrompts, h as clientConfigSchema, i as ensureFreshAccessToken, l as saveAgentConfig, m as agentConfigSchema, o as loadCredentials, p as DEFAULT_HOME_DIR$1, u as saveCredentials, v as initConfig, w as resolveConfigReadonly, y as loadAgents } from "./bootstrap-DUeYbwm-.mjs";
4
- import { $ as refreshTokenSchema, A as createOrgFromMeSchema, B as imageInlineContentSchema, C as clientRegisterSchema, D as createAgentSchema, E as createAdapterMappingSchema, F as dryRunAgentRuntimeConfigSchema, G as isReservedAgentName$1, H as inboxDeliverFrameSchema$1, I as extractMentions, J as loginSchema, K as joinByInvitationSchema, L as githubCallbackQuerySchema, M as createTaskSchema, N as defaultRuntimeConfigPayload, O as createChatSchema, P as delegateFeishuUserSchema, Q as rebindAgentSchema, R as githubDevCallbackQuerySchema, S as clientCapabilitiesSchema$1, St as wsAuthFrameSchema, T as createAdapterConfigSchema, U as inboxPollQuerySchema, V as inboxAckFrameSchema, W as isRedactedEnvValue, X as notificationQuerySchema, Y as messageSourceSchema$1, Z as paginationQuerySchema, _ as adminUpdateTaskSchema, _t as updateClientCapabilitiesSchema, a as AGENT_STATUSES, at as sendToAgentSchema, b as agentRuntimeConfigPayloadSchema$1, bt as updateSystemConfigSchema, ct as sessionEventSchema$1, d as TASK_HEALTH_SIGNALS, dt as switchOrgSchema, et as runtimeStateMessageSchema, f as TASK_STATUSES, ft as taskListQuerySchema, g as adminCreateTaskSchema, gt as updateChatSchema, h as addParticipantSchema, ht as updateAgentSchema, i as AGENT_SOURCES, it as sendMessageSchema, j as createOrganizationSchema, k as createMemberSchema, l as SYSTEM_CONFIG_DEFAULTS, lt as sessionReconcileRequestSchema, m as WS_AUTH_FRAME_TIMEOUT_MS, mt as updateAgentRuntimeConfigSchema, n as AGENT_NAME_REGEX$1, nt as scanMentionTokens, o as AGENT_TYPES, ot as sessionCompletionMessageSchema, p as TASK_TERMINAL_STATUSES, pt as updateAdapterConfigSchema, q as linkTaskChatSchema, r as AGENT_SELECTOR_HEADER$1, rt as selfServiceFeishuBotSchema, s as AGENT_VISIBILITY, st as sessionEventMessageSchema, t as AGENT_BIND_REJECT_REASONS, tt as safeRedirectPath, u as TASK_CREATOR_TYPES, ut as sessionStateMessageSchema, v as agentBindRequestSchema, vt as updateMemberSchema, w as connectTokenExchangeSchema, x as agentTypeSchema$1, xt as updateTaskStatusSchema, y as agentPinnedMessageSchema$1, yt as updateOrganizationSchema, z as githubStartQuerySchema } from "./dist-D6AOiyNg.mjs";
4
+ import { $ as messageSourceSchema$1, A as createChatSchema, B as githubCallbackQuerySchema, C as agentTypeSchema$1, Ct as updateMemberSchema, D as createAdapterConfigSchema, Dt as wsAuthFrameSchema, E as connectTokenExchangeSchema, Et as updateTaskStatusSchema, F as createTaskSchema, G as inboxDeliverFrameSchema$1, H as githubStartQuerySchema, I as defaultRuntimeConfigPayload, J as isReservedAgentName$1, K as inboxPollQuerySchema, L as delegateFeishuUserSchema, M as createMemberSchema, N as createOrgFromMeSchema, O as createAdapterMappingSchema, P as createOrganizationSchema, Q as loginSchema, R as dryRunAgentRuntimeConfigSchema, S as agentRuntimeConfigPayloadSchema$1, St as updateClientCapabilitiesSchema, T as clientRegisterSchema, Tt as updateSystemConfigSchema, U as imageInlineContentSchema, V as githubDevCallbackQuerySchema, W as inboxAckFrameSchema, X as linkTaskChatSchema, Y as joinByInvitationSchema, Z as listMeChatsQuerySchema, _ as addParticipantSchema, _t as taskListQuerySchema, a as AGENT_STATUSES, at as safeRedirectPath, b as agentBindRequestSchema, bt as updateAgentSchema, ct as sendMessageSchema, d as TASK_CREATOR_TYPES, dt as sessionEventMessageSchema, et as notificationQuerySchema, f as TASK_HEALTH_SIGNALS, ft as sessionEventSchema$1, g as addMeChatParticipantsSchema, gt as switchOrgSchema, h as WS_AUTH_FRAME_TIMEOUT_MS, ht as stripCode, i as AGENT_SOURCES, it as runtimeStateMessageSchema, j as createMeChatSchema, k as createAgentSchema, l as MENTION_REGEX, lt as sendToAgentSchema, m as TASK_TERMINAL_STATUSES, mt as sessionStateMessageSchema, n as AGENT_NAME_REGEX$1, nt as rebindAgentSchema, o as AGENT_TYPES, ot as scanMentionTokens, p as TASK_STATUSES, pt as sessionReconcileRequestSchema, q as isRedactedEnvValue, r as AGENT_SELECTOR_HEADER$1, rt as refreshTokenSchema, s as AGENT_VISIBILITY, st as selfServiceFeishuBotSchema, t as AGENT_BIND_REJECT_REASONS, tt as paginationQuerySchema, u as SYSTEM_CONFIG_DEFAULTS, ut as sessionCompletionMessageSchema, v as adminCreateTaskSchema, vt as updateAdapterConfigSchema, w as clientCapabilitiesSchema$1, wt as updateOrganizationSchema, x as agentPinnedMessageSchema$1, xt as updateChatSchema, y as adminUpdateTaskSchema, yt as updateAgentRuntimeConfigSchema, z as extractMentions } from "./dist-BoHl9HwW.mjs";
5
5
  import { _ as recordRedemption, a as ConflictError, b as uuidv7, c as UnauthorizedError, d as findActiveByToken, f as getActiveInvitation, h as organizations, i as ClientUserMismatchError$1, l as buildInviteUrl, m as invitations, n as BadRequestError, o as ForbiddenError, p as invitationRedemptions, r as ClientOrgMismatchError$1, s as NotFoundError, t as AppError, u as ensureActiveInvitation, y as users } from "./invitation-B1pjAyOz-BaCA9PII.mjs";
6
6
  import { createRequire } from "node:module";
7
7
  import { ZodError, z } from "zod";
@@ -734,7 +734,11 @@ z.object({
734
734
  metadata: z.record(z.string(), z.unknown()),
735
735
  createdAt: z.string(),
736
736
  updatedAt: z.string()
737
- }).extend({ participants: z.array(chatParticipantSchema) });
737
+ }).extend({
738
+ participants: z.array(chatParticipantSchema),
739
+ title: z.string(),
740
+ firstMessagePreview: z.string().nullable()
741
+ });
738
742
  z.object({ topic: z.string().trim().max(500).nullable() });
739
743
  z.object({
740
744
  agentId: z.string().min(1),
@@ -1041,6 +1045,59 @@ z.object({
1041
1045
  });
1042
1046
  z.object({ token: z.string().min(1) });
1043
1047
  z.object({}).optional();
1048
+ const meChatFilterSchema = z.enum([
1049
+ "all",
1050
+ "unread",
1051
+ "watching"
1052
+ ]);
1053
+ const meChatMembershipKindSchema = z.enum(["participant", "watching"]);
1054
+ z.object({
1055
+ cursor: z.string().optional(),
1056
+ limit: z.coerce.number().int().min(1).max(200).default(50),
1057
+ filter: meChatFilterSchema.default("all")
1058
+ });
1059
+ const meChatParticipantSchema = z.object({
1060
+ agentId: z.string(),
1061
+ displayName: z.string(),
1062
+ type: z.string()
1063
+ });
1064
+ const meChatRowSchema = z.object({
1065
+ chatId: z.string(),
1066
+ type: z.string(),
1067
+ membershipKind: meChatMembershipKindSchema,
1068
+ title: z.string(),
1069
+ topic: z.string().nullable(),
1070
+ participants: z.array(meChatParticipantSchema),
1071
+ participantCount: z.number().int(),
1072
+ lastMessageAt: z.string().nullable(),
1073
+ lastMessagePreview: z.string().nullable(),
1074
+ unreadMentionCount: z.number().int(),
1075
+ canReply: z.boolean(),
1076
+ taskId: z.string().nullable(),
1077
+ taskStatus: z.string().nullable()
1078
+ });
1079
+ z.object({
1080
+ rows: z.array(meChatRowSchema),
1081
+ nextCursor: z.string().nullable()
1082
+ });
1083
+ z.object({
1084
+ participantIds: z.array(z.string().min(1)).min(1),
1085
+ topic: z.string().trim().max(500).optional().nullable()
1086
+ });
1087
+ z.object({ participantIds: z.array(z.string().min(1)).min(1) });
1088
+ z.object({
1089
+ chatId: z.string(),
1090
+ lastReadAt: z.string(),
1091
+ unreadMentionCount: z.number().int()
1092
+ });
1093
+ z.object({
1094
+ chatId: z.string(),
1095
+ membershipKind: meChatMembershipKindSchema.nullable()
1096
+ });
1097
+ z.object({
1098
+ type: z.literal("chat:message"),
1099
+ chatId: z.string()
1100
+ });
1044
1101
  z.enum([
1045
1102
  "connect",
1046
1103
  "create_agent",
@@ -8146,7 +8203,7 @@ async function onboardCreate(args) {
8146
8203
  }
8147
8204
  const runtimeAgent = args.type === "human" ? args.assistant : args.id;
8148
8205
  if (args.feishuBotAppId && args.feishuBotAppSecret) {
8149
- const { bindFeishuBot } = await import("./feishu-DQ1l18Ah.mjs").then((n) => n.r);
8206
+ const { bindFeishuBot } = await import("./feishu-Dxk6ArOK.mjs").then((n) => n.r);
8150
8207
  const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
8151
8208
  if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
8152
8209
  else {
@@ -9126,7 +9183,7 @@ function createFeedbackHandler(config) {
9126
9183
  return { handle };
9127
9184
  }
9128
9185
  //#endregion
9129
- //#region ../server/dist/app-DFDhctwC.mjs
9186
+ //#region ../server/dist/app-DNJkrky7.mjs
9130
9187
  var __defProp = Object.defineProperty;
9131
9188
  var __exportAll = (all, no_symbols) => {
9132
9189
  let target = {};
@@ -9210,17 +9267,44 @@ const chats = pgTable("chats", {
9210
9267
  lifecyclePolicy: text("lifecycle_policy").default("persistent"),
9211
9268
  parentChatId: text("parent_chat_id"),
9212
9269
  metadata: jsonb("metadata").$type().notNull().default({}),
9270
+ lastMessageAt: timestamp("last_message_at", { withTimezone: true }),
9271
+ lastMessagePreview: text("last_message_preview"),
9213
9272
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
9214
9273
  updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
9215
- });
9216
- /** Chat participants (M:N). */
9274
+ }, (table) => [index("idx_chats_org_last_message").on(table.organizationId, desc(table.lastMessageAt))]);
9275
+ /** Speaking participants of a chat (M:N). Watchers live in chat_subscriptions. */
9217
9276
  const chatParticipants = pgTable("chat_participants", {
9218
9277
  chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
9219
9278
  agentId: text("agent_id").notNull().references(() => agents.uuid),
9220
9279
  role: text("role").notNull().default("member"),
9221
9280
  mode: text("mode").notNull().default("full"),
9281
+ lastReadAt: timestamp("last_read_at", { withTimezone: true }),
9282
+ unreadMentionCount: integer("unread_mention_count").notNull().default(0),
9222
9283
  joinedAt: timestamp("joined_at", { withTimezone: true }).notNull().defaultNow()
9223
9284
  }, (table) => [primaryKey({ columns: [table.chatId, table.agentId] }), index("idx_participants_agent").on(table.agentId)]);
9285
+ /**
9286
+ * Non-speaking observers ("watchers"). Used by the chat-first workspace so a
9287
+ * user can supervise chats their managed agents participate in without
9288
+ * accidentally being part of fan-out.
9289
+ *
9290
+ * Invariants:
9291
+ * 1. (chat_id, agent_id) is mutually exclusive with chat_participants.
9292
+ * 2. Rows here NEVER produce inbox_entries (fan-out exclusivity).
9293
+ * 3. Mention candidate resolution NEVER includes these rows.
9294
+ * 4. State transitions (join/leave) carry last_read_at + counter; lifecycle
9295
+ * recomputes default to NULL/0 and MUST NOT run on the join/leave path.
9296
+ *
9297
+ * See docs/chat-first-workspace-product-design.md "Data Model" + "State
9298
+ * Transitions" for the full contract.
9299
+ */
9300
+ const chatSubscriptions = pgTable("chat_subscriptions", {
9301
+ chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
9302
+ agentId: text("agent_id").notNull().references(() => agents.uuid),
9303
+ kind: text("kind").notNull().default("watching"),
9304
+ lastReadAt: timestamp("last_read_at", { withTimezone: true }),
9305
+ unreadMentionCount: integer("unread_mention_count").notNull().default(0),
9306
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
9307
+ }, (table) => [primaryKey({ columns: [table.chatId, table.agentId] }), index("idx_chat_subscriptions_agent").on(table.agentId)]);
9224
9308
  /** Organization membership. Links a user to an org with a role and a 1:1 human agent. */
9225
9309
  const members = pgTable("members", {
9226
9310
  id: text("id").primaryKey(),
@@ -10015,7 +10099,7 @@ async function deleteAdapterConfig(db, id) {
10015
10099
  const [row] = await db.delete(adapterConfigs).where(eq(adapterConfigs.id, id)).returning();
10016
10100
  if (!row) throw new NotFoundError(`Adapter config "${id}" not found`);
10017
10101
  }
10018
- const log$5 = createLogger$1("AdminAdapters");
10102
+ const log$6 = createLogger$1("AdminAdapters");
10019
10103
  const orgQuerySchema$1 = z.object({ organizationId: z.string().min(1).optional() });
10020
10104
  function parseId(raw) {
10021
10105
  const id = Number(raw);
@@ -10038,7 +10122,7 @@ async function adminAdapterRoutes(app) {
10038
10122
  const scope = memberScope(request);
10039
10123
  await assertCanManage(app.db, scope, body.agentId);
10040
10124
  const config = await createAdapterConfig(app.db, body, app.config.secrets.encryptionKey);
10041
- app.adapterManager.reload().catch((err) => log$5.error({ err }, "adapter reload failed after create"));
10125
+ app.adapterManager.reload().catch((err) => log$6.error({ err }, "adapter reload failed after create"));
10042
10126
  app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
10043
10127
  return reply.status(201).send({
10044
10128
  ...config,
@@ -10062,7 +10146,7 @@ async function adminAdapterRoutes(app) {
10062
10146
  const existing = await getAdapterConfig(app.db, id);
10063
10147
  await assertCanManage(app.db, scope, existing.agentId);
10064
10148
  const config = await updateAdapterConfig(app.db, id, body, app.config.secrets.encryptionKey);
10065
- app.adapterManager.reload().catch((err) => log$5.error({ err }, "adapter reload failed after update"));
10149
+ app.adapterManager.reload().catch((err) => log$6.error({ err }, "adapter reload failed after update"));
10066
10150
  app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
10067
10151
  return {
10068
10152
  ...config,
@@ -10076,7 +10160,7 @@ async function adminAdapterRoutes(app) {
10076
10160
  const existing = await getAdapterConfig(app.db, id);
10077
10161
  await assertCanManage(app.db, scope, existing.agentId);
10078
10162
  await deleteAdapterConfig(app.db, id);
10079
- app.adapterManager.reload().catch((err) => log$5.error({ err }, "adapter reload failed after delete"));
10163
+ app.adapterManager.reload().catch((err) => log$6.error({ err }, "adapter reload failed after delete"));
10080
10164
  app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
10081
10165
  return reply.status(204).send();
10082
10166
  });
@@ -10157,6 +10241,227 @@ const agentConfigs = pgTable("agent_configs", {
10157
10241
  updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
10158
10242
  });
10159
10243
  /**
10244
+ * Chat-first workspace — watcher subscription helpers.
10245
+ *
10246
+ * Watchers (rows in `chat_subscriptions`) are non-speaking observers. A
10247
+ * member who manages an agent that participates in a chat — but whose own
10248
+ * human agent is not a speaker there — sees the chat in their workspace
10249
+ * via a watcher row.
10250
+ *
10251
+ * Two distinct kinds of operation live here:
10252
+ *
10253
+ * 1. Set rebuilds (`recompute*`). Idempotent set-based recomputations
10254
+ * driven by lifecycle events (chat created, participant added/removed,
10255
+ * member status flipped, etc.). These DEFAULT new rows to NULL/0 read
10256
+ * state.
10257
+ *
10258
+ * 2. State-carry transitions (`joinAsParticipant`, `leaveAsParticipant`).
10259
+ * Move a single (chat, agent) pair between `chat_participants` and
10260
+ * `chat_subscriptions` while preserving `last_read_at` and
10261
+ * `unread_mention_count`. NEVER call recompute on this path or you'll
10262
+ * lose read state.
10263
+ *
10264
+ * See docs/chat-first-workspace-product-design.md "State Transitions" and
10265
+ * "Risk Constraints".
10266
+ */
10267
+ /**
10268
+ * Recompute watcher rows for ONE chat. For every active member who:
10269
+ * - manages a non-human agent that speaks in the chat, AND
10270
+ * - whose own human agent is NOT a speaker in the chat
10271
+ * an `(chat_id, member.agent_id)` watcher row is upserted (NULL read state).
10272
+ *
10273
+ * Watchers whose anchoring condition no longer holds (manager left, the
10274
+ * managed agent was removed from the chat, the manager joined as a speaker
10275
+ * themselves) are deleted.
10276
+ *
10277
+ * Idempotent: safe to call multiple times for the same chat.
10278
+ */
10279
+ async function recomputeChatWatchers(db, chatId) {
10280
+ await db.execute(sql`
10281
+ INSERT INTO chat_subscriptions
10282
+ (chat_id, agent_id, kind, last_read_at, unread_mention_count, created_at)
10283
+ SELECT DISTINCT cp.chat_id, m.agent_id, 'watching', NULL::timestamp with time zone, 0, now()
10284
+ FROM chat_participants cp
10285
+ JOIN agents a ON a.uuid = cp.agent_id
10286
+ JOIN members m ON m.id = a.manager_id
10287
+ WHERE cp.chat_id = ${chatId}
10288
+ AND m.status = 'active'
10289
+ AND a.type <> 'human'
10290
+ AND NOT EXISTS (
10291
+ SELECT 1 FROM chat_participants cp2
10292
+ WHERE cp2.chat_id = cp.chat_id
10293
+ AND cp2.agent_id = m.agent_id
10294
+ )
10295
+ ON CONFLICT (chat_id, agent_id) DO NOTHING
10296
+ `);
10297
+ await db.execute(sql`
10298
+ DELETE FROM chat_subscriptions cs
10299
+ WHERE cs.chat_id = ${chatId}
10300
+ AND NOT EXISTS (
10301
+ SELECT 1
10302
+ FROM chat_participants cp
10303
+ JOIN agents a ON a.uuid = cp.agent_id
10304
+ JOIN members m ON m.id = a.manager_id
10305
+ WHERE cp.chat_id = cs.chat_id
10306
+ AND m.agent_id = cs.agent_id
10307
+ AND m.status = 'active'
10308
+ AND a.type <> 'human'
10309
+ AND NOT EXISTS (
10310
+ SELECT 1 FROM chat_participants cp2
10311
+ WHERE cp2.chat_id = cp.chat_id
10312
+ AND cp2.agent_id = m.agent_id
10313
+ )
10314
+ )
10315
+ `);
10316
+ }
10317
+ /**
10318
+ * Recompute watcher rows touching ONE agent across all chats it speaks in.
10319
+ * Used after `rebindAgent` (manager change) so the new manager picks up
10320
+ * watcher rows and the old manager's are dropped.
10321
+ */
10322
+ async function recomputeWatchersForAgent(db, agentId) {
10323
+ const chatRows = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(eq(chatParticipants.agentId, agentId));
10324
+ for (const { chatId } of chatRows) await recomputeChatWatchers(db, chatId);
10325
+ }
10326
+ /**
10327
+ * Recompute watcher rows touching ONE member across all chats. Triggered
10328
+ * when the member's status flips active ↔ left.
10329
+ */
10330
+ async function recomputeWatchersForMember(db, memberId) {
10331
+ 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")));
10332
+ for (const { chatId } of rows) await recomputeChatWatchers(db, chatId);
10333
+ }
10334
+ /**
10335
+ * Mirror of `services/chat.ts` `maybeUpgradeDirectToGroup`. Inlined here so
10336
+ * `joinAsParticipant` keeps the upgrade rule + the state carry in one
10337
+ * transaction without depending on chat.ts (avoids a circular import).
10338
+ */
10339
+ async function maybeUpgradeDirectToGroup$1(tx, chatId, existingParticipantIds) {
10340
+ if (existingParticipantIds.length + 1 < 3) return;
10341
+ const [chat] = await tx.select({ type: chats.type }).from(chats).where(eq(chats.id, chatId)).limit(1);
10342
+ if (!chat || chat.type !== "direct") return;
10343
+ await tx.update(chats).set({
10344
+ type: "group",
10345
+ updatedAt: /* @__PURE__ */ new Date()
10346
+ }).where(eq(chats.id, chatId));
10347
+ if (existingParticipantIds.length === 0) return;
10348
+ const ids = (await tx.select({ uuid: agents.uuid }).from(agents).where(and(inArray(agents.uuid, existingParticipantIds), ne(agents.type, "human")))).map((r) => r.uuid);
10349
+ if (ids.length === 0) return;
10350
+ await tx.update(chatParticipants).set({ mode: "mention_only" }).where(and(eq(chatParticipants.chatId, chatId), inArray(chatParticipants.agentId, ids)));
10351
+ }
10352
+ /**
10353
+ * Watcher → speaking participant. State-carry transaction.
10354
+ *
10355
+ * 1. DELETE the watcher row (returning read state).
10356
+ * 2. If a participant row already exists, no-op (idempotent).
10357
+ * 3. Otherwise, run the direct → group upgrade rule against the *current*
10358
+ * participant set, then INSERT the participant row carrying read state.
10359
+ *
10360
+ * If `requireWatcherOrVisible` is true, refuse when the user has neither a
10361
+ * watcher row nor admin-derived visibility — used to keep the public
10362
+ * `/me/chats/:chatId/join` endpoint honest. Pre-check happens in the
10363
+ * route layer where we have the full member scope.
10364
+ */
10365
+ async function joinAsParticipant(db, chatId, humanAgentId) {
10366
+ return db.transaction(async (tx) => {
10367
+ const [carriedRow] = await tx.delete(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, humanAgentId))).returning({
10368
+ lastReadAt: chatSubscriptions.lastReadAt,
10369
+ unreadMentionCount: chatSubscriptions.unreadMentionCount
10370
+ });
10371
+ const [existing] = await tx.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, humanAgentId))).limit(1);
10372
+ if (existing) return {
10373
+ chatId,
10374
+ inserted: false,
10375
+ carried: carriedRow ?? null
10376
+ };
10377
+ await maybeUpgradeDirectToGroup$1(tx, chatId, (await tx.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId))).map((p) => p.agentId));
10378
+ await tx.insert(chatParticipants).values({
10379
+ chatId,
10380
+ agentId: humanAgentId,
10381
+ role: "member",
10382
+ mode: "full",
10383
+ lastReadAt: carriedRow?.lastReadAt ?? null,
10384
+ unreadMentionCount: carriedRow?.unreadMentionCount ?? 0
10385
+ });
10386
+ return {
10387
+ chatId,
10388
+ inserted: true,
10389
+ carried: carriedRow ?? null
10390
+ };
10391
+ });
10392
+ }
10393
+ /**
10394
+ * Speaking participant → watcher (or fully detach).
10395
+ *
10396
+ * 1. DELETE the participant row (returning read state).
10397
+ * 2. Test "still visible": is the user still the manager of an agent that
10398
+ * remains a participant in this chat? If yes, INSERT a watcher row
10399
+ * carrying read state. If no, drop entirely.
10400
+ *
10401
+ * Caller must validate that the user actually has a participant row to
10402
+ * leave (returns `NotFoundError` if not).
10403
+ */
10404
+ async function leaveAsParticipant(db, chatId, humanAgentId) {
10405
+ return db.transaction(async (tx) => {
10406
+ const [carried] = await tx.delete(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, humanAgentId))).returning({
10407
+ lastReadAt: chatParticipants.lastReadAt,
10408
+ unreadMentionCount: chatParticipants.unreadMentionCount
10409
+ });
10410
+ if (!carried) throw new NotFoundError("Not a participant of this chat");
10411
+ const [stillVisibleRow] = await tx.execute(sql`
10412
+ SELECT EXISTS (
10413
+ SELECT 1
10414
+ FROM chat_participants cp
10415
+ JOIN agents a ON a.uuid = cp.agent_id
10416
+ JOIN members m ON m.id = a.manager_id
10417
+ WHERE cp.chat_id = ${chatId}
10418
+ AND m.agent_id = ${humanAgentId}
10419
+ AND m.status = 'active'
10420
+ AND a.type <> 'human'
10421
+ ) AS visible
10422
+ `);
10423
+ if (!Boolean(stillVisibleRow?.visible)) return {
10424
+ chatId,
10425
+ membershipKind: null
10426
+ };
10427
+ await tx.insert(chatSubscriptions).values({
10428
+ chatId,
10429
+ agentId: humanAgentId,
10430
+ kind: "watching",
10431
+ lastReadAt: carried.lastReadAt,
10432
+ unreadMentionCount: carried.unreadMentionCount
10433
+ }).onConflictDoNothing();
10434
+ return {
10435
+ chatId,
10436
+ membershipKind: "watching"
10437
+ };
10438
+ });
10439
+ }
10440
+ /**
10441
+ * Resolve the membership row of the human agent for the given chat. Returns
10442
+ * one of: 'participant', 'watching', or null.
10443
+ *
10444
+ * Used by `/me/chats/:chatId/join` to refuse a join when the user has
10445
+ * neither a watcher row nor a participant row, and isn't otherwise
10446
+ * authorised (admin in the chat's org).
10447
+ */
10448
+ async function resolveChatMembership(db, chatId, humanAgentId) {
10449
+ const [participant] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, humanAgentId))).limit(1);
10450
+ if (participant) return "participant";
10451
+ const [sub] = await db.select({ chatId: chatSubscriptions.chatId }).from(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, humanAgentId))).limit(1);
10452
+ if (sub) return "watching";
10453
+ return null;
10454
+ }
10455
+ /**
10456
+ * Used by `/me/chats/:chatId/join`. Throw 409 if already a speaker (no work
10457
+ * to do) and 403 if no watcher row and no admin override. Admin override is
10458
+ * resolved at the route layer; this helper only reports the watcher state.
10459
+ */
10460
+ function ensureCanJoin(membership) {
10461
+ if (membership === "participant") throw new ConflictError("Already a participant in this chat");
10462
+ if (membership === null) throw new ForbiddenError("Not a watcher of this chat — open the chat from your workspace before joining");
10463
+ }
10464
+ /**
10160
10465
  * Names beginning with `__` are reserved for Hub-internal pseudo agents
10161
10466
  * (e.g. the task notifier). User-facing creation must not be able to
10162
10467
  * squat on them, otherwise internal traffic could be routed through a
@@ -10463,6 +10768,7 @@ async function updateAgent(db, uuid, data) {
10463
10768
  }
10464
10769
  const [updated] = await db.update(agents).set(updates).where(eq(agents.uuid, agent.uuid)).returning();
10465
10770
  if (!updated) throw new Error("Unexpected: UPDATE RETURNING produced no row");
10771
+ if (data.managerId !== void 0 && data.managerId !== agent.managerId) await recomputeWatchersForAgent(db, agent.uuid);
10466
10772
  return updated;
10467
10773
  }
10468
10774
  /**
@@ -10549,6 +10855,71 @@ async function deleteAgent(db, uuid) {
10549
10855
  return agent;
10550
10856
  }
10551
10857
  /**
10858
+ * Process-local cache for the per-chat realtime push audience
10859
+ * (`chat_participants ∪ chat_subscriptions`, keyed by human agent
10860
+ * uuid). Sits in front of the admin WS dispatch so a chat with N
10861
+ * messages/sec doesn't issue N audience-resolution queries; one query
10862
+ * + cache hit per chat per TTL window.
10863
+ *
10864
+ * The cache exposes both a populator (`getCachedAudience`) and an
10865
+ * invalidator (`invalidateChatAudience`). Participant-mutation paths
10866
+ * (`addMeChatParticipants`, `joinMeChat`, `leaveMeChat`,
10867
+ * `recomputeChatWatchers`, `joinAsParticipant`, `leaveAsParticipant`)
10868
+ * MUST call `invalidateChatAudience` after their tx commits so the
10869
+ * very next dispatch reflects the new audience without waiting for
10870
+ * the TTL to age out — without invalidation, a freshly-added speaker
10871
+ * would miss `chat:message` pushes for up to TTL_MS.
10872
+ *
10873
+ * Cross-instance correctness: not handled here. The PG NOTIFY layer
10874
+ * already broadcasts message events to every replica; each replica's
10875
+ * audience cache is independently invalidated by its own
10876
+ * service-layer mutations on chats it routes traffic for. For
10877
+ * cross-replica participant changes to invalidate this cache, route
10878
+ * the mutation through the same replica that hosts the WS connection
10879
+ * (sticky routing) or add a dedicated `chat:audience` PG NOTIFY in
10880
+ * a follow-up.
10881
+ */
10882
+ const log$5 = createLogger$1("ChatAudienceCache");
10883
+ const TTL_MS = 5e3;
10884
+ const MAX_ENTRIES = 1024;
10885
+ const cache = /* @__PURE__ */ new Map();
10886
+ /** Resolve a chat's push audience, hitting the cache when fresh.
10887
+ * Returns null on DB error (caller should skip dispatch). */
10888
+ async function getCachedAudience(db, chatId) {
10889
+ const now = Date.now();
10890
+ const cached = cache.get(chatId);
10891
+ if (cached && cached.expiresAt > now) return cached.audience;
10892
+ try {
10893
+ const rows = await db.execute(sql`
10894
+ SELECT agent_id FROM chat_participants WHERE chat_id = ${chatId}
10895
+ UNION
10896
+ SELECT agent_id FROM chat_subscriptions WHERE chat_id = ${chatId}
10897
+ `);
10898
+ const audience = new Set(rows.map((r) => r.agent_id));
10899
+ cache.set(chatId, {
10900
+ audience,
10901
+ expiresAt: now + TTL_MS
10902
+ });
10903
+ if (cache.size > MAX_ENTRIES) {
10904
+ for (const [k, v] of cache) if (v.expiresAt <= now) cache.delete(k);
10905
+ }
10906
+ return audience;
10907
+ } catch (err) {
10908
+ log$5.warn({
10909
+ err,
10910
+ chatId
10911
+ }, "failed to resolve chat audience");
10912
+ return null;
10913
+ }
10914
+ }
10915
+ /** Drop the cached audience for a chat. Called from participant-
10916
+ * mutation paths after their transaction commits, so the next
10917
+ * `chat:message` dispatch hits the DB and reflects the new
10918
+ * membership instead of serving a stale TTL window. */
10919
+ function invalidateChatAudience(chatId) {
10920
+ cache.delete(chatId);
10921
+ }
10922
+ /**
10552
10923
  * When a direct chat grows past 2 participants, upgrade it to `group` and
10553
10924
  * flip every existing non-human agent participant to `mention_only` — see
10554
10925
  * proposals/hub-agent-messaging-reply-and-mentions §3.3. The caller is
@@ -10603,6 +10974,7 @@ async function createChat(db, creatorId, data) {
10603
10974
  ...isDirectAgentOnly ? { mode: "mention_only" } : {}
10604
10975
  }));
10605
10976
  await tx.insert(chatParticipants).values(participantRows);
10977
+ await recomputeChatWatchers(tx, chatId);
10606
10978
  const participants = await tx.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
10607
10979
  if (!chat) throw new Error("Unexpected: INSERT RETURNING produced no row");
10608
10980
  return {
@@ -10666,12 +11038,15 @@ async function ensureParticipant(db, chatId, agentId) {
10666
11038
  if (existing) return;
10667
11039
  await db.transaction(async (tx) => {
10668
11040
  await maybeUpgradeDirectToGroup(tx, chatId, (await tx.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId))).map((p) => p.agentId), 1);
11041
+ await tx.delete(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, agentId)));
10669
11042
  await tx.insert(chatParticipants).values({
10670
11043
  chatId,
10671
11044
  agentId,
10672
11045
  mode: "full"
10673
11046
  }).onConflictDoNothing({ target: [chatParticipants.chatId, chatParticipants.agentId] });
11047
+ await recomputeChatWatchers(tx, chatId);
10674
11048
  });
11049
+ invalidateChatAudience(chatId);
10675
11050
  }
10676
11051
  async function addParticipant(db, chatId, requesterId, data) {
10677
11052
  const chat = await getChat(db, chatId);
@@ -10686,12 +11061,15 @@ async function addParticipant(db, chatId, requesterId, data) {
10686
11061
  if (existing) throw new ConflictError(`Agent "${data.agentId}" is already a participant`);
10687
11062
  await db.transaction(async (tx) => {
10688
11063
  await maybeUpgradeDirectToGroup(tx, chatId, (await tx.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId))).map((p) => p.agentId), 1);
11064
+ await tx.delete(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, data.agentId)));
10689
11065
  await tx.insert(chatParticipants).values({
10690
11066
  chatId,
10691
11067
  agentId: data.agentId,
10692
11068
  mode: data.mode ?? "full"
10693
11069
  });
11070
+ await recomputeChatWatchers(tx, chatId);
10694
11071
  });
11072
+ invalidateChatAudience(chatId);
10695
11073
  return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
10696
11074
  }
10697
11075
  async function removeParticipant(db, chatId, requesterId, targetAgentId) {
@@ -10699,6 +11077,8 @@ async function removeParticipant(db, chatId, requesterId, targetAgentId) {
10699
11077
  if (requesterId === targetAgentId) throw new BadRequestError("Cannot remove yourself from a chat");
10700
11078
  const [removed] = await db.delete(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, targetAgentId))).returning();
10701
11079
  if (!removed) throw new NotFoundError(`Agent "${targetAgentId}" is not a participant of this chat`);
11080
+ await recomputeChatWatchers(db, chatId);
11081
+ invalidateChatAudience(chatId);
10702
11082
  return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
10703
11083
  }
10704
11084
  /**
@@ -10790,23 +11170,38 @@ async function joinChat(db, chatId, memberId, humanAgentId) {
10790
11170
  const [humanAgent] = await db.select({ organizationId: agents.organizationId }).from(agents).where(eq(agents.uuid, humanAgentId)).limit(1);
10791
11171
  if (!humanAgent || humanAgent.organizationId !== chat.organizationId) throw new BadRequestError("Agent does not belong to the same organization as the chat");
10792
11172
  await db.transaction(async (tx) => {
11173
+ const [carriedRow] = await tx.delete(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, humanAgentId))).returning({
11174
+ lastReadAt: chatSubscriptions.lastReadAt,
11175
+ unreadMentionCount: chatSubscriptions.unreadMentionCount
11176
+ });
10793
11177
  await maybeUpgradeDirectToGroup(tx, chatId, participantAgentIds, 1);
10794
11178
  await tx.insert(chatParticipants).values({
10795
11179
  chatId,
10796
11180
  agentId: humanAgentId,
10797
11181
  role: "member",
10798
- mode: "full"
11182
+ mode: "full",
11183
+ lastReadAt: carriedRow?.lastReadAt ?? null,
11184
+ unreadMentionCount: carriedRow?.unreadMentionCount ?? 0
10799
11185
  });
11186
+ await recomputeChatWatchers(tx, chatId);
10800
11187
  });
11188
+ invalidateChatAudience(chatId);
10801
11189
  return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
10802
11190
  }
10803
11191
  /**
10804
11192
  * Manager leaves a chat. Removes their human agent from participants.
10805
11193
  * Only allowed if the human agent is a participant.
11194
+ *
11195
+ * Delegates the participant→watcher transition to `leaveAsParticipant`
11196
+ * so admin-side and `/me/chats/:id/leave` share one canonical path. The
11197
+ * earlier "recompute then UPDATE-back state" variant violated the design
11198
+ * rule that recompute is only for set rebuild — never on a transition
11199
+ * path (review #228 issue #2). The returned participant list is fetched
11200
+ * after the tx commits, matching the admin route's existing contract.
10806
11201
  */
10807
11202
  async function leaveChat(db, chatId, humanAgentId) {
10808
- const [removed] = await db.delete(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, humanAgentId))).returning();
10809
- if (!removed) throw new NotFoundError("Not a participant of this chat");
11203
+ await leaveAsParticipant(db, chatId, humanAgentId);
11204
+ invalidateChatAudience(chatId);
10810
11205
  return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
10811
11206
  }
10812
11207
  async function findOrCreateDirectChat(db, agentAId, agentBId) {
@@ -10846,6 +11241,7 @@ async function findOrCreateDirectChat(db, agentAId, agentBId) {
10846
11241
  role: "member",
10847
11242
  mode
10848
11243
  }]);
11244
+ await recomputeChatWatchers(tx, chatId);
10849
11245
  if (!chat) throw new Error("Unexpected: INSERT RETURNING produced no row");
10850
11246
  return chat;
10851
11247
  });
@@ -11548,6 +11944,88 @@ async function listAgentsWithRuntime(db, scope) {
11548
11944
  type: agents.type
11549
11945
  }).from(agentPresence).innerJoin(agents, eq(agentPresence.agentId, agents.uuid)).where(and(isNotNull(agentPresence.runtimeState), agentVisibilityCondition(scope.organizationId, scope.memberId)));
11550
11946
  }
11947
+ /**
11948
+ * Chat-first workspace — append-only post-fan-out projection.
11949
+ *
11950
+ * The single sanctioned extension point on the message hot path. Called
11951
+ * from `services/message.ts` AFTER existing fan-out completes, inside the
11952
+ * same transaction. Three responsibilities:
11953
+ *
11954
+ * 1. Mention propagation: increment `unread_mention_count` for mentioned
11955
+ * speaking participants AND for watcher rows whose managed agent was
11956
+ * mentioned. Sender row is excluded.
11957
+ *
11958
+ * 2. Chats projection: roll forward `chats.last_message_at`,
11959
+ * `chats.last_message_preview`. Powers the conversation list cursor +
11960
+ * sort + preview.
11961
+ *
11962
+ * 3. Realtime kick: fire-and-forget `pg_notify('chat_message_events', …)`
11963
+ * so admin WS sockets can translate it into a `chat:message` frame.
11964
+ * Failure is swallowed — durable persistence is the correctness path.
11965
+ *
11966
+ * Strict invariants (see docs/chat-first-workspace-product-design.md
11967
+ * "Risk Constraints"):
11968
+ * - This module appends ONLY. Never edits existing fan-out / inbox /
11969
+ * mention-extraction code.
11970
+ * - Watchers (chat_subscriptions) are NEVER added to inbox_entries here.
11971
+ * Their counters are bumped purely as a per-user red-dot signal.
11972
+ * - Mention candidate set is `chat_participants` only; watchers are not
11973
+ * direct `@`-mention targets.
11974
+ */
11975
+ let dispatcher = null;
11976
+ function registerChatMessageDispatcher(fn) {
11977
+ dispatcher = fn;
11978
+ }
11979
+ /**
11980
+ * Best-effort cross-process kick for the chat-first workspace. Call AFTER
11981
+ * the message transaction commits — never inside the tx. Failure logs +
11982
+ * drops; web reconnect refetches.
11983
+ *
11984
+ * Speakers also get an inbox NOTIFY through the existing path. They will
11985
+ * receive both, and the web client de-dupes naturally because both end up
11986
+ * invalidating the same query keys.
11987
+ */
11988
+ function fireChatMessageKick(chatId, messageId) {
11989
+ if (!dispatcher) return;
11990
+ try {
11991
+ dispatcher(chatId, messageId);
11992
+ } catch {}
11993
+ }
11994
+ /**
11995
+ * Apply the post-fan-out projection. MUST be called inside the same
11996
+ * transaction as the message INSERT. Safe to call when `mentionedAgentIds`
11997
+ * is empty (degenerate case skips the mention UPDATEs).
11998
+ */
11999
+ async function applyAfterFanOut(tx, input) {
12000
+ const { chatId, senderId, mentionedAgentIds, contentPreview, messageCreatedAt } = input;
12001
+ const previewClipped = contentPreview.length > 0 ? contentPreview.slice(0, 200) : null;
12002
+ const ts = messageCreatedAt ?? /* @__PURE__ */ new Date();
12003
+ await tx.update(chats).set({
12004
+ lastMessageAt: ts,
12005
+ lastMessagePreview: previewClipped
12006
+ }).where(eq(chats.id, chatId));
12007
+ if (mentionedAgentIds.length === 0) return;
12008
+ await tx.update(chatParticipants).set({ unreadMentionCount: sql`${chatParticipants.unreadMentionCount} + 1` }).where(and(eq(chatParticipants.chatId, chatId), inArray(chatParticipants.agentId, mentionedAgentIds), ne(chatParticipants.agentId, senderId)));
12009
+ const managerHumanAgentIds = (await tx.execute(sql`
12010
+ SELECT DISTINCT m.agent_id AS human_agent_id
12011
+ FROM agents a
12012
+ JOIN members m ON m.id = a.manager_id
12013
+ WHERE a.uuid IN ${makeUuidList(mentionedAgentIds)}
12014
+ AND a.type <> 'human'
12015
+ AND m.status = 'active'
12016
+ `)).map((r) => r.human_agent_id);
12017
+ if (managerHumanAgentIds.length === 0) return;
12018
+ await tx.update(chatSubscriptions).set({ unreadMentionCount: sql`${chatSubscriptions.unreadMentionCount} + 1` }).where(and(eq(chatSubscriptions.chatId, chatId), inArray(chatSubscriptions.agentId, managerHumanAgentIds)));
12019
+ }
12020
+ /**
12021
+ * Build a parenthesised, comma-separated list of bound parameters: `(?, ?, ?)`.
12022
+ * Used in raw SQL where drizzle's `inArray` can't be directly applied (e.g.
12023
+ * inside a hand-rolled SELECT). Always called with a non-empty list — the
12024
+ * caller short-circuits the empty case.
12025
+ */
12026
+ function makeUuidList(ids) {
12027
+ return sql`(${sql.join(ids.map((id) => sql`${id}`), sql`, `)})`;
12028
+ }
11551
12029
  const log$4 = createLogger$1("message");
11552
12030
  async function sendMessage(db, chatId, senderId, data, options = {}) {
11553
12031
  return withSpan("inbox.enqueue", messageAttrs({
@@ -11649,6 +12127,15 @@ async function sendMessageInner(db, chatId, senderId, data, options) {
11649
12127
  }
11650
12128
  await tx.update(chats).set({ updatedAt: /* @__PURE__ */ new Date() }).where(eq(chats.id, chatId));
11651
12129
  if (!msg) throw new Error("Unexpected: INSERT RETURNING produced no row");
12130
+ const previewText = typeof outboundContent === "string" ? outboundContent.trim() : "";
12131
+ await applyAfterFanOut(tx, {
12132
+ chatId,
12133
+ messageId: msg.id,
12134
+ senderId,
12135
+ mentionedAgentIds: mergedMentions,
12136
+ contentPreview: previewText,
12137
+ messageCreatedAt: msg.createdAt
12138
+ });
11652
12139
  return {
11653
12140
  message: msg,
11654
12141
  recipients,
@@ -11665,6 +12152,7 @@ async function sendMessageInner(db, chatId, senderId, data, options) {
11665
12152
  agentId: txResult.recipientAgentIds[i]
11666
12153
  }, "predictive session activation failed");
11667
12154
  }
12155
+ fireChatMessageKick(chatId, txResult.message.id);
11668
12156
  return {
11669
12157
  message: txResult.message,
11670
12158
  recipients: txResult.recipients
@@ -11726,15 +12214,24 @@ const INBOX_CHANNEL = "inbox_notifications";
11726
12214
  const CONFIG_CHANNEL = "config_changes";
11727
12215
  const SESSION_STATE_CHANNEL = "session_state_changes";
11728
12216
  const RUNTIME_STATE_CHANNEL = "runtime_state_changes";
12217
+ /**
12218
+ * Chat-first workspace cross-process kick. Carries `<chatId>:<messageId>`.
12219
+ * Lets admin WS sockets translate every chat message (speaker AND watcher
12220
+ * audience) into a `chat:message` frame, without being coupled to the
12221
+ * inbox NOTIFY path that only reaches speakers.
12222
+ */
12223
+ const CHAT_MESSAGE_CHANNEL = "chat_message_events";
11729
12224
  function createNotifier(listenClient) {
11730
12225
  const subscriptions = /* @__PURE__ */ new Map();
11731
12226
  const configChangeHandlers = [];
11732
12227
  const sessionStateChangeHandlers = [];
11733
12228
  const runtimeStateChangeHandlers = [];
12229
+ const chatMessageHandlers = [];
11734
12230
  let unlistenInboxFn = null;
11735
12231
  let unlistenConfigFn = null;
11736
12232
  let unlistenSessionStateFn = null;
11737
12233
  let unlistenRuntimeStateFn = null;
12234
+ let unlistenChatMessageFn = null;
11738
12235
  function handleNotification(payload) {
11739
12236
  const sepIdx = payload.indexOf(":");
11740
12237
  if (sepIdx === -1) return;
@@ -11789,6 +12286,11 @@ function createNotifier(listenClient) {
11789
12286
  await listenClient`SELECT pg_notify(${RUNTIME_STATE_CHANNEL}, ${`${agentId}:${state}:${organizationId}`})`;
11790
12287
  } catch {}
11791
12288
  },
12289
+ async notifyChatMessage(chatId, messageId) {
12290
+ try {
12291
+ await listenClient`SELECT pg_notify(${CHAT_MESSAGE_CHANNEL}, ${`${chatId}:${messageId}`})`;
12292
+ } catch {}
12293
+ },
11792
12294
  async pushFrameToInbox(inboxId, frame) {
11793
12295
  const map = subscriptions.get(inboxId);
11794
12296
  if (!map) return 0;
@@ -11815,6 +12317,9 @@ function createNotifier(listenClient) {
11815
12317
  onRuntimeStateChange(handler) {
11816
12318
  runtimeStateChangeHandlers.push(handler);
11817
12319
  },
12320
+ onChatMessage(handler) {
12321
+ chatMessageHandlers.push(handler);
12322
+ },
11818
12323
  async start() {
11819
12324
  unlistenInboxFn = (await listenClient.listen(INBOX_CHANNEL, (payload) => {
11820
12325
  if (payload) handleNotification(payload);
@@ -11857,6 +12362,19 @@ function createNotifier(listenClient) {
11857
12362
  }
11858
12363
  }
11859
12364
  })).unlisten;
12365
+ unlistenChatMessageFn = (await listenClient.listen(CHAT_MESSAGE_CHANNEL, (payload) => {
12366
+ if (!payload) return;
12367
+ const sep = payload.indexOf(":");
12368
+ if (sep <= 0) return;
12369
+ const chatId = payload.slice(0, sep);
12370
+ const messageId = payload.slice(sep + 1);
12371
+ for (const handler of chatMessageHandlers) try {
12372
+ handler({
12373
+ chatId,
12374
+ messageId
12375
+ });
12376
+ } catch {}
12377
+ })).unlisten;
11860
12378
  },
11861
12379
  async stop() {
11862
12380
  if (unlistenInboxFn) {
@@ -11875,6 +12393,10 @@ function createNotifier(listenClient) {
11875
12393
  await unlistenRuntimeStateFn();
11876
12394
  unlistenRuntimeStateFn = null;
11877
12395
  }
12396
+ if (unlistenChatMessageFn) {
12397
+ await unlistenChatMessageFn();
12398
+ unlistenChatMessageFn = null;
12399
+ }
11878
12400
  }
11879
12401
  };
11880
12402
  }
@@ -12317,101 +12839,640 @@ async function collectTargetInboxes(db, chatId, inReplyTo) {
12317
12839
  }
12318
12840
  return [...set];
12319
12841
  }
12320
- async function adminChatRoutes(app) {
12321
- /** List all chats in org (admin-only, for audit). Members should use GET /mine. */
12322
- app.get("/", { preHandler: requireAdminRoleHook() }, async (request) => {
12323
- const query = paginationQuerySchema.parse(request.query);
12324
- const rawQuery = request.query;
12325
- const orgParam = rawQuery.organizationId ?? rawQuery.org;
12326
- let orgId;
12327
- if (orgParam) orgId = (await resolveOrganization(app.db, orgParam)).id;
12328
- else orgId = await resolveDefaultOrgId(app.db);
12329
- const conditions = [eq(chats.organizationId, orgId)];
12330
- if (query.cursor) conditions.push(lt(chats.createdAt, new Date(query.cursor)));
12331
- const where = and(...conditions);
12332
- const rows = await app.db.select({
12333
- id: chats.id,
12334
- organizationId: chats.organizationId,
12335
- type: chats.type,
12336
- topic: chats.topic,
12337
- lifecyclePolicy: chats.lifecyclePolicy,
12338
- metadata: chats.metadata,
12339
- createdAt: chats.createdAt,
12340
- updatedAt: chats.updatedAt,
12341
- participantCount: sql`(
12342
- SELECT count(*)::int FROM chat_participants WHERE chat_id = ${chats.id}
12343
- )`
12344
- }).from(chats).where(where).orderBy(desc(chats.createdAt)).limit(query.limit + 1);
12345
- const hasMore = rows.length > query.limit;
12346
- const items = hasMore ? rows.slice(0, query.limit) : rows;
12347
- const last = items[items.length - 1];
12348
- const nextCursor = hasMore && last ? last.createdAt.toISOString() : null;
12349
- return {
12350
- items: items.map((c) => ({
12351
- id: c.id,
12352
- organizationId: c.organizationId,
12353
- type: c.type,
12354
- topic: c.topic,
12355
- lifecyclePolicy: c.lifecyclePolicy,
12356
- metadata: c.metadata,
12357
- participantCount: c.participantCount,
12358
- createdAt: c.createdAt.toISOString(),
12359
- updatedAt: c.updatedAt.toISOString()
12360
- })),
12361
- nextCursor
12362
- };
12363
- });
12364
- /** Get chat detail with participants (requires participation or supervision) */
12365
- app.get("/:chatId", async (request) => {
12366
- const { chatId } = request.params;
12367
- const scope = memberScope(request);
12368
- await assertChatAccess(app.db, scope, chatId);
12369
- const [chat] = await app.db.select().from(chats).where(eq(chats.id, chatId)).limit(1);
12370
- if (!chat) throw new Error("Unexpected: chat missing after access check");
12371
- const participants = await app.db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
12372
- return {
12373
- ...chat,
12374
- createdAt: chat.createdAt.toISOString(),
12375
- updatedAt: chat.updatedAt.toISOString(),
12376
- participants: participants.map((p) => ({
12377
- agentId: p.agentId,
12378
- role: p.role,
12379
- mode: p.mode,
12380
- joinedAt: p.joinedAt.toISOString()
12381
- }))
12382
- };
12383
- });
12384
- /** Rename (or clear) a chat's topic. Requires participation or supervision — same gate as reading it. */
12385
- app.patch("/:chatId", async (request) => {
12386
- const { chatId } = request.params;
12387
- const scope = memberScope(request);
12388
- await assertChatAccess(app.db, scope, chatId);
12389
- const body = updateChatSchema.parse(request.body);
12390
- const nextTopic = body.topic && body.topic.length > 0 ? body.topic : null;
12391
- const [updated] = await app.db.update(chats).set({
12392
- topic: nextTopic,
12393
- updatedAt: /* @__PURE__ */ new Date()
12394
- }).where(eq(chats.id, chatId)).returning();
12395
- if (!updated) throw new Error("Unexpected: chat missing after update");
12396
- return {
12397
- ...updated,
12398
- createdAt: updated.createdAt.toISOString(),
12399
- updatedAt: updated.updatedAt.toISOString()
12400
- };
12401
- });
12402
- /** List messages in a chat with delivery status (requires participation or supervision) */
12403
- app.get("/:chatId/messages", async (request) => {
12404
- const { chatId } = request.params;
12405
- const scope = memberScope(request);
12406
- await assertChatAccess(app.db, scope, chatId);
12407
- const query = paginationQuerySchema.parse(request.query);
12408
- const where = query.cursor ? and(eq(messages.chatId, chatId), lt(messages.createdAt, new Date(query.cursor))) : eq(messages.chatId, chatId);
12409
- const rows = await app.db.select({
12410
- id: messages.id,
12411
- chatId: messages.chatId,
12412
- senderId: messages.senderId,
12413
- format: messages.format,
12414
- content: messages.content,
12842
+ /** Extract a plain-text summary from a message's JSONB content field.
12843
+ * Used as the auto-title fallback in chat list rendering see
12844
+ * `me-chat.ts:resolveChatTitle` and `admin/chats.ts:getChat`.
12845
+ *
12846
+ * - `@<name>` mention tokens are stripped before truncation: in the
12847
+ * chat-first model they're routing/audience metadata, not part of
12848
+ * the user's intent. Leaving them in produces noisy titles like
12849
+ * "@hub-agent-01 帮我重构这个文件" or "你好 @hub-agent-02 看看".
12850
+ * - Whitespace runs (including those left behind by mention removal)
12851
+ * collapse to single spaces.
12852
+ * - If the cleaned text is empty (e.g., a message that's only
12853
+ * `@hub-agent-01`), returns null so the caller falls through to
12854
+ * the participant-join fallback.
12855
+ * - Slicing is code-point-aware (`Array.from + join`) so emoji /
12856
+ * surrogate pairs aren't split into garbled half-characters. */
12857
+ function extractSummary(content, maxLen = 50) {
12858
+ let text = "";
12859
+ if (typeof content === "object" && content !== null && "text" in content) text = String(content.text ?? "");
12860
+ else if (typeof content === "string") text = content;
12861
+ if (!text) return null;
12862
+ const cleaned = stripCode(text).replace(MENTION_REGEX, "").replace(/\s+/g, " ").trim();
12863
+ if (!cleaned) return null;
12864
+ return Array.from(cleaned).slice(0, maxLen).join("");
12865
+ }
12866
+ /** List sessions for a specific agent, with optional state filters. */
12867
+ async function listAgentSessions(db, agentId, filters) {
12868
+ const conditions = [eq(agentChatSessions.agentId, agentId)];
12869
+ if (filters?.state) conditions.push(eq(agentChatSessions.state, filters.state));
12870
+ else conditions.push(ne(agentChatSessions.state, "evicted"));
12871
+ const rows = await db.select({
12872
+ agentId: agentChatSessions.agentId,
12873
+ chatId: agentChatSessions.chatId,
12874
+ state: agentChatSessions.state,
12875
+ updatedAt: agentChatSessions.updatedAt,
12876
+ chatCreatedAt: chats.createdAt,
12877
+ chatTopic: chats.topic
12878
+ }).from(agentChatSessions).innerJoin(chats, eq(agentChatSessions.chatId, chats.id)).where(and(...conditions)).orderBy(desc(agentChatSessions.updatedAt));
12879
+ const [presence] = await db.select({ runtimeState: agentPresence.runtimeState }).from(agentPresence).where(eq(agentPresence.agentId, agentId)).limit(1);
12880
+ const agentRuntimeState = presence?.runtimeState ?? null;
12881
+ if (filters?.runtimeState && agentRuntimeState !== filters.runtimeState) return [];
12882
+ const chatIds = rows.map((r) => r.chatId);
12883
+ const messageCounts = chatIds.length > 0 ? await db.select({
12884
+ chatId: inboxEntries.chatId,
12885
+ count: sql`count(*)::int`
12886
+ }).from(inboxEntries).where(and(eq(inboxEntries.inboxId, sql`(SELECT inbox_id FROM agents WHERE uuid = ${agentId})`), inArray(inboxEntries.chatId, chatIds))).groupBy(inboxEntries.chatId) : [];
12887
+ const countMap = new Map(messageCounts.map((r) => [r.chatId, r.count]));
12888
+ const firstMessages = chatIds.length > 0 ? await db.selectDistinctOn([messages.chatId], {
12889
+ chatId: messages.chatId,
12890
+ content: messages.content
12891
+ }).from(messages).where(inArray(messages.chatId, chatIds)).orderBy(messages.chatId, messages.createdAt) : [];
12892
+ const summaryMap = /* @__PURE__ */ new Map();
12893
+ for (const row of firstMessages) {
12894
+ const summary = extractSummary(row.content);
12895
+ if (summary) summaryMap.set(row.chatId, summary);
12896
+ }
12897
+ return rows.map((r) => ({
12898
+ agentId: r.agentId,
12899
+ chatId: r.chatId,
12900
+ state: r.state,
12901
+ runtimeState: agentRuntimeState,
12902
+ startedAt: r.chatCreatedAt.toISOString(),
12903
+ lastActivityAt: r.updatedAt.toISOString(),
12904
+ messageCount: countMap.get(r.chatId) ?? 0,
12905
+ summary: summaryMap.get(r.chatId) ?? null,
12906
+ topic: r.chatTopic ?? null
12907
+ }));
12908
+ }
12909
+ /** Get a single session's detail. */
12910
+ async function getSession(db, agentId, chatId) {
12911
+ const [row] = await db.select({
12912
+ agentId: agentChatSessions.agentId,
12913
+ chatId: agentChatSessions.chatId,
12914
+ state: agentChatSessions.state,
12915
+ updatedAt: agentChatSessions.updatedAt,
12916
+ chatCreatedAt: chats.createdAt,
12917
+ chatTopic: chats.topic
12918
+ }).from(agentChatSessions).innerJoin(chats, eq(agentChatSessions.chatId, chats.id)).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId))).limit(1);
12919
+ if (!row) throw new NotFoundError(`Session (${agentId}, ${chatId}) not found`);
12920
+ const [presence] = await db.select({ runtimeState: agentPresence.runtimeState }).from(agentPresence).where(eq(agentPresence.agentId, agentId)).limit(1);
12921
+ const [countRow] = await db.select({ count: sql`count(*)::int` }).from(inboxEntries).where(and(eq(inboxEntries.inboxId, sql`(SELECT inbox_id FROM agents WHERE uuid = ${agentId})`), eq(inboxEntries.chatId, chatId)));
12922
+ const firstMsg = (await db.execute(sql`SELECT content FROM messages WHERE chat_id = ${chatId} ORDER BY created_at ASC LIMIT 1`))[0];
12923
+ const summary = firstMsg ? extractSummary(firstMsg.content) : null;
12924
+ return {
12925
+ agentId: row.agentId,
12926
+ chatId: row.chatId,
12927
+ state: row.state,
12928
+ runtimeState: presence?.runtimeState ?? null,
12929
+ startedAt: row.chatCreatedAt.toISOString(),
12930
+ lastActivityAt: row.updatedAt.toISOString(),
12931
+ messageCount: countRow?.count ?? 0,
12932
+ summary,
12933
+ topic: row.chatTopic ?? null
12934
+ };
12935
+ }
12936
+ /** List all sessions across all agents, with pagination. Scoped to organization. */
12937
+ async function listAllSessions(db, limit, cursor, filters) {
12938
+ const conditions = [];
12939
+ if (filters?.state) conditions.push(eq(agentChatSessions.state, filters.state));
12940
+ else conditions.push(ne(agentChatSessions.state, "evicted"));
12941
+ if (filters?.agentId) conditions.push(eq(agentChatSessions.agentId, filters.agentId));
12942
+ if (filters?.organizationId) conditions.push(eq(agents.organizationId, filters.organizationId));
12943
+ if (cursor) conditions.push(sql`${agentChatSessions.updatedAt} < ${new Date(cursor)}`);
12944
+ const rows = await db.select({
12945
+ agentId: agentChatSessions.agentId,
12946
+ chatId: agentChatSessions.chatId,
12947
+ state: agentChatSessions.state,
12948
+ updatedAt: agentChatSessions.updatedAt,
12949
+ chatCreatedAt: chats.createdAt,
12950
+ chatTopic: chats.topic
12951
+ }).from(agentChatSessions).innerJoin(chats, eq(agentChatSessions.chatId, chats.id)).innerJoin(agents, eq(agentChatSessions.agentId, agents.uuid)).where(conditions.length > 0 ? and(...conditions) : void 0).orderBy(desc(agentChatSessions.updatedAt)).limit(limit + 1);
12952
+ const hasMore = rows.length > limit;
12953
+ const items = hasMore ? rows.slice(0, limit) : rows;
12954
+ const agentIds = [...new Set(items.map((r) => r.agentId))];
12955
+ const presenceRows = agentIds.length > 0 ? await db.select({
12956
+ agentId: agentPresence.agentId,
12957
+ runtimeState: agentPresence.runtimeState
12958
+ }).from(agentPresence).where(inArray(agentPresence.agentId, agentIds)) : [];
12959
+ const runtimeMap = new Map(presenceRows.map((r) => [r.agentId, r.runtimeState]));
12960
+ const chatIds = [...new Set(items.map((r) => r.chatId))];
12961
+ const firstMessages = chatIds.length > 0 ? await db.selectDistinctOn([messages.chatId], {
12962
+ chatId: messages.chatId,
12963
+ content: messages.content
12964
+ }).from(messages).where(inArray(messages.chatId, chatIds)).orderBy(messages.chatId, messages.createdAt) : [];
12965
+ const summaryMap = /* @__PURE__ */ new Map();
12966
+ for (const row of firstMessages) {
12967
+ const summary = extractSummary(row.content);
12968
+ if (summary) summaryMap.set(row.chatId, summary);
12969
+ }
12970
+ const last = items[items.length - 1];
12971
+ const nextCursor = hasMore && last ? last.updatedAt.toISOString() : null;
12972
+ return {
12973
+ items: items.map((r) => ({
12974
+ agentId: r.agentId,
12975
+ chatId: r.chatId,
12976
+ state: r.state,
12977
+ runtimeState: runtimeMap.get(r.agentId) ?? null,
12978
+ startedAt: r.chatCreatedAt.toISOString(),
12979
+ lastActivityAt: r.updatedAt.toISOString(),
12980
+ messageCount: 0,
12981
+ summary: summaryMap.get(r.chatId) ?? null,
12982
+ topic: r.chatTopic ?? null
12983
+ })),
12984
+ nextCursor
12985
+ };
12986
+ }
12987
+ /** Commit `active → suspended`. No-op on suspended/evicted. Throws if row is missing. */
12988
+ async function suspendSession(db, agentId, chatId, organizationId, notifier) {
12989
+ return transitionSessionState(db, agentId, chatId, "suspended", ["active"], organizationId, notifier);
12990
+ }
12991
+ /** Commit `suspended → evicted` (terminal — listings hide it, revival defense blocks resurrection). */
12992
+ async function archiveSession(db, agentId, chatId, organizationId, notifier) {
12993
+ return transitionSessionState(db, agentId, chatId, "evicted", ["suspended"], organizationId, notifier);
12994
+ }
12995
+ async function transitionSessionState(db, agentId, chatId, target, from, organizationId, notifier) {
12996
+ const now = /* @__PURE__ */ new Date();
12997
+ let finalState = null;
12998
+ let transitioned = false;
12999
+ await db.transaction(async (tx) => {
13000
+ const [existing] = await tx.select({ state: agentChatSessions.state }).from(agentChatSessions).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId))).for("update");
13001
+ if (!existing) return;
13002
+ const current = existing.state;
13003
+ finalState = current;
13004
+ if (!from.includes(current)) return;
13005
+ await tx.update(agentChatSessions).set({
13006
+ state: target,
13007
+ updatedAt: now
13008
+ }).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId)));
13009
+ const [counts] = await tx.select({
13010
+ active: sql`count(*) FILTER (WHERE ${agentChatSessions.state} = 'active')::int`,
13011
+ total: sql`count(*) FILTER (WHERE ${agentChatSessions.state} != 'evicted')::int`
13012
+ }).from(agentChatSessions).where(eq(agentChatSessions.agentId, agentId));
13013
+ await tx.update(agentPresence).set({
13014
+ activeSessions: counts?.active ?? 0,
13015
+ totalSessions: counts?.total ?? 0,
13016
+ lastSeenAt: now
13017
+ }).where(eq(agentPresence.agentId, agentId));
13018
+ finalState = target;
13019
+ transitioned = true;
13020
+ });
13021
+ if (finalState === null) throw new NotFoundError(`Session (${agentId}, ${chatId}) not found`);
13022
+ if (transitioned && notifier) notifier.notifySessionStateChange(agentId, chatId, target, organizationId).catch(() => {});
13023
+ return {
13024
+ state: finalState,
13025
+ transitioned
13026
+ };
13027
+ }
13028
+ /**
13029
+ * Filter sessions to only those where the given agent is also a participant in the chat.
13030
+ * Used when a non-manager views sessions of an org-visible agent — they should only see
13031
+ * sessions for chats they participate in.
13032
+ */
13033
+ async function filterSessionsByParticipant(db, sessions, participantAgentId) {
13034
+ if (sessions.length === 0) return [];
13035
+ const chatIds = sessions.map((s) => s.chatId);
13036
+ const participantRows = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(inArray(chatParticipants.chatId, chatIds), eq(chatParticipants.agentId, participantAgentId)));
13037
+ const allowedChatIds = new Set(participantRows.map((r) => r.chatId));
13038
+ return sessions.filter((s) => allowedChatIds.has(s.chatId));
13039
+ }
13040
+ /**
13041
+ * Member-facing chat service backing `/me/chats*` endpoints (chat-first
13042
+ * workspace).
13043
+ *
13044
+ * Responsibilities:
13045
+ * - Cursor-paginated conversation list across participant + watcher rows
13046
+ * for the caller's human agent.
13047
+ * - Create a new chat (no dedupe, runs `recomputeChatWatchers` after).
13048
+ * - Add participants (idempotent, runs `recomputeChatWatchers` after).
13049
+ * - Mark-read (touches whichever of the two tables holds the user's row).
13050
+ * - Join → state-carry watcher → speaker (delegates to `watcher.ts`).
13051
+ * - Leave → state-carry speaker → watcher (delegates to `watcher.ts`).
13052
+ *
13053
+ * See docs/chat-first-workspace-product-design.md "API Contract" + "Data
13054
+ * Model".
13055
+ */
13056
+ function encodeCursor(lastMessageAt, chatId) {
13057
+ const payload = `${lastMessageAt ? lastMessageAt.toISOString() : ""}|${chatId}`;
13058
+ return Buffer.from(payload, "utf8").toString("base64url");
13059
+ }
13060
+ function decodeCursor(cursor) {
13061
+ try {
13062
+ const decoded = Buffer.from(cursor, "base64url").toString("utf8");
13063
+ const sep = decoded.indexOf("|");
13064
+ if (sep < 0) return null;
13065
+ const tsPart = decoded.slice(0, sep);
13066
+ const chatId = decoded.slice(sep + 1);
13067
+ if (!chatId) return null;
13068
+ const lastMessageAt = tsPart.length > 0 ? new Date(tsPart) : null;
13069
+ if (lastMessageAt && Number.isNaN(lastMessageAt.getTime())) return null;
13070
+ return {
13071
+ lastMessageAt,
13072
+ chatId
13073
+ };
13074
+ } catch {
13075
+ return null;
13076
+ }
13077
+ }
13078
+ /**
13079
+ * GET /me/chats — cursor-paginated conversation list.
13080
+ *
13081
+ * SQL strategy:
13082
+ * - One query that UNIONs participant rows and subscription rows for the
13083
+ * caller's human agent, joined to chats. The UNION+coalesce keeps both
13084
+ * `unread_mention_count` and `membership_kind` per row.
13085
+ * - Filter `parent_chat_id IS NULL` (threads are excluded in v1).
13086
+ * - Sort `(last_message_at DESC NULLS LAST, chat_id DESC)`.
13087
+ * - Cursor narrows the result to rows STRICTLY before `(cursor.ts, cursor.id)`.
13088
+ * - Followed by a small participant-list lookup for the page only.
13089
+ */
13090
+ async function listMeChats(db, humanAgentId, query) {
13091
+ const limit = query.limit;
13092
+ const cursor = query.cursor ? decodeCursor(query.cursor) : null;
13093
+ if (query.cursor && !cursor) throw new BadRequestError("Invalid cursor");
13094
+ const filterUnreadOnly = query.filter === "unread";
13095
+ const filterWatchingOnly = query.filter === "watching";
13096
+ const cursorTsIso = cursor?.lastMessageAt ? cursor.lastMessageAt.toISOString() : null;
13097
+ const cursorPredicate = !cursor ? sql`TRUE` : cursor.lastMessageAt === null ? sql`(c.last_message_at IS NULL AND c.id < ${cursor.chatId})` : sql`(c.last_message_at IS NULL
13098
+ OR c.last_message_at < ${cursorTsIso}::timestamptz
13099
+ OR (c.last_message_at = ${cursorTsIso}::timestamptz AND c.id < ${cursor.chatId}))`;
13100
+ const rawRows = await db.execute(sql`
13101
+ WITH membership AS (
13102
+ SELECT chat_id, 'participant'::text AS membership_kind, unread_mention_count
13103
+ FROM chat_participants
13104
+ WHERE agent_id = ${humanAgentId}
13105
+ UNION ALL
13106
+ SELECT chat_id, 'watching'::text AS membership_kind, unread_mention_count
13107
+ FROM chat_subscriptions
13108
+ WHERE agent_id = ${humanAgentId}
13109
+ ),
13110
+ /* Resolve duplicates (should not happen post-invariant-1, but cheap) by
13111
+ preferring the participant row. */
13112
+ deduped AS (
13113
+ SELECT DISTINCT ON (chat_id)
13114
+ chat_id, membership_kind, unread_mention_count
13115
+ FROM membership
13116
+ ORDER BY chat_id, CASE WHEN membership_kind = 'participant' THEN 0 ELSE 1 END
13117
+ )
13118
+ SELECT
13119
+ c.id AS chat_id,
13120
+ c.type AS type,
13121
+ c.topic AS topic,
13122
+ c.parent_chat_id AS parent_chat_id,
13123
+ c.last_message_at AS last_message_at,
13124
+ c.last_message_preview AS last_message_preview,
13125
+ (SELECT count(*) FROM chat_participants WHERE chat_id = c.id) AS participant_count,
13126
+ d.membership_kind AS membership_kind,
13127
+ d.unread_mention_count AS unread_mention_count
13128
+ FROM chats c
13129
+ JOIN deduped d ON d.chat_id = c.id
13130
+ WHERE c.parent_chat_id IS NULL
13131
+ /* Filter: unread / watching */
13132
+ AND (${!filterUnreadOnly}::bool OR d.unread_mention_count > 0)
13133
+ AND (${!filterWatchingOnly}::bool OR d.membership_kind = 'watching')
13134
+ AND ${cursorPredicate}
13135
+ ORDER BY c.last_message_at DESC NULLS LAST, c.id DESC
13136
+ LIMIT ${limit + 1}
13137
+ `);
13138
+ const toDate = (v) => {
13139
+ if (v === null) return null;
13140
+ return v instanceof Date ? v : new Date(v);
13141
+ };
13142
+ const hasMore = rawRows.length > limit;
13143
+ const pageRaw = hasMore ? rawRows.slice(0, limit) : rawRows;
13144
+ const last = pageRaw[pageRaw.length - 1];
13145
+ const nextCursor = hasMore && last ? encodeCursor(toDate(last.last_message_at), last.chat_id) : null;
13146
+ if (pageRaw.length === 0) return {
13147
+ rows: [],
13148
+ nextCursor: null
13149
+ };
13150
+ const chatIds = pageRaw.map((r) => r.chat_id);
13151
+ const participantRows = await db.select({
13152
+ chatId: chatParticipants.chatId,
13153
+ agentId: chatParticipants.agentId,
13154
+ displayName: agents.displayName,
13155
+ type: agents.type
13156
+ }).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(inArray(chatParticipants.chatId, chatIds));
13157
+ const participantsByChat = /* @__PURE__ */ new Map();
13158
+ for (const p of participantRows) {
13159
+ const list = participantsByChat.get(p.chatId) ?? [];
13160
+ list.push({
13161
+ agentId: p.agentId,
13162
+ displayName: p.displayName,
13163
+ type: p.type
13164
+ });
13165
+ participantsByChat.set(p.chatId, list);
13166
+ }
13167
+ const firstMessageRows = chatIds.length > 0 ? await db.selectDistinctOn([messages.chatId], {
13168
+ chatId: messages.chatId,
13169
+ content: messages.content
13170
+ }).from(messages).where(inArray(messages.chatId, chatIds)).orderBy(messages.chatId, messages.createdAt) : [];
13171
+ const firstMessageSummary = /* @__PURE__ */ new Map();
13172
+ for (const row of firstMessageRows) {
13173
+ const s = extractSummary(row.content);
13174
+ if (s) firstMessageSummary.set(row.chatId, s);
13175
+ }
13176
+ return {
13177
+ rows: pageRaw.map((r) => {
13178
+ const participants = participantsByChat.get(r.chat_id) ?? [];
13179
+ const title = resolveChatTitle(r.topic, firstMessageSummary.get(r.chat_id) ?? null, participants, humanAgentId);
13180
+ return {
13181
+ chatId: r.chat_id,
13182
+ type: r.type,
13183
+ membershipKind: r.membership_kind,
13184
+ title,
13185
+ topic: r.topic,
13186
+ participants,
13187
+ participantCount: Number(r.participant_count),
13188
+ lastMessageAt: toDate(r.last_message_at)?.toISOString() ?? null,
13189
+ lastMessagePreview: r.last_message_preview,
13190
+ unreadMentionCount: r.unread_mention_count,
13191
+ canReply: r.membership_kind === "participant",
13192
+ taskId: null,
13193
+ taskStatus: null
13194
+ };
13195
+ }),
13196
+ nextCursor
13197
+ };
13198
+ }
13199
+ /**
13200
+ * Title resolution priority:
13201
+ *
13202
+ * 1. `chat.topic` (manual, set via `PATCH /admin/chats/:id`)
13203
+ * 2. First message summary (auto, ≤ 50 chars from `extractSummary`)
13204
+ * 3. Participant join (fallback when chat has no messages yet)
13205
+ *
13206
+ * The first-message fallback is the chat-first equivalent of how
13207
+ * ChatGPT / Claude.ai name conversations from the user's opening
13208
+ * prompt — gives same-agent multi-chats distinct identities and
13209
+ * removes the "title duplicates participants chip row" anti-pattern.
13210
+ */
13211
+ function resolveChatTitle(topic, firstMessageSummary, participants, selfAgentId) {
13212
+ if (topic && topic.length > 0) return topic;
13213
+ if (firstMessageSummary && firstMessageSummary.length > 0) return firstMessageSummary;
13214
+ const others = participants.filter((p) => p.agentId !== selfAgentId);
13215
+ if (others.length === 0) return "Empty chat";
13216
+ if (others.length <= 3) return others.map((p) => p.displayName).join(", ");
13217
+ return `${others[0]?.displayName}, ${others[1]?.displayName} +${others.length - 2}`;
13218
+ }
13219
+ async function createMeChat(db, humanAgentId, organizationId, body) {
13220
+ const distinctIds = [...new Set(body.participantIds)].filter((id) => id !== humanAgentId);
13221
+ if (distinctIds.length === 0) throw new BadRequestError("At least one non-self participant required");
13222
+ const allIds = [humanAgentId, ...distinctIds];
13223
+ const found = await db.select({
13224
+ uuid: agents.uuid,
13225
+ organizationId: agents.organizationId,
13226
+ type: agents.type
13227
+ }).from(agents).where(inArray(agents.uuid, allIds));
13228
+ if (found.length !== allIds.length) {
13229
+ const foundSet = new Set(found.map((a) => a.uuid));
13230
+ throw new BadRequestError(`Agents not found: ${allIds.filter((id) => !foundSet.has(id)).join(", ")}`);
13231
+ }
13232
+ const crossOrg = found.filter((a) => a.organizationId !== organizationId);
13233
+ if (crossOrg.length > 0) throw new BadRequestError(`Cross-organization chat not allowed: ${crossOrg.map((a) => a.uuid).join(", ")}`);
13234
+ const chatType = distinctIds.length === 1 ? "direct" : "group";
13235
+ const isDirectAgentOnly = chatType === "direct" && found.every((a) => a.type !== "human");
13236
+ const chatId = randomUUID();
13237
+ const topic = body.topic ?? null;
13238
+ await db.transaction(async (tx) => {
13239
+ await tx.insert(chats).values({
13240
+ id: chatId,
13241
+ organizationId,
13242
+ type: chatType,
13243
+ topic
13244
+ });
13245
+ await tx.insert(chatParticipants).values(allIds.map((agentId) => ({
13246
+ chatId,
13247
+ agentId,
13248
+ role: agentId === humanAgentId ? "owner" : "member",
13249
+ ...isDirectAgentOnly ? { mode: "mention_only" } : {}
13250
+ })));
13251
+ await recomputeChatWatchers(tx, chatId);
13252
+ });
13253
+ invalidateChatAudience(chatId);
13254
+ return { chatId };
13255
+ }
13256
+ async function addMeChatParticipants(db, chatId, callerHumanAgentId, callerOrganizationId, body) {
13257
+ const distinct = [...new Set(body.participantIds)];
13258
+ if (distinct.length === 0) throw new BadRequestError("At least one participant required");
13259
+ const [chat] = await db.select({
13260
+ id: chats.id,
13261
+ organizationId: chats.organizationId,
13262
+ type: chats.type
13263
+ }).from(chats).where(eq(chats.id, chatId)).limit(1);
13264
+ if (!chat) throw new NotFoundError(`Chat "${chatId}" not found`);
13265
+ if (chat.organizationId !== callerOrganizationId) throw new NotFoundError(`Chat "${chatId}" not found`);
13266
+ const [callerRow] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, callerHumanAgentId))).limit(1);
13267
+ if (!callerRow) throw new NotFoundError(`Chat "${chatId}" not found`);
13268
+ const found = await db.select({
13269
+ uuid: agents.uuid,
13270
+ organizationId: agents.organizationId,
13271
+ type: agents.type
13272
+ }).from(agents).where(inArray(agents.uuid, distinct));
13273
+ if (found.length !== distinct.length) {
13274
+ const foundSet = new Set(found.map((a) => a.uuid));
13275
+ throw new BadRequestError(`Agents not found: ${distinct.filter((id) => !foundSet.has(id)).join(", ")}`);
13276
+ }
13277
+ const crossOrg = found.filter((a) => a.organizationId !== chat.organizationId);
13278
+ if (crossOrg.length > 0) throw new BadRequestError(`Cross-organization participant rejected: ${crossOrg.map((a) => a.uuid).join(", ")}`);
13279
+ await db.transaction(async (tx) => {
13280
+ const existing = await tx.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
13281
+ const existingSet = new Set(existing.map((e) => e.agentId));
13282
+ const toInsert = distinct.filter((id) => !existingSet.has(id));
13283
+ if (toInsert.length === 0) {
13284
+ await recomputeChatWatchers(tx, chatId);
13285
+ return;
13286
+ }
13287
+ const isUpgradingToGroup = existing.length + toInsert.length >= 3 && chat.type === "direct";
13288
+ const isAlreadyGroup = chat.type === "group";
13289
+ const isGroupAfter = isUpgradingToGroup || isAlreadyGroup;
13290
+ if (isUpgradingToGroup) {
13291
+ await tx.update(chats).set({
13292
+ type: "group",
13293
+ updatedAt: /* @__PURE__ */ new Date()
13294
+ }).where(eq(chats.id, chatId));
13295
+ const nonHumanIds = (await tx.select({ uuid: agents.uuid }).from(agents).where(and(inArray(agents.uuid, existing.map((e) => e.agentId)), sql`${agents.type} <> 'human'`))).map((a) => a.uuid);
13296
+ if (nonHumanIds.length > 0) await tx.update(chatParticipants).set({ mode: "mention_only" }).where(and(eq(chatParticipants.chatId, chatId), inArray(chatParticipants.agentId, nonHumanIds)));
13297
+ }
13298
+ const carriedRows = await tx.delete(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), inArray(chatSubscriptions.agentId, toInsert))).returning({
13299
+ agentId: chatSubscriptions.agentId,
13300
+ lastReadAt: chatSubscriptions.lastReadAt,
13301
+ unreadMentionCount: chatSubscriptions.unreadMentionCount
13302
+ });
13303
+ const carriedByAgent = new Map(carriedRows.map((r) => [r.agentId, r]));
13304
+ const typeByAgent = new Map(found.map((a) => [a.uuid, a.type]));
13305
+ await tx.insert(chatParticipants).values(toInsert.map((agentId) => {
13306
+ const agentType = typeByAgent.get(agentId);
13307
+ const mode = isGroupAfter && agentType !== "human" ? "mention_only" : "full";
13308
+ const carried = carriedByAgent.get(agentId);
13309
+ return {
13310
+ chatId,
13311
+ agentId,
13312
+ role: "member",
13313
+ mode,
13314
+ lastReadAt: carried?.lastReadAt ?? null,
13315
+ unreadMentionCount: carried?.unreadMentionCount ?? 0
13316
+ };
13317
+ })).onConflictDoNothing();
13318
+ await recomputeChatWatchers(tx, chatId);
13319
+ });
13320
+ invalidateChatAudience(chatId);
13321
+ }
13322
+ async function markMeChatRead(db, chatId, humanAgentId) {
13323
+ const now = /* @__PURE__ */ new Date();
13324
+ await db.update(chatParticipants).set({
13325
+ lastReadAt: now,
13326
+ unreadMentionCount: 0
13327
+ }).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, humanAgentId)));
13328
+ await db.update(chatSubscriptions).set({
13329
+ lastReadAt: now,
13330
+ unreadMentionCount: 0
13331
+ }).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, humanAgentId)));
13332
+ return {
13333
+ chatId,
13334
+ lastReadAt: now.toISOString(),
13335
+ unreadMentionCount: 0
13336
+ };
13337
+ }
13338
+ async function joinMeChat(db, chatId, humanAgentId) {
13339
+ ensureCanJoin(await resolveChatMembership(db, chatId, humanAgentId));
13340
+ await joinAsParticipant(db, chatId, humanAgentId);
13341
+ invalidateChatAudience(chatId);
13342
+ }
13343
+ async function leaveMeChat(db, chatId, humanAgentId) {
13344
+ const result = await leaveAsParticipant(db, chatId, humanAgentId);
13345
+ invalidateChatAudience(chatId);
13346
+ return result;
13347
+ }
13348
+ async function adminChatRoutes(app) {
13349
+ /** List all chats in org (admin-only, for audit). Members should use GET /mine. */
13350
+ app.get("/", { preHandler: requireAdminRoleHook() }, async (request) => {
13351
+ const query = paginationQuerySchema.parse(request.query);
13352
+ const rawQuery = request.query;
13353
+ const orgParam = rawQuery.organizationId ?? rawQuery.org;
13354
+ let orgId;
13355
+ if (orgParam) orgId = (await resolveOrganization(app.db, orgParam)).id;
13356
+ else orgId = await resolveDefaultOrgId(app.db);
13357
+ const conditions = [eq(chats.organizationId, orgId)];
13358
+ if (query.cursor) conditions.push(lt(chats.createdAt, new Date(query.cursor)));
13359
+ const where = and(...conditions);
13360
+ const rows = await app.db.select({
13361
+ id: chats.id,
13362
+ organizationId: chats.organizationId,
13363
+ type: chats.type,
13364
+ topic: chats.topic,
13365
+ lifecyclePolicy: chats.lifecyclePolicy,
13366
+ metadata: chats.metadata,
13367
+ createdAt: chats.createdAt,
13368
+ updatedAt: chats.updatedAt,
13369
+ participantCount: sql`(
13370
+ SELECT count(*)::int FROM chat_participants WHERE chat_id = ${chats.id}
13371
+ )`
13372
+ }).from(chats).where(where).orderBy(desc(chats.createdAt)).limit(query.limit + 1);
13373
+ const hasMore = rows.length > query.limit;
13374
+ const items = hasMore ? rows.slice(0, query.limit) : rows;
13375
+ const last = items[items.length - 1];
13376
+ const nextCursor = hasMore && last ? last.createdAt.toISOString() : null;
13377
+ return {
13378
+ items: items.map((c) => ({
13379
+ id: c.id,
13380
+ organizationId: c.organizationId,
13381
+ type: c.type,
13382
+ topic: c.topic,
13383
+ lifecyclePolicy: c.lifecyclePolicy,
13384
+ metadata: c.metadata,
13385
+ participantCount: c.participantCount,
13386
+ createdAt: c.createdAt.toISOString(),
13387
+ updatedAt: c.updatedAt.toISOString()
13388
+ })),
13389
+ nextCursor
13390
+ };
13391
+ });
13392
+ /** Get chat detail with participants (requires participation or supervision).
13393
+ *
13394
+ * Response includes a server-resolved `title` and `firstMessagePreview`
13395
+ * so the chat-view header can mirror the conversation list's title
13396
+ * fallback chain (`topic > first message summary > participant join`)
13397
+ * without re-implementing it client-side. The `firstMessagePreview`
13398
+ * is exposed alongside the resolved title so the client can also use
13399
+ * it for tooltips / debugging — the resolved `title` should be the
13400
+ * default render target. */
13401
+ app.get("/:chatId", async (request) => {
13402
+ const { chatId } = request.params;
13403
+ const scope = memberScope(request);
13404
+ await assertChatAccess(app.db, scope, chatId);
13405
+ const [chat] = await app.db.select().from(chats).where(eq(chats.id, chatId)).limit(1);
13406
+ if (!chat) throw new Error("Unexpected: chat missing after access check");
13407
+ const participants = await app.db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
13408
+ const firstMsgRows = await app.db.execute(sql`
13409
+ SELECT content FROM messages
13410
+ WHERE chat_id = ${chatId}
13411
+ ORDER BY created_at ASC
13412
+ LIMIT 1
13413
+ `);
13414
+ const firstMessagePreview = firstMsgRows[0] ? extractSummary(firstMsgRows[0].content) : null;
13415
+ const participantAgentIds = participants.map((p) => p.agentId);
13416
+ const agentRows = participantAgentIds.length > 0 ? await app.db.select({
13417
+ agentId: agents.uuid,
13418
+ displayName: agents.displayName,
13419
+ type: agents.type
13420
+ }).from(agents).where(inArray(agents.uuid, participantAgentIds)) : [];
13421
+ const agentMeta = new Map(agentRows.map((a) => [a.agentId, a]));
13422
+ const participantsForTitle = participants.map((p) => {
13423
+ const meta = agentMeta.get(p.agentId);
13424
+ return {
13425
+ agentId: p.agentId,
13426
+ displayName: meta?.displayName ?? p.agentId,
13427
+ type: meta?.type ?? "unknown"
13428
+ };
13429
+ });
13430
+ const title = resolveChatTitle(chat.topic, firstMessagePreview, participantsForTitle, scope.humanAgentId);
13431
+ return {
13432
+ ...chat,
13433
+ title,
13434
+ firstMessagePreview,
13435
+ createdAt: chat.createdAt.toISOString(),
13436
+ updatedAt: chat.updatedAt.toISOString(),
13437
+ participants: participants.map((p) => ({
13438
+ agentId: p.agentId,
13439
+ role: p.role,
13440
+ mode: p.mode,
13441
+ joinedAt: p.joinedAt.toISOString()
13442
+ }))
13443
+ };
13444
+ });
13445
+ /** Rename (or clear) a chat's topic. Requires participation or supervision — same gate as reading it. */
13446
+ app.patch("/:chatId", async (request) => {
13447
+ const { chatId } = request.params;
13448
+ const scope = memberScope(request);
13449
+ await assertChatAccess(app.db, scope, chatId);
13450
+ const body = updateChatSchema.parse(request.body);
13451
+ const nextTopic = body.topic && body.topic.length > 0 ? body.topic : null;
13452
+ const [updated] = await app.db.update(chats).set({
13453
+ topic: nextTopic,
13454
+ updatedAt: /* @__PURE__ */ new Date()
13455
+ }).where(eq(chats.id, chatId)).returning();
13456
+ if (!updated) throw new Error("Unexpected: chat missing after update");
13457
+ return {
13458
+ ...updated,
13459
+ createdAt: updated.createdAt.toISOString(),
13460
+ updatedAt: updated.updatedAt.toISOString()
13461
+ };
13462
+ });
13463
+ /** List messages in a chat with delivery status (requires participation or supervision) */
13464
+ app.get("/:chatId/messages", async (request) => {
13465
+ const { chatId } = request.params;
13466
+ const scope = memberScope(request);
13467
+ await assertChatAccess(app.db, scope, chatId);
13468
+ const query = paginationQuerySchema.parse(request.query);
13469
+ const where = query.cursor ? and(eq(messages.chatId, chatId), lt(messages.createdAt, new Date(query.cursor))) : eq(messages.chatId, chatId);
13470
+ const rows = await app.db.select({
13471
+ id: messages.id,
13472
+ chatId: messages.chatId,
13473
+ senderId: messages.senderId,
13474
+ format: messages.format,
13475
+ content: messages.content,
12415
13476
  metadata: messages.metadata,
12416
13477
  replyToInbox: messages.replyToInbox,
12417
13478
  replyToChat: messages.replyToChat,
@@ -13147,189 +14208,6 @@ async function adminOverviewRoutes(app) {
13147
14208
  };
13148
14209
  });
13149
14210
  }
13150
- const SUMMARY_MAX_LENGTH = 50;
13151
- /** Extract a plain-text summary from a message's JSONB content field. */
13152
- function extractSummary(content, maxLen = SUMMARY_MAX_LENGTH) {
13153
- let text = "";
13154
- if (typeof content === "object" && content !== null && "text" in content) text = String(content.text ?? "");
13155
- else if (typeof content === "string") text = content;
13156
- if (!text) return null;
13157
- return Array.from(text).slice(0, maxLen).join("");
13158
- }
13159
- /** List sessions for a specific agent, with optional state filters. */
13160
- async function listAgentSessions(db, agentId, filters) {
13161
- const conditions = [eq(agentChatSessions.agentId, agentId)];
13162
- if (filters?.state) conditions.push(eq(agentChatSessions.state, filters.state));
13163
- else conditions.push(ne(agentChatSessions.state, "evicted"));
13164
- const rows = await db.select({
13165
- agentId: agentChatSessions.agentId,
13166
- chatId: agentChatSessions.chatId,
13167
- state: agentChatSessions.state,
13168
- updatedAt: agentChatSessions.updatedAt,
13169
- chatCreatedAt: chats.createdAt,
13170
- chatTopic: chats.topic
13171
- }).from(agentChatSessions).innerJoin(chats, eq(agentChatSessions.chatId, chats.id)).where(and(...conditions)).orderBy(desc(agentChatSessions.updatedAt));
13172
- const [presence] = await db.select({ runtimeState: agentPresence.runtimeState }).from(agentPresence).where(eq(agentPresence.agentId, agentId)).limit(1);
13173
- const agentRuntimeState = presence?.runtimeState ?? null;
13174
- if (filters?.runtimeState && agentRuntimeState !== filters.runtimeState) return [];
13175
- const chatIds = rows.map((r) => r.chatId);
13176
- const messageCounts = chatIds.length > 0 ? await db.select({
13177
- chatId: inboxEntries.chatId,
13178
- count: sql`count(*)::int`
13179
- }).from(inboxEntries).where(and(eq(inboxEntries.inboxId, sql`(SELECT inbox_id FROM agents WHERE uuid = ${agentId})`), inArray(inboxEntries.chatId, chatIds))).groupBy(inboxEntries.chatId) : [];
13180
- const countMap = new Map(messageCounts.map((r) => [r.chatId, r.count]));
13181
- const firstMessages = chatIds.length > 0 ? await db.selectDistinctOn([messages.chatId], {
13182
- chatId: messages.chatId,
13183
- content: messages.content
13184
- }).from(messages).where(inArray(messages.chatId, chatIds)).orderBy(messages.chatId, messages.createdAt) : [];
13185
- const summaryMap = /* @__PURE__ */ new Map();
13186
- for (const row of firstMessages) {
13187
- const summary = extractSummary(row.content);
13188
- if (summary) summaryMap.set(row.chatId, summary);
13189
- }
13190
- return rows.map((r) => ({
13191
- agentId: r.agentId,
13192
- chatId: r.chatId,
13193
- state: r.state,
13194
- runtimeState: agentRuntimeState,
13195
- startedAt: r.chatCreatedAt.toISOString(),
13196
- lastActivityAt: r.updatedAt.toISOString(),
13197
- messageCount: countMap.get(r.chatId) ?? 0,
13198
- summary: summaryMap.get(r.chatId) ?? null,
13199
- topic: r.chatTopic ?? null
13200
- }));
13201
- }
13202
- /** Get a single session's detail. */
13203
- async function getSession(db, agentId, chatId) {
13204
- const [row] = await db.select({
13205
- agentId: agentChatSessions.agentId,
13206
- chatId: agentChatSessions.chatId,
13207
- state: agentChatSessions.state,
13208
- updatedAt: agentChatSessions.updatedAt,
13209
- chatCreatedAt: chats.createdAt,
13210
- chatTopic: chats.topic
13211
- }).from(agentChatSessions).innerJoin(chats, eq(agentChatSessions.chatId, chats.id)).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId))).limit(1);
13212
- if (!row) throw new NotFoundError(`Session (${agentId}, ${chatId}) not found`);
13213
- const [presence] = await db.select({ runtimeState: agentPresence.runtimeState }).from(agentPresence).where(eq(agentPresence.agentId, agentId)).limit(1);
13214
- const [countRow] = await db.select({ count: sql`count(*)::int` }).from(inboxEntries).where(and(eq(inboxEntries.inboxId, sql`(SELECT inbox_id FROM agents WHERE uuid = ${agentId})`), eq(inboxEntries.chatId, chatId)));
13215
- const firstMsg = (await db.execute(sql`SELECT content FROM messages WHERE chat_id = ${chatId} ORDER BY created_at ASC LIMIT 1`))[0];
13216
- const summary = firstMsg ? extractSummary(firstMsg.content) : null;
13217
- return {
13218
- agentId: row.agentId,
13219
- chatId: row.chatId,
13220
- state: row.state,
13221
- runtimeState: presence?.runtimeState ?? null,
13222
- startedAt: row.chatCreatedAt.toISOString(),
13223
- lastActivityAt: row.updatedAt.toISOString(),
13224
- messageCount: countRow?.count ?? 0,
13225
- summary,
13226
- topic: row.chatTopic ?? null
13227
- };
13228
- }
13229
- /** List all sessions across all agents, with pagination. Scoped to organization. */
13230
- async function listAllSessions(db, limit, cursor, filters) {
13231
- const conditions = [];
13232
- if (filters?.state) conditions.push(eq(agentChatSessions.state, filters.state));
13233
- else conditions.push(ne(agentChatSessions.state, "evicted"));
13234
- if (filters?.agentId) conditions.push(eq(agentChatSessions.agentId, filters.agentId));
13235
- if (filters?.organizationId) conditions.push(eq(agents.organizationId, filters.organizationId));
13236
- if (cursor) conditions.push(sql`${agentChatSessions.updatedAt} < ${new Date(cursor)}`);
13237
- const rows = await db.select({
13238
- agentId: agentChatSessions.agentId,
13239
- chatId: agentChatSessions.chatId,
13240
- state: agentChatSessions.state,
13241
- updatedAt: agentChatSessions.updatedAt,
13242
- chatCreatedAt: chats.createdAt,
13243
- chatTopic: chats.topic
13244
- }).from(agentChatSessions).innerJoin(chats, eq(agentChatSessions.chatId, chats.id)).innerJoin(agents, eq(agentChatSessions.agentId, agents.uuid)).where(conditions.length > 0 ? and(...conditions) : void 0).orderBy(desc(agentChatSessions.updatedAt)).limit(limit + 1);
13245
- const hasMore = rows.length > limit;
13246
- const items = hasMore ? rows.slice(0, limit) : rows;
13247
- const agentIds = [...new Set(items.map((r) => r.agentId))];
13248
- const presenceRows = agentIds.length > 0 ? await db.select({
13249
- agentId: agentPresence.agentId,
13250
- runtimeState: agentPresence.runtimeState
13251
- }).from(agentPresence).where(inArray(agentPresence.agentId, agentIds)) : [];
13252
- const runtimeMap = new Map(presenceRows.map((r) => [r.agentId, r.runtimeState]));
13253
- const chatIds = [...new Set(items.map((r) => r.chatId))];
13254
- const firstMessages = chatIds.length > 0 ? await db.selectDistinctOn([messages.chatId], {
13255
- chatId: messages.chatId,
13256
- content: messages.content
13257
- }).from(messages).where(inArray(messages.chatId, chatIds)).orderBy(messages.chatId, messages.createdAt) : [];
13258
- const summaryMap = /* @__PURE__ */ new Map();
13259
- for (const row of firstMessages) {
13260
- const summary = extractSummary(row.content);
13261
- if (summary) summaryMap.set(row.chatId, summary);
13262
- }
13263
- const last = items[items.length - 1];
13264
- const nextCursor = hasMore && last ? last.updatedAt.toISOString() : null;
13265
- return {
13266
- items: items.map((r) => ({
13267
- agentId: r.agentId,
13268
- chatId: r.chatId,
13269
- state: r.state,
13270
- runtimeState: runtimeMap.get(r.agentId) ?? null,
13271
- startedAt: r.chatCreatedAt.toISOString(),
13272
- lastActivityAt: r.updatedAt.toISOString(),
13273
- messageCount: 0,
13274
- summary: summaryMap.get(r.chatId) ?? null,
13275
- topic: r.chatTopic ?? null
13276
- })),
13277
- nextCursor
13278
- };
13279
- }
13280
- /** Commit `active → suspended`. No-op on suspended/evicted. Throws if row is missing. */
13281
- async function suspendSession(db, agentId, chatId, organizationId, notifier) {
13282
- return transitionSessionState(db, agentId, chatId, "suspended", ["active"], organizationId, notifier);
13283
- }
13284
- /** Commit `suspended → evicted` (terminal — listings hide it, revival defense blocks resurrection). */
13285
- async function archiveSession(db, agentId, chatId, organizationId, notifier) {
13286
- return transitionSessionState(db, agentId, chatId, "evicted", ["suspended"], organizationId, notifier);
13287
- }
13288
- async function transitionSessionState(db, agentId, chatId, target, from, organizationId, notifier) {
13289
- const now = /* @__PURE__ */ new Date();
13290
- let finalState = null;
13291
- let transitioned = false;
13292
- await db.transaction(async (tx) => {
13293
- const [existing] = await tx.select({ state: agentChatSessions.state }).from(agentChatSessions).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId))).for("update");
13294
- if (!existing) return;
13295
- const current = existing.state;
13296
- finalState = current;
13297
- if (!from.includes(current)) return;
13298
- await tx.update(agentChatSessions).set({
13299
- state: target,
13300
- updatedAt: now
13301
- }).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId)));
13302
- const [counts] = await tx.select({
13303
- active: sql`count(*) FILTER (WHERE ${agentChatSessions.state} = 'active')::int`,
13304
- total: sql`count(*) FILTER (WHERE ${agentChatSessions.state} != 'evicted')::int`
13305
- }).from(agentChatSessions).where(eq(agentChatSessions.agentId, agentId));
13306
- await tx.update(agentPresence).set({
13307
- activeSessions: counts?.active ?? 0,
13308
- totalSessions: counts?.total ?? 0,
13309
- lastSeenAt: now
13310
- }).where(eq(agentPresence.agentId, agentId));
13311
- finalState = target;
13312
- transitioned = true;
13313
- });
13314
- if (finalState === null) throw new NotFoundError(`Session (${agentId}, ${chatId}) not found`);
13315
- if (transitioned && notifier) notifier.notifySessionStateChange(agentId, chatId, target, organizationId).catch(() => {});
13316
- return {
13317
- state: finalState,
13318
- transitioned
13319
- };
13320
- }
13321
- /**
13322
- * Filter sessions to only those where the given agent is also a participant in the chat.
13323
- * Used when a non-manager views sessions of an org-visible agent — they should only see
13324
- * sessions for chats they participate in.
13325
- */
13326
- async function filterSessionsByParticipant(db, sessions, participantAgentId) {
13327
- if (sessions.length === 0) return [];
13328
- const chatIds = sessions.map((s) => s.chatId);
13329
- const participantRows = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(inArray(chatParticipants.chatId, chatIds), eq(chatParticipants.agentId, participantAgentId)));
13330
- const allowedChatIds = new Set(participantRows.map((r) => r.chatId));
13331
- return sessions.filter((s) => allowedChatIds.has(s.chatId));
13332
- }
13333
14211
  /**
13334
14212
  * Session events — structured event stream per (agent, chat) session.
13335
14213
  * `kind` is 'tool_call' | 'error'; the payload shape is enforced by the
@@ -14140,6 +15018,33 @@ function adminWsRoutes(notifier, jwtSecret) {
14140
15018
  ...payload
14141
15019
  });
14142
15020
  });
15021
+ notifier.onChatMessage(({ chatId }) => {
15022
+ dispatchChatMessage(chatId);
15023
+ });
15024
+ async function dispatchChatMessage(chatId) {
15025
+ if (adminSockets.size === 0) return;
15026
+ const audience = await getCachedAudience(getDbForChatLookup(), chatId);
15027
+ if (!audience || audience.size === 0) return;
15028
+ const frame = JSON.stringify({
15029
+ type: "chat:message",
15030
+ chatId
15031
+ });
15032
+ for (const [ws, meta] of adminSockets) {
15033
+ if (ws.readyState !== 1) continue;
15034
+ if (!audience.has(meta.humanAgentId)) continue;
15035
+ try {
15036
+ ws.send(frame);
15037
+ } catch {}
15038
+ }
15039
+ }
15040
+ let cachedDbForChatLookup = null;
15041
+ function getDbForChatLookup() {
15042
+ if (!cachedDbForChatLookup) throw new Error("admin WS: db not initialised yet");
15043
+ return cachedDbForChatLookup;
15044
+ }
15045
+ function rememberDb(db) {
15046
+ if (!cachedDbForChatLookup) cachedDbForChatLookup = db;
15047
+ }
14143
15048
  return async (app) => {
14144
15049
  app.get("/admin", {
14145
15050
  websocket: true,
@@ -14182,7 +15087,8 @@ function adminWsRoutes(notifier, jwtSecret) {
14182
15087
  }
14183
15088
  const [memberRow] = await app.db.select({
14184
15089
  id: members.id,
14185
- role: members.role
15090
+ role: members.role,
15091
+ agentId: members.agentId
14186
15092
  }).from(members).where(and(eq(members.userId, userId), eq(members.organizationId, organizationId), eq(members.status, "active"))).limit(1);
14187
15093
  if (!memberRow) {
14188
15094
  socket.send(JSON.stringify({
@@ -14198,10 +15104,14 @@ function adminWsRoutes(notifier, jwtSecret) {
14198
15104
  organizationId,
14199
15105
  memberId
14200
15106
  });
15107
+ rememberDb(app.db);
14201
15108
  const visibleAgentIds = await loadVisibleAgentIds(app.db, organizationId, memberId);
15109
+ const [humanAgentRow] = await app.db.select({ uuid: agents.uuid }).from(agents).where(eq(agents.uuid, memberRow.agentId)).limit(1);
15110
+ const humanAgentId = humanAgentRow?.uuid ?? memberRow.agentId;
14202
15111
  adminSockets.set(socket, {
14203
15112
  organizationId,
14204
15113
  memberId,
15114
+ humanAgentId,
14205
15115
  visibleAgentIds
14206
15116
  });
14207
15117
  socket.send(JSON.stringify({ type: "admin:connected" }));
@@ -15784,6 +16694,7 @@ async function ensureMembership(db, data) {
15784
16694
  if (existing) {
15785
16695
  if (existing.status === "left") {
15786
16696
  await db.update(members).set({ status: "active" }).where(eq(members.id, existing.id));
16697
+ await recomputeWatchersForMember(db, existing.id);
15787
16698
  return {
15788
16699
  ...existing,
15789
16700
  status: "active"
@@ -15911,11 +16822,18 @@ async function pickPrimaryMembership(db, userId) {
15911
16822
  * (last admin allowed to leave, leaves an orphan team) and the cleanup is
15912
16823
  * a v2 sweep job.
15913
16824
  */
16825
+ /**
16826
+ * Soft-leave an organization. Flips the member's row to `status='left'`
16827
+ * and reconciles watcher rows: `recomputeChatWatchers`'s active-member
16828
+ * predicate now drops every watcher anchored to this member, removing
16829
+ * the chats from their `/me/chats` watching list.
16830
+ */
15914
16831
  async function leaveOrganization(db, memberId) {
15915
16832
  const [existing] = await db.select().from(members).where(eq(members.id, memberId)).limit(1);
15916
16833
  if (!existing) throw new NotFoundError(`Membership "${memberId}" not found`);
15917
16834
  if (existing.status === "left") return existing;
15918
16835
  await db.update(members).set({ status: "left" }).where(eq(members.id, memberId));
16836
+ await recomputeWatchersForMember(db, memberId);
15919
16837
  return {
15920
16838
  ...existing,
15921
16839
  status: "left"
@@ -16573,7 +17491,7 @@ async function inferWizardStep(app, m) {
16573
17491
  * landing page.
16574
17492
  */
16575
17493
  async function publicInvitePreviewRoute(app) {
16576
- const { previewInvitation } = await import("./invitation-CBnQyB7o-Bulf3Sl7.mjs");
17494
+ const { previewInvitation } = await import("./invitation-CBnQyB7o-TmnIj3kx.mjs");
16577
17495
  app.get("/:token/preview", async (request, reply) => {
16578
17496
  if (!request.params.token) throw new UnauthorizedError("Token required");
16579
17497
  const preview = await previewInvitation(app.db, request.params.token);
@@ -16603,7 +17521,7 @@ async function adminInvitationRoutes(app) {
16603
17521
  const m = requireMember(request);
16604
17522
  if (m.role !== "admin") throw new ForbiddenError("Admin role required");
16605
17523
  if (request.params.id !== m.organizationId) throw new ForbiddenError("Cannot rotate invitations for another organization");
16606
- const { rotateInvitation } = await import("./invitation-CBnQyB7o-Bulf3Sl7.mjs");
17524
+ const { rotateInvitation } = await import("./invitation-CBnQyB7o-TmnIj3kx.mjs");
16607
17525
  const inv = await rotateInvitation(app.db, m.organizationId, m.userId);
16608
17526
  return {
16609
17527
  id: inv.id,
@@ -16616,6 +17534,57 @@ async function adminInvitationRoutes(app) {
16616
17534
  };
16617
17535
  });
16618
17536
  }
17537
+ /**
17538
+ * `/me/chats*` member-facing chat APIs for the chat-first workspace. Mounted
17539
+ * under the existing `memberAuth` hook.
17540
+ *
17541
+ * Auth & visibility model: every read/write here resolves through the
17542
+ * caller's human agent uuid (member.agentId). Server-side authorisation
17543
+ * keeps cross-org leakage impossible — `chats.organization_id` is verified
17544
+ * against the participant's own membership inside each service.
17545
+ */
17546
+ async function meChatRoutes(app) {
17547
+ /** GET /me/chats — paginated conversation list (chat-first workspace). */
17548
+ app.get("/", async (request) => {
17549
+ const scope = memberScope(request);
17550
+ const query = listMeChatsQuerySchema.parse(request.query);
17551
+ return listMeChats(app.db, scope.humanAgentId, query);
17552
+ });
17553
+ /** POST /me/chats — always creates a new chat (no dedupe). */
17554
+ app.post("/", async (request, reply) => {
17555
+ const scope = memberScope(request);
17556
+ const body = createMeChatSchema.parse(request.body);
17557
+ const result = await createMeChat(app.db, scope.humanAgentId, scope.organizationId, body);
17558
+ return reply.status(201).send(result);
17559
+ });
17560
+ /** POST /me/chats/:chatId/read — mark the user's row read. Idempotent. */
17561
+ app.post("/:chatId/read", async (request) => {
17562
+ const { chatId } = request.params;
17563
+ const scope = memberScope(request);
17564
+ return markMeChatRead(app.db, chatId, scope.humanAgentId);
17565
+ });
17566
+ /** POST /me/chats/:chatId/participants — add one or more speaking participants. Idempotent. */
17567
+ app.post("/:chatId/participants", async (request, reply) => {
17568
+ const { chatId } = request.params;
17569
+ const scope = memberScope(request);
17570
+ const body = addMeChatParticipantsSchema.parse(request.body);
17571
+ await addMeChatParticipants(app.db, chatId, scope.humanAgentId, scope.organizationId, body);
17572
+ return reply.status(204).send();
17573
+ });
17574
+ /** POST /me/chats/:chatId/join — watcher → speaking participant. State-carry. */
17575
+ app.post("/:chatId/join", async (request, reply) => {
17576
+ const { chatId } = request.params;
17577
+ const scope = memberScope(request);
17578
+ await joinMeChat(app.db, chatId, scope.humanAgentId);
17579
+ return reply.status(204).send();
17580
+ });
17581
+ /** POST /me/chats/:chatId/leave — speaking participant → watcher (or detach). */
17582
+ app.post("/:chatId/leave", async (request) => {
17583
+ const { chatId } = request.params;
17584
+ const scope = memberScope(request);
17585
+ return leaveMeChat(app.db, chatId, scope.humanAgentId);
17586
+ });
17587
+ }
16619
17588
  const SALT_ROUNDS = 10;
16620
17589
  /**
16621
17590
  * Create a member in an organization.
@@ -17254,6 +18223,7 @@ var schema_exports = /* @__PURE__ */ __exportAll({
17254
18223
  agents: () => agents,
17255
18224
  authIdentities: () => authIdentities,
17256
18225
  chatParticipants: () => chatParticipants,
18226
+ chatSubscriptions: () => chatSubscriptions,
17257
18227
  chats: () => chats,
17258
18228
  clients: () => clients,
17259
18229
  inboxEntries: () => inboxEntries,
@@ -18598,6 +19568,10 @@ async function buildApp(config) {
18598
19568
  adminApp.addHook("onRequest", memberAuth);
18599
19569
  await adminApp.register(adminChatRoutes);
18600
19570
  }, { prefix: "/admin/chats" });
19571
+ await api.register(async (memberApp) => {
19572
+ memberApp.addHook("onRequest", memberAuth);
19573
+ await memberApp.register(meChatRoutes);
19574
+ }, { prefix: "/me/chats" });
18601
19575
  await api.register(async (adminApp) => {
18602
19576
  adminApp.addHook("onRequest", memberAuth);
18603
19577
  await adminApp.register(adminClientRoutes);
@@ -18704,6 +19678,13 @@ async function buildApp(config) {
18704
19678
  kaelRuntime?.reload().catch((err) => hotReloadLog.error({ err }, "kael hot-reload failed (PG NOTIFY)"));
18705
19679
  }
18706
19680
  });
19681
+ registerChatMessageDispatcher((chatId, messageId) => {
19682
+ notifier.notifyChatMessage(chatId, messageId).catch((err) => createLogger$1("chat-message-kick").warn({
19683
+ err,
19684
+ chatId,
19685
+ messageId
19686
+ }, "chat:message kick failed"));
19687
+ });
18707
19688
  app.addHook("onReady", async () => {
18708
19689
  await ensureDefaultOrganization(db);
18709
19690
  await notifier.start();