@agent-team-foundation/first-tree-hub 0.10.14 → 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
- import { C as resolveConfigReadonly, S as resetConfigMeta, T as setConfigValue, _ as initConfig, a as loadCredentials, c as saveAgentConfig, d as DEFAULT_DATA_DIR$1, f as DEFAULT_HOME_DIR$1, h as collectMissingPrompts, l as saveCredentials, m as clientConfigSchema, p as agentConfigSchema, r as ensureFreshAccessToken, s as resolveServerUrl, u as DEFAULT_CONFIG_DIR, v as loadAgents, w as serverConfigSchema, x as resetConfig, y as migrateLegacyHome } from "./bootstrap-B2x4TTyJ.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";
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 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";
@@ -21,7 +21,7 @@ import { fileURLToPath } from "node:url";
21
21
  import * as semver from "semver";
22
22
  import { confirm, input, password, select } from "@inquirer/prompts";
23
23
  import bcrypt from "bcrypt";
24
- import { and, asc, count, desc, eq, gt, inArray, isNotNull, isNull, lt, ne, or, sql } from "drizzle-orm";
24
+ import { and, asc, count, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, ne, or, sql } from "drizzle-orm";
25
25
  import { drizzle } from "drizzle-orm/postgres-js";
26
26
  import postgres from "postgres";
27
27
  import { migrate } from "drizzle-orm/postgres-js/migrator";
@@ -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",
@@ -1883,6 +1940,13 @@ var ClientConnection = class extends EventEmitter {
1883
1940
  /** Fires ~60s before JWT exp so we reconnect with a fresh token first. */
1884
1941
  authRefreshTimer = null;
1885
1942
  reconnectAttempt = 0;
1943
+ /**
1944
+ * If the most recent refresh attempt was rate-limited (HTTP 429), the
1945
+ * server-suggested wait in ms — consumed by the next `scheduleReconnect`
1946
+ * to floor its delay so we don't keep retrying inside the same 60s
1947
+ * limiter window. Cleared after one use.
1948
+ */
1949
+ nextReconnectMinDelayMs = 0;
1886
1950
  closing = false;
1887
1951
  registered = false;
1888
1952
  /** Count of `server:welcome` frames received; drives `isReconnect` flag. */
@@ -2095,6 +2159,10 @@ var ClientConnection = class extends EventEmitter {
2095
2159
  if (e.name === "AuthRefreshFailedError") {
2096
2160
  this.closing = true;
2097
2161
  this.emit("auth:fatal", e);
2162
+ } else if (e.name === "AuthRefreshRateLimitedError") {
2163
+ const retryAfterMs = e.retryAfterMs ?? 3e4;
2164
+ this.nextReconnectMinDelayMs = Math.max(this.nextReconnectMinDelayMs, retryAfterMs);
2165
+ this.authLogger.warn({ retryAfterMs }, "refresh rate-limited; deferring reconnect");
2098
2166
  }
2099
2167
  settle(reject, e);
2100
2168
  ws.close();
@@ -2348,10 +2416,18 @@ var ClientConnection = class extends EventEmitter {
2348
2416
  if (this.closing) return;
2349
2417
  this.reconnectAttempt++;
2350
2418
  this.emit("reconnecting", this.reconnectAttempt);
2351
- const delay = Math.min(RECONNECT_BASE_MS * 2 ** (this.reconnectAttempt - 1), RECONNECT_MAX_MS);
2419
+ const exponential = Math.min(RECONNECT_BASE_MS * 2 ** (this.reconnectAttempt - 1), RECONNECT_MAX_MS);
2420
+ const floor = this.nextReconnectMinDelayMs;
2421
+ this.nextReconnectMinDelayMs = 0;
2422
+ let delay = Math.max(exponential, floor);
2423
+ if (floor > 0) {
2424
+ const jitter = delay * .2 * (Math.random() * 2 - 1);
2425
+ delay = Math.max(0, Math.round(delay + jitter));
2426
+ }
2352
2427
  this.wsLogger.debug({
2353
2428
  attempt: this.reconnectAttempt,
2354
- delayMs: delay
2429
+ delayMs: delay,
2430
+ floorMs: floor || void 0
2355
2431
  }, "scheduling reconnect");
2356
2432
  this.reconnectTimer = setTimeout(() => {
2357
2433
  this.reconnectTimer = null;
@@ -2401,6 +2477,18 @@ var ClientConnection = class extends EventEmitter {
2401
2477
  * before the server's scheduleAuthExpiry timer fires. Short-lived tokens
2402
2478
  * (exp <= lead window) skip the proactive reconnect entirely — we let the
2403
2479
  * server push `auth:expired` and handle that path.
2480
+ *
2481
+ * Order is "refresh-then-close", not "close-then-let-reconnect-refresh".
2482
+ * The earlier shape relied on the new connection's open handler to do the
2483
+ * `/auth/refresh` HTTP, which forced ≥1s of WS downtime per cycle even on
2484
+ * the happy path (one base reconnect delay + the refresh round-trip) and
2485
+ * compounded badly under 429: every retry attempt also closed/reopened the
2486
+ * WS, holding the agent offline for 15-20s while the limiter cooled down.
2487
+ * Refreshing first lets us swap the new token onto a still-open WS with no
2488
+ * observable disconnect when the refresh succeeds; the original close-and-
2489
+ * reconnect flow only runs on failure as a last-ditch fallback (it'll hit
2490
+ * the same 429 on its next retry, but at least the Retry-After floor is
2491
+ * now wired up so we don't pile attempts inside the same window).
2404
2492
  */
2405
2493
  scheduleProactiveAuthRefresh(token) {
2406
2494
  this.clearAuthRefreshTimer();
@@ -2410,12 +2498,29 @@ var ClientConnection = class extends EventEmitter {
2410
2498
  if (delay <= 0) return;
2411
2499
  this.authLogger.debug({ delayMs: delay }, "scheduled proactive auth refresh");
2412
2500
  this.authRefreshTimer = setTimeout(() => {
2413
- this.authRefreshTimer = null;
2414
- if (this.closing) return;
2415
- this.authLogger.info("triggering proactive auth refresh");
2416
- this.ws?.close(1e3, "proactive auth refresh");
2501
+ this.runProactiveAuthRefresh();
2417
2502
  }, delay);
2418
2503
  }
2504
+ async runProactiveAuthRefresh() {
2505
+ this.authRefreshTimer = null;
2506
+ if (this.closing) return;
2507
+ this.authLogger.info("triggering proactive auth refresh");
2508
+ try {
2509
+ await this.getAccessToken({ minValidityMs: AUTH_REFRESH_LEAD_MS + 5e3 });
2510
+ } catch (err) {
2511
+ const e = err instanceof Error ? err : new Error(String(err));
2512
+ if (e.name === "AuthRefreshRateLimitedError") {
2513
+ const retryAfterMs = e.retryAfterMs ?? 3e4;
2514
+ this.nextReconnectMinDelayMs = Math.max(this.nextReconnectMinDelayMs, retryAfterMs);
2515
+ this.authLogger.warn({ retryAfterMs }, "proactive refresh rate-limited; deferring reconnect");
2516
+ } else if (e.name === "AuthRefreshFailedError") {
2517
+ this.closing = true;
2518
+ this.emit("auth:fatal", e);
2519
+ return;
2520
+ } else this.authLogger.warn({ err: e }, "proactive refresh failed; falling back to reconnect path");
2521
+ }
2522
+ this.ws?.close(1e3, "proactive auth refresh");
2523
+ }
2419
2524
  };
2420
2525
  /** Built-in handler registry. Populated by handler modules. */
2421
2526
  const HANDLER_REGISTRY = /* @__PURE__ */ new Map();
@@ -8098,7 +8203,7 @@ async function onboardCreate(args) {
8098
8203
  }
8099
8204
  const runtimeAgent = args.type === "human" ? args.assistant : args.id;
8100
8205
  if (args.feishuBotAppId && args.feishuBotAppSecret) {
8101
- const { bindFeishuBot } = await import("./feishu-DQ1l18Ah.mjs").then((n) => n.r);
8206
+ const { bindFeishuBot } = await import("./feishu-Dxk6ArOK.mjs").then((n) => n.r);
8102
8207
  const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
8103
8208
  if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
8104
8209
  else {
@@ -9078,7 +9183,7 @@ function createFeedbackHandler(config) {
9078
9183
  return { handle };
9079
9184
  }
9080
9185
  //#endregion
9081
- //#region ../server/dist/app-Le92-WQA.mjs
9186
+ //#region ../server/dist/app-DNJkrky7.mjs
9082
9187
  var __defProp = Object.defineProperty;
9083
9188
  var __exportAll = (all, no_symbols) => {
9084
9189
  let target = {};
@@ -9162,17 +9267,44 @@ const chats = pgTable("chats", {
9162
9267
  lifecyclePolicy: text("lifecycle_policy").default("persistent"),
9163
9268
  parentChatId: text("parent_chat_id"),
9164
9269
  metadata: jsonb("metadata").$type().notNull().default({}),
9270
+ lastMessageAt: timestamp("last_message_at", { withTimezone: true }),
9271
+ lastMessagePreview: text("last_message_preview"),
9165
9272
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
9166
9273
  updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
9167
- });
9168
- /** 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. */
9169
9276
  const chatParticipants = pgTable("chat_participants", {
9170
9277
  chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
9171
9278
  agentId: text("agent_id").notNull().references(() => agents.uuid),
9172
9279
  role: text("role").notNull().default("member"),
9173
9280
  mode: text("mode").notNull().default("full"),
9281
+ lastReadAt: timestamp("last_read_at", { withTimezone: true }),
9282
+ unreadMentionCount: integer("unread_mention_count").notNull().default(0),
9174
9283
  joinedAt: timestamp("joined_at", { withTimezone: true }).notNull().defaultNow()
9175
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)]);
9176
9308
  /** Organization membership. Links a user to an org with a role and a 1:1 human agent. */
9177
9309
  const members = pgTable("members", {
9178
9310
  id: text("id").primaryKey(),
@@ -9967,7 +10099,7 @@ async function deleteAdapterConfig(db, id) {
9967
10099
  const [row] = await db.delete(adapterConfigs).where(eq(adapterConfigs.id, id)).returning();
9968
10100
  if (!row) throw new NotFoundError(`Adapter config "${id}" not found`);
9969
10101
  }
9970
- const log$5 = createLogger$1("AdminAdapters");
10102
+ const log$6 = createLogger$1("AdminAdapters");
9971
10103
  const orgQuerySchema$1 = z.object({ organizationId: z.string().min(1).optional() });
9972
10104
  function parseId(raw) {
9973
10105
  const id = Number(raw);
@@ -9990,7 +10122,7 @@ async function adminAdapterRoutes(app) {
9990
10122
  const scope = memberScope(request);
9991
10123
  await assertCanManage(app.db, scope, body.agentId);
9992
10124
  const config = await createAdapterConfig(app.db, body, app.config.secrets.encryptionKey);
9993
- 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"));
9994
10126
  app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
9995
10127
  return reply.status(201).send({
9996
10128
  ...config,
@@ -10014,7 +10146,7 @@ async function adminAdapterRoutes(app) {
10014
10146
  const existing = await getAdapterConfig(app.db, id);
10015
10147
  await assertCanManage(app.db, scope, existing.agentId);
10016
10148
  const config = await updateAdapterConfig(app.db, id, body, app.config.secrets.encryptionKey);
10017
- 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"));
10018
10150
  app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
10019
10151
  return {
10020
10152
  ...config,
@@ -10028,7 +10160,7 @@ async function adminAdapterRoutes(app) {
10028
10160
  const existing = await getAdapterConfig(app.db, id);
10029
10161
  await assertCanManage(app.db, scope, existing.agentId);
10030
10162
  await deleteAdapterConfig(app.db, id);
10031
- 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"));
10032
10164
  app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
10033
10165
  return reply.status(204).send();
10034
10166
  });
@@ -10109,6 +10241,227 @@ const agentConfigs = pgTable("agent_configs", {
10109
10241
  updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
10110
10242
  });
10111
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
+ /**
10112
10465
  * Names beginning with `__` are reserved for Hub-internal pseudo agents
10113
10466
  * (e.g. the task notifier). User-facing creation must not be able to
10114
10467
  * squat on them, otherwise internal traffic could be routed through a
@@ -10415,6 +10768,7 @@ async function updateAgent(db, uuid, data) {
10415
10768
  }
10416
10769
  const [updated] = await db.update(agents).set(updates).where(eq(agents.uuid, agent.uuid)).returning();
10417
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);
10418
10772
  return updated;
10419
10773
  }
10420
10774
  /**
@@ -10501,6 +10855,71 @@ async function deleteAgent(db, uuid) {
10501
10855
  return agent;
10502
10856
  }
10503
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
+ /**
10504
10923
  * When a direct chat grows past 2 participants, upgrade it to `group` and
10505
10924
  * flip every existing non-human agent participant to `mention_only` — see
10506
10925
  * proposals/hub-agent-messaging-reply-and-mentions §3.3. The caller is
@@ -10555,6 +10974,7 @@ async function createChat(db, creatorId, data) {
10555
10974
  ...isDirectAgentOnly ? { mode: "mention_only" } : {}
10556
10975
  }));
10557
10976
  await tx.insert(chatParticipants).values(participantRows);
10977
+ await recomputeChatWatchers(tx, chatId);
10558
10978
  const participants = await tx.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
10559
10979
  if (!chat) throw new Error("Unexpected: INSERT RETURNING produced no row");
10560
10980
  return {
@@ -10618,12 +11038,15 @@ async function ensureParticipant(db, chatId, agentId) {
10618
11038
  if (existing) return;
10619
11039
  await db.transaction(async (tx) => {
10620
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)));
10621
11042
  await tx.insert(chatParticipants).values({
10622
11043
  chatId,
10623
11044
  agentId,
10624
11045
  mode: "full"
10625
11046
  }).onConflictDoNothing({ target: [chatParticipants.chatId, chatParticipants.agentId] });
11047
+ await recomputeChatWatchers(tx, chatId);
10626
11048
  });
11049
+ invalidateChatAudience(chatId);
10627
11050
  }
10628
11051
  async function addParticipant(db, chatId, requesterId, data) {
10629
11052
  const chat = await getChat(db, chatId);
@@ -10638,12 +11061,15 @@ async function addParticipant(db, chatId, requesterId, data) {
10638
11061
  if (existing) throw new ConflictError(`Agent "${data.agentId}" is already a participant`);
10639
11062
  await db.transaction(async (tx) => {
10640
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)));
10641
11065
  await tx.insert(chatParticipants).values({
10642
11066
  chatId,
10643
11067
  agentId: data.agentId,
10644
11068
  mode: data.mode ?? "full"
10645
11069
  });
11070
+ await recomputeChatWatchers(tx, chatId);
10646
11071
  });
11072
+ invalidateChatAudience(chatId);
10647
11073
  return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
10648
11074
  }
10649
11075
  async function removeParticipant(db, chatId, requesterId, targetAgentId) {
@@ -10651,6 +11077,8 @@ async function removeParticipant(db, chatId, requesterId, targetAgentId) {
10651
11077
  if (requesterId === targetAgentId) throw new BadRequestError("Cannot remove yourself from a chat");
10652
11078
  const [removed] = await db.delete(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, targetAgentId))).returning();
10653
11079
  if (!removed) throw new NotFoundError(`Agent "${targetAgentId}" is not a participant of this chat`);
11080
+ await recomputeChatWatchers(db, chatId);
11081
+ invalidateChatAudience(chatId);
10654
11082
  return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
10655
11083
  }
10656
11084
  /**
@@ -10742,23 +11170,38 @@ async function joinChat(db, chatId, memberId, humanAgentId) {
10742
11170
  const [humanAgent] = await db.select({ organizationId: agents.organizationId }).from(agents).where(eq(agents.uuid, humanAgentId)).limit(1);
10743
11171
  if (!humanAgent || humanAgent.organizationId !== chat.organizationId) throw new BadRequestError("Agent does not belong to the same organization as the chat");
10744
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
+ });
10745
11177
  await maybeUpgradeDirectToGroup(tx, chatId, participantAgentIds, 1);
10746
11178
  await tx.insert(chatParticipants).values({
10747
11179
  chatId,
10748
11180
  agentId: humanAgentId,
10749
11181
  role: "member",
10750
- mode: "full"
11182
+ mode: "full",
11183
+ lastReadAt: carriedRow?.lastReadAt ?? null,
11184
+ unreadMentionCount: carriedRow?.unreadMentionCount ?? 0
10751
11185
  });
11186
+ await recomputeChatWatchers(tx, chatId);
10752
11187
  });
11188
+ invalidateChatAudience(chatId);
10753
11189
  return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
10754
11190
  }
10755
11191
  /**
10756
11192
  * Manager leaves a chat. Removes their human agent from participants.
10757
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.
10758
11201
  */
10759
11202
  async function leaveChat(db, chatId, humanAgentId) {
10760
- const [removed] = await db.delete(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, humanAgentId))).returning();
10761
- if (!removed) throw new NotFoundError("Not a participant of this chat");
11203
+ await leaveAsParticipant(db, chatId, humanAgentId);
11204
+ invalidateChatAudience(chatId);
10762
11205
  return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
10763
11206
  }
10764
11207
  async function findOrCreateDirectChat(db, agentAId, agentBId) {
@@ -10798,6 +11241,7 @@ async function findOrCreateDirectChat(db, agentAId, agentBId) {
10798
11241
  role: "member",
10799
11242
  mode
10800
11243
  }]);
11244
+ await recomputeChatWatchers(tx, chatId);
10801
11245
  if (!chat) throw new Error("Unexpected: INSERT RETURNING produced no row");
10802
11246
  return chat;
10803
11247
  });
@@ -11316,6 +11760,22 @@ function removeClientConnection(clientId, ws) {
11316
11760
  clientConnections.delete(clientId);
11317
11761
  return agentIds;
11318
11762
  }
11763
+ /**
11764
+ * Was `ws` the socket currently registered as `clientId`'s active connection
11765
+ * at the time of the call? Used by ws-client.ts's `socket.on("close")` to
11766
+ * decide whether to write `clients.status='disconnected'` to the DB — when a
11767
+ * fast reconnect happens, the new socket has already swapped itself in via
11768
+ * `setClientConnection`, so the old socket's late-arriving onClose must NOT
11769
+ * stamp the row back to disconnected.
11770
+ *
11771
+ * The check is "this socket equals the registered ws", not "this socket is
11772
+ * still OPEN" — the close handler runs precisely when the socket is no
11773
+ * longer OPEN, but the in-memory entry might still legitimately point at
11774
+ * us if no new connection has taken over yet.
11775
+ */
11776
+ function isActiveClientConnection(clientId, ws) {
11777
+ return clientConnections.get(clientId)?.ws === ws;
11778
+ }
11319
11779
  /** Send a message to a client's WebSocket. Returns true if delivered. */
11320
11780
  function sendToClient(clientId, message) {
11321
11781
  const entry = clientConnections.get(clientId);
@@ -11484,6 +11944,88 @@ async function listAgentsWithRuntime(db, scope) {
11484
11944
  type: agents.type
11485
11945
  }).from(agentPresence).innerJoin(agents, eq(agentPresence.agentId, agents.uuid)).where(and(isNotNull(agentPresence.runtimeState), agentVisibilityCondition(scope.organizationId, scope.memberId)));
11486
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
+ }
11487
12029
  const log$4 = createLogger$1("message");
11488
12030
  async function sendMessage(db, chatId, senderId, data, options = {}) {
11489
12031
  return withSpan("inbox.enqueue", messageAttrs({
@@ -11585,6 +12127,15 @@ async function sendMessageInner(db, chatId, senderId, data, options) {
11585
12127
  }
11586
12128
  await tx.update(chats).set({ updatedAt: /* @__PURE__ */ new Date() }).where(eq(chats.id, chatId));
11587
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
+ });
11588
12139
  return {
11589
12140
  message: msg,
11590
12141
  recipients,
@@ -11601,6 +12152,7 @@ async function sendMessageInner(db, chatId, senderId, data, options) {
11601
12152
  agentId: txResult.recipientAgentIds[i]
11602
12153
  }, "predictive session activation failed");
11603
12154
  }
12155
+ fireChatMessageKick(chatId, txResult.message.id);
11604
12156
  return {
11605
12157
  message: txResult.message,
11606
12158
  recipients: txResult.recipients
@@ -11662,15 +12214,24 @@ const INBOX_CHANNEL = "inbox_notifications";
11662
12214
  const CONFIG_CHANNEL = "config_changes";
11663
12215
  const SESSION_STATE_CHANNEL = "session_state_changes";
11664
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";
11665
12224
  function createNotifier(listenClient) {
11666
12225
  const subscriptions = /* @__PURE__ */ new Map();
11667
12226
  const configChangeHandlers = [];
11668
12227
  const sessionStateChangeHandlers = [];
11669
12228
  const runtimeStateChangeHandlers = [];
12229
+ const chatMessageHandlers = [];
11670
12230
  let unlistenInboxFn = null;
11671
12231
  let unlistenConfigFn = null;
11672
12232
  let unlistenSessionStateFn = null;
11673
12233
  let unlistenRuntimeStateFn = null;
12234
+ let unlistenChatMessageFn = null;
11674
12235
  function handleNotification(payload) {
11675
12236
  const sepIdx = payload.indexOf(":");
11676
12237
  if (sepIdx === -1) return;
@@ -11725,6 +12286,11 @@ function createNotifier(listenClient) {
11725
12286
  await listenClient`SELECT pg_notify(${RUNTIME_STATE_CHANNEL}, ${`${agentId}:${state}:${organizationId}`})`;
11726
12287
  } catch {}
11727
12288
  },
12289
+ async notifyChatMessage(chatId, messageId) {
12290
+ try {
12291
+ await listenClient`SELECT pg_notify(${CHAT_MESSAGE_CHANNEL}, ${`${chatId}:${messageId}`})`;
12292
+ } catch {}
12293
+ },
11728
12294
  async pushFrameToInbox(inboxId, frame) {
11729
12295
  const map = subscriptions.get(inboxId);
11730
12296
  if (!map) return 0;
@@ -11751,6 +12317,9 @@ function createNotifier(listenClient) {
11751
12317
  onRuntimeStateChange(handler) {
11752
12318
  runtimeStateChangeHandlers.push(handler);
11753
12319
  },
12320
+ onChatMessage(handler) {
12321
+ chatMessageHandlers.push(handler);
12322
+ },
11754
12323
  async start() {
11755
12324
  unlistenInboxFn = (await listenClient.listen(INBOX_CHANNEL, (payload) => {
11756
12325
  if (payload) handleNotification(payload);
@@ -11793,6 +12362,19 @@ function createNotifier(listenClient) {
11793
12362
  }
11794
12363
  }
11795
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;
11796
12378
  },
11797
12379
  async stop() {
11798
12380
  if (unlistenInboxFn) {
@@ -11811,6 +12393,10 @@ function createNotifier(listenClient) {
11811
12393
  await unlistenRuntimeStateFn();
11812
12394
  unlistenRuntimeStateFn = null;
11813
12395
  }
12396
+ if (unlistenChatMessageFn) {
12397
+ await unlistenChatMessageFn();
12398
+ unlistenChatMessageFn = null;
12399
+ }
11814
12400
  }
11815
12401
  };
11816
12402
  }
@@ -12253,86 +12839,625 @@ async function collectTargetInboxes(db, chatId, inReplyTo) {
12253
12839
  }
12254
12840
  return [...set];
12255
12841
  }
12256
- async function adminChatRoutes(app) {
12257
- /** List all chats in org (admin-only, for audit). Members should use GET /mine. */
12258
- app.get("/", { preHandler: requireAdminRoleHook() }, async (request) => {
12259
- const query = paginationQuerySchema.parse(request.query);
12260
- const rawQuery = request.query;
12261
- const orgParam = rawQuery.organizationId ?? rawQuery.org;
12262
- let orgId;
12263
- if (orgParam) orgId = (await resolveOrganization(app.db, orgParam)).id;
12264
- else orgId = await resolveDefaultOrgId(app.db);
12265
- const conditions = [eq(chats.organizationId, orgId)];
12266
- if (query.cursor) conditions.push(lt(chats.createdAt, new Date(query.cursor)));
12267
- const where = and(...conditions);
12268
- const rows = await app.db.select({
12269
- id: chats.id,
12270
- organizationId: chats.organizationId,
12271
- type: chats.type,
12272
- topic: chats.topic,
12273
- lifecyclePolicy: chats.lifecyclePolicy,
12274
- metadata: chats.metadata,
12275
- createdAt: chats.createdAt,
12276
- updatedAt: chats.updatedAt,
12277
- participantCount: sql`(
12278
- SELECT count(*)::int FROM chat_participants WHERE chat_id = ${chats.id}
12279
- )`
12280
- }).from(chats).where(where).orderBy(desc(chats.createdAt)).limit(query.limit + 1);
12281
- const hasMore = rows.length > query.limit;
12282
- const items = hasMore ? rows.slice(0, query.limit) : rows;
12283
- const last = items[items.length - 1];
12284
- const nextCursor = hasMore && last ? last.createdAt.toISOString() : null;
12285
- return {
12286
- items: items.map((c) => ({
12287
- id: c.id,
12288
- organizationId: c.organizationId,
12289
- type: c.type,
12290
- topic: c.topic,
12291
- lifecyclePolicy: c.lifecyclePolicy,
12292
- metadata: c.metadata,
12293
- participantCount: c.participantCount,
12294
- createdAt: c.createdAt.toISOString(),
12295
- updatedAt: c.updatedAt.toISOString()
12296
- })),
12297
- nextCursor
12298
- };
12299
- });
12300
- /** Get chat detail with participants (requires participation or supervision) */
12301
- app.get("/:chatId", async (request) => {
12302
- const { chatId } = request.params;
12303
- const scope = memberScope(request);
12304
- await assertChatAccess(app.db, scope, chatId);
12305
- const [chat] = await app.db.select().from(chats).where(eq(chats.id, chatId)).limit(1);
12306
- if (!chat) throw new Error("Unexpected: chat missing after access check");
12307
- const participants = await app.db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
12308
- return {
12309
- ...chat,
12310
- createdAt: chat.createdAt.toISOString(),
12311
- updatedAt: chat.updatedAt.toISOString(),
12312
- participants: participants.map((p) => ({
12313
- agentId: p.agentId,
12314
- role: p.role,
12315
- mode: p.mode,
12316
- joinedAt: p.joinedAt.toISOString()
12317
- }))
12318
- };
12319
- });
12320
- /** Rename (or clear) a chat's topic. Requires participation or supervision — same gate as reading it. */
12321
- app.patch("/:chatId", async (request) => {
12322
- const { chatId } = request.params;
12323
- const scope = memberScope(request);
12324
- await assertChatAccess(app.db, scope, chatId);
12325
- const body = updateChatSchema.parse(request.body);
12326
- const nextTopic = body.topic && body.topic.length > 0 ? body.topic : null;
12327
- const [updated] = await app.db.update(chats).set({
12328
- topic: nextTopic,
12329
- updatedAt: /* @__PURE__ */ new Date()
12330
- }).where(eq(chats.id, chatId)).returning();
12331
- if (!updated) throw new Error("Unexpected: chat missing after update");
12332
- return {
12333
- ...updated,
12334
- createdAt: updated.createdAt.toISOString(),
12335
- updatedAt: updated.updatedAt.toISOString()
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()
12336
13461
  };
12337
13462
  });
12338
13463
  /** List messages in a chat with delivery status (requires participation or supervision) */
@@ -13083,189 +14208,6 @@ async function adminOverviewRoutes(app) {
13083
14208
  };
13084
14209
  });
13085
14210
  }
13086
- const SUMMARY_MAX_LENGTH = 50;
13087
- /** Extract a plain-text summary from a message's JSONB content field. */
13088
- function extractSummary(content, maxLen = SUMMARY_MAX_LENGTH) {
13089
- let text = "";
13090
- if (typeof content === "object" && content !== null && "text" in content) text = String(content.text ?? "");
13091
- else if (typeof content === "string") text = content;
13092
- if (!text) return null;
13093
- return Array.from(text).slice(0, maxLen).join("");
13094
- }
13095
- /** List sessions for a specific agent, with optional state filters. */
13096
- async function listAgentSessions(db, agentId, filters) {
13097
- const conditions = [eq(agentChatSessions.agentId, agentId)];
13098
- if (filters?.state) conditions.push(eq(agentChatSessions.state, filters.state));
13099
- else conditions.push(ne(agentChatSessions.state, "evicted"));
13100
- const rows = await db.select({
13101
- agentId: agentChatSessions.agentId,
13102
- chatId: agentChatSessions.chatId,
13103
- state: agentChatSessions.state,
13104
- updatedAt: agentChatSessions.updatedAt,
13105
- chatCreatedAt: chats.createdAt,
13106
- chatTopic: chats.topic
13107
- }).from(agentChatSessions).innerJoin(chats, eq(agentChatSessions.chatId, chats.id)).where(and(...conditions)).orderBy(desc(agentChatSessions.updatedAt));
13108
- const [presence] = await db.select({ runtimeState: agentPresence.runtimeState }).from(agentPresence).where(eq(agentPresence.agentId, agentId)).limit(1);
13109
- const agentRuntimeState = presence?.runtimeState ?? null;
13110
- if (filters?.runtimeState && agentRuntimeState !== filters.runtimeState) return [];
13111
- const chatIds = rows.map((r) => r.chatId);
13112
- const messageCounts = chatIds.length > 0 ? await db.select({
13113
- chatId: inboxEntries.chatId,
13114
- count: sql`count(*)::int`
13115
- }).from(inboxEntries).where(and(eq(inboxEntries.inboxId, sql`(SELECT inbox_id FROM agents WHERE uuid = ${agentId})`), inArray(inboxEntries.chatId, chatIds))).groupBy(inboxEntries.chatId) : [];
13116
- const countMap = new Map(messageCounts.map((r) => [r.chatId, r.count]));
13117
- const firstMessages = chatIds.length > 0 ? await db.selectDistinctOn([messages.chatId], {
13118
- chatId: messages.chatId,
13119
- content: messages.content
13120
- }).from(messages).where(inArray(messages.chatId, chatIds)).orderBy(messages.chatId, messages.createdAt) : [];
13121
- const summaryMap = /* @__PURE__ */ new Map();
13122
- for (const row of firstMessages) {
13123
- const summary = extractSummary(row.content);
13124
- if (summary) summaryMap.set(row.chatId, summary);
13125
- }
13126
- return rows.map((r) => ({
13127
- agentId: r.agentId,
13128
- chatId: r.chatId,
13129
- state: r.state,
13130
- runtimeState: agentRuntimeState,
13131
- startedAt: r.chatCreatedAt.toISOString(),
13132
- lastActivityAt: r.updatedAt.toISOString(),
13133
- messageCount: countMap.get(r.chatId) ?? 0,
13134
- summary: summaryMap.get(r.chatId) ?? null,
13135
- topic: r.chatTopic ?? null
13136
- }));
13137
- }
13138
- /** Get a single session's detail. */
13139
- async function getSession(db, agentId, chatId) {
13140
- const [row] = await db.select({
13141
- agentId: agentChatSessions.agentId,
13142
- chatId: agentChatSessions.chatId,
13143
- state: agentChatSessions.state,
13144
- updatedAt: agentChatSessions.updatedAt,
13145
- chatCreatedAt: chats.createdAt,
13146
- chatTopic: chats.topic
13147
- }).from(agentChatSessions).innerJoin(chats, eq(agentChatSessions.chatId, chats.id)).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId))).limit(1);
13148
- if (!row) throw new NotFoundError(`Session (${agentId}, ${chatId}) not found`);
13149
- const [presence] = await db.select({ runtimeState: agentPresence.runtimeState }).from(agentPresence).where(eq(agentPresence.agentId, agentId)).limit(1);
13150
- 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)));
13151
- const firstMsg = (await db.execute(sql`SELECT content FROM messages WHERE chat_id = ${chatId} ORDER BY created_at ASC LIMIT 1`))[0];
13152
- const summary = firstMsg ? extractSummary(firstMsg.content) : null;
13153
- return {
13154
- agentId: row.agentId,
13155
- chatId: row.chatId,
13156
- state: row.state,
13157
- runtimeState: presence?.runtimeState ?? null,
13158
- startedAt: row.chatCreatedAt.toISOString(),
13159
- lastActivityAt: row.updatedAt.toISOString(),
13160
- messageCount: countRow?.count ?? 0,
13161
- summary,
13162
- topic: row.chatTopic ?? null
13163
- };
13164
- }
13165
- /** List all sessions across all agents, with pagination. Scoped to organization. */
13166
- async function listAllSessions(db, limit, cursor, filters) {
13167
- const conditions = [];
13168
- if (filters?.state) conditions.push(eq(agentChatSessions.state, filters.state));
13169
- else conditions.push(ne(agentChatSessions.state, "evicted"));
13170
- if (filters?.agentId) conditions.push(eq(agentChatSessions.agentId, filters.agentId));
13171
- if (filters?.organizationId) conditions.push(eq(agents.organizationId, filters.organizationId));
13172
- if (cursor) conditions.push(sql`${agentChatSessions.updatedAt} < ${new Date(cursor)}`);
13173
- const rows = await db.select({
13174
- agentId: agentChatSessions.agentId,
13175
- chatId: agentChatSessions.chatId,
13176
- state: agentChatSessions.state,
13177
- updatedAt: agentChatSessions.updatedAt,
13178
- chatCreatedAt: chats.createdAt,
13179
- chatTopic: chats.topic
13180
- }).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);
13181
- const hasMore = rows.length > limit;
13182
- const items = hasMore ? rows.slice(0, limit) : rows;
13183
- const agentIds = [...new Set(items.map((r) => r.agentId))];
13184
- const presenceRows = agentIds.length > 0 ? await db.select({
13185
- agentId: agentPresence.agentId,
13186
- runtimeState: agentPresence.runtimeState
13187
- }).from(agentPresence).where(inArray(agentPresence.agentId, agentIds)) : [];
13188
- const runtimeMap = new Map(presenceRows.map((r) => [r.agentId, r.runtimeState]));
13189
- const chatIds = [...new Set(items.map((r) => r.chatId))];
13190
- const firstMessages = chatIds.length > 0 ? await db.selectDistinctOn([messages.chatId], {
13191
- chatId: messages.chatId,
13192
- content: messages.content
13193
- }).from(messages).where(inArray(messages.chatId, chatIds)).orderBy(messages.chatId, messages.createdAt) : [];
13194
- const summaryMap = /* @__PURE__ */ new Map();
13195
- for (const row of firstMessages) {
13196
- const summary = extractSummary(row.content);
13197
- if (summary) summaryMap.set(row.chatId, summary);
13198
- }
13199
- const last = items[items.length - 1];
13200
- const nextCursor = hasMore && last ? last.updatedAt.toISOString() : null;
13201
- return {
13202
- items: items.map((r) => ({
13203
- agentId: r.agentId,
13204
- chatId: r.chatId,
13205
- state: r.state,
13206
- runtimeState: runtimeMap.get(r.agentId) ?? null,
13207
- startedAt: r.chatCreatedAt.toISOString(),
13208
- lastActivityAt: r.updatedAt.toISOString(),
13209
- messageCount: 0,
13210
- summary: summaryMap.get(r.chatId) ?? null,
13211
- topic: r.chatTopic ?? null
13212
- })),
13213
- nextCursor
13214
- };
13215
- }
13216
- /** Commit `active → suspended`. No-op on suspended/evicted. Throws if row is missing. */
13217
- async function suspendSession(db, agentId, chatId, organizationId, notifier) {
13218
- return transitionSessionState(db, agentId, chatId, "suspended", ["active"], organizationId, notifier);
13219
- }
13220
- /** Commit `suspended → evicted` (terminal — listings hide it, revival defense blocks resurrection). */
13221
- async function archiveSession(db, agentId, chatId, organizationId, notifier) {
13222
- return transitionSessionState(db, agentId, chatId, "evicted", ["suspended"], organizationId, notifier);
13223
- }
13224
- async function transitionSessionState(db, agentId, chatId, target, from, organizationId, notifier) {
13225
- const now = /* @__PURE__ */ new Date();
13226
- let finalState = null;
13227
- let transitioned = false;
13228
- await db.transaction(async (tx) => {
13229
- const [existing] = await tx.select({ state: agentChatSessions.state }).from(agentChatSessions).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId))).for("update");
13230
- if (!existing) return;
13231
- const current = existing.state;
13232
- finalState = current;
13233
- if (!from.includes(current)) return;
13234
- await tx.update(agentChatSessions).set({
13235
- state: target,
13236
- updatedAt: now
13237
- }).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId)));
13238
- const [counts] = await tx.select({
13239
- active: sql`count(*) FILTER (WHERE ${agentChatSessions.state} = 'active')::int`,
13240
- total: sql`count(*) FILTER (WHERE ${agentChatSessions.state} != 'evicted')::int`
13241
- }).from(agentChatSessions).where(eq(agentChatSessions.agentId, agentId));
13242
- await tx.update(agentPresence).set({
13243
- activeSessions: counts?.active ?? 0,
13244
- totalSessions: counts?.total ?? 0,
13245
- lastSeenAt: now
13246
- }).where(eq(agentPresence.agentId, agentId));
13247
- finalState = target;
13248
- transitioned = true;
13249
- });
13250
- if (finalState === null) throw new NotFoundError(`Session (${agentId}, ${chatId}) not found`);
13251
- if (transitioned && notifier) notifier.notifySessionStateChange(agentId, chatId, target, organizationId).catch(() => {});
13252
- return {
13253
- state: finalState,
13254
- transitioned
13255
- };
13256
- }
13257
- /**
13258
- * Filter sessions to only those where the given agent is also a participant in the chat.
13259
- * Used when a non-manager views sessions of an org-visible agent — they should only see
13260
- * sessions for chats they participate in.
13261
- */
13262
- async function filterSessionsByParticipant(db, sessions, participantAgentId) {
13263
- if (sessions.length === 0) return [];
13264
- const chatIds = sessions.map((s) => s.chatId);
13265
- const participantRows = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(inArray(chatParticipants.chatId, chatIds), eq(chatParticipants.agentId, participantAgentId)));
13266
- const allowedChatIds = new Set(participantRows.map((r) => r.chatId));
13267
- return sessions.filter((s) => allowedChatIds.has(s.chatId));
13268
- }
13269
14211
  /**
13270
14212
  * Session events — structured event stream per (agent, chat) session.
13271
14213
  * `kind` is 'tool_call' | 'error'; the payload shape is enforced by the
@@ -14076,6 +15018,33 @@ function adminWsRoutes(notifier, jwtSecret) {
14076
15018
  ...payload
14077
15019
  });
14078
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
+ }
14079
15048
  return async (app) => {
14080
15049
  app.get("/admin", {
14081
15050
  websocket: true,
@@ -14118,7 +15087,8 @@ function adminWsRoutes(notifier, jwtSecret) {
14118
15087
  }
14119
15088
  const [memberRow] = await app.db.select({
14120
15089
  id: members.id,
14121
- role: members.role
15090
+ role: members.role,
15091
+ agentId: members.agentId
14122
15092
  }).from(members).where(and(eq(members.userId, userId), eq(members.organizationId, organizationId), eq(members.status, "active"))).limit(1);
14123
15093
  if (!memberRow) {
14124
15094
  socket.send(JSON.stringify({
@@ -14134,10 +15104,14 @@ function adminWsRoutes(notifier, jwtSecret) {
14134
15104
  organizationId,
14135
15105
  memberId
14136
15106
  });
15107
+ rememberDb(app.db);
14137
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;
14138
15111
  adminSockets.set(socket, {
14139
15112
  organizationId,
14140
15113
  memberId,
15114
+ humanAgentId,
14141
15115
  visibleAgentIds
14142
15116
  });
14143
15117
  socket.send(JSON.stringify({ type: "admin:connected" }));
@@ -14406,18 +15380,11 @@ async function pollInbox(db, inboxId, limit) {
14406
15380
  }
14407
15381
  async function pollInboxInner(db, inboxId, limit) {
14408
15382
  return db.transaction(async (tx) => {
14409
- return bundleDeliveryWithSilentContext(tx, inboxId, await tx.execute(sql`
14410
- UPDATE inbox_entries
14411
- SET status = 'delivered', delivered_at = NOW()
14412
- WHERE id IN (
14413
- SELECT id FROM inbox_entries
14414
- WHERE inbox_id = ${inboxId} AND status = 'pending' AND notify = true
14415
- ORDER BY created_at
14416
- LIMIT ${limit}
14417
- FOR UPDATE SKIP LOCKED
14418
- )
14419
- RETURNING *
14420
- `));
15383
+ const targetIds = tx.select({ id: inboxEntries.id }).from(inboxEntries).where(and(eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.status, "pending"), eq(inboxEntries.notify, true))).orderBy(asc(inboxEntries.createdAt)).limit(limit).for("update", { skipLocked: true });
15384
+ return bundleDeliveryWithSilentContext(tx, inboxId, await tx.update(inboxEntries).set({
15385
+ status: "delivered",
15386
+ deliveredAt: /* @__PURE__ */ new Date()
15387
+ }).where(inArray(inboxEntries.id, targetIds)).returning());
14421
15388
  });
14422
15389
  }
14423
15390
  /**
@@ -14430,7 +15397,7 @@ async function pollInboxInner(db, inboxId, limit) {
14430
15397
  * hub-inbox-ws-data-plane §3.2 risk #1).
14431
15398
  *
14432
15399
  * Steps:
14433
- * 1. Sort by `created_at` ASC (PG `RETURNING` does not guarantee order).
15400
+ * 1. Sort by `createdAt` ASC (PG `RETURNING` does not guarantee order).
14434
15401
  * 2. For each trigger, collect silent context & bulk-ack stale silent rows.
14435
15402
  * 3. Fetch the trigger messages.
14436
15403
  * 4. Build wire payloads via the single dispatcher.
@@ -14439,16 +15406,16 @@ async function pollInboxInner(db, inboxId, limit) {
14439
15406
  */
14440
15407
  async function bundleDeliveryWithSilentContext(tx, inboxId, claimed) {
14441
15408
  if (claimed.length === 0) return [];
14442
- claimed.sort((a, b) => a.created_at.localeCompare(b.created_at));
15409
+ claimed.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
14443
15410
  const precedingByEntryId = await collectPrecedingContext(tx, inboxId, claimed);
14444
- const messageIds = claimed.map((e) => e.message_id);
15411
+ const messageIds = claimed.map((e) => e.messageId);
14445
15412
  const msgs = await tx.select().from(messages).where(inArray(messages.id, messageIds));
14446
15413
  const msgMap = new Map(msgs.map((m) => [m.id, m]));
14447
15414
  const payloads = await buildClientMessagePayloadsForInbox(tx, inboxId, claimed.map((entry) => {
14448
- const msg = msgMap.get(entry.message_id);
14449
- if (!msg) throw new Error(`Unexpected: message ${entry.message_id} not found`);
15415
+ const msg = msgMap.get(entry.messageId);
15416
+ if (!msg) throw new Error(`Unexpected: message ${entry.messageId} not found`);
14450
15417
  return {
14451
- entryChatId: entry.chat_id,
15418
+ entryChatId: entry.chatId,
14452
15419
  precedingMessages: precedingByEntryId.get(entry.id) ?? [],
14453
15420
  message: {
14454
15421
  id: msg.id,
@@ -14469,15 +15436,15 @@ async function bundleDeliveryWithSilentContext(tx, inboxId, claimed) {
14469
15436
  const payload = payloads[idx];
14470
15437
  if (!payload) throw new Error(`Unexpected: payload for entry ${entry.id} not built`);
14471
15438
  return {
14472
- id: Number(entry.id),
14473
- inboxId: entry.inbox_id,
14474
- messageId: entry.message_id,
14475
- chatId: entry.chat_id,
15439
+ id: entry.id,
15440
+ inboxId: entry.inboxId,
15441
+ messageId: entry.messageId,
15442
+ chatId: entry.chatId,
14476
15443
  status: entry.status,
14477
- retryCount: entry.retry_count,
14478
- createdAt: entry.created_at,
14479
- deliveredAt: entry.delivered_at ?? null,
14480
- ackedAt: entry.acked_at ?? null,
15444
+ retryCount: entry.retryCount,
15445
+ createdAt: entry.createdAt.toISOString(),
15446
+ deliveredAt: entry.deliveredAt?.toISOString() ?? null,
15447
+ ackedAt: entry.ackedAt?.toISOString() ?? null,
14481
15448
  message: payload
14482
15449
  };
14483
15450
  });
@@ -14512,21 +15479,11 @@ async function claimAndBuildForPush(db, inboxId, messageId) {
14512
15479
  "inbox.id": inboxId,
14513
15480
  "message.id": messageId
14514
15481
  }, () => db.transaction(async (tx) => {
14515
- return bundleDeliveryWithSilentContext(tx, inboxId, await tx.execute(sql`
14516
- UPDATE inbox_entries
14517
- SET status = 'delivered', delivered_at = NOW()
14518
- WHERE id IN (
14519
- SELECT id FROM inbox_entries
14520
- WHERE inbox_id = ${inboxId}
14521
- AND message_id = ${messageId}
14522
- AND status = 'pending'
14523
- AND notify = true
14524
- ORDER BY created_at
14525
- LIMIT ${PUSH_CLAIM_BATCH_LIMIT}
14526
- FOR UPDATE SKIP LOCKED
14527
- )
14528
- RETURNING *
14529
- `));
15482
+ const targetIds = tx.select({ id: inboxEntries.id }).from(inboxEntries).where(and(eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.messageId, messageId), eq(inboxEntries.status, "pending"), eq(inboxEntries.notify, true))).orderBy(asc(inboxEntries.createdAt)).limit(PUSH_CLAIM_BATCH_LIMIT).for("update", { skipLocked: true });
15483
+ return bundleDeliveryWithSilentContext(tx, inboxId, await tx.update(inboxEntries).set({
15484
+ status: "delivered",
15485
+ deliveredAt: /* @__PURE__ */ new Date()
15486
+ }).where(inArray(inboxEntries.id, targetIds)).returning());
14530
15487
  }));
14531
15488
  }
14532
15489
  /**
@@ -14549,7 +15506,7 @@ async function claimBacklogForPush(db, inboxId, limit) {
14549
15506
  * `PRECEDING_CONTEXT_WINDOW_SECONDS`. Returned messages are oldest-first.
14550
15507
  *
14551
15508
  * Side effect: bulk-ack ALL silent pending rows in each chat with
14552
- * created_at < latest_trigger.created_at — including ones that fell outside
15509
+ * createdAt < latest_trigger.createdAt — including ones that fell outside
14553
15510
  * the window/cap. Otherwise stale silent rows would accumulate and re-load
14554
15511
  * on every poll.
14555
15512
  */
@@ -14557,51 +15514,41 @@ async function collectPrecedingContext(tx, inboxId, triggers) {
14557
15514
  const result = /* @__PURE__ */ new Map();
14558
15515
  const byChat = /* @__PURE__ */ new Map();
14559
15516
  for (const t of triggers) {
14560
- if (t.chat_id === null) continue;
14561
- const list = byChat.get(t.chat_id) ?? [];
15517
+ if (t.chatId === null) continue;
15518
+ const list = byChat.get(t.chatId) ?? [];
14562
15519
  list.push(t);
14563
- byChat.set(t.chat_id, list);
15520
+ byChat.set(t.chatId, list);
14564
15521
  }
14565
15522
  for (const [chatId, chatTriggers] of byChat) {
14566
- chatTriggers.sort((a, b) => a.created_at.localeCompare(b.created_at));
15523
+ chatTriggers.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
14567
15524
  let prevCreatedAt = null;
14568
15525
  for (const trigger of chatTriggers) {
14569
- const preceding = (await tx.execute(sql`
14570
- SELECT ie.id, m.id AS message_id, m.sender_id, m.format, m.content, m.metadata,
14571
- m.created_at
14572
- FROM inbox_entries ie
14573
- JOIN messages m ON m.id = ie.message_id
14574
- WHERE ie.inbox_id = ${inboxId}
14575
- AND ie.chat_id = ${chatId}
14576
- AND ie.status = 'pending'
14577
- AND ie.notify = false
14578
- AND ie.created_at < ${trigger.created_at}
14579
- ${prevCreatedAt === null ? sql`` : sql`AND ie.created_at > ${prevCreatedAt}`}
14580
- AND ie.created_at > NOW() - make_interval(secs => ${PRECEDING_CONTEXT_WINDOW_SECONDS})
14581
- ORDER BY ie.created_at DESC
14582
- LIMIT ${50}
14583
- FOR UPDATE OF ie SKIP LOCKED
14584
- `)).map((r) => ({
14585
- id: r.message_id,
14586
- senderId: r.sender_id,
15526
+ const preceding = (await tx.select({
15527
+ messageId: messages.id,
15528
+ senderId: messages.senderId,
15529
+ format: messages.format,
15530
+ content: messages.content,
15531
+ metadata: messages.metadata,
15532
+ createdAt: messages.createdAt
15533
+ }).from(inboxEntries).innerJoin(messages, eq(messages.id, inboxEntries.messageId)).where(and(eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.chatId, chatId), eq(inboxEntries.status, "pending"), eq(inboxEntries.notify, false), lt(inboxEntries.createdAt, trigger.createdAt), prevCreatedAt === null ? void 0 : gt(inboxEntries.createdAt, prevCreatedAt), sql`${inboxEntries.createdAt} > NOW() - make_interval(secs => ${PRECEDING_CONTEXT_WINDOW_SECONDS})`)).orderBy(desc(inboxEntries.createdAt)).limit(50).for("update", {
15534
+ of: inboxEntries,
15535
+ skipLocked: true
15536
+ })).map((r) => ({
15537
+ id: r.messageId,
15538
+ senderId: r.senderId,
14587
15539
  format: r.format,
14588
15540
  content: r.content,
14589
15541
  metadata: r.metadata ?? {},
14590
- createdAt: r.created_at
15542
+ createdAt: r.createdAt.toISOString()
14591
15543
  })).reverse();
14592
15544
  result.set(trigger.id, preceding);
14593
- prevCreatedAt = trigger.created_at;
15545
+ prevCreatedAt = trigger.createdAt;
14594
15546
  }
14595
15547
  const latestTrigger = chatTriggers[chatTriggers.length - 1];
14596
- if (latestTrigger) await tx.execute(sql`
14597
- UPDATE inbox_entries
14598
- SET status = 'acked', acked_at = NOW()
14599
- WHERE inbox_id = ${inboxId}
14600
- AND chat_id = ${chatId}
14601
- AND status = 'pending'
14602
- AND notify = false
14603
- AND created_at < ${latestTrigger.created_at}
14604
- `);
15548
+ if (latestTrigger) await tx.update(inboxEntries).set({
15549
+ status: "acked",
15550
+ ackedAt: /* @__PURE__ */ new Date()
15551
+ }).where(and(eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.chatId, chatId), eq(inboxEntries.status, "pending"), eq(inboxEntries.notify, false), lt(inboxEntries.createdAt, latestTrigger.createdAt)));
14605
15552
  }
14606
15553
  return result;
14607
15554
  }
@@ -14644,23 +15591,14 @@ async function renewEntry(db, entryId, inboxId) {
14644
15591
  return entry;
14645
15592
  }
14646
15593
  async function resetTimedOutEntries(db, timeoutSeconds = DEFAULT_INBOX_TIMEOUT_SECONDS, maxRetries = DEFAULT_MAX_RETRY_COUNT) {
14647
- const resetResult = await db.execute(sql`
14648
- UPDATE inbox_entries SET status = 'pending', retry_count = retry_count + 1
14649
- WHERE status = 'delivered'
14650
- AND delivered_at < NOW() - make_interval(secs => ${timeoutSeconds})
14651
- AND retry_count < ${maxRetries}
14652
- RETURNING id
14653
- `);
14654
- const failedResult = await db.execute(sql`
14655
- UPDATE inbox_entries SET status = 'failed'
14656
- WHERE status = 'delivered'
14657
- AND delivered_at < NOW() - make_interval(secs => ${timeoutSeconds})
14658
- AND retry_count >= ${maxRetries}
14659
- RETURNING id
14660
- `);
15594
+ const reset = await db.update(inboxEntries).set({
15595
+ status: "pending",
15596
+ retryCount: sql`${inboxEntries.retryCount} + 1`
15597
+ }).where(and(eq(inboxEntries.status, "delivered"), sql`${inboxEntries.deliveredAt} < NOW() - make_interval(secs => ${timeoutSeconds})`, lt(inboxEntries.retryCount, maxRetries))).returning({ id: inboxEntries.id });
15598
+ const failed = await db.update(inboxEntries).set({ status: "failed" }).where(and(eq(inboxEntries.status, "delivered"), sql`${inboxEntries.deliveredAt} < NOW() - make_interval(secs => ${timeoutSeconds})`, gte(inboxEntries.retryCount, maxRetries))).returning({ id: inboxEntries.id });
14661
15599
  return {
14662
- reset: resetResult.length,
14663
- failed: failedResult.length
15600
+ reset: reset.length,
15601
+ failed: failed.length
14664
15602
  };
14665
15603
  }
14666
15604
  /** Default age (30 days) past which silent rows that no notify-true delivery
@@ -14679,7 +15617,7 @@ const SILENT_ROW_GC_MAX_AGE_SECONDS = 720 * 60 * 60;
14679
15617
  * `(inbox_id, message_id, chat_id)` means leaving them around blocks
14680
15618
  * legitimate retries with the same key.
14681
15619
  *
14682
- * 2. `notify=false AND status='pending' AND created_at < NOW() - maxAge` —
15620
+ * 2. `notify=false AND status='pending' AND createdAt < NOW() - maxAge` —
14683
15621
  * stale silent rows that no trigger ever caught up with. After 30
14684
15622
  * days they're useless as preceding context (the @mention almost
14685
15623
  * certainly already happened or the chat went dormant).
@@ -14688,22 +15626,11 @@ const SILENT_ROW_GC_MAX_AGE_SECONDS = 720 * 60 * 60;
14688
15626
  * can log meaningful counts.
14689
15627
  */
14690
15628
  async function pruneStaleSilentEntries(db, maxAgeSeconds = SILENT_ROW_GC_MAX_AGE_SECONDS) {
14691
- const ackedResult = await db.execute(sql`
14692
- DELETE FROM inbox_entries
14693
- WHERE notify = false
14694
- AND status = 'acked'
14695
- RETURNING id
14696
- `);
14697
- const staleResult = await db.execute(sql`
14698
- DELETE FROM inbox_entries
14699
- WHERE notify = false
14700
- AND status = 'pending'
14701
- AND created_at < NOW() - make_interval(secs => ${maxAgeSeconds})
14702
- RETURNING id
14703
- `);
15629
+ const ackedDeleted = await db.delete(inboxEntries).where(and(eq(inboxEntries.notify, false), eq(inboxEntries.status, "acked"))).returning({ id: inboxEntries.id });
15630
+ const stalePendingDeleted = await db.delete(inboxEntries).where(and(eq(inboxEntries.notify, false), eq(inboxEntries.status, "pending"), sql`${inboxEntries.createdAt} < NOW() - make_interval(secs => ${maxAgeSeconds})`)).returning({ id: inboxEntries.id });
14704
15631
  return {
14705
- ackedDeleted: ackedResult.length,
14706
- stalePendingDeleted: staleResult.length
15632
+ ackedDeleted: ackedDeleted.length,
15633
+ stalePendingDeleted: stalePendingDeleted.length
14707
15634
  };
14708
15635
  }
14709
15636
  async function agentInboxRoutes(app) {
@@ -15581,8 +16508,9 @@ function clientWsRoutes(notifier, instanceId) {
15581
16508
  }
15582
16509
  boundAgents.clear();
15583
16510
  if (clientId) {
16511
+ const stillActive = isActiveClientConnection(clientId, socket);
15584
16512
  removeClientConnection(clientId, socket);
15585
- try {
16513
+ if (stillActive) try {
15586
16514
  await disconnectClient(app.db, clientId);
15587
16515
  } catch {}
15588
16516
  }
@@ -15766,6 +16694,7 @@ async function ensureMembership(db, data) {
15766
16694
  if (existing) {
15767
16695
  if (existing.status === "left") {
15768
16696
  await db.update(members).set({ status: "active" }).where(eq(members.id, existing.id));
16697
+ await recomputeWatchersForMember(db, existing.id);
15769
16698
  return {
15770
16699
  ...existing,
15771
16700
  status: "active"
@@ -15893,11 +16822,18 @@ async function pickPrimaryMembership(db, userId) {
15893
16822
  * (last admin allowed to leave, leaves an orphan team) and the cleanup is
15894
16823
  * a v2 sweep job.
15895
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
+ */
15896
16831
  async function leaveOrganization(db, memberId) {
15897
16832
  const [existing] = await db.select().from(members).where(eq(members.id, memberId)).limit(1);
15898
16833
  if (!existing) throw new NotFoundError(`Membership "${memberId}" not found`);
15899
16834
  if (existing.status === "left") return existing;
15900
16835
  await db.update(members).set({ status: "left" }).where(eq(members.id, memberId));
16836
+ await recomputeWatchersForMember(db, memberId);
15901
16837
  return {
15902
16838
  ...existing,
15903
16839
  status: "left"
@@ -16555,7 +17491,7 @@ async function inferWizardStep(app, m) {
16555
17491
  * landing page.
16556
17492
  */
16557
17493
  async function publicInvitePreviewRoute(app) {
16558
- const { previewInvitation } = await import("./invitation-CBnQyB7o-Bulf3Sl7.mjs");
17494
+ const { previewInvitation } = await import("./invitation-CBnQyB7o-TmnIj3kx.mjs");
16559
17495
  app.get("/:token/preview", async (request, reply) => {
16560
17496
  if (!request.params.token) throw new UnauthorizedError("Token required");
16561
17497
  const preview = await previewInvitation(app.db, request.params.token);
@@ -16585,7 +17521,7 @@ async function adminInvitationRoutes(app) {
16585
17521
  const m = requireMember(request);
16586
17522
  if (m.role !== "admin") throw new ForbiddenError("Admin role required");
16587
17523
  if (request.params.id !== m.organizationId) throw new ForbiddenError("Cannot rotate invitations for another organization");
16588
- const { rotateInvitation } = await import("./invitation-CBnQyB7o-Bulf3Sl7.mjs");
17524
+ const { rotateInvitation } = await import("./invitation-CBnQyB7o-TmnIj3kx.mjs");
16589
17525
  const inv = await rotateInvitation(app.db, m.organizationId, m.userId);
16590
17526
  return {
16591
17527
  id: inv.id,
@@ -16598,6 +17534,57 @@ async function adminInvitationRoutes(app) {
16598
17534
  };
16599
17535
  });
16600
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
+ }
16601
17588
  const SALT_ROUNDS = 10;
16602
17589
  /**
16603
17590
  * Create a member in an organization.
@@ -17236,6 +18223,7 @@ var schema_exports = /* @__PURE__ */ __exportAll({
17236
18223
  agents: () => agents,
17237
18224
  authIdentities: () => authIdentities,
17238
18225
  chatParticipants: () => chatParticipants,
18226
+ chatSubscriptions: () => chatSubscriptions,
17239
18227
  chats: () => chats,
17240
18228
  clients: () => clients,
17241
18229
  inboxEntries: () => inboxEntries,
@@ -18580,6 +19568,10 @@ async function buildApp(config) {
18580
19568
  adminApp.addHook("onRequest", memberAuth);
18581
19569
  await adminApp.register(adminChatRoutes);
18582
19570
  }, { prefix: "/admin/chats" });
19571
+ await api.register(async (memberApp) => {
19572
+ memberApp.addHook("onRequest", memberAuth);
19573
+ await memberApp.register(meChatRoutes);
19574
+ }, { prefix: "/me/chats" });
18583
19575
  await api.register(async (adminApp) => {
18584
19576
  adminApp.addHook("onRequest", memberAuth);
18585
19577
  await adminApp.register(adminClientRoutes);
@@ -18686,6 +19678,13 @@ async function buildApp(config) {
18686
19678
  kaelRuntime?.reload().catch((err) => hotReloadLog.error({ err }, "kael hot-reload failed (PG NOTIFY)"));
18687
19679
  }
18688
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
+ });
18689
19688
  app.addHook("onReady", async () => {
18690
19689
  await ensureDefaultOrganization(db);
18691
19690
  await notifier.start();