@agent-team-foundation/first-tree-hub 0.14.1 → 0.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,10 @@
1
- import { O as withSpan, f as messageAttrs, s as createLogger } from "./observability-BAScT_5S-BcW9HgkG.mjs";
2
- import { L as extractMentions, N as defaultParticipantMode, S as clientCapabilitiesSchema, b as agentTypeSchema, ct as questionAnswerMessageContentSchema, i as AGENT_STATUSES, lt as questionMessageContentSchema, mt as scanMentionTokens, o as AGENT_VISIBILITY, s as CHAT_ENGAGEMENT_STATUSES } from "./dist-1XGLJMOq.mjs";
3
- import { a as ConflictError, i as ClientUserMismatchError, l as organizations, n as BadRequestError, o as ForbiddenError, s as NotFoundError, u as users } from "./errors-LPcARA4K-Dbrptiyz.mjs";
1
+ import { A as FIRST_TREE_HUB_ATTR, O as withSpan, f as messageAttrs, s as createLogger } from "./observability-BAScT_5S-BcW9HgkG.mjs";
2
+ import { L as extractMentions, N as defaultParticipantMode, P as defaultRuntimeConfigPayload, S as clientCapabilitiesSchema, St as stripCode, Z as isReservedAgentName, a as AGENT_TYPES, b as agentTypeSchema, ct as questionAnswerMessageContentSchema, d as MENTION_REGEX, i as AGENT_STATUSES, l as GITHUB_ENTITY_TYPES, lt as questionMessageContentSchema, mt as scanMentionTokens, n as AGENT_NAME_REGEX, nt as messageSourceSchema, o as AGENT_VISIBILITY, s as CHAT_ENGAGEMENT_STATUSES } from "./dist-DmYxT5Kb.mjs";
3
+ import { a as ClientUserMismatchError, c as NotFoundError, d as users, f as uuidv7, o as ConflictError, r as BadRequestError, s as ForbiddenError, t as AgentSendNonMemberError, u as organizations } from "./uuid-DbS_4vFh-iFghv4zA.mjs";
4
4
  import { randomUUID } from "node:crypto";
5
- import { and, desc, eq, inArray, isNotNull, lt, ne, or, sql } from "drizzle-orm";
6
- import { bigserial, boolean, customType, index, integer, jsonb, pgTable, primaryKey, text, timestamp, unique } from "drizzle-orm/pg-core";
7
- //#region ../server/dist/client-CzXmweS9.mjs
5
+ import { and, asc, count, desc, eq, gt, gte, inArray, isNotNull, lt, ne, or, sql } from "drizzle-orm";
6
+ import { bigserial, boolean, customType, index, integer, jsonb, pgTable, primaryKey, serial, text, timestamp, unique } from "drizzle-orm/pg-core";
7
+ //#region ../server/dist/client-CZ_VnbEc.mjs
8
8
  /**
9
9
  * Client connections. A client is a single SDK process (AgentRuntime) that may
10
10
  * host multiple agents. From the unified-user-token milestone on, a client is
@@ -69,6 +69,17 @@ const agents = pgTable("agents", {
69
69
  index("idx_agents_client").on(table.clientId),
70
70
  unique("uq_agents_org_name").on(table.organizationId, table.name)
71
71
  ]);
72
+ /** Maps external user identities to internal Agents. */
73
+ const adapterAgentMappings = pgTable("adapter_agent_mappings", {
74
+ id: serial("id").primaryKey(),
75
+ platform: text("platform").notNull(),
76
+ externalUserId: text("external_user_id").notNull(),
77
+ agentId: text("agent_id").notNull().references(() => agents.uuid),
78
+ boundVia: text("bound_via"),
79
+ displayName: text("display_name"),
80
+ metadata: jsonb("metadata").$type().notNull().default({}),
81
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
82
+ }, (table) => [unique("uq_adapter_agent_mapping").on(table.platform, table.externalUserId)]);
72
83
  /**
73
84
  * Unified membership table. Replaces the two-table split
74
85
  * (chat_participants speakers ∪ chat_subscriptions watchers) — both
@@ -130,6 +141,30 @@ const members = pgTable("members", {
130
141
  index("idx_members_user").on(table.userId),
131
142
  index("idx_members_org").on(table.organizationId)
132
143
  ]);
144
+ /** Bot credentials for external platform adapters. Credentials are encrypted at application layer (AES-256-GCM). */
145
+ const adapterConfigs = pgTable("adapter_configs", {
146
+ id: serial("id").primaryKey(),
147
+ platform: text("platform").notNull(),
148
+ agentId: text("agent_id").notNull().references(() => agents.uuid),
149
+ credentials: jsonb("credentials").$type().notNull(),
150
+ status: text("status").notNull().default("active"),
151
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
152
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
153
+ }, (t) => [unique("uq_adapter_configs_agent_platform").on(t.agentId, t.platform)]);
154
+ /** Messages. Immutable after creation. Each message belongs to exactly one Chat. */
155
+ const messages = pgTable("messages", {
156
+ id: text("id").primaryKey(),
157
+ chatId: text("chat_id").notNull().references(() => chats.id),
158
+ senderId: text("sender_id").notNull(),
159
+ format: text("format").notNull(),
160
+ content: jsonb("content").$type().notNull(),
161
+ metadata: jsonb("metadata").$type().notNull().default({}),
162
+ replyToInbox: text("reply_to_inbox"),
163
+ replyToChat: text("reply_to_chat"),
164
+ inReplyTo: text("in_reply_to"),
165
+ source: text("source"),
166
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
167
+ }, (table) => [index("idx_messages_chat_time").on(table.chatId, table.createdAt), index("idx_messages_in_reply_to").on(table.inReplyTo)]);
133
168
  /**
134
169
  * Process-local cache for the per-chat realtime push audience
135
170
  * (every row in `chat_membership` for the chat — speakers + watchers,
@@ -193,6 +228,571 @@ async function getCachedAudience(db, chatId) {
193
228
  function invalidateChatAudience(chatId) {
194
229
  cache.delete(chatId);
195
230
  }
231
+ /** Delivery queue (envelope). One entry per recipient created during message fan-out. Uses SKIP LOCKED for concurrent-safe consumption. */
232
+ const inboxEntries = pgTable("inbox_entries", {
233
+ id: bigserial("id", { mode: "number" }).primaryKey(),
234
+ inboxId: text("inbox_id").notNull(),
235
+ messageId: text("message_id").notNull().references(() => messages.id),
236
+ chatId: text("chat_id"),
237
+ status: text("status").notNull().default("pending"),
238
+ notify: boolean("notify").notNull().default(true),
239
+ retryCount: integer("retry_count").notNull().default(0),
240
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
241
+ deliveredAt: timestamp("delivered_at", { withTimezone: true }),
242
+ ackedAt: timestamp("acked_at", { withTimezone: true })
243
+ }, (table) => [
244
+ unique("uq_inbox_delivery").on(table.inboxId, table.messageId, table.chatId),
245
+ index("idx_inbox_pending").on(table.inboxId, table.createdAt),
246
+ index("idx_inbox_pending_notify").on(table.inboxId, table.createdAt).where(sql`status = 'pending' AND notify = true`),
247
+ index("idx_inbox_chat_silent").on(table.inboxId, table.chatId, table.notify, table.status)
248
+ ]);
249
+ /**
250
+ * Per-agent runtime configuration (Hub-managed; not the local YAML config).
251
+ *
252
+ * One row per agent. `version` increments on every successful UPDATE
253
+ * (optimistic locking via WHERE version = :expected). Sensitive env values
254
+ * inside `payload.env[*]` are AES-256-GCM encrypted at write time and
255
+ * masked when echoed via the Admin API (see Step 2).
256
+ *
257
+ * Integrity is enforced by the service layer per project convention:
258
+ * no FK / CHECK / triggers on this table.
259
+ */
260
+ const agentConfigs = pgTable("agent_configs", {
261
+ agentId: text("agent_id").primaryKey(),
262
+ version: integer("version").notNull().default(1),
263
+ payload: jsonb("payload").$type().notNull(),
264
+ updatedBy: text("updated_by").notNull(),
265
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
266
+ });
267
+ function normaliseSource(source) {
268
+ if (source === null) return null;
269
+ const parsed = messageSourceSchema.safeParse(source);
270
+ return parsed.success ? parsed.data : null;
271
+ }
272
+ function normaliseMode(mode) {
273
+ return mode === "mention_only" ? "mention_only" : "full";
274
+ }
275
+ /**
276
+ * Batch variant — builds all payloads with a single DB lookup per agent plus
277
+ * batched lookups for participant modes and inReplyTo snapshots.
278
+ */
279
+ async function buildClientMessagePayloadsForInbox(db, inboxId, items) {
280
+ if (items.length === 0) return [];
281
+ const agentId = await resolveAgentId(db, {
282
+ kind: "inboxId",
283
+ inboxId
284
+ });
285
+ const [cfg] = await db.select({ version: agentConfigs.version }).from(agentConfigs).where(eq(agentConfigs.agentId, agentId)).limit(1);
286
+ const version = cfg?.version ?? 1;
287
+ const chatIds = [...new Set(items.map((it) => it.entryChatId ?? it.message.chatId).filter((id) => id !== null))];
288
+ const modeByChat = /* @__PURE__ */ new Map();
289
+ if (chatIds.length > 0) {
290
+ const rows = await db.select({
291
+ chatId: chatMembership.chatId,
292
+ mode: chatMembership.mode
293
+ }).from(chatMembership).where(and(eq(chatMembership.agentId, agentId), inArray(chatMembership.chatId, chatIds), eq(chatMembership.accessMode, "speaker")));
294
+ for (const r of rows) modeByChat.set(r.chatId, normaliseMode(r.mode));
295
+ }
296
+ const inReplyToIds = [...new Set(items.map((it) => it.message.inReplyTo).filter((id) => id !== null))];
297
+ const snapshotById = /* @__PURE__ */ new Map();
298
+ if (inReplyToIds.length > 0) {
299
+ const origs = await db.select({
300
+ id: messages.id,
301
+ senderId: messages.senderId,
302
+ chatId: messages.chatId,
303
+ replyToChat: messages.replyToChat
304
+ }).from(messages).where(inArray(messages.id, inReplyToIds));
305
+ for (const o of origs) snapshotById.set(o.id, {
306
+ senderId: o.senderId,
307
+ chatId: o.chatId,
308
+ replyToChat: o.replyToChat
309
+ });
310
+ }
311
+ return items.map(({ entryChatId, message: m, precedingMessages = [] }) => ({
312
+ id: m.id,
313
+ chatId: m.chatId,
314
+ senderId: m.senderId,
315
+ format: m.format,
316
+ content: m.content,
317
+ metadata: m.metadata,
318
+ replyToInbox: m.replyToInbox,
319
+ replyToChat: m.replyToChat,
320
+ inReplyTo: m.inReplyTo,
321
+ source: normaliseSource(m.source),
322
+ createdAt: m.createdAt,
323
+ configVersion: version,
324
+ recipientMode: modeByChat.get(entryChatId ?? m.chatId) ?? "full",
325
+ inReplyToSnapshot: m.inReplyTo ? snapshotById.get(m.inReplyTo) ?? null : null,
326
+ precedingMessages
327
+ }));
328
+ }
329
+ async function resolveAgentId(db, source) {
330
+ if (source.kind === "agentId") return source.agentId;
331
+ const [agent] = await db.select({ uuid: agents.uuid }).from(agents).where(eq(agents.inboxId, source.inboxId)).limit(1);
332
+ if (!agent) throw new Error(`No agent owns inbox "${source.inboxId}"`);
333
+ return agent.uuid;
334
+ }
335
+ const DEFAULT_INBOX_TIMEOUT_SECONDS = 300;
336
+ const DEFAULT_MAX_RETRY_COUNT = 3;
337
+ const PRECEDING_CONTEXT_WINDOW_SECONDS = 1440 * 60;
338
+ /**
339
+ * Backfill the most recent `PRECEDING_CONTEXT_MAX_ENTRIES` messages of `chatId`
340
+ * as silent (notify=false) inbox rows for every new participant. Called from
341
+ * `addParticipant()` inside the participant-insert transaction so a freshly
342
+ * added member already has prior chat history available the first time they
343
+ * are woken (mentioned / `chat send`-ed).
344
+ *
345
+ * Invariants the implementation upholds:
346
+ *
347
+ * - **`notify=false` everywhere**: adding a participant is not itself a
348
+ * wake event. The new participant only runs the LLM when an actual
349
+ * trigger lands later; the backfill rows then piggy-back as preceding
350
+ * context (see `collectPrecedingContext`).
351
+ * - **Old members are not woken**: only inbox rows for the brand-new
352
+ * participants are written.
353
+ * - **Transaction-scoped**: writes go through the caller's `tx`, so a
354
+ * rollback of `addParticipant` rolls the backfill back too.
355
+ * - **Quiet on chats with no prior history**: a chat with zero messages
356
+ * produces zero backfill rows; no error, no INSERT.
357
+ * - **Idempotent**: collides cleanly on the
358
+ * `(inbox_id, message_id, chat_id)` unique key via
359
+ * `ON CONFLICT DO NOTHING`. This matters when a watcher → speaker
360
+ * promotion already had inbox rows for some of these messages.
361
+ *
362
+ * Pure data write — no PG NOTIFY, no participant-mode logic, no watcher
363
+ * recompute. Callers stay responsible for those.
364
+ *
365
+ * **Caller responsibility — bulk batching**: writes a single
366
+ * `INSERT VALUES (...)` of `newParticipants.length * PRECEDING_CONTEXT_MAX_ENTRIES`
367
+ * tuples. v1's only caller (`addParticipant`) always passes 1 participant
368
+ * (≤ 50 rows). Future bulk-add paths (sub-chat / spawn / etc.) should split
369
+ * input into chunks (suggested: ≤ 512 rows per call) before passing here.
370
+ *
371
+ * See proposals/hub-chat-message-v1-design §四 改造 2.
372
+ */
373
+ async function backfillSilentContextForNewParticipants(tx, chatId, newParticipants) {
374
+ if (newParticipants.length === 0) return;
375
+ const recent = await tx.select({ id: messages.id }).from(messages).where(eq(messages.chatId, chatId)).orderBy(desc(messages.createdAt)).limit(50);
376
+ if (recent.length === 0) return;
377
+ const rows = [];
378
+ for (const p of newParticipants) for (const m of recent) rows.push({
379
+ inboxId: p.inboxId,
380
+ messageId: m.id,
381
+ chatId,
382
+ notify: false
383
+ });
384
+ await tx.insert(inboxEntries).values(rows).onConflictDoNothing();
385
+ }
386
+ async function pollInbox(db, inboxId, limit) {
387
+ return withSpan("inbox.deliver", {
388
+ "inbox.id": inboxId,
389
+ "inbox.poll.limit": limit
390
+ }, () => pollInboxInner(db, inboxId, limit));
391
+ }
392
+ async function pollInboxInner(db, inboxId, limit) {
393
+ return db.transaction(async (tx) => {
394
+ 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 });
395
+ return bundleDeliveryWithSilentContext(tx, inboxId, await tx.update(inboxEntries).set({
396
+ status: "delivered",
397
+ deliveredAt: /* @__PURE__ */ new Date()
398
+ }).where(inArray(inboxEntries.id, targetIds)).returning());
399
+ });
400
+ }
401
+ /**
402
+ * Shared payload assembler for already-claimed `inbox_entries` rows.
403
+ *
404
+ * Both the HTTP poll path (`pollInbox`) and the WS push path
405
+ * (`claimAndBuildForPush`) call this with rows they have just `UPDATE`d to
406
+ * `status='delivered'`. Keeping the silent-context bundling in one place is
407
+ * the only way to keep the two paths from drifting (proposal
408
+ * hub-inbox-ws-data-plane §3.2 risk #1).
409
+ *
410
+ * Steps:
411
+ * 1. Sort by `createdAt` ASC (PG `RETURNING` does not guarantee order).
412
+ * 2. For each trigger, collect silent context & bulk-ack stale silent rows.
413
+ * 3. Fetch the trigger messages.
414
+ * 4. Build wire payloads via the single dispatcher.
415
+ *
416
+ * Returns `[]` if `claimed` is empty.
417
+ */
418
+ async function bundleDeliveryWithSilentContext(tx, inboxId, claimed) {
419
+ if (claimed.length === 0) return [];
420
+ claimed.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
421
+ const precedingByEntryId = await collectPrecedingContext(tx, inboxId, claimed);
422
+ const messageIds = claimed.map((e) => e.messageId);
423
+ const msgs = await tx.select().from(messages).where(inArray(messages.id, messageIds));
424
+ const msgMap = new Map(msgs.map((m) => [m.id, m]));
425
+ const payloads = await buildClientMessagePayloadsForInbox(tx, inboxId, claimed.map((entry) => {
426
+ const msg = msgMap.get(entry.messageId);
427
+ if (!msg) throw new Error(`Unexpected: message ${entry.messageId} not found`);
428
+ return {
429
+ entryChatId: entry.chatId,
430
+ precedingMessages: precedingByEntryId.get(entry.id) ?? [],
431
+ message: {
432
+ id: msg.id,
433
+ chatId: msg.chatId,
434
+ senderId: msg.senderId,
435
+ format: msg.format,
436
+ content: msg.content,
437
+ metadata: msg.metadata,
438
+ replyToInbox: msg.replyToInbox,
439
+ replyToChat: msg.replyToChat,
440
+ inReplyTo: msg.inReplyTo,
441
+ source: msg.source,
442
+ createdAt: msg.createdAt.toISOString()
443
+ }
444
+ };
445
+ }));
446
+ return claimed.map((entry, idx) => {
447
+ const payload = payloads[idx];
448
+ if (!payload) throw new Error(`Unexpected: payload for entry ${entry.id} not built`);
449
+ return {
450
+ id: entry.id,
451
+ inboxId: entry.inboxId,
452
+ messageId: entry.messageId,
453
+ chatId: entry.chatId,
454
+ status: entry.status,
455
+ retryCount: entry.retryCount,
456
+ createdAt: entry.createdAt.toISOString(),
457
+ deliveredAt: entry.deliveredAt?.toISOString() ?? null,
458
+ ackedAt: entry.ackedAt?.toISOString() ?? null,
459
+ message: payload
460
+ };
461
+ });
462
+ }
463
+ /**
464
+ * Realistic upper bound on rows a single NOTIFY references. The unique
465
+ * constraint `(inbox_id, message_id, chat_id)` caps a `(inbox, message)`
466
+ * pair at one row per chatId; the only way to exceed 1 today is the replyTo
467
+ * cross-chat path (`message.ts` writes a second row keyed by the original's
468
+ * `replyToChat`). 8 leaves headroom for any future fan-out variant without
469
+ * requiring a schema change here.
470
+ */
471
+ const PUSH_CLAIM_BATCH_LIMIT = 8;
472
+ /**
473
+ * WS-push path: atomically claim every pending entry the just-fired
474
+ * `NOTIFY (inboxId:messageId)` references and assemble their wire payloads.
475
+ *
476
+ * Returns `[]` if no row matches — benign race with HTTP poll or another
477
+ * server instance that already claimed the entry. NOTIFY is fire-and-forget
478
+ * (proposal §3.2).
479
+ *
480
+ * Why an array, not a single row: `sendMessage` can write **two** rows for
481
+ * the same `(inbox, messageId)` pair when the recipient is both a chat
482
+ * participant and the `replyToInbox` of an earlier message — the unique key
483
+ * is `(inbox_id, message_id, chat_id)`, so the rows differ by chatId. The
484
+ * old `LIMIT 1` shape would only push the first; the second sat `pending`
485
+ * until reconnect. Aligning with `pollInboxInner`'s `LIMIT N` shape closes
486
+ * that gap and keeps push/poll behaviour interchangeable.
487
+ */
488
+ async function claimAndBuildForPush(db, inboxId, messageId) {
489
+ return withSpan("inbox.deliver.push", {
490
+ "inbox.id": inboxId,
491
+ "message.id": messageId
492
+ }, () => db.transaction(async (tx) => {
493
+ 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 });
494
+ return bundleDeliveryWithSilentContext(tx, inboxId, await tx.update(inboxEntries).set({
495
+ status: "delivered",
496
+ deliveredAt: /* @__PURE__ */ new Date()
497
+ }).where(inArray(inboxEntries.id, targetIds)).returning());
498
+ }));
499
+ }
500
+ /**
501
+ * WS-push backlog path: on agent rebind (or once an in-flight slot frees up
502
+ * after an ack), drain up to `limit` pending `notify=true` entries oldest-
503
+ * first and assemble wire payloads. Identical claim shape to the HTTP poll
504
+ * path — they are intentionally interchangeable so a hot-path bug fixed in
505
+ * one shows up in the other (proposal §3.3 / §3.5).
506
+ */
507
+ async function claimBacklogForPush(db, inboxId, limit) {
508
+ return withSpan("inbox.deliver.backlog", {
509
+ "inbox.id": inboxId,
510
+ "inbox.backlog.limit": limit
511
+ }, () => pollInboxInner(db, inboxId, limit));
512
+ }
513
+ /**
514
+ * Per claimed trigger: SELECT silent (notify=false) pending rows in the same
515
+ * chat that occurred between the previous trigger in this batch (or beginning
516
+ * of time) and this trigger, capped by `PRECEDING_CONTEXT_MAX_ENTRIES` and
517
+ * `PRECEDING_CONTEXT_WINDOW_SECONDS`. Returned messages are oldest-first.
518
+ *
519
+ * Side effect: bulk-ack ALL silent pending rows in each chat with
520
+ * createdAt < latest_trigger.createdAt — including ones that fell outside
521
+ * the window/cap. Otherwise stale silent rows would accumulate and re-load
522
+ * on every poll.
523
+ */
524
+ async function collectPrecedingContext(tx, inboxId, triggers) {
525
+ const result = /* @__PURE__ */ new Map();
526
+ const byChat = /* @__PURE__ */ new Map();
527
+ for (const t of triggers) {
528
+ if (t.chatId === null) continue;
529
+ const list = byChat.get(t.chatId) ?? [];
530
+ list.push(t);
531
+ byChat.set(t.chatId, list);
532
+ }
533
+ for (const [chatId, chatTriggers] of byChat) {
534
+ chatTriggers.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
535
+ let prevCreatedAt = null;
536
+ for (const trigger of chatTriggers) {
537
+ const preceding = (await tx.select({
538
+ messageId: messages.id,
539
+ senderId: messages.senderId,
540
+ format: messages.format,
541
+ content: messages.content,
542
+ metadata: messages.metadata,
543
+ createdAt: messages.createdAt
544
+ }).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(messages.createdAt)).limit(50).for("update", {
545
+ of: inboxEntries,
546
+ skipLocked: true
547
+ })).map((r) => ({
548
+ id: r.messageId,
549
+ senderId: r.senderId,
550
+ format: r.format,
551
+ content: r.content,
552
+ metadata: r.metadata ?? {},
553
+ createdAt: r.createdAt.toISOString()
554
+ })).reverse();
555
+ result.set(trigger.id, preceding);
556
+ prevCreatedAt = trigger.createdAt;
557
+ }
558
+ const latestTrigger = chatTriggers[chatTriggers.length - 1];
559
+ if (latestTrigger) await tx.update(inboxEntries).set({
560
+ status: "acked",
561
+ ackedAt: /* @__PURE__ */ new Date()
562
+ }).where(and(eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.chatId, chatId), eq(inboxEntries.status, "pending"), eq(inboxEntries.notify, false), lt(inboxEntries.createdAt, latestTrigger.createdAt)));
563
+ }
564
+ return result;
565
+ }
566
+ async function ackEntry(db, entryId, inboxId) {
567
+ return withSpan("inbox.ack", {
568
+ [FIRST_TREE_HUB_ATTR.INBOX_ENTRY_ID]: String(entryId),
569
+ "inbox.id": inboxId
570
+ }, async () => {
571
+ const [entry] = await db.update(inboxEntries).set({
572
+ status: "acked",
573
+ ackedAt: /* @__PURE__ */ new Date()
574
+ }).where(and(eq(inboxEntries.id, entryId), eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.status, "delivered"))).returning();
575
+ if (!entry) throw new NotFoundError("Inbox entry not found or not in delivered status");
576
+ return entry;
577
+ });
578
+ }
579
+ /**
580
+ * Ack a delivered entry from the WS data plane, scoped to the inboxes the
581
+ * connected socket has bound. Returns the acked row on success, `null` if no
582
+ * row matches — a benign outcome the caller should ignore (the entry may
583
+ * have already been acked, timed out, or never belonged to this socket).
584
+ *
585
+ * Distinct from {@link ackEntry} so the WS path can ack without trusting an
586
+ * `inboxId` from the wire — only entries whose `inboxId` is in `inboxIds`
587
+ * are eligible. Empty `inboxIds` short-circuits to `null`.
588
+ */
589
+ async function ackEntryByIdForBoundAgents(db, entryId, inboxIds) {
590
+ if (inboxIds.length === 0) return null;
591
+ return withSpan("inbox.ack.ws", { [FIRST_TREE_HUB_ATTR.INBOX_ENTRY_ID]: String(entryId) }, async () => {
592
+ const [entry] = await db.update(inboxEntries).set({
593
+ status: "acked",
594
+ ackedAt: /* @__PURE__ */ new Date()
595
+ }).where(and(eq(inboxEntries.id, entryId), inArray(inboxEntries.inboxId, inboxIds), eq(inboxEntries.status, "delivered"))).returning();
596
+ return entry ?? null;
597
+ });
598
+ }
599
+ async function renewEntry(db, entryId, inboxId) {
600
+ const [entry] = await db.update(inboxEntries).set({ deliveredAt: /* @__PURE__ */ new Date() }).where(and(eq(inboxEntries.id, entryId), eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.status, "delivered"))).returning();
601
+ if (!entry) throw new NotFoundError("Inbox entry not found or not in delivered status");
602
+ return entry;
603
+ }
604
+ async function resetTimedOutEntries(db, timeoutSeconds = DEFAULT_INBOX_TIMEOUT_SECONDS, maxRetries = DEFAULT_MAX_RETRY_COUNT) {
605
+ const reset = await db.update(inboxEntries).set({
606
+ status: "pending",
607
+ retryCount: sql`${inboxEntries.retryCount} + 1`
608
+ }).where(and(eq(inboxEntries.status, "delivered"), sql`${inboxEntries.deliveredAt} < NOW() - make_interval(secs => ${timeoutSeconds})`, lt(inboxEntries.retryCount, maxRetries))).returning({ id: inboxEntries.id });
609
+ 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 });
610
+ return {
611
+ reset: reset.length,
612
+ failed: failed.length
613
+ };
614
+ }
615
+ /** Default age (30 days) past which silent rows that no notify-true delivery
616
+ * ever picked up are physically deleted. */
617
+ const SILENT_ROW_GC_MAX_AGE_SECONDS = 720 * 60 * 60;
618
+ /**
619
+ * Garbage-collect silent inbox rows so the table doesn't grow forever in
620
+ * chats where a `mention_only` agent is never @mentioned.
621
+ *
622
+ * Two cleanup paths:
623
+ *
624
+ * 1. `notify=false AND status='acked'` of any age — these are fully
625
+ * consumed (either bundled into a previous trigger or aged out via the
626
+ * bulk-ack in `collectPrecedingContext`); keep them only as long as
627
+ * the corresponding message rows we link to. The unique constraint
628
+ * `(inbox_id, message_id, chat_id)` means leaving them around blocks
629
+ * legitimate retries with the same key.
630
+ *
631
+ * 2. `notify=false AND status='pending' AND createdAt < NOW() - maxAge` —
632
+ * stale silent rows that no trigger ever caught up with. After 30
633
+ * days they're useless as preceding context (the @mention almost
634
+ * certainly already happened or the chat went dormant).
635
+ *
636
+ * Returns the number of rows deleted in each bucket so the background task
637
+ * can log meaningful counts.
638
+ */
639
+ async function pruneStaleSilentEntries(db, maxAgeSeconds = SILENT_ROW_GC_MAX_AGE_SECONDS) {
640
+ const ackedDeleted = await db.delete(inboxEntries).where(and(eq(inboxEntries.notify, false), eq(inboxEntries.status, "acked"))).returning({ id: inboxEntries.id });
641
+ 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 });
642
+ return {
643
+ ackedDeleted: ackedDeleted.length,
644
+ stalePendingDeleted: stalePendingDeleted.length
645
+ };
646
+ }
647
+ /** Per-session state snapshot. One row per (agent, chat) pair, upserted on each session:state message. */
648
+ const agentChatSessions = pgTable("agent_chat_sessions", {
649
+ agentId: text("agent_id").notNull().references(() => agents.uuid, { onDelete: "cascade" }),
650
+ chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
651
+ state: text("state").notNull(),
652
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
653
+ }, (table) => [primaryKey({ columns: [table.agentId, table.chatId] })]);
654
+ /**
655
+ * Per-(chat, agent) user state — independent from membership structure.
656
+ *
657
+ * This is the third layer of the chat data model: while `chats` owns
658
+ * the entity and `chat_membership` owns the structural relation
659
+ * (who can speak, who watches), this table owns the user's private
660
+ * state about a chat. The reason it lives apart: structural changes
661
+ * (speaker ↔ watcher, manager rebind, recompute) must never overwrite
662
+ * user-private state — physical separation makes that an invariant
663
+ * rather than a service-layer discipline.
664
+ *
665
+ * Columns evolve incrementally as new per-user state is needed.
666
+ * Currently:
667
+ * - `last_read_at`, `unread_mention_count` — seeded by PR-A from
668
+ * the legacy `chat_participants` / `chat_subscriptions` columns.
669
+ * - `engagement_status` — added in 0040; per-(chat, user) view
670
+ * state (active / archived / deleted). Auto-revives archived →
671
+ * active on new message; deleted is sticky (only the user can
672
+ * restore from the chat detail page).
673
+ *
674
+ * Future fields slated for this table: pinned, mute_until, draft,
675
+ * custom_title, last_seen_at — each as a separate change.
676
+ *
677
+ * Rows are lazy-upserted on first user write (markRead / mention
678
+ * counter bump / engagement transition). Reads use COALESCE for
679
+ * defaults so callers see `'active'` etc. even when no row exists.
680
+ * Service-layer integrity (no FK / CHECK / trigger).
681
+ *
682
+ * See proposals/chat-data-model-restructure.20260512.md §8.6.
683
+ */
684
+ const chatUserState = pgTable("chat_user_state", {
685
+ chatId: text("chat_id").notNull(),
686
+ agentId: text("agent_id").notNull(),
687
+ lastReadAt: timestamp("last_read_at", { withTimezone: true }),
688
+ unreadMentionCount: integer("unread_mention_count").notNull().default(0),
689
+ engagementStatus: text("engagement_status").notNull().default("active")
690
+ }, (table) => [
691
+ primaryKey({ columns: [table.chatId, table.agentId] }),
692
+ index("idx_user_state_agent").on(table.agentId),
693
+ index("idx_user_state_unread").on(table.agentId).where(sql`unread_mention_count > 0`)
694
+ ]);
695
+ /** Agent presence and runtime state. Tracked via WebSocket connections; stale entries are cleaned up using server_instances heartbeat. */
696
+ const agentPresence = pgTable("agent_presence", {
697
+ agentId: text("agent_id").primaryKey().references(() => agents.uuid, { onDelete: "cascade" }),
698
+ status: text("status").notNull().default("offline"),
699
+ instanceId: text("instance_id"),
700
+ connectedAt: timestamp("connected_at", { withTimezone: true }),
701
+ lastSeenAt: timestamp("last_seen_at", { withTimezone: true }).notNull().defaultNow(),
702
+ clientId: text("client_id").references(() => clients.id, { onDelete: "set null" }),
703
+ runtimeType: text("runtime_type"),
704
+ runtimeVersion: text("runtime_version"),
705
+ runtimeState: text("runtime_state"),
706
+ activeSessions: integer("active_sessions"),
707
+ totalSessions: integer("total_sessions"),
708
+ runtimeUpdatedAt: timestamp("runtime_updated_at", { withTimezone: true })
709
+ });
710
+ /**
711
+ * Shared access-control primitives. Most route-level gating now lives in
712
+ * `scope/require-*.ts` — this module is reduced to two helpers that need
713
+ * SQL building blocks reused across routes and tests:
714
+ *
715
+ * - `agentVisibilityCondition` — WHERE clause for "agents visible to a
716
+ * member" (org-visible OR managerId = the caller's member). Composed
717
+ * into list queries that already select from `agents`.
718
+ * - `listAgentsManagedByUser` — cross-org list of agents personally
719
+ * managed by a user; powers the CLI `agent list --remote` view.
720
+ *
721
+ * Visibility is the same for all roles — admin sees the same set as a
722
+ * regular member. Admin privilege is expressed through manageability
723
+ * (`requireAgentAccess(..., "manage")`), not visibility.
724
+ */
725
+ /**
726
+ * SQL WHERE conditions for agents visible to a member.
727
+ * target org + not deleted + (organization-visible OR managerId = caller's member)
728
+ */
729
+ function agentVisibilityCondition(orgId, memberId) {
730
+ return and(eq(agents.organizationId, orgId), ne(agents.status, AGENT_STATUSES.DELETED), or(eq(agents.visibility, AGENT_VISIBILITY.ORGANIZATION), eq(agents.managerId, memberId)));
731
+ }
732
+ /**
733
+ * Cross-org listing helper for "agents I personally manage". Used by the
734
+ * CLI `agent list --remote` view — JOINs `agents → members.id` and filters
735
+ * by `members.user_id`.
736
+ */
737
+ async function listAgentsManagedByUser(db, userId) {
738
+ return db.select({
739
+ uuid: agents.uuid,
740
+ name: agents.name,
741
+ displayName: agents.displayName,
742
+ type: agents.type,
743
+ organizationId: agents.organizationId,
744
+ inboxId: agents.inboxId,
745
+ visibility: agents.visibility,
746
+ runtimeProvider: agents.runtimeProvider,
747
+ clientId: agents.clientId
748
+ }).from(agents).innerJoin(members, eq(agents.managerId, members.id)).where(and(eq(members.userId, userId), eq(members.status, "active"), ne(agents.status, AGENT_STATUSES.DELETED)));
749
+ }
750
+ /**
751
+ * Resolve the UUID of the "default" organization. Internal use only —
752
+ * webhooks, fallbacks, etc. The HTTP API layer no longer falls back to
753
+ * the JWT default org.
754
+ */
755
+ async function resolveDefaultOrgId(db) {
756
+ const [org] = await db.select({ id: organizations.id }).from(organizations).where(eq(organizations.name, "default")).limit(1);
757
+ if (!org) throw new Error("Default organization not found. Ensure the server has started and ensureDefaultOrganization() ran.");
758
+ return org.id;
759
+ }
760
+ async function getOrganization(db, id) {
761
+ const [org] = await db.select().from(organizations).where(eq(organizations.id, id)).limit(1);
762
+ if (!org) throw new NotFoundError(`Organization "${id}" not found`);
763
+ return org;
764
+ }
765
+ async function updateOrganization(db, id, data) {
766
+ const updates = { updatedAt: /* @__PURE__ */ new Date() };
767
+ if (data.name !== void 0) updates.name = data.name;
768
+ if (data.displayName !== void 0) updates.displayName = data.displayName;
769
+ if (data.maxAgents !== void 0) updates.maxAgents = data.maxAgents;
770
+ if (data.maxMessagesPerMinute !== void 0) updates.maxMessagesPerMinute = data.maxMessagesPerMinute;
771
+ if (data.features !== void 0) updates.features = data.features;
772
+ try {
773
+ const [org] = await db.update(organizations).set(updates).where(eq(organizations.id, id)).returning();
774
+ if (!org) throw new NotFoundError(`Organization "${id}" not found`);
775
+ return org;
776
+ } catch (err) {
777
+ if ((err?.code ?? err?.cause?.code ?? "") === "23505") throw new ConflictError(`Organization name "${data.name}" is already taken`);
778
+ throw err;
779
+ }
780
+ }
781
+ /**
782
+ * Ensure the default organization exists. Called on server startup.
783
+ * Uses a fixed UUID for the default org to ensure idempotency.
784
+ */
785
+ async function ensureDefaultOrganization(db) {
786
+ const [existing] = await db.select({ id: organizations.id }).from(organizations).where(eq(organizations.name, "default")).limit(1);
787
+ if (existing) return existing;
788
+ const id = uuidv7();
789
+ const [org] = await db.insert(organizations).values({
790
+ id,
791
+ name: "default",
792
+ displayName: "Default Organization"
793
+ }).onConflictDoNothing().returning();
794
+ return org ?? existing;
795
+ }
196
796
  /**
197
797
  * Single source of truth for writing speaker rows into `chat_membership`.
198
798
  *
@@ -563,586 +1163,615 @@ function ensureCanJoin(membership) {
563
1163
  if (membership === "participant") throw new ConflictError("Already a participant in this chat");
564
1164
  if (membership === null) throw new ForbiddenError("Not a watcher of this chat — open the chat from your workspace before joining");
565
1165
  }
566
- async function createChat(db, creatorId, data) {
567
- const chatId = randomUUID();
568
- const allParticipantIds = new Set([creatorId, ...data.participantIds]);
569
- const existingAgents = await db.select({
570
- id: agents.uuid,
571
- organizationId: agents.organizationId,
572
- type: agents.type
573
- }).from(agents).where(inArray(agents.uuid, [...allParticipantIds]));
574
- if (existingAgents.length !== allParticipantIds.size) {
575
- const found = new Set(existingAgents.map((a) => a.id));
576
- throw new BadRequestError(`Agents not found: ${[...allParticipantIds].filter((id) => !found.has(id)).join(", ")}`);
577
- }
578
- const creator = existingAgents.find((a) => a.id === creatorId);
579
- if (!creator) throw new Error("Unexpected: creator not in existingAgents");
580
- const orgId = creator.organizationId;
581
- const crossOrg = existingAgents.filter((a) => a.organizationId !== orgId);
582
- if (crossOrg.length > 0) throw new BadRequestError(`Cross-organization chat not allowed: ${crossOrg.map((a) => a.id).join(", ")}`);
583
- return db.transaction(async (tx) => {
584
- const [chat] = await tx.insert(chats).values({
585
- id: chatId,
586
- organizationId: orgId,
587
- type: data.type,
588
- topic: data.topic ?? null,
589
- metadata: data.metadata ?? {}
590
- }).returning();
591
- await addChatParticipants(tx, chatId, [...allParticipantIds].map((agentId) => ({
592
- agentId,
593
- role: agentId === creatorId ? "owner" : "member"
594
- })));
595
- await recomputeChatWatchers(tx, chatId);
596
- const participants = await tx.select().from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
597
- if (!chat) throw new Error("Unexpected: INSERT RETURNING produced no row");
598
- return {
599
- ...chat,
600
- participants
601
- };
602
- });
1166
+ /**
1167
+ * Names beginning with `__` are reserved for Hub-internal pseudo agents.
1168
+ * User-facing creation must not be able to squat on them, otherwise
1169
+ * internal traffic could be routed through a real account.
1170
+ */
1171
+ const RESERVED_AGENT_NAME_PREFIX = "__";
1172
+ /**
1173
+ * Derive the relative URL clients should use to fetch a manager-uploaded
1174
+ * avatar image. Returns `null` when no image is set. Embeds the upload
1175
+ * timestamp as `?v=<epoch>` so a fresh upload busts any browser cache
1176
+ * that may have memoised the previous version.
1177
+ *
1178
+ * Auth: the image route is intentionally public read — the URL leaks no
1179
+ * more than the agent's UUID, which is already required to address it.
1180
+ * Keeping it unauthenticated lets `<img src>` render without bespoke
1181
+ * fetch-and-blob plumbing.
1182
+ */
1183
+ function agentAvatarImageUrl(uuid, updatedAt) {
1184
+ if (!updatedAt) return null;
1185
+ return `/api/v1/agents/${uuid}/avatar?v=${updatedAt.getTime()}`;
603
1186
  }
604
- async function getChat(db, chatId) {
605
- const [chat] = await db.select().from(chats).where(eq(chats.id, chatId)).limit(1);
606
- if (!chat) throw new NotFoundError(`Chat "${chatId}" not found`);
607
- return chat;
1187
+ /**
1188
+ * True iff `clients.metadata.capabilities` is a non-empty object — i.e. the
1189
+ * client has reported at least one runtime probe result. Used to distinguish
1190
+ * "we don't know what's installed yet" (empty / never reported) from
1191
+ * "client explicitly reports this provider is missing".
1192
+ */
1193
+ function clientCapabilitiesReported(metadata) {
1194
+ if (!metadata || typeof metadata !== "object") return false;
1195
+ const caps = metadata.capabilities;
1196
+ if (!caps || typeof caps !== "object") return false;
1197
+ return Object.keys(caps).length > 0;
608
1198
  }
609
- async function getChatDetail(db, chatId) {
610
- const chat = await getChat(db, chatId);
611
- const participants = await db.select().from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
612
- return {
613
- ...chat,
614
- participants
615
- };
1199
+ /**
1200
+ * Inspect a `clients.metadata.capabilities` blob (jsonb) for a specific
1201
+ * runtime provider entry. Capabilities live under the `metadata.capabilities`
1202
+ * subkey (Option C); the column is unstructured at the DB layer, so we
1203
+ * defensively narrow before key access.
1204
+ *
1205
+ * "Supports" requires the entry's SDK to be **available** — `state: "ok"` or
1206
+ * `state: "unauthenticated"`. A `missing` or `error` entry is *reported* but
1207
+ * not usable, so we explicitly reject those rather than treating mere key
1208
+ * presence as support. Auth state is left to the user to fix at runtime
1209
+ * (the re-bind dialog surfaces an `unauthenticated` hint).
1210
+ */
1211
+ function clientSupportsRuntimeProvider(metadata, provider) {
1212
+ if (!metadata || typeof metadata !== "object") return false;
1213
+ const caps = metadata.capabilities;
1214
+ if (!caps || typeof caps !== "object") return false;
1215
+ const entry = caps[provider];
1216
+ if (!entry || typeof entry !== "object") return false;
1217
+ return entry.available === true;
616
1218
  }
617
- async function listChats(db, agentId, limit, cursor) {
618
- const chatIds = (await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(eq(chatMembership.agentId, agentId), eq(chatMembership.accessMode, "speaker")))).map((r) => r.chatId);
619
- if (chatIds.length === 0) return {
620
- items: [],
621
- nextCursor: null
622
- };
623
- const where = cursor ? and(inArray(chats.id, chatIds), lt(chats.updatedAt, new Date(cursor))) : inArray(chats.id, chatIds);
624
- const rows = await db.select().from(chats).where(where).orderBy(desc(chats.updatedAt)).limit(limit + 1);
625
- const hasMore = rows.length > limit;
626
- const items = hasMore ? rows.slice(0, limit) : rows;
627
- const last = items[items.length - 1];
628
- return {
629
- items,
630
- nextCursor: hasMore && last ? last.updatedAt.toISOString() : null
631
- };
1219
+ /** Default visibility per agent type. */
1220
+ function defaultVisibility(type) {
1221
+ switch (type) {
1222
+ case "human":
1223
+ case "autonomous_agent": return AGENT_VISIBILITY.ORGANIZATION;
1224
+ case "personal_assistant": return AGENT_VISIBILITY.PRIVATE;
1225
+ default: return AGENT_VISIBILITY.PRIVATE;
1226
+ }
632
1227
  }
633
1228
  /**
634
- * List participants of a chat with their agent names used by the client
635
- * runtime to resolve `@<name>` mentions against the authoritative participant
636
- * set (see proposals/hub-agent-messaging-reply-and-mentions §4).
1229
+ * Resolve + validate the client that will own the new agent.
1230
+ *
1231
+ * Rule (unified-user-token, post-first-bind relaxation):
1232
+ * - Human agents represent the member themselves and have no runtime; a
1233
+ * missing `clientId` is required and the column stays NULL.
1234
+ * - Non-human agents MAY omit `clientId` at creation; the row stays NULL
1235
+ * and is claimed on the first WS bind (see `api/agent/ws-client.ts`).
1236
+ * - When a non-human agent IS created with a `clientId`, the pinned client
1237
+ * must already be owned by the manager's user (Rule R-RUN).
637
1238
  */
638
- async function listChatParticipantsWithNames(db, chatId) {
639
- return await db.select({
640
- agentId: chatMembership.agentId,
641
- role: chatMembership.role,
642
- mode: chatMembership.mode,
643
- joinedAt: chatMembership.joinedAt,
644
- name: agents.name,
645
- displayName: agents.displayName,
646
- type: agents.type
647
- }).from(chatMembership).innerJoin(agents, eq(chatMembership.agentId, agents.uuid)).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
1239
+ /**
1240
+ * Check that a client's reported capabilities show the given runtime provider
1241
+ * as **available** (SDK installed, regardless of auth state).
1242
+ *
1243
+ * Tri-state semantics by `clients.metadata.capabilities` shape:
1244
+ * - empty / absent — client hasn't probed yet (newly registered or pre-P2
1245
+ * install). Treat as "unknown" and allow; the in-band repair path
1246
+ * (RUNTIME_PROVIDER_MISMATCH on bind) catches actual incompatibility.
1247
+ * - reported, entry shows `state: ok | unauthenticated` (i.e. `available:
1248
+ * true`) allow.
1249
+ * - reported, entry missing OR `state: missing | error` — block unless
1250
+ * `force` is set. We deliberately do NOT treat mere key presence as
1251
+ * support: probeCapabilities() always emits an entry per built-in
1252
+ * provider, including `{ state: "missing" }` for absent SDKs.
1253
+ *
1254
+ * Skipped entirely for human agents (no clientId) and when `force` is set
1255
+ * (e.g. operator overrides for an offline client).
1256
+ */
1257
+ async function ensureClientSupportsRuntimeProvider(db, clientId, runtimeProvider, options = {}) {
1258
+ if (clientId === null) return;
1259
+ if (options.force) return;
1260
+ const [client] = await db.select({ metadata: clients.metadata }).from(clients).where(eq(clients.id, clientId)).limit(1);
1261
+ if (!client) return;
1262
+ if (!clientCapabilitiesReported(client.metadata)) return;
1263
+ if (!clientSupportsRuntimeProvider(client.metadata, runtimeProvider)) throw new BadRequestError(`Client "${clientId}" does not have runtime provider "${runtimeProvider}" available. Install the matching SDK on that machine and re-run capability detection, or retry with \`force: true\` if the client is offline / capabilities are stale.`);
648
1264
  }
649
- async function assertParticipant(db, chatId, agentId) {
650
- const [row] = await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, agentId), eq(chatMembership.accessMode, "speaker"))).limit(1);
651
- if (!row) throw new ForbiddenError("Not a participant of this chat");
1265
+ async function resolveAgentClient(db, data) {
1266
+ if (data.type === "human") {
1267
+ if (data.clientId) throw new BadRequestError("Human agents cannot be pinned to a client");
1268
+ return null;
1269
+ }
1270
+ if (!data.clientId) return null;
1271
+ const [manager] = await db.select({ userId: members.userId }).from(members).where(eq(members.id, data.managerId)).limit(1);
1272
+ if (!manager) throw new BadRequestError(`Manager "${data.managerId}" not found`);
1273
+ const [client] = await db.select({
1274
+ id: clients.id,
1275
+ userId: clients.userId
1276
+ }).from(clients).where(eq(clients.id, data.clientId)).limit(1);
1277
+ if (!client) throw new BadRequestError(`Client "${data.clientId}" not found`);
1278
+ if (!client.userId) throw new BadRequestError(`Client "${data.clientId}" has not been claimed by a user yet. Have the operator run \`first-tree-hub connect <token>\` on that machine before pinning an agent to it.`);
1279
+ if (client.userId !== manager.userId) throw new ForbiddenError(`Client "${data.clientId}" is not owned by the manager's user — pick a client belonging to that user.`);
1280
+ return client.id;
652
1281
  }
653
1282
  /**
654
- * Non-throwing membership check. Used by routing logic that needs to fall
655
- * back to a different chat when the candidate target isn't a member of the
656
- * caller's current chat (see `sendToAgent`'s current-chat routing branch).
1283
+ * Validate a `delegateMention` write at the service layer. Two checks:
1284
+ * 1. Target uuid must resolve to an existing agent dangling references
1285
+ * would silently break webhook delegation at runtime.
1286
+ * 2. Target must belong to the same organization as the source agent —
1287
+ * cross-org delegate links are rejected here at the source so the
1288
+ * database never accumulates dirty rows. The webhook router has a
1289
+ * defense-in-depth check that filters them at fan-out time, but this
1290
+ * keeps the data clean and gives the admin UI an immediate 422 instead
1291
+ * of a silent runtime drop.
1292
+ *
1293
+ * `null` clears the field — handled by the caller; we are only invoked when
1294
+ * the caller wrote a non-null uuid.
657
1295
  */
658
- async function isParticipant(db, chatId, agentId) {
659
- const [row] = await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, agentId), eq(chatMembership.accessMode, "speaker"))).limit(1);
660
- return Boolean(row);
1296
+ async function validateDelegateMentionTarget(db, targetUuid, sourceOrgId) {
1297
+ const [target] = await db.select({
1298
+ uuid: agents.uuid,
1299
+ organizationId: agents.organizationId
1300
+ }).from(agents).where(eq(agents.uuid, targetUuid)).limit(1);
1301
+ if (!target) throw new BadRequestError(`delegateMention target "${targetUuid}" not found`);
1302
+ if (target.organizationId !== sourceOrgId) throw new BadRequestError("delegateMention target must belong to the same organization as the agent");
661
1303
  }
662
- /** Ensure an agent is a speaker of a chat. Silently adds them if not already. */
663
- async function ensureParticipant(db, chatId, agentId) {
664
- const [existing] = await db.select({ accessMode: chatMembership.accessMode }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, agentId))).limit(1);
665
- if (existing?.accessMode === "speaker") return;
666
- await db.transaction(async (tx) => {
667
- if (wouldUpgradeToGroup((await tx.select({ agentId: chatMembership.agentId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")))).length, 1)) await changeChatType(tx, chatId, "group");
668
- await addChatParticipants(tx, chatId, [{ agentId }], { upgradeWatcherToSpeaker: true });
669
- await recomputeChatWatchers(tx, chatId);
670
- });
671
- invalidateChatAudience(chatId);
1304
+ /**
1305
+ * Service-layer guard: `delegateMention` is only available for `human` agents.
1306
+ * Mirrors the Web UI in `identity-section.tsx`, which only renders the
1307
+ * delegate-mention selector when `agent.type === "human"`. Without this
1308
+ * server-side check, CLI / Admin API / internal scripts could write
1309
+ * delegateMention onto non-human rows, silently re-enabling the
1310
+ * autonomous-agent-self-mention path that resolveAudience would then fan
1311
+ * out. Called from `createAgent` / `updateAgent` before
1312
+ * `validateDelegateMentionTarget` so a wrong source type fails fast without
1313
+ * the target lookup round-trip.
1314
+ */
1315
+ function assertDelegateMentionAllowed(sourceType) {
1316
+ if (sourceType !== AGENT_TYPES.HUMAN) throw new BadRequestError("delegateMention can only be set on human agents");
672
1317
  }
673
- async function addParticipant(db, chatId, requesterId, data) {
674
- const chat = await getChat(db, chatId);
675
- await assertParticipant(db, chatId, requesterId);
676
- const [targetAgent] = await db.select({
677
- id: agents.uuid,
678
- organizationId: agents.organizationId
679
- }).from(agents).where(eq(agents.uuid, data.agentId)).limit(1);
680
- if (!targetAgent) throw new NotFoundError(`Agent "${data.agentId}" not found`);
681
- if (targetAgent.organizationId !== chat.organizationId) throw new BadRequestError("Cannot add agent from different organization");
682
- const [existing] = await db.select({ accessMode: chatMembership.accessMode }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, data.agentId))).limit(1);
683
- if (existing?.accessMode === "speaker") throw new ConflictError(`Agent "${data.agentId}" is already a participant`);
684
- await db.transaction(async (tx) => {
685
- if (wouldUpgradeToGroup((await tx.select({ agentId: chatMembership.agentId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")))).length, 1)) await changeChatType(tx, chatId, "group");
686
- await addChatParticipants(tx, chatId, [{ agentId: data.agentId }], { upgradeWatcherToSpeaker: true });
687
- await recomputeChatWatchers(tx, chatId);
1318
+ /**
1319
+ * Pick the first admin member in the org for internal system agents. Throws
1320
+ * if the org has no admin — the caller should surface the error so an admin
1321
+ * is created before the system tries to register more agents.
1322
+ */
1323
+ async function resolveFallbackManagerId(db, orgId) {
1324
+ const [row] = await db.select({ id: members.id }).from(members).where(and(eq(members.organizationId, orgId), eq(members.role, "admin"))).orderBy(members.createdAt).limit(1);
1325
+ if (!row) throw new BadRequestError(`Cannot create agent in organization "${orgId}" no admin member exists. Create an admin member first (see \`first-tree-hub onboard\`).`);
1326
+ return row.id;
1327
+ }
1328
+ async function createAgent(db, data, options = {}) {
1329
+ const uuid = uuidv7();
1330
+ const name = data.name ?? null;
1331
+ const runtimeProvider = data.runtimeProvider ?? "claude-code";
1332
+ if (name?.startsWith(RESERVED_AGENT_NAME_PREFIX)) throw new BadRequestError(`Agent name "${name}" is reserved — names starting with "${RESERVED_AGENT_NAME_PREFIX}" are Hub-internal`);
1333
+ if (name && isReservedAgentName(name)) throw new BadRequestError(`Agent name "${name}" is reserved — pick a different one.`);
1334
+ const inboxId = `inbox_${uuid}`;
1335
+ let orgId;
1336
+ let managerId;
1337
+ if (data.managerId && data.organizationId) {
1338
+ orgId = data.organizationId;
1339
+ managerId = data.managerId;
1340
+ } else if (data.managerId) {
1341
+ const [manager] = await db.select({
1342
+ id: members.id,
1343
+ organizationId: members.organizationId
1344
+ }).from(members).where(eq(members.id, data.managerId)).limit(1);
1345
+ if (!manager) throw new BadRequestError(`Manager "${data.managerId}" not found`);
1346
+ orgId = manager.organizationId;
1347
+ managerId = manager.id;
1348
+ } else {
1349
+ orgId = data.organizationId ?? await resolveDefaultOrgId(db);
1350
+ managerId = await resolveFallbackManagerId(db, orgId);
1351
+ }
1352
+ const clientId = await resolveAgentClient(db, {
1353
+ clientId: data.clientId,
1354
+ managerId,
1355
+ type: data.type
688
1356
  });
689
- invalidateChatAudience(chatId);
690
- return db.select().from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
1357
+ await ensureClientSupportsRuntimeProvider(db, clientId, runtimeProvider, { force: options.force });
1358
+ if (data.delegateMention) {
1359
+ assertDelegateMentionAllowed(data.type);
1360
+ await validateDelegateMentionTarget(db, data.delegateMention, orgId);
1361
+ }
1362
+ const [org] = await db.select({ maxAgents: organizations.maxAgents }).from(organizations).where(eq(organizations.id, orgId)).limit(1);
1363
+ if (org && org.maxAgents > 0) {
1364
+ if (((await db.select({ value: count() }).from(agents).where(and(eq(agents.organizationId, orgId), ne(agents.status, AGENT_STATUSES.DELETED))))[0]?.value ?? 0) >= org.maxAgents) throw new ForbiddenError(`Organization "${orgId}" has reached its agent limit (${org.maxAgents}). Upgrade your plan or delete unused agents.`);
1365
+ }
1366
+ const resolvedDisplayName = data.displayName?.trim() || name || "Unnamed Agent";
1367
+ try {
1368
+ return await db.transaction(async (tx) => {
1369
+ const [row] = await tx.insert(agents).values({
1370
+ uuid,
1371
+ name,
1372
+ organizationId: orgId,
1373
+ type: data.type,
1374
+ displayName: resolvedDisplayName,
1375
+ delegateMention: data.delegateMention ?? null,
1376
+ inboxId,
1377
+ source: data.source ?? null,
1378
+ visibility: data.visibility ?? defaultVisibility(data.type),
1379
+ metadata: data.metadata ?? {},
1380
+ managerId,
1381
+ clientId,
1382
+ runtimeProvider
1383
+ }).returning();
1384
+ if (!row) throw new Error("Unexpected: INSERT RETURNING produced no row");
1385
+ const initialPayload = defaultRuntimeConfigPayload(runtimeProvider);
1386
+ if (data.gitRepos && data.gitRepos.length > 0) initialPayload.gitRepos = data.gitRepos;
1387
+ await tx.insert(agentConfigs).values({
1388
+ agentId: row.uuid,
1389
+ version: 1,
1390
+ payload: initialPayload,
1391
+ updatedBy: "system"
1392
+ }).onConflictDoNothing();
1393
+ return row;
1394
+ });
1395
+ } catch (err) {
1396
+ if ((err?.code ?? err?.cause?.code ?? "") === "23505" && name) throw new ConflictError(`Agent name "${name}" already exists in organization "${orgId}"`);
1397
+ throw err;
1398
+ }
691
1399
  }
692
- async function removeParticipant(db, chatId, requesterId, targetAgentId) {
693
- await assertParticipant(db, chatId, requesterId);
694
- if (requesterId === targetAgentId) throw new BadRequestError("Cannot remove yourself from a chat");
695
- const [removed] = await db.delete(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, targetAgentId), eq(chatMembership.accessMode, "speaker"))).returning();
696
- if (!removed) throw new NotFoundError(`Agent "${targetAgentId}" is not a participant of this chat`);
697
- await recomputeChatWatchers(db, chatId);
698
- invalidateChatAudience(chatId);
699
- return db.select().from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
1400
+ async function checkAgentNameAvailability(db, orgId, name) {
1401
+ if (!AGENT_NAME_REGEX.test(name)) return {
1402
+ available: false,
1403
+ reason: "invalid"
1404
+ };
1405
+ if (isReservedAgentName(name) || name.startsWith(RESERVED_AGENT_NAME_PREFIX)) return {
1406
+ available: false,
1407
+ reason: "reserved"
1408
+ };
1409
+ const [existing] = await db.select({ uuid: agents.uuid }).from(agents).where(and(eq(agents.organizationId, orgId), eq(agents.name, name), ne(agents.status, AGENT_STATUSES.DELETED))).limit(1);
1410
+ return existing ? {
1411
+ available: false,
1412
+ reason: "taken"
1413
+ } : { available: true };
1414
+ }
1415
+ async function getAgent(db, uuid) {
1416
+ const [agent] = await db.select().from(agents).where(and(eq(agents.uuid, uuid), ne(agents.status, AGENT_STATUSES.DELETED))).limit(1);
1417
+ if (!agent) throw new NotFoundError(`Agent "${uuid}" not found`);
1418
+ return agent;
700
1419
  }
701
1420
  /**
702
- * List chats visible to a member, grouped by agent.
703
- * A member sees chats where:
704
- * 1. Their human agent is a participant, OR
705
- * 2. Any agent they manage (managerId = memberId) is a participant (supervision)
1421
+ * Admin-only variant: return every non-deleted agent in the org, ignoring
1422
+ * the visibility filter. Used by the `/admin` "All Agents" view so a team
1423
+ * admin can see and act on private agents owned by other members. The
1424
+ * route layer is responsible for gating this to admin callers — the
1425
+ * service does not enforce role by itself, but it does enforce org scope
1426
+ * and the not-deleted predicate.
706
1427
  */
707
- async function listChatsForMember(db, memberId, humanAgentId) {
708
- const managedAgents = await db.select({
1428
+ async function listAgentsForAdmin(db, scope, limit, cursor) {
1429
+ const conditions = [eq(agents.organizationId, scope.organizationId), ne(agents.status, AGENT_STATUSES.DELETED)];
1430
+ if (cursor) conditions.push(lt(agents.createdAt, new Date(cursor)));
1431
+ const where = and(...conditions);
1432
+ const rows = await db.select({
709
1433
  uuid: agents.uuid,
710
1434
  name: agents.name,
1435
+ organizationId: agents.organizationId,
711
1436
  type: agents.type,
712
- displayName: agents.displayName
713
- }).from(agents).where(eq(agents.managerId, memberId));
714
- const agentMap = /* @__PURE__ */ new Map();
715
- for (const a of managedAgents) agentMap.set(a.uuid, a);
716
- if (!agentMap.has(humanAgentId)) {
717
- const [ha] = await db.select({
718
- uuid: agents.uuid,
719
- name: agents.name,
720
- type: agents.type,
721
- displayName: agents.displayName
722
- }).from(agents).where(eq(agents.uuid, humanAgentId)).limit(1);
723
- if (ha) agentMap.set(ha.uuid, ha);
1437
+ displayName: agents.displayName,
1438
+ delegateMention: agents.delegateMention,
1439
+ inboxId: agents.inboxId,
1440
+ status: agents.status,
1441
+ visibility: agents.visibility,
1442
+ metadata: agents.metadata,
1443
+ managerId: agents.managerId,
1444
+ clientId: agents.clientId,
1445
+ runtimeProvider: agents.runtimeProvider,
1446
+ avatarColorToken: agents.avatarColorToken,
1447
+ avatarImageUpdatedAt: agents.avatarImageUpdatedAt,
1448
+ createdAt: agents.createdAt,
1449
+ updatedAt: agents.updatedAt,
1450
+ presenceStatus: agentPresence.status,
1451
+ runtimeType: agentPresence.runtimeType,
1452
+ runtimeState: agentPresence.runtimeState,
1453
+ activeSessions: agentPresence.activeSessions
1454
+ }).from(agents).leftJoin(agentPresence, eq(agents.uuid, agentPresence.agentId)).where(where).orderBy(desc(agents.createdAt)).limit(limit + 1);
1455
+ const hasMore = rows.length > limit;
1456
+ const items = hasMore ? rows.slice(0, limit) : rows;
1457
+ const last = items[items.length - 1];
1458
+ return {
1459
+ items,
1460
+ nextCursor: hasMore && last ? last.createdAt.toISOString() : null
1461
+ };
1462
+ }
1463
+ /**
1464
+ * List agents visible to a specific member.
1465
+ * Uses agentVisibilityCondition from access-control (same rules for all roles).
1466
+ */
1467
+ async function listAgentsForMember(db, scope, limit, cursor, type) {
1468
+ const conditions = [agentVisibilityCondition(scope.organizationId, scope.memberId)];
1469
+ if (cursor) conditions.push(lt(agents.createdAt, new Date(cursor)));
1470
+ if (type) conditions.push(eq(agents.type, type));
1471
+ const where = and(...conditions);
1472
+ const rows = await db.select({
1473
+ uuid: agents.uuid,
1474
+ name: agents.name,
1475
+ organizationId: agents.organizationId,
1476
+ type: agents.type,
1477
+ displayName: agents.displayName,
1478
+ delegateMention: agents.delegateMention,
1479
+ inboxId: agents.inboxId,
1480
+ status: agents.status,
1481
+ visibility: agents.visibility,
1482
+ metadata: agents.metadata,
1483
+ managerId: agents.managerId,
1484
+ clientId: agents.clientId,
1485
+ runtimeProvider: agents.runtimeProvider,
1486
+ avatarColorToken: agents.avatarColorToken,
1487
+ avatarImageUpdatedAt: agents.avatarImageUpdatedAt,
1488
+ createdAt: agents.createdAt,
1489
+ updatedAt: agents.updatedAt,
1490
+ presenceStatus: agentPresence.status,
1491
+ runtimeType: agentPresence.runtimeType,
1492
+ runtimeState: agentPresence.runtimeState,
1493
+ activeSessions: agentPresence.activeSessions
1494
+ }).from(agents).leftJoin(agentPresence, eq(agents.uuid, agentPresence.agentId)).where(where).orderBy(desc(agents.createdAt)).limit(limit + 1);
1495
+ const hasMore = rows.length > limit;
1496
+ const items = hasMore ? rows.slice(0, limit) : rows;
1497
+ const last = items[items.length - 1];
1498
+ return {
1499
+ items,
1500
+ nextCursor: hasMore && last ? last.createdAt.toISOString() : null
1501
+ };
1502
+ }
1503
+ async function updateAgent(db, uuid, data) {
1504
+ const agent = await getAgent(db, uuid);
1505
+ if (data.clientId !== void 0) {
1506
+ if (data.clientId === null) throw new BadRequestError("clientId cannot be cleared — once bound, an agent stays bound to its client");
1507
+ if (agent.clientId !== null && agent.clientId !== data.clientId) throw new BadRequestError("clientId is immutable through this entry — cross-client moves go through rebindAgent (PATCH /agents/:uuid/rebind), which runs owner / org / capability checks atomically.");
724
1508
  }
725
- const agentIds = [...agentMap.keys()];
726
- if (agentIds.length === 0) return [];
727
- const participations = await db.select({
728
- chatId: chatMembership.chatId,
729
- agentId: chatMembership.agentId,
730
- role: chatMembership.role,
731
- mode: chatMembership.mode
732
- }).from(chatMembership).where(and(inArray(chatMembership.agentId, agentIds), eq(chatMembership.accessMode, "speaker")));
733
- if (participations.length === 0) return [];
734
- const chatIds = [...new Set(participations.map((p) => p.chatId))];
735
- const agentChatMap = /* @__PURE__ */ new Map();
736
- for (const p of participations) {
737
- const list = agentChatMap.get(p.agentId) ?? [];
738
- list.push(p.chatId);
739
- agentChatMap.set(p.agentId, list);
1509
+ const updates = { updatedAt: /* @__PURE__ */ new Date() };
1510
+ if (data.type !== void 0) {
1511
+ if (data.type !== AGENT_TYPES.HUMAN && agent.delegateMention !== null && data.delegateMention !== null) throw new BadRequestError("Cannot change type away from `human` while delegateMention is set — clear delegateMention in the same patch.");
1512
+ updates.type = data.type;
740
1513
  }
741
- const chatRows = await db.select({
742
- id: chats.id,
743
- type: chats.type,
744
- topic: chats.topic,
745
- metadata: chats.metadata,
746
- createdAt: chats.createdAt,
747
- updatedAt: chats.updatedAt,
748
- participantCount: sql`(SELECT count(*)::int FROM chat_membership WHERE chat_id = ${chats.id} AND access_mode = 'speaker')`
749
- }).from(chats).where(inArray(chats.id, chatIds)).orderBy(desc(chats.updatedAt));
750
- const chatMap = new Map(chatRows.map((c) => [c.id, c]));
751
- const humanParticipantChatIds = new Set(participations.filter((p) => p.agentId === humanAgentId).map((p) => p.chatId));
752
- const result = [];
753
- for (const [agentId, agentChatIds] of agentChatMap) {
754
- const agentInfo = agentMap.get(agentId);
755
- if (!agentInfo) continue;
756
- const agentChats = agentChatIds.map((chatId) => {
757
- const chat = chatMap.get(chatId);
758
- if (!chat) return null;
759
- const isSupervisionOnly = agentId !== humanAgentId && !humanParticipantChatIds.has(chatId);
760
- return {
761
- id: chat.id,
762
- type: chat.type,
763
- topic: chat.topic,
764
- participantCount: chat.participantCount,
765
- isSupervisionOnly,
766
- createdAt: chat.createdAt.toISOString(),
767
- updatedAt: chat.updatedAt.toISOString()
768
- };
769
- }).filter((c) => c !== null);
770
- if (agentChats.length > 0) result.push({
771
- agent: agentInfo,
772
- chats: agentChats
1514
+ if (data.displayName !== void 0) updates.displayName = data.displayName;
1515
+ if (data.delegateMention !== void 0) {
1516
+ if (data.delegateMention !== null) {
1517
+ assertDelegateMentionAllowed(data.type ?? agent.type);
1518
+ await validateDelegateMentionTarget(db, data.delegateMention, agent.organizationId);
1519
+ }
1520
+ updates.delegateMention = data.delegateMention;
1521
+ }
1522
+ if (data.visibility !== void 0) updates.visibility = data.visibility;
1523
+ if (data.metadata !== void 0) updates.metadata = data.metadata;
1524
+ if (data.avatarColorToken !== void 0) updates.avatarColorToken = data.avatarColorToken;
1525
+ if (data.managerId !== void 0) {
1526
+ if (data.managerId === null) throw new BadRequestError("managerId cannot be cleared — every agent must have a manager");
1527
+ const [manager] = await db.select({
1528
+ id: members.id,
1529
+ organizationId: members.organizationId
1530
+ }).from(members).where(eq(members.id, data.managerId)).limit(1);
1531
+ if (!manager) throw new BadRequestError(`Manager "${data.managerId}" not found`);
1532
+ if (manager.organizationId !== agent.organizationId) throw new BadRequestError("Manager must belong to the same organization as the agent");
1533
+ updates.managerId = data.managerId;
1534
+ }
1535
+ if (data.clientId !== void 0 && data.clientId !== null && agent.clientId === null) {
1536
+ const resolvedClientId = await resolveAgentClient(db, {
1537
+ clientId: data.clientId,
1538
+ managerId: updates.managerId ?? agent.managerId,
1539
+ type: agent.type
773
1540
  });
1541
+ if (resolvedClientId !== null) updates.clientId = resolvedClientId;
774
1542
  }
775
- return result;
1543
+ const [updated] = await db.update(agents).set(updates).where(eq(agents.uuid, agent.uuid)).returning();
1544
+ if (!updated) throw new Error("Unexpected: UPDATE RETURNING produced no row");
1545
+ if (data.managerId !== void 0 && data.managerId !== agent.managerId) await recomputeWatchersForAgent(db, agent.uuid);
1546
+ return updated;
776
1547
  }
777
1548
  /**
778
- * Manager joins a chat. Adds their human agent as a participant.
779
- * Requires the member to have supervision rights (manages at least one existing participant).
1549
+ * Atomically re-bind an agent to a new client and/or runtime provider.
1550
+ *
1551
+ * Validations: agent must exist and not be human; new client must belong to
1552
+ * the same owner (manager.userId) and same organization; client must report
1553
+ * the requested runtime provider in its capabilities (skipped under `force`).
1554
+ *
1555
+ * Intended caller: PATCH /agents/:uuid/rebind. The Web "Re-bind"
1556
+ * dialog routes both same-client runtime-only switches and cross-client
1557
+ * moves through this single entry.
1558
+ *
1559
+ * NOTE: active sessions on the previous client are not auto-suspended in P1.
1560
+ * P3 will wire in cross-service coordination (inbox + presence + session)
1561
+ * so the destination client can resume cleanly.
780
1562
  */
781
- async function joinChat(db, chatId, memberId, humanAgentId) {
782
- const chat = await getChat(db, chatId);
783
- const participantAgentIds = (await db.select().from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")))).map((p) => p.agentId);
784
- if (participantAgentIds.length === 0) throw new NotFoundError("Chat has no participants");
785
- if (participantAgentIds.includes(humanAgentId)) throw new ConflictError("Already a participant in this chat");
786
- if ((await db.select({ uuid: agents.uuid }).from(agents).where(and(inArray(agents.uuid, participantAgentIds), eq(agents.managerId, memberId)))).length === 0) throw new ForbiddenError("You can only join chats where you manage at least one participant");
787
- const [humanAgent] = await db.select({ organizationId: agents.organizationId }).from(agents).where(eq(agents.uuid, humanAgentId)).limit(1);
788
- if (!humanAgent || humanAgent.organizationId !== chat.organizationId) throw new BadRequestError("Agent does not belong to the same organization as the chat");
789
- await db.transaction(async (tx) => {
790
- if (wouldUpgradeToGroup(participantAgentIds.length, 1)) await changeChatType(tx, chatId, "group");
791
- await addChatParticipants(tx, chatId, [{
792
- agentId: humanAgentId,
793
- role: "member"
794
- }], {
795
- assertHuman: true,
796
- upgradeWatcherToSpeaker: true
797
- });
798
- await recomputeChatWatchers(tx, chatId);
1563
+ async function rebindAgent(db, uuid, data) {
1564
+ const agent = await getAgent(db, uuid);
1565
+ if (agent.type === "human") throw new BadRequestError("Human agents have no runtime — they cannot be re-bound to a client.");
1566
+ const newClientId = await resolveAgentClient(db, {
1567
+ clientId: data.clientId,
1568
+ managerId: agent.managerId,
1569
+ type: agent.type
799
1570
  });
800
- invalidateChatAudience(chatId);
801
- return db.select().from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
1571
+ if (newClientId === null) throw new BadRequestError("Rebind requires a non-null clientId.");
1572
+ await ensureClientSupportsRuntimeProvider(db, newClientId, data.runtimeProvider, { force: data.force });
1573
+ const [updated] = await db.update(agents).set({
1574
+ clientId: newClientId,
1575
+ runtimeProvider: data.runtimeProvider,
1576
+ updatedAt: /* @__PURE__ */ new Date()
1577
+ }).where(eq(agents.uuid, uuid)).returning();
1578
+ if (!updated) throw new Error("Unexpected: UPDATE RETURNING produced no row");
1579
+ return updated;
802
1580
  }
803
1581
  /**
804
- * Manager leaves a chat. Removes their human agent from participants.
805
- * Only allowed if the human agent is a participant.
806
- *
807
- * Delegates the participant→watcher transition to `leaveAsParticipant`
808
- * so admin-side and `/me/chats/:id/leave` share one canonical path. The
809
- * earlier "recompute then UPDATE-back state" variant violated the design
810
- * rule that recompute is only for set rebuild — never on a transition
811
- * path (review #228 issue #2). The returned participant list is fetched
812
- * after the tx commits, matching the admin route's existing contract.
1582
+ * Reactivate a suspended agent.
813
1583
  */
814
- async function leaveChat(db, chatId, humanAgentId) {
815
- await leaveAsParticipant(db, chatId, humanAgentId);
816
- invalidateChatAudience(chatId);
817
- return db.select().from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
818
- }
819
- async function findOrCreateDirectChat(db, agentAId, agentBId) {
820
- const ends = await db.select({
1584
+ async function reactivateAgent(db, uuid) {
1585
+ const [existing] = await db.select({
821
1586
  uuid: agents.uuid,
822
- organizationId: agents.organizationId,
823
- type: agents.type
824
- }).from(agents).where(inArray(agents.uuid, [agentAId, agentBId]));
825
- const agentA = ends.find((a) => a.uuid === agentAId);
826
- if (!agentA) throw new NotFoundError(`Agent "${agentAId}" not found`);
827
- const agentB = ends.find((a) => a.uuid === agentBId);
828
- if (!agentB) throw new NotFoundError(`Agent "${agentBId}" not found`);
829
- if (agentA.organizationId !== agentB.organizationId) throw new BadRequestError(`Cannot create direct chat across organizations: agent "${agentAId}" (org "${agentA.organizationId}") vs agent "${agentBId}" (org "${agentB.organizationId}")`);
830
- const orgId = agentA.organizationId;
831
- const commonChatIds = (await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(inArray(chatMembership.agentId, [agentAId, agentBId]), eq(chatMembership.accessMode, "speaker"))).groupBy(chatMembership.chatId).having(sql`COUNT(DISTINCT ${chatMembership.agentId}) = 2`)).map((r) => r.chatId);
832
- if (commonChatIds.length > 0) {
833
- const directChats = await db.select().from(chats).where(and(inArray(chats.id, commonChatIds), eq(chats.type, "direct"), eq(chats.organizationId, orgId))).orderBy(chats.createdAt, chats.id).limit(1);
834
- if (directChats.length > 0 && directChats[0]) return directChats[0];
835
- }
836
- const chatId = randomUUID();
837
- return db.transaction(async (tx) => {
838
- const [chat] = await tx.insert(chats).values({
839
- id: chatId,
840
- organizationId: orgId,
841
- type: "direct"
842
- }).returning();
843
- await addChatParticipants(tx, chatId, [{
844
- agentId: agentAId,
845
- role: "member"
846
- }, {
847
- agentId: agentBId,
848
- role: "member"
849
- }]);
850
- await recomputeChatWatchers(tx, chatId);
851
- if (!chat) throw new Error("Unexpected: INSERT RETURNING produced no row");
852
- return chat;
853
- });
1587
+ status: agents.status
1588
+ }).from(agents).where(eq(agents.uuid, uuid)).limit(1);
1589
+ if (!existing || existing.status === AGENT_STATUSES.DELETED) throw new NotFoundError(`Agent "${uuid}" not found`);
1590
+ if (existing.status !== AGENT_STATUSES.SUSPENDED) throw new BadRequestError("Only suspended agents can be reactivated.");
1591
+ const [agent] = await db.update(agents).set({
1592
+ status: AGENT_STATUSES.ACTIVE,
1593
+ updatedAt: /* @__PURE__ */ new Date()
1594
+ }).where(eq(agents.uuid, uuid)).returning();
1595
+ if (!agent) throw new Error("Unexpected: UPDATE RETURNING produced no row");
1596
+ return agent;
854
1597
  }
855
- /** Agent presence and runtime state. Tracked via WebSocket connections; stale entries are cleaned up using server_instances heartbeat. */
856
- const agentPresence = pgTable("agent_presence", {
857
- agentId: text("agent_id").primaryKey().references(() => agents.uuid, { onDelete: "cascade" }),
858
- status: text("status").notNull().default("offline"),
859
- instanceId: text("instance_id"),
860
- connectedAt: timestamp("connected_at", { withTimezone: true }),
861
- lastSeenAt: timestamp("last_seen_at", { withTimezone: true }).notNull().defaultNow(),
862
- clientId: text("client_id").references(() => clients.id, { onDelete: "set null" }),
863
- runtimeType: text("runtime_type"),
864
- runtimeVersion: text("runtime_version"),
865
- runtimeState: text("runtime_state"),
866
- activeSessions: integer("active_sessions"),
867
- totalSessions: integer("total_sessions"),
868
- runtimeUpdatedAt: timestamp("runtime_updated_at", { withTimezone: true })
869
- });
870
1598
  /**
871
- * Shared access-control primitives. Most route-level gating now lives in
872
- * `scope/require-*.ts` this module is reduced to two helpers that need
873
- * SQL building blocks reused across routes and tests:
874
- *
875
- * - `agentVisibilityCondition` — WHERE clause for "agents visible to a
876
- * member" (org-visible OR managerId = the caller's member). Composed
877
- * into list queries that already select from `agents`.
878
- * - `listAgentsManagedByUser` — cross-org list of agents personally
879
- * managed by a user; powers the CLI `agent list --remote` view.
880
- *
881
- * Visibility is the same for all roles — admin sees the same set as a
882
- * regular member. Admin privilege is expressed through manageability
883
- * (`requireAgentAccess(..., "manage")`), not visibility.
1599
+ * Suspend an agent. Once suspended, Rule R-RUN refuses every runtime bind
1600
+ * and every agent-selector-authorised HTTP call.
884
1601
  */
1602
+ async function suspendAgent(db, uuid) {
1603
+ const [agent] = await db.update(agents).set({
1604
+ status: AGENT_STATUSES.SUSPENDED,
1605
+ updatedAt: /* @__PURE__ */ new Date()
1606
+ }).where(and(eq(agents.uuid, uuid), ne(agents.status, AGENT_STATUSES.DELETED))).returning();
1607
+ if (!agent) throw new NotFoundError(`Agent "${uuid}" not found`);
1608
+ return agent;
1609
+ }
885
1610
  /**
886
- * SQL WHERE conditions for agents visible to a member.
887
- * target org + not deleted + (organization-visible OR managerId = caller's member)
1611
+ * Delete an agent. Only allowed when status is "suspended". Sets name to NULL
1612
+ * so the name becomes reusable.
888
1613
  */
889
- function agentVisibilityCondition(orgId, memberId) {
890
- return and(eq(agents.organizationId, orgId), ne(agents.status, AGENT_STATUSES.DELETED), or(eq(agents.visibility, AGENT_VISIBILITY.ORGANIZATION), eq(agents.managerId, memberId)));
1614
+ async function deleteAgent(db, uuid) {
1615
+ const [existing] = await db.select({
1616
+ uuid: agents.uuid,
1617
+ status: agents.status
1618
+ }).from(agents).where(eq(agents.uuid, uuid)).limit(1);
1619
+ if (!existing || existing.status === AGENT_STATUSES.DELETED) throw new NotFoundError(`Agent "${uuid}" not found`);
1620
+ if (existing.status !== AGENT_STATUSES.SUSPENDED) throw new BadRequestError("Only suspended agents can be deleted. Suspend the agent first.");
1621
+ const [agent] = await db.update(agents).set({
1622
+ status: AGENT_STATUSES.DELETED,
1623
+ name: null,
1624
+ updatedAt: /* @__PURE__ */ new Date()
1625
+ }).where(eq(agents.uuid, uuid)).returning();
1626
+ await db.delete(adapterConfigs).where(eq(adapterConfigs.agentId, uuid));
1627
+ await db.delete(adapterAgentMappings).where(eq(adapterAgentMappings.agentId, uuid));
1628
+ if (!agent) throw new Error("Unexpected: UPDATE RETURNING produced no row");
1629
+ return agent;
891
1630
  }
892
1631
  /**
893
- * Cross-org listing helper for "agents I personally manage". Used by the
894
- * CLI `agent list --remote` view JOINs `agents members.id` and filters
895
- * by `members.user_id`.
1632
+ * Supported avatar-image MIME types. The web client always uploads WEBP after
1633
+ * its own resize step; we accept PNG/JPEG too so a caller using the raw HTTP
1634
+ * API (curl, scripts) doesn't have to re-encode. Anything else is rejected at
1635
+ * the boundary — we never store an unknown content type.
896
1636
  */
897
- async function listAgentsManagedByUser(db, userId) {
898
- return db.select({
899
- uuid: agents.uuid,
900
- name: agents.name,
901
- displayName: agents.displayName,
902
- type: agents.type,
903
- organizationId: agents.organizationId,
904
- inboxId: agents.inboxId,
905
- visibility: agents.visibility,
906
- runtimeProvider: agents.runtimeProvider,
907
- clientId: agents.clientId
908
- }).from(agents).innerJoin(members, eq(agents.managerId, members.id)).where(and(eq(members.userId, userId), eq(members.status, "active"), ne(agents.status, AGENT_STATUSES.DELETED)));
1637
+ const SUPPORTED_AVATAR_IMAGE_MIMES = [
1638
+ "image/webp",
1639
+ "image/png",
1640
+ "image/jpeg"
1641
+ ];
1642
+ /** Hard server-side ceiling for the stored bytea blob. Client pre-resizes to ~50KB. */
1643
+ const MAX_AVATAR_IMAGE_BYTES = 512 * 1024;
1644
+ function isSupportedAvatarMime(mime) {
1645
+ return SUPPORTED_AVATAR_IMAGE_MIMES.find((m) => m === mime) !== void 0;
909
1646
  }
910
- /** Messages. Immutable after creation. Each message belongs to exactly one Chat. */
911
- const messages = pgTable("messages", {
912
- id: text("id").primaryKey(),
913
- chatId: text("chat_id").notNull().references(() => chats.id),
914
- senderId: text("sender_id").notNull(),
915
- format: text("format").notNull(),
916
- content: jsonb("content").$type().notNull(),
917
- metadata: jsonb("metadata").$type().notNull().default({}),
918
- replyToInbox: text("reply_to_inbox"),
919
- replyToChat: text("reply_to_chat"),
920
- inReplyTo: text("in_reply_to"),
921
- source: text("source"),
922
- createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
923
- }, (table) => [index("idx_messages_chat_time").on(table.chatId, table.createdAt), index("idx_messages_in_reply_to").on(table.inReplyTo)]);
924
- /** Delivery queue (envelope). One entry per recipient created during message fan-out. Uses SKIP LOCKED for concurrent-safe consumption. */
925
- const inboxEntries = pgTable("inbox_entries", {
926
- id: bigserial("id", { mode: "number" }).primaryKey(),
927
- inboxId: text("inbox_id").notNull(),
928
- messageId: text("message_id").notNull().references(() => messages.id),
929
- chatId: text("chat_id"),
930
- status: text("status").notNull().default("pending"),
931
- notify: boolean("notify").notNull().default(true),
932
- retryCount: integer("retry_count").notNull().default(0),
933
- createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
934
- deliveredAt: timestamp("delivered_at", { withTimezone: true }),
935
- ackedAt: timestamp("acked_at", { withTimezone: true })
936
- }, (table) => [
937
- unique("uq_inbox_delivery").on(table.inboxId, table.messageId, table.chatId),
938
- index("idx_inbox_pending").on(table.inboxId, table.createdAt),
939
- index("idx_inbox_pending_notify").on(table.inboxId, table.createdAt).where(sql`status = 'pending' AND notify = true`),
940
- index("idx_inbox_chat_silent").on(table.inboxId, table.chatId, table.notify, table.status)
941
- ]);
942
- /** Server instance heartbeat. Used to detect crashed instances and clean up associated agent_presence records. */
943
- const serverInstances = pgTable("server_instances", {
944
- instanceId: text("instance_id").primaryKey(),
945
- lastHeartbeat: timestamp("last_heartbeat", { withTimezone: true }).notNull().defaultNow()
946
- });
947
- /** Common field reset when agent goes offline or is unbound. */
948
- function runtimeFieldsReset(now) {
1647
+ /**
1648
+ * Fetch the avatar image blob for an agent. Returns `null` when no image
1649
+ * is set (the column is NULL). The data + mime pair is always coherent
1650
+ * (set/cleared together by the service writes below).
1651
+ */
1652
+ async function getAgentAvatarImage(db, uuid) {
1653
+ const [row] = await db.select({
1654
+ data: agents.avatarImageData,
1655
+ mime: agents.avatarImageMime,
1656
+ updatedAt: agents.avatarImageUpdatedAt
1657
+ }).from(agents).where(and(eq(agents.uuid, uuid), ne(agents.status, AGENT_STATUSES.DELETED))).limit(1);
1658
+ if (!row || !row.data || !row.mime || !row.updatedAt) return null;
949
1659
  return {
950
- runtimeState: null,
951
- activeSessions: null,
952
- totalSessions: null,
953
- runtimeUpdatedAt: now,
954
- lastSeenAt: now
1660
+ data: row.data,
1661
+ mime: row.mime,
1662
+ updatedAt: row.updatedAt
955
1663
  };
956
1664
  }
957
- async function setOffline(db, agentId) {
1665
+ /** Replace (or set) an agent's avatar image. Validates mime + size. */
1666
+ async function setAgentAvatarImage(db, uuid, data, mime) {
1667
+ if (!isSupportedAvatarMime(mime)) throw new BadRequestError(`Unsupported avatar image type "${mime}". Use PNG, JPEG, or WEBP.`);
1668
+ if (data.length === 0) throw new BadRequestError("Avatar image payload is empty.");
1669
+ if (data.length > 524288) throw new BadRequestError(`Avatar image is too large (${data.length} bytes; max ${MAX_AVATAR_IMAGE_BYTES}).`);
958
1670
  const now = /* @__PURE__ */ new Date();
959
- await db.update(agentPresence).set({
960
- status: "offline",
961
- instanceId: null,
962
- ...runtimeFieldsReset(now)
963
- }).where(eq(agentPresence.agentId, agentId));
1671
+ if ((await db.update(agents).set({
1672
+ avatarImageData: data,
1673
+ avatarImageMime: mime,
1674
+ avatarImageUpdatedAt: now,
1675
+ updatedAt: now
1676
+ }).where(and(eq(agents.uuid, uuid), ne(agents.status, AGENT_STATUSES.DELETED))).returning({ uuid: agents.uuid })).length === 0) throw new NotFoundError(`Agent "${uuid}" not found`);
1677
+ return now;
964
1678
  }
965
- async function getPresence(db, agentId) {
966
- const [row] = await db.select().from(agentPresence).where(eq(agentPresence.agentId, agentId)).limit(1);
967
- return row ?? null;
1679
+ /** Clear an agent's avatar image (falls back to color + initial). */
1680
+ async function clearAgentAvatarImage(db, uuid) {
1681
+ if ((await db.update(agents).set({
1682
+ avatarImageData: null,
1683
+ avatarImageMime: null,
1684
+ avatarImageUpdatedAt: null,
1685
+ updatedAt: /* @__PURE__ */ new Date()
1686
+ }).where(and(eq(agents.uuid, uuid), ne(agents.status, AGENT_STATUSES.DELETED))).returning({ uuid: agents.uuid })).length === 0) throw new NotFoundError(`Agent "${uuid}" not found`);
968
1687
  }
969
- async function getOnlineCount(db) {
970
- const [result] = await db.select({ count: sql`count(*)::int` }).from(agentPresence).where(eq(agentPresence.status, "online"));
971
- return result?.count ?? 0;
1688
+ /**
1689
+ * Server-side lifecycle tracker for `format=question` messages.
1690
+ *
1691
+ * Written when an agent emits a question through `sendMessage`; status
1692
+ * flips to `answered` when the user posts an answer, or to `superseded`
1693
+ * when the chat session is archived or its client is claimed away.
1694
+ *
1695
+ * Per the team's "integrity in service layer" convention, NO foreign-key
1696
+ * constraints — referential integrity is enforced by the question
1697
+ * service itself (chat-id / agent-id / message-id are validated at
1698
+ * write time and the lifecycle hooks supersede orphaned rows).
1699
+ */
1700
+ const pendingQuestions = pgTable("pending_questions", {
1701
+ id: text("id").primaryKey(),
1702
+ agentId: text("agent_id").notNull(),
1703
+ chatId: text("chat_id").notNull(),
1704
+ messageId: text("message_id").notNull(),
1705
+ status: text("status").notNull().default("pending"),
1706
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
1707
+ answeredAt: timestamp("answered_at", { withTimezone: true }),
1708
+ supersededAt: timestamp("superseded_at", { withTimezone: true }),
1709
+ supersededReason: text("superseded_reason")
1710
+ }, (table) => [index("idx_pending_questions_agent_status").on(table.agentId, table.status), index("idx_pending_questions_chat_status").on(table.chatId, table.status)]);
1711
+ /**
1712
+ * Upsert session state + refresh presence aggregates + NOTIFY.
1713
+ *
1714
+ * `agent_chat_sessions.(agent_id, chat_id)` is a single-row "current session
1715
+ * state" cache, not a session history log. A new runtime session starting on
1716
+ * the same (agent, chat) pair MUST overwrite whatever ended before — including
1717
+ * an `evicted` row left by a previous terminate. The previous "revival
1718
+ * defense" conflated two concerns: "this runtime session ended" (which is
1719
+ * what `evicted` actually means) and "this chat is permanently archived for
1720
+ * this agent" (a chat-level decision that should live on `chats`, not here).
1721
+ * See proposals/hub-agent-messaging-reply-and-mentions §M2-session-lifecycle.
1722
+ *
1723
+ * Presence row contract: this function tolerates a missing `agent_presence`
1724
+ * row by using `INSERT ... ON CONFLICT DO UPDATE`. The predictive-write path
1725
+ * (sendMessage on first message) may target an agent whose client has never
1726
+ * bound, so a prior `update agent_presence ... where agentId` would silently
1727
+ * drop the activeSessions/totalSessions refresh. See PR #198 review §2.
1728
+ */
1729
+ async function upsertSessionState(db, agentId, chatId, state, organizationId, notifier, options) {
1730
+ const now = /* @__PURE__ */ new Date();
1731
+ let wrote = false;
1732
+ await db.transaction(async (tx) => {
1733
+ await tx.insert(agentChatSessions).values({
1734
+ agentId,
1735
+ chatId,
1736
+ state,
1737
+ updatedAt: now
1738
+ }).onConflictDoUpdate({
1739
+ target: [agentChatSessions.agentId, agentChatSessions.chatId],
1740
+ set: {
1741
+ state,
1742
+ updatedAt: now
1743
+ },
1744
+ setWhere: ne(agentChatSessions.state, state)
1745
+ });
1746
+ const [counts] = await tx.select({
1747
+ active: sql`count(*) FILTER (WHERE ${agentChatSessions.state} = 'active')::int`,
1748
+ total: sql`count(*) FILTER (WHERE ${agentChatSessions.state} != 'evicted')::int`
1749
+ }).from(agentChatSessions).where(eq(agentChatSessions.agentId, agentId));
1750
+ const activeSessions = counts?.active ?? 0;
1751
+ const totalSessions = counts?.total ?? 0;
1752
+ const presenceSet = options?.touchPresenceLastSeen ?? true ? {
1753
+ activeSessions,
1754
+ totalSessions,
1755
+ lastSeenAt: now
1756
+ } : {
1757
+ activeSessions,
1758
+ totalSessions
1759
+ };
1760
+ await tx.insert(agentPresence).values({
1761
+ agentId,
1762
+ activeSessions,
1763
+ totalSessions
1764
+ }).onConflictDoUpdate({
1765
+ target: [agentPresence.agentId],
1766
+ set: presenceSet
1767
+ });
1768
+ wrote = true;
1769
+ });
1770
+ if (wrote && notifier) notifier.notifySessionStateChange(agentId, chatId, state, organizationId).catch(() => {});
972
1771
  }
973
- async function bindAgent(db, agentId, data) {
1772
+ async function resetActivity(db, agentId) {
974
1773
  const now = /* @__PURE__ */ new Date();
975
- await db.insert(agentPresence).values({
976
- agentId,
977
- status: "online",
978
- instanceId: data.instanceId,
979
- clientId: data.clientId,
980
- runtimeType: data.runtimeType,
981
- runtimeVersion: data.runtimeVersion ?? null,
982
- runtimeState: "idle",
983
- connectedAt: now,
984
- lastSeenAt: now,
985
- runtimeUpdatedAt: now
986
- }).onConflictDoUpdate({
987
- target: agentPresence.agentId,
988
- set: {
989
- status: "online",
990
- instanceId: data.instanceId,
991
- clientId: data.clientId,
992
- runtimeType: data.runtimeType,
993
- runtimeVersion: data.runtimeVersion ?? null,
994
- runtimeState: "idle",
995
- activeSessions: null,
996
- totalSessions: null,
997
- connectedAt: now,
998
- lastSeenAt: now,
999
- runtimeUpdatedAt: now
1000
- }
1001
- });
1002
- }
1003
- async function unbindAgent(db, agentId) {
1004
- const now = /* @__PURE__ */ new Date();
1005
- await db.update(agentPresence).set({
1006
- status: "offline",
1007
- clientId: null,
1008
- ...runtimeFieldsReset(now)
1009
- }).where(eq(agentPresence.agentId, agentId));
1010
- }
1011
- /** Set runtime state directly from client-reported value.
1012
- *
1013
- * When an org-scoped notifier is provided, emit a PG NOTIFY on the
1014
- * `runtime_state_changes` channel so the pulse aggregator (and any future
1015
- * admin-side consumers) can observe the transition. Fire-and-forget to match
1016
- * notifier semantics elsewhere in this module. */
1017
- async function setRuntimeState(db, agentId, runtimeState, options) {
1018
- const now = /* @__PURE__ */ new Date();
1019
- await db.update(agentPresence).set({
1020
- runtimeState,
1021
- runtimeUpdatedAt: now,
1022
- lastSeenAt: now
1023
- }).where(eq(agentPresence.agentId, agentId));
1024
- if (options?.notifier && options.organizationId) options.notifier.notifyRuntimeStateChange(agentId, runtimeState, options.organizationId).catch(() => {});
1025
- }
1026
- /** Touch agent last_seen_at on heartbeat (per-agent liveness). */
1027
- async function touchAgent(db, agentId) {
1028
- await db.update(agentPresence).set({ lastSeenAt: /* @__PURE__ */ new Date() }).where(eq(agentPresence.agentId, agentId));
1029
- }
1030
- async function heartbeatInstance(db, instanceId) {
1031
- await db.insert(serverInstances).values({
1032
- instanceId,
1033
- lastHeartbeat: /* @__PURE__ */ new Date()
1034
- }).onConflictDoUpdate({
1035
- target: serverInstances.instanceId,
1036
- set: { lastHeartbeat: /* @__PURE__ */ new Date() }
1037
- });
1038
- }
1039
- /**
1040
- * M1: Mark agents as offline whose last_seen_at is older than staleSeconds.
1041
- * Unlike cleanupStalePresence (which checks instance liveness), this checks
1042
- * per-agent heartbeat liveness — detecting agents that stopped heartbeating
1043
- * while the client process may still be alive.
1044
- *
1045
- * Returns the list of agent IDs that were marked stale (for notification in Step 6).
1046
- */
1047
- async function markStaleAgents(db, staleSeconds = 60) {
1048
- return (await db.execute(sql`
1049
- UPDATE agent_presence SET
1050
- status = 'offline',
1051
- client_id = NULL,
1052
- runtime_state = NULL,
1053
- active_sessions = NULL,
1054
- total_sessions = NULL,
1055
- runtime_updated_at = NOW()
1056
- WHERE status = 'online'
1057
- AND last_seen_at < NOW() - make_interval(secs => ${staleSeconds})
1058
- RETURNING agent_id
1059
- `)).map((r) => r.agent_id);
1060
- }
1061
- async function cleanupStalePresence(db, staleSeconds = 60) {
1062
- return (await db.execute(sql`
1063
- UPDATE agent_presence SET status = 'offline', instance_id = NULL,
1064
- runtime_state = NULL,
1065
- active_sessions = NULL, total_sessions = NULL,
1066
- runtime_updated_at = NOW()
1067
- WHERE instance_id IN (
1068
- SELECT instance_id FROM server_instances
1069
- WHERE last_heartbeat < NOW() - make_interval(secs => ${staleSeconds})
1070
- )
1071
- AND status = 'online'
1072
- RETURNING agent_id
1073
- `)).length;
1074
- }
1075
- /** Per-session state snapshot. One row per (agent, chat) pair, upserted on each session:state message. */
1076
- const agentChatSessions = pgTable("agent_chat_sessions", {
1077
- agentId: text("agent_id").notNull().references(() => agents.uuid, { onDelete: "cascade" }),
1078
- chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
1079
- state: text("state").notNull(),
1080
- updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
1081
- }, (table) => [primaryKey({ columns: [table.agentId, table.chatId] })]);
1082
- /**
1083
- * Upsert session state + refresh presence aggregates + NOTIFY.
1084
- *
1085
- * `agent_chat_sessions.(agent_id, chat_id)` is a single-row "current session
1086
- * state" cache, not a session history log. A new runtime session starting on
1087
- * the same (agent, chat) pair MUST overwrite whatever ended before — including
1088
- * an `evicted` row left by a previous terminate. The previous "revival
1089
- * defense" conflated two concerns: "this runtime session ended" (which is
1090
- * what `evicted` actually means) and "this chat is permanently archived for
1091
- * this agent" (a chat-level decision that should live on `chats`, not here).
1092
- * See proposals/hub-agent-messaging-reply-and-mentions §M2-session-lifecycle.
1093
- *
1094
- * Presence row contract: this function tolerates a missing `agent_presence`
1095
- * row by using `INSERT ... ON CONFLICT DO UPDATE`. The predictive-write path
1096
- * (sendMessage on first message) may target an agent whose client has never
1097
- * bound, so a prior `update agent_presence ... where agentId` would silently
1098
- * drop the activeSessions/totalSessions refresh. See PR #198 review §2.
1099
- */
1100
- async function upsertSessionState(db, agentId, chatId, state, organizationId, notifier, options) {
1101
- const now = /* @__PURE__ */ new Date();
1102
- let wrote = false;
1103
- await db.transaction(async (tx) => {
1104
- await tx.insert(agentChatSessions).values({
1105
- agentId,
1106
- chatId,
1107
- state,
1108
- updatedAt: now
1109
- }).onConflictDoUpdate({
1110
- target: [agentChatSessions.agentId, agentChatSessions.chatId],
1111
- set: {
1112
- state,
1113
- updatedAt: now
1114
- },
1115
- setWhere: ne(agentChatSessions.state, state)
1116
- });
1117
- const [counts] = await tx.select({
1118
- active: sql`count(*) FILTER (WHERE ${agentChatSessions.state} = 'active')::int`,
1119
- total: sql`count(*) FILTER (WHERE ${agentChatSessions.state} != 'evicted')::int`
1120
- }).from(agentChatSessions).where(eq(agentChatSessions.agentId, agentId));
1121
- const activeSessions = counts?.active ?? 0;
1122
- const totalSessions = counts?.total ?? 0;
1123
- const presenceSet = options?.touchPresenceLastSeen ?? true ? {
1124
- activeSessions,
1125
- totalSessions,
1126
- lastSeenAt: now
1127
- } : {
1128
- activeSessions,
1129
- totalSessions
1130
- };
1131
- await tx.insert(agentPresence).values({
1132
- agentId,
1133
- activeSessions,
1134
- totalSessions
1135
- }).onConflictDoUpdate({
1136
- target: [agentPresence.agentId],
1137
- set: presenceSet
1138
- });
1139
- wrote = true;
1140
- });
1141
- if (wrote && notifier) notifier.notifySessionStateChange(agentId, chatId, state, organizationId).catch(() => {});
1142
- }
1143
- async function resetActivity(db, agentId) {
1144
- const now = /* @__PURE__ */ new Date();
1145
- await db.update(agentPresence).set({
1774
+ await db.update(agentPresence).set({
1146
1775
  runtimeState: "idle",
1147
1776
  runtimeUpdatedAt: now
1148
1777
  }).where(eq(agentPresence.agentId, agentId));
@@ -1225,7 +1854,7 @@ async function listAgentsWithRuntime(db, scope) {
1225
1854
  * - Mention candidate set is `chat_membership` speakers only;
1226
1855
  * watchers are not direct `@`-mention targets.
1227
1856
  */
1228
- const { ACTIVE, ARCHIVED } = CHAT_ENGAGEMENT_STATUSES;
1857
+ const { ACTIVE: ACTIVE$1, ARCHIVED: ARCHIVED$1 } = CHAT_ENGAGEMENT_STATUSES;
1229
1858
  let dispatcher = null;
1230
1859
  function registerChatMessageDispatcher(fn) {
1231
1860
  dispatcher = fn;
@@ -1260,9 +1889,9 @@ async function applyAfterFanOut(tx, input) {
1260
1889
  }).where(eq(chats.id, chatId));
1261
1890
  await tx.execute(sql`
1262
1891
  UPDATE chat_user_state
1263
- SET engagement_status = ${ACTIVE}
1892
+ SET engagement_status = ${ACTIVE$1}
1264
1893
  WHERE chat_id = ${chatId}
1265
- AND engagement_status = ${ARCHIVED}
1894
+ AND engagement_status = ${ARCHIVED$1}
1266
1895
  `);
1267
1896
  if (mentionedAgentIds.length === 0) return;
1268
1897
  const mentionedList = sql.join(mentionedAgentIds.map((id) => sql`${id}`), sql`, `);
@@ -1291,791 +1920,1991 @@ async function applyAfterFanOut(tx, input) {
1291
1920
  DO UPDATE SET unread_mention_count = chat_user_state.unread_mention_count + 1
1292
1921
  `);
1293
1922
  }
1294
- /**
1295
- * Server-side lifecycle tracker for `format=question` messages.
1296
- *
1297
- * Written when an agent emits a question through `sendMessage`; status
1298
- * flips to `answered` when the user posts an answer, or to `superseded`
1299
- * when the chat session is archived or its client is claimed away.
1300
- *
1301
- * Per the team's "integrity in service layer" convention, NO foreign-key
1302
- * constraints referential integrity is enforced by the question
1303
- * service itself (chat-id / agent-id / message-id are validated at
1304
- * write time and the lifecycle hooks supersede orphaned rows).
1305
- */
1306
- const pendingQuestions = pgTable("pending_questions", {
1307
- id: text("id").primaryKey(),
1308
- agentId: text("agent_id").notNull(),
1309
- chatId: text("chat_id").notNull(),
1310
- messageId: text("message_id").notNull(),
1311
- status: text("status").notNull().default("pending"),
1312
- createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
1313
- answeredAt: timestamp("answered_at", { withTimezone: true }),
1314
- supersededAt: timestamp("superseded_at", { withTimezone: true }),
1315
- supersededReason: text("superseded_reason")
1316
- }, (table) => [index("idx_pending_questions_agent_status").on(table.agentId, table.status), index("idx_pending_questions_chat_status").on(table.chatId, table.status)]);
1317
- const INBOX_CHANNEL = "inbox_notifications";
1318
- const CONFIG_CHANNEL = "config_changes";
1319
- const SESSION_STATE_CHANNEL = "session_state_changes";
1320
- const SESSION_EVENT_CHANNEL = "session_event_changes";
1321
- const RUNTIME_STATE_CHANNEL = "runtime_state_changes";
1322
- /**
1323
- * Chat-first workspace cross-process kick. Carries `<chatId>:<messageId>`.
1324
- * Lets admin WS sockets translate every chat message (speaker AND watcher
1325
- * audience) into a `chat:message` frame, without being coupled to the
1326
- * inbox NOTIFY path that only reaches speakers.
1327
- */
1328
- const CHAT_MESSAGE_CHANNEL = "chat_message_events";
1329
- /**
1330
- * Generic admin-broadcast envelope channel. Producers (e.g. notification.ts)
1331
- * emit a JSON-stringified `AdminBroadcastPayload`; every server instance
1332
- * LISTENs and hands the envelope to its local admin-socket fanout. Keeps
1333
- * cross-instance and single-instance code paths identical from the admin WS
1334
- * route's perspective.
1335
- */
1336
- const ADMIN_BROADCAST_CHANNEL = "admin_broadcast_envelopes";
1337
- function createNotifier(listenClient) {
1338
- const subscriptions = /* @__PURE__ */ new Map();
1339
- const configChangeHandlers = [];
1340
- const sessionStateChangeHandlers = [];
1341
- const sessionEventHandlers = [];
1342
- const runtimeStateChangeHandlers = [];
1343
- const chatMessageHandlers = [];
1344
- const adminBroadcastHandlers = [];
1345
- let unlistenInboxFn = null;
1346
- let unlistenConfigFn = null;
1347
- let unlistenSessionStateFn = null;
1348
- let unlistenSessionEventFn = null;
1349
- let unlistenRuntimeStateFn = null;
1350
- let unlistenChatMessageFn = null;
1351
- let unlistenAdminBroadcastFn = null;
1352
- function handleNotification(payload) {
1353
- const sepIdx = payload.indexOf(":");
1354
- if (sepIdx === -1) return;
1355
- const inboxId = payload.slice(0, sepIdx);
1356
- const messageId = payload.slice(sepIdx + 1);
1357
- const sockets = subscriptions.get(inboxId);
1358
- if (!sockets) return;
1359
- const doorbellFrame = JSON.stringify({
1360
- type: "new_message",
1361
- inboxId,
1362
- messageId
1363
- });
1364
- for (const [ws, pushHandler] of sockets) {
1365
- if (ws.readyState !== ws.OPEN) continue;
1366
- if (pushHandler) Promise.resolve(pushHandler(messageId)).catch(() => {});
1367
- else ws.send(doorbellFrame);
1368
- }
1369
- }
1370
- return {
1371
- subscribe(inboxId, ws, pushHandler) {
1372
- let map = subscriptions.get(inboxId);
1373
- if (!map) {
1374
- map = /* @__PURE__ */ new Map();
1375
- subscriptions.set(inboxId, map);
1376
- }
1377
- map.set(ws, pushHandler ?? null);
1378
- },
1379
- unsubscribe(inboxId, ws) {
1380
- const map = subscriptions.get(inboxId);
1381
- if (map) {
1382
- map.delete(ws);
1383
- if (map.size === 0) subscriptions.delete(inboxId);
1384
- }
1385
- },
1386
- async notify(inboxId, messageId) {
1387
- try {
1388
- await listenClient`SELECT pg_notify(${INBOX_CHANNEL}, ${`${inboxId}:${messageId}`})`;
1389
- } catch {}
1390
- },
1391
- async notifyConfigChange(configType) {
1392
- try {
1393
- await listenClient`SELECT pg_notify(${CONFIG_CHANNEL}, ${configType})`;
1394
- } catch {}
1395
- },
1396
- async notifySessionStateChange(agentId, chatId, state, organizationId) {
1397
- try {
1398
- await listenClient`SELECT pg_notify(${SESSION_STATE_CHANNEL}, ${`${agentId}:${chatId}:${state}:${organizationId}`})`;
1399
- } catch {}
1400
- },
1401
- async notifySessionEvent(agentId, chatId, kind, organizationId) {
1402
- try {
1403
- await listenClient`SELECT pg_notify(${SESSION_EVENT_CHANNEL}, ${`${agentId}:${chatId}:${kind}:${organizationId}`})`;
1404
- } catch {}
1405
- },
1406
- async notifyRuntimeStateChange(agentId, state, organizationId) {
1407
- try {
1408
- await listenClient`SELECT pg_notify(${RUNTIME_STATE_CHANNEL}, ${`${agentId}:${state}:${organizationId}`})`;
1409
- } catch {}
1410
- },
1411
- async notifyChatMessage(chatId, messageId) {
1412
- try {
1413
- await listenClient`SELECT pg_notify(${CHAT_MESSAGE_CHANNEL}, ${`${chatId}:${messageId}`})`;
1414
- } catch {}
1415
- },
1416
- async notifyAdminBroadcast(payload) {
1417
- let encoded;
1418
- try {
1419
- encoded = JSON.stringify(payload);
1420
- } catch {
1421
- return;
1422
- }
1423
- if (encoded.length > 7500) {
1424
- console.error(`[notifier] admin broadcast payload too large (${encoded.length} bytes); refusing to NOTIFY`);
1425
- return;
1426
- }
1427
- try {
1428
- await listenClient`SELECT pg_notify(${ADMIN_BROADCAST_CHANNEL}, ${encoded})`;
1429
- } catch {}
1430
- },
1431
- async pushFrameToInbox(inboxId, frame) {
1432
- const map = subscriptions.get(inboxId);
1433
- if (!map) return 0;
1434
- let queued = 0;
1435
- const pending = [];
1436
- for (const ws of map.keys()) {
1437
- if (ws.readyState !== ws.OPEN) continue;
1438
- pending.push(new Promise((resolve) => {
1439
- ws.send(frame, (err) => {
1440
- if (!err) queued += 1;
1441
- resolve();
1442
- });
1443
- }));
1923
+ const log$1 = createLogger("message");
1924
+ async function sendMessage(db, chatId, senderId, data, options = {}) {
1925
+ return withSpan("inbox.enqueue", messageAttrs({
1926
+ chatId,
1927
+ senderAgentId: senderId,
1928
+ source: data.source ?? void 0
1929
+ }), () => sendMessageInner(db, chatId, senderId, data, options));
1930
+ }
1931
+ async function sendMessageInner(db, chatId, senderId, data, options) {
1932
+ const txResult = await db.transaction(async (tx) => {
1933
+ const [participants, [chatRow], [senderRow]] = await Promise.all([
1934
+ tx.select({
1935
+ agentId: chatMembership.agentId,
1936
+ inboxId: agents.inboxId,
1937
+ mode: chatMembership.mode,
1938
+ name: agents.name
1939
+ }).from(chatMembership).innerJoin(agents, eq(chatMembership.agentId, agents.uuid)).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker"))),
1940
+ tx.select({ type: chats.type }).from(chats).where(eq(chats.id, chatId)).limit(1),
1941
+ tx.select({
1942
+ inboxId: agents.inboxId,
1943
+ organizationId: agents.organizationId,
1944
+ type: agents.type
1945
+ }).from(agents).where(eq(agents.uuid, senderId)).limit(1)
1946
+ ]);
1947
+ const chatType = chatRow?.type ?? null;
1948
+ if (!senderRow) throw new NotFoundError(`Sender agent "${senderId}" not found`);
1949
+ let effectiveContent = data.content;
1950
+ if (senderRow.type !== "human" && typeof effectiveContent === "string") {
1951
+ const unwrapped = maybeUnwrapDoubleEncoded(effectiveContent);
1952
+ if (unwrapped !== null) {
1953
+ log$1.warn({
1954
+ metric: "double_encoded_content_unwrapped_total",
1955
+ chatId,
1956
+ senderId
1957
+ }, "agent sent JSON-encoded string content — unwrapping to restore markdown rendering");
1958
+ effectiveContent = unwrapped;
1444
1959
  }
1445
- await Promise.all(pending);
1446
- return queued;
1447
- },
1448
- onConfigChange(handler) {
1449
- configChangeHandlers.push(handler);
1450
- },
1451
- onSessionStateChange(handler) {
1452
- sessionStateChangeHandlers.push(handler);
1453
- },
1454
- onSessionEvent(handler) {
1455
- sessionEventHandlers.push(handler);
1456
- },
1457
- onRuntimeStateChange(handler) {
1458
- runtimeStateChangeHandlers.push(handler);
1459
- },
1460
- onChatMessage(handler) {
1461
- chatMessageHandlers.push(handler);
1462
- },
1463
- onAdminBroadcast(handler) {
1464
- adminBroadcastHandlers.push(handler);
1465
- },
1466
- async start() {
1467
- unlistenInboxFn = (await listenClient.listen(INBOX_CHANNEL, (payload) => {
1468
- if (payload) handleNotification(payload);
1469
- })).unlisten;
1470
- unlistenConfigFn = (await listenClient.listen(CONFIG_CHANNEL, (payload) => {
1471
- if (payload) for (const handler of configChangeHandlers) handler(payload);
1472
- })).unlisten;
1473
- unlistenSessionStateFn = (await listenClient.listen(SESSION_STATE_CHANNEL, (payload) => {
1474
- if (payload) {
1475
- const firstSep = payload.indexOf(":");
1476
- const secondSep = payload.indexOf(":", firstSep + 1);
1477
- const thirdSep = payload.indexOf(":", secondSep + 1);
1478
- if (firstSep > 0 && secondSep > firstSep && thirdSep > secondSep) {
1479
- const agentId = payload.slice(0, firstSep);
1480
- const chatId = payload.slice(firstSep + 1, secondSep);
1481
- const state = payload.slice(secondSep + 1, thirdSep);
1482
- const organizationId = payload.slice(thirdSep + 1);
1483
- for (const handler of sessionStateChangeHandlers) handler({
1484
- agentId,
1485
- chatId,
1486
- state,
1487
- organizationId
1488
- });
1489
- }
1490
- }
1491
- })).unlisten;
1492
- unlistenSessionEventFn = (await listenClient.listen(SESSION_EVENT_CHANNEL, (payload) => {
1493
- if (payload) {
1494
- const firstSep = payload.indexOf(":");
1495
- const secondSep = payload.indexOf(":", firstSep + 1);
1496
- const thirdSep = payload.indexOf(":", secondSep + 1);
1497
- if (firstSep > 0 && secondSep > firstSep && thirdSep > secondSep) {
1498
- const agentId = payload.slice(0, firstSep);
1499
- const chatId = payload.slice(firstSep + 1, secondSep);
1500
- const kind = payload.slice(secondSep + 1, thirdSep);
1501
- const organizationId = payload.slice(thirdSep + 1);
1502
- for (const handler of sessionEventHandlers) try {
1503
- handler({
1504
- agentId,
1505
- chatId,
1506
- kind,
1507
- organizationId
1508
- });
1509
- } catch {}
1510
- }
1511
- }
1512
- })).unlisten;
1513
- unlistenRuntimeStateFn = (await listenClient.listen(RUNTIME_STATE_CHANNEL, (payload) => {
1514
- if (payload) {
1515
- const firstSep = payload.indexOf(":");
1516
- const secondSep = payload.indexOf(":", firstSep + 1);
1517
- if (firstSep > 0 && secondSep > firstSep) {
1518
- const agentId = payload.slice(0, firstSep);
1519
- const state = payload.slice(firstSep + 1, secondSep);
1520
- const organizationId = payload.slice(secondSep + 1);
1521
- for (const handler of runtimeStateChangeHandlers) handler({
1522
- agentId,
1523
- state,
1524
- organizationId
1525
- });
1526
- }
1527
- }
1528
- })).unlisten;
1529
- unlistenChatMessageFn = (await listenClient.listen(CHAT_MESSAGE_CHANNEL, (payload) => {
1530
- if (!payload) return;
1531
- const sep = payload.indexOf(":");
1532
- if (sep <= 0) return;
1533
- const chatId = payload.slice(0, sep);
1534
- const messageId = payload.slice(sep + 1);
1535
- for (const handler of chatMessageHandlers) try {
1536
- handler({
1537
- chatId,
1538
- messageId
1539
- });
1540
- } catch {}
1541
- })).unlisten;
1542
- unlistenAdminBroadcastFn = (await listenClient.listen(ADMIN_BROADCAST_CHANNEL, (payload) => {
1543
- if (!payload) return;
1544
- let parsed;
1545
- try {
1546
- parsed = JSON.parse(payload);
1547
- } catch {
1548
- return;
1960
+ }
1961
+ if (data.replyToInbox !== void 0 && data.replyToInbox !== null) {
1962
+ if (senderRow.inboxId !== data.replyToInbox) throw new BadRequestError("replyToInbox must reference the sender's own inbox");
1963
+ }
1964
+ const incomingMeta = data.metadata ?? {};
1965
+ const explicitMentionsRaw = incomingMeta.mentions;
1966
+ const explicitMentions = Array.isArray(explicitMentionsRaw) ? explicitMentionsRaw.filter((m) => typeof m === "string") : [];
1967
+ const contentText = typeof effectiveContent === "string" ? effectiveContent : "";
1968
+ const resolved = contentText ? extractMentions(contentText, participants) : [];
1969
+ const mergedMentions = [...new Set([...explicitMentions, ...resolved])];
1970
+ const metadataToStore = mergedMentions.length > 0 ? {
1971
+ ...incomingMeta,
1972
+ mentions: mergedMentions
1973
+ } : incomingMeta;
1974
+ const dmAutoProjection = chatType === "direct" ? [...new Set([...mergedMentions, ...participants.filter((p) => p.agentId !== senderId).map((p) => p.agentId)])] : mergedMentions;
1975
+ const isAgentFinalText = data.purpose === "agent-final-text";
1976
+ if (options.enforceGroupMention && chatType === "group" && !isAgentFinalText) {
1977
+ if (mergedMentions.filter((id) => id !== senderId).length === 0) throw new BadRequestError("Sending to a group chat requires an explicit @mention. Use `first-tree-hub chat send <name>` to message a single agent, or @<name> in the content to address one or more group members.");
1978
+ }
1979
+ if (options.enforceGroupMention && !isAgentFinalText && typeof effectiveContent === "string") {
1980
+ const rawTokens = scanMentionTokens(effectiveContent);
1981
+ if (rawTokens.length > 0) {
1982
+ const speakerNames = new Set(participants.map((p) => p.name).filter((n) => typeof n === "string" && n.length > 0).map((n) => n.toLowerCase()));
1983
+ const unresolved = rawTokens.filter((t) => !speakerNames.has(t));
1984
+ if (unresolved.length > 0) {
1985
+ const sample = unresolved[0];
1986
+ throw new BadRequestError(`Cannot @-mention "${sample}" they are not a participant of this chat. Use \`first-tree-hub chat send --direct ${sample} "..."\` to message them in a side conversation, or ask a human in this chat to add them as a participant first.`);
1549
1987
  }
1550
- if (typeof parsed !== "object" || parsed === null) return;
1551
- const envelope = parsed;
1552
- for (const handler of adminBroadcastHandlers) try {
1553
- handler(envelope);
1554
- } catch {}
1555
- })).unlisten;
1556
- },
1557
- async stop() {
1558
- if (unlistenInboxFn) {
1559
- await unlistenInboxFn();
1560
- unlistenInboxFn = null;
1561
- }
1562
- if (unlistenConfigFn) {
1563
- await unlistenConfigFn();
1564
- unlistenConfigFn = null;
1565
- }
1566
- if (unlistenSessionStateFn) {
1567
- await unlistenSessionStateFn();
1568
- unlistenSessionStateFn = null;
1569
- }
1570
- if (unlistenSessionEventFn) {
1571
- await unlistenSessionEventFn();
1572
- unlistenSessionEventFn = null;
1573
- }
1574
- if (unlistenRuntimeStateFn) {
1575
- await unlistenRuntimeStateFn();
1576
- unlistenRuntimeStateFn = null;
1577
1988
  }
1578
- if (unlistenChatMessageFn) {
1579
- await unlistenChatMessageFn();
1580
- unlistenChatMessageFn = null;
1989
+ }
1990
+ let outboundContent = effectiveContent;
1991
+ if (options.normalizeMentionsInContent && typeof outboundContent === "string") {
1992
+ const present = new Set(scanMentionTokens(outboundContent));
1993
+ const missingNames = [];
1994
+ for (const id of mergedMentions) {
1995
+ if (id === senderId) continue;
1996
+ const p = participants.find((q) => q.agentId === id);
1997
+ if (!p?.name) continue;
1998
+ if (present.has(p.name.toLowerCase())) continue;
1999
+ missingNames.push(p.name);
1581
2000
  }
1582
- if (unlistenAdminBroadcastFn) {
1583
- await unlistenAdminBroadcastFn();
1584
- unlistenAdminBroadcastFn = null;
2001
+ if (missingNames.length > 0) {
2002
+ const prefix = missingNames.map((n) => `@${n}`).join(" ");
2003
+ outboundContent = outboundContent.length > 0 ? `${prefix} ${outboundContent}` : prefix;
1585
2004
  }
1586
2005
  }
1587
- };
2006
+ if (data.format === "question") await assertSenderMayEmitQuestion(tx, senderId);
2007
+ const isSilentSend = typeof outboundContent === "string" && outboundContent.replace(/^(@\S+\s*)+/, "").trim().length === 0;
2008
+ if (isSilentSend) log$1.info({
2009
+ chatId,
2010
+ senderId,
2011
+ source: data.source ?? null
2012
+ }, "silent send: empty content after mention strip — no fan-out wake-up");
2013
+ const projectionMentions = isSilentSend ? [] : dmAutoProjection;
2014
+ const messageId = randomUUID();
2015
+ const [msg] = await tx.insert(messages).values({
2016
+ id: messageId,
2017
+ chatId,
2018
+ senderId,
2019
+ format: data.format,
2020
+ content: outboundContent,
2021
+ metadata: metadataToStore,
2022
+ replyToInbox: data.replyToInbox ?? null,
2023
+ replyToChat: data.replyToChat ?? null,
2024
+ inReplyTo: data.inReplyTo ?? null,
2025
+ source: data.source ?? null
2026
+ }).returning();
2027
+ if (data.format === "question" && msg) await recordPendingQuestionFromMessage(tx, {
2028
+ agentId: senderId,
2029
+ chatId,
2030
+ messageId: msg.id,
2031
+ content: outboundContent
2032
+ });
2033
+ const mentionSet = new Set(mergedMentions);
2034
+ const fanout = participants.filter((p) => p.agentId !== senderId).map((p) => ({
2035
+ agentId: p.agentId,
2036
+ inboxId: p.inboxId,
2037
+ notify: !isSilentSend && !isAgentFinalText && (p.mode !== "mention_only" || mentionSet.has(p.agentId))
2038
+ }));
2039
+ if (fanout.length > 0) await tx.insert(inboxEntries).values(fanout.map((f) => ({
2040
+ inboxId: f.inboxId,
2041
+ messageId,
2042
+ chatId,
2043
+ notify: f.notify
2044
+ })));
2045
+ const notified = fanout.filter((f) => f.notify);
2046
+ const recipients = notified.map((f) => f.inboxId);
2047
+ const recipientAgentIds = notified.map((f) => f.agentId);
2048
+ if (data.inReplyTo) {
2049
+ const [original] = await tx.select({
2050
+ replyToInbox: messages.replyToInbox,
2051
+ replyToChat: messages.replyToChat
2052
+ }).from(messages).where(eq(messages.id, data.inReplyTo)).limit(1);
2053
+ if (original?.replyToInbox && original?.replyToChat) {
2054
+ const replyNotify = !isSilentSend;
2055
+ await tx.insert(inboxEntries).values({
2056
+ inboxId: original.replyToInbox,
2057
+ messageId,
2058
+ chatId: original.replyToChat,
2059
+ notify: replyNotify
2060
+ }).onConflictDoNothing();
2061
+ if (replyNotify && !recipients.includes(original.replyToInbox)) recipients.push(original.replyToInbox);
2062
+ }
2063
+ }
2064
+ await tx.update(chats).set({ updatedAt: /* @__PURE__ */ new Date() }).where(eq(chats.id, chatId));
2065
+ if (!msg) throw new Error("Unexpected: INSERT RETURNING produced no row");
2066
+ const previewText = typeof outboundContent === "string" ? outboundContent.trim() : "";
2067
+ await applyAfterFanOut(tx, {
2068
+ chatId,
2069
+ messageId: msg.id,
2070
+ senderId,
2071
+ mentionedAgentIds: projectionMentions,
2072
+ contentPreview: previewText,
2073
+ messageCreatedAt: msg.createdAt
2074
+ });
2075
+ return {
2076
+ message: msg,
2077
+ recipients,
2078
+ recipientAgentIds,
2079
+ organizationId: senderRow.organizationId
2080
+ };
2081
+ });
2082
+ const settled = await Promise.allSettled(txResult.recipientAgentIds.map((agentId) => upsertSessionState(db, agentId, chatId, "active", txResult.organizationId, void 0, { touchPresenceLastSeen: false })));
2083
+ for (let i = 0; i < settled.length; i++) {
2084
+ const r = settled[i];
2085
+ if (r?.status === "rejected") log$1.error({
2086
+ err: r.reason,
2087
+ chatId,
2088
+ agentId: txResult.recipientAgentIds[i]
2089
+ }, "predictive session activation failed");
2090
+ }
2091
+ fireChatMessageKick(chatId, txResult.message.id);
2092
+ observeLoopPattern(db, chatId).catch((err) => {
2093
+ log$1.error({
2094
+ err,
2095
+ chatId
2096
+ }, "loop pattern observation failed");
2097
+ });
2098
+ return {
2099
+ message: txResult.message,
2100
+ recipients: txResult.recipients
2101
+ };
2102
+ }
2103
+ /**
2104
+ * Detect agent-sent content that was JSON.stringify-ed once before reaching
2105
+ * the CLI / API. The bad shape is an outer `"..."` wrapper + interior `\n` /
2106
+ * `\"` escape sequences, which the UI renders as a quoted literal instead of
2107
+ * markdown (issue #389). Returns the unwrapped inner string on a confident
2108
+ * match, or `null` to leave the content alone.
2109
+ *
2110
+ * Match conditions (all required) — kept strict so legitimate human content
2111
+ * that happens to look like a quoted phrase is never touched. The caller is
2112
+ * additionally responsible for restricting this to non-human senders.
2113
+ *
2114
+ * - first and last char are `"`
2115
+ * - body contains at least one typical JSON escape sequence
2116
+ * (`\n`, `\r`, `\t`, `\"`, or `\\`)
2117
+ * - `JSON.parse` succeeds
2118
+ * - the parse result is a `string` (excludes `{...}`, `[...]`, numbers)
2119
+ */
2120
+ function maybeUnwrapDoubleEncoded(content) {
2121
+ if (content.length < 4) return null;
2122
+ if (content.charCodeAt(0) !== 34) return null;
2123
+ if (content.charCodeAt(content.length - 1) !== 34) return null;
2124
+ if (!/\\[nrt"\\]/.test(content)) return null;
2125
+ try {
2126
+ const parsed = JSON.parse(content);
2127
+ return typeof parsed === "string" ? parsed : null;
2128
+ } catch {
2129
+ return null;
2130
+ }
2131
+ }
2132
+ function stripMentionPrefix(content) {
2133
+ return content.replace(/^(@\S+\s*)+/, "").trim();
2134
+ }
2135
+ const defaultLoopObserver = (data) => {
2136
+ log$1.warn({
2137
+ metric: "loop_pattern_observed_total",
2138
+ ...data
2139
+ }, "loop pattern observed (not blocked) — prompt discipline may be drifting");
2140
+ };
2141
+ /**
2142
+ * Pure observation: detect short, fast, two-agent ping-pong reply chains and
2143
+ * surface them via a structured log line. Does NOT modify the `notify` flag
2144
+ * or otherwise interfere with delivery — loop *prevention* lives client-side
2145
+ * (prompt + silent-turn protocol in `result-sink`). Six conjunctive
2146
+ * conditions (see design `agent-reply-loop-prevention-design.md` §3.4) so
2147
+ * normal multi-agent collaboration is never flagged:
2148
+ *
2149
+ * C1 — every message is `format=text`
2150
+ * C2 — no human sender in the window (any human reply resets the chain)
2151
+ * C3 — exactly two senders, perfectly alternating
2152
+ * C4 — strict `inReplyTo` chain across the whole window
2153
+ * C5 — every message body (after stripping leading `@<name>` tokens) is
2154
+ * ≤ `LOOP_OBSERVATION_SHORT_CHARS` characters
2155
+ * C6 — the whole window spans ≤ `LOOP_OBSERVATION_TIME_WINDOW_MS` ms
2156
+ *
2157
+ * Exported for direct test coverage of the detection logic; the `sendMessage`
2158
+ * call site uses the default observer.
2159
+ */
2160
+ async function observeLoopPattern(db, chatId, observer = defaultLoopObserver) {
2161
+ const window = await db.select({
2162
+ id: messages.id,
2163
+ senderId: messages.senderId,
2164
+ content: messages.content,
2165
+ inReplyTo: messages.inReplyTo,
2166
+ createdAt: messages.createdAt,
2167
+ format: messages.format,
2168
+ senderType: agents.type
2169
+ }).from(messages).innerJoin(agents, eq(messages.senderId, agents.uuid)).where(eq(messages.chatId, chatId)).orderBy(desc(messages.createdAt)).limit(4);
2170
+ if (window.length < 4) return;
2171
+ if (window.some((m) => m.format !== "text")) return;
2172
+ if (window.some((m) => m.senderType === "human")) return;
2173
+ if (new Set(window.map((m) => m.senderId)).size !== 2) return;
2174
+ for (let i = 0; i < window.length - 1; i++) if (window[i]?.senderId === window[i + 1]?.senderId) return;
2175
+ for (let i = 0; i < window.length - 1; i++) if (window[i]?.inReplyTo !== window[i + 1]?.id) return;
2176
+ const lens = [];
2177
+ for (const m of window) {
2178
+ const text = typeof m.content === "string" ? m.content : null;
2179
+ if (text === null) return;
2180
+ const len = stripMentionPrefix(text).length;
2181
+ if (len > 10) return;
2182
+ lens.push(len);
2183
+ }
2184
+ const newest = window[0];
2185
+ const oldest = window[window.length - 1];
2186
+ if (!newest || !oldest) return;
2187
+ const spanMs = newest.createdAt.getTime() - oldest.createdAt.getTime();
2188
+ if (spanMs > 3e4) return;
2189
+ observer({
2190
+ chatId,
2191
+ recentMessageIds: window.map((m) => m.id),
2192
+ windowSpanMs: spanMs,
2193
+ contentLengths: lens
2194
+ });
2195
+ }
2196
+ async function sendToAgent(db, senderUuid, targetName, data) {
2197
+ const [sender] = await db.select({
2198
+ uuid: agents.uuid,
2199
+ organizationId: agents.organizationId
2200
+ }).from(agents).where(eq(agents.uuid, senderUuid)).limit(1);
2201
+ if (!sender) throw new NotFoundError(`Agent "${senderUuid}" not found`);
2202
+ const [target] = await db.select({ uuid: agents.uuid }).from(agents).where(and(eq(agents.organizationId, sender.organizationId), eq(agents.name, targetName), ne(agents.status, "deleted"))).limit(1);
2203
+ if (!target) throw new NotFoundError(`Agent "${targetName}" not found${/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(targetName) ? " — `first-tree-hub chat send` expects an agent NAME, not a uuid. Run `first-tree-hub agent list` to see available names." : ""}`);
2204
+ const incomingMeta = data.metadata ?? {};
2205
+ const existingMentionsRaw = incomingMeta.mentions;
2206
+ const existingMentions = Array.isArray(existingMentionsRaw) ? existingMentionsRaw.filter((m) => typeof m === "string") : [];
2207
+ const mergedMentions = existingMentions.includes(target.uuid) ? existingMentions : [...existingMentions, target.uuid];
2208
+ const metadata = {
2209
+ ...incomingMeta,
2210
+ mentions: mergedMentions
2211
+ };
2212
+ if (data.replyToChat) {
2213
+ const [targetIsMember, senderIsMember] = await Promise.all([isParticipant(db, data.replyToChat, target.uuid), isParticipant(db, data.replyToChat, senderUuid)]);
2214
+ if (targetIsMember && senderIsMember) return sendMessage(db, data.replyToChat, senderUuid, {
2215
+ format: data.format,
2216
+ content: data.content,
2217
+ metadata,
2218
+ replyToInbox: void 0,
2219
+ replyToChat: void 0,
2220
+ source: data.source
2221
+ }, { normalizeMentionsInContent: true });
2222
+ }
2223
+ if (!data.direct) throw new AgentSendNonMemberError(`Agent "${targetName}" is not a member of your current chat. Either open or reuse a side-conversation explicitly with \`first-tree-hub chat send --direct ${targetName} "..."\`, or ask a human in this chat to add ${targetName} as a participant.`);
2224
+ return sendMessage(db, (await findOrCreateDirectChat(db, senderUuid, target.uuid)).id, senderUuid, {
2225
+ format: data.format,
2226
+ content: data.content,
2227
+ metadata,
2228
+ replyToInbox: data.replyToInbox,
2229
+ replyToChat: data.replyToChat,
2230
+ source: data.source
2231
+ }, { normalizeMentionsInContent: true });
2232
+ }
2233
+ async function editMessage(db, chatId, messageId, senderId, data) {
2234
+ const [msg] = await db.select().from(messages).where(eq(messages.id, messageId)).limit(1);
2235
+ if (!msg) throw new NotFoundError(`Message "${messageId}" not found`);
2236
+ if (msg.chatId !== chatId) throw new NotFoundError(`Message "${messageId}" not found in this chat`);
2237
+ if (msg.senderId !== senderId) throw new ForbiddenError("Only the sender can edit a message");
2238
+ const setClause = {};
2239
+ if (data.format !== void 0) setClause.format = data.format;
2240
+ if (data.content !== void 0) setClause.content = data.content;
2241
+ const meta = msg.metadata ?? {};
2242
+ meta.editedAt = (/* @__PURE__ */ new Date()).toISOString();
2243
+ setClause.metadata = meta;
2244
+ const [updated] = await db.update(messages).set(setClause).where(eq(messages.id, messageId)).returning();
2245
+ if (!updated) throw new Error("Unexpected: UPDATE RETURNING produced no row");
2246
+ return updated;
2247
+ }
2248
+ async function listMessages(db, chatId, limit, cursor) {
2249
+ const where = cursor ? and(eq(messages.chatId, chatId), lt(messages.createdAt, new Date(cursor))) : eq(messages.chatId, chatId);
2250
+ const rows = await db.select().from(messages).where(where).orderBy(desc(messages.createdAt)).limit(limit + 1);
2251
+ const hasMore = rows.length > limit;
2252
+ const items = hasMore ? rows.slice(0, limit) : rows;
2253
+ const last = items[items.length - 1];
2254
+ return {
2255
+ items,
2256
+ nextCursor: hasMore && last ? last.createdAt.toISOString() : null
2257
+ };
2258
+ }
2259
+ const INBOX_CHANNEL = "inbox_notifications";
2260
+ const CONFIG_CHANNEL = "config_changes";
2261
+ const SESSION_STATE_CHANNEL = "session_state_changes";
2262
+ const SESSION_EVENT_CHANNEL = "session_event_changes";
2263
+ const RUNTIME_STATE_CHANNEL = "runtime_state_changes";
2264
+ /**
2265
+ * Chat-first workspace cross-process kick. Carries `<chatId>:<messageId>`.
2266
+ * Lets admin WS sockets translate every chat message (speaker AND watcher
2267
+ * audience) into a `chat:message` frame, without being coupled to the
2268
+ * inbox NOTIFY path that only reaches speakers.
2269
+ */
2270
+ const CHAT_MESSAGE_CHANNEL = "chat_message_events";
2271
+ /**
2272
+ * Generic admin-broadcast envelope channel. Producers (e.g. notification.ts)
2273
+ * emit a JSON-stringified `AdminBroadcastPayload`; every server instance
2274
+ * LISTENs and hands the envelope to its local admin-socket fanout. Keeps
2275
+ * cross-instance and single-instance code paths identical from the admin WS
2276
+ * route's perspective.
2277
+ */
2278
+ const ADMIN_BROADCAST_CHANNEL = "admin_broadcast_envelopes";
2279
+ function createNotifier(listenClient) {
2280
+ const subscriptions = /* @__PURE__ */ new Map();
2281
+ const configChangeHandlers = [];
2282
+ const sessionStateChangeHandlers = [];
2283
+ const sessionEventHandlers = [];
2284
+ const runtimeStateChangeHandlers = [];
2285
+ const chatMessageHandlers = [];
2286
+ const adminBroadcastHandlers = [];
2287
+ let unlistenInboxFn = null;
2288
+ let unlistenConfigFn = null;
2289
+ let unlistenSessionStateFn = null;
2290
+ let unlistenSessionEventFn = null;
2291
+ let unlistenRuntimeStateFn = null;
2292
+ let unlistenChatMessageFn = null;
2293
+ let unlistenAdminBroadcastFn = null;
2294
+ function handleNotification(payload) {
2295
+ const sepIdx = payload.indexOf(":");
2296
+ if (sepIdx === -1) return;
2297
+ const inboxId = payload.slice(0, sepIdx);
2298
+ const messageId = payload.slice(sepIdx + 1);
2299
+ const sockets = subscriptions.get(inboxId);
2300
+ if (!sockets) return;
2301
+ const doorbellFrame = JSON.stringify({
2302
+ type: "new_message",
2303
+ inboxId,
2304
+ messageId
2305
+ });
2306
+ for (const [ws, pushHandler] of sockets) {
2307
+ if (ws.readyState !== ws.OPEN) continue;
2308
+ if (pushHandler) Promise.resolve(pushHandler(messageId)).catch(() => {});
2309
+ else ws.send(doorbellFrame);
2310
+ }
2311
+ }
2312
+ return {
2313
+ subscribe(inboxId, ws, pushHandler) {
2314
+ let map = subscriptions.get(inboxId);
2315
+ if (!map) {
2316
+ map = /* @__PURE__ */ new Map();
2317
+ subscriptions.set(inboxId, map);
2318
+ }
2319
+ map.set(ws, pushHandler ?? null);
2320
+ },
2321
+ unsubscribe(inboxId, ws) {
2322
+ const map = subscriptions.get(inboxId);
2323
+ if (map) {
2324
+ map.delete(ws);
2325
+ if (map.size === 0) subscriptions.delete(inboxId);
2326
+ }
2327
+ },
2328
+ async notify(inboxId, messageId) {
2329
+ try {
2330
+ await listenClient`SELECT pg_notify(${INBOX_CHANNEL}, ${`${inboxId}:${messageId}`})`;
2331
+ } catch {}
2332
+ },
2333
+ async notifyConfigChange(configType) {
2334
+ try {
2335
+ await listenClient`SELECT pg_notify(${CONFIG_CHANNEL}, ${configType})`;
2336
+ } catch {}
2337
+ },
2338
+ async notifySessionStateChange(agentId, chatId, state, organizationId) {
2339
+ try {
2340
+ await listenClient`SELECT pg_notify(${SESSION_STATE_CHANNEL}, ${`${agentId}:${chatId}:${state}:${organizationId}`})`;
2341
+ } catch {}
2342
+ },
2343
+ async notifySessionEvent(agentId, chatId, kind, organizationId) {
2344
+ try {
2345
+ await listenClient`SELECT pg_notify(${SESSION_EVENT_CHANNEL}, ${`${agentId}:${chatId}:${kind}:${organizationId}`})`;
2346
+ } catch {}
2347
+ },
2348
+ async notifyRuntimeStateChange(agentId, state, organizationId) {
2349
+ try {
2350
+ await listenClient`SELECT pg_notify(${RUNTIME_STATE_CHANNEL}, ${`${agentId}:${state}:${organizationId}`})`;
2351
+ } catch {}
2352
+ },
2353
+ async notifyChatMessage(chatId, messageId) {
2354
+ try {
2355
+ await listenClient`SELECT pg_notify(${CHAT_MESSAGE_CHANNEL}, ${`${chatId}:${messageId}`})`;
2356
+ } catch {}
2357
+ },
2358
+ async notifyAdminBroadcast(payload) {
2359
+ let encoded;
2360
+ try {
2361
+ encoded = JSON.stringify(payload);
2362
+ } catch {
2363
+ return;
2364
+ }
2365
+ if (encoded.length > 7500) {
2366
+ console.error(`[notifier] admin broadcast payload too large (${encoded.length} bytes); refusing to NOTIFY`);
2367
+ return;
2368
+ }
2369
+ try {
2370
+ await listenClient`SELECT pg_notify(${ADMIN_BROADCAST_CHANNEL}, ${encoded})`;
2371
+ } catch {}
2372
+ },
2373
+ async pushFrameToInbox(inboxId, frame) {
2374
+ const map = subscriptions.get(inboxId);
2375
+ if (!map) return 0;
2376
+ let queued = 0;
2377
+ const pending = [];
2378
+ for (const ws of map.keys()) {
2379
+ if (ws.readyState !== ws.OPEN) continue;
2380
+ pending.push(new Promise((resolve) => {
2381
+ ws.send(frame, (err) => {
2382
+ if (!err) queued += 1;
2383
+ resolve();
2384
+ });
2385
+ }));
2386
+ }
2387
+ await Promise.all(pending);
2388
+ return queued;
2389
+ },
2390
+ onConfigChange(handler) {
2391
+ configChangeHandlers.push(handler);
2392
+ },
2393
+ onSessionStateChange(handler) {
2394
+ sessionStateChangeHandlers.push(handler);
2395
+ },
2396
+ onSessionEvent(handler) {
2397
+ sessionEventHandlers.push(handler);
2398
+ },
2399
+ onRuntimeStateChange(handler) {
2400
+ runtimeStateChangeHandlers.push(handler);
2401
+ },
2402
+ onChatMessage(handler) {
2403
+ chatMessageHandlers.push(handler);
2404
+ },
2405
+ onAdminBroadcast(handler) {
2406
+ adminBroadcastHandlers.push(handler);
2407
+ },
2408
+ async start() {
2409
+ unlistenInboxFn = (await listenClient.listen(INBOX_CHANNEL, (payload) => {
2410
+ if (payload) handleNotification(payload);
2411
+ })).unlisten;
2412
+ unlistenConfigFn = (await listenClient.listen(CONFIG_CHANNEL, (payload) => {
2413
+ if (payload) for (const handler of configChangeHandlers) handler(payload);
2414
+ })).unlisten;
2415
+ unlistenSessionStateFn = (await listenClient.listen(SESSION_STATE_CHANNEL, (payload) => {
2416
+ if (payload) {
2417
+ const firstSep = payload.indexOf(":");
2418
+ const secondSep = payload.indexOf(":", firstSep + 1);
2419
+ const thirdSep = payload.indexOf(":", secondSep + 1);
2420
+ if (firstSep > 0 && secondSep > firstSep && thirdSep > secondSep) {
2421
+ const agentId = payload.slice(0, firstSep);
2422
+ const chatId = payload.slice(firstSep + 1, secondSep);
2423
+ const state = payload.slice(secondSep + 1, thirdSep);
2424
+ const organizationId = payload.slice(thirdSep + 1);
2425
+ for (const handler of sessionStateChangeHandlers) handler({
2426
+ agentId,
2427
+ chatId,
2428
+ state,
2429
+ organizationId
2430
+ });
2431
+ }
2432
+ }
2433
+ })).unlisten;
2434
+ unlistenSessionEventFn = (await listenClient.listen(SESSION_EVENT_CHANNEL, (payload) => {
2435
+ if (payload) {
2436
+ const firstSep = payload.indexOf(":");
2437
+ const secondSep = payload.indexOf(":", firstSep + 1);
2438
+ const thirdSep = payload.indexOf(":", secondSep + 1);
2439
+ if (firstSep > 0 && secondSep > firstSep && thirdSep > secondSep) {
2440
+ const agentId = payload.slice(0, firstSep);
2441
+ const chatId = payload.slice(firstSep + 1, secondSep);
2442
+ const kind = payload.slice(secondSep + 1, thirdSep);
2443
+ const organizationId = payload.slice(thirdSep + 1);
2444
+ for (const handler of sessionEventHandlers) try {
2445
+ handler({
2446
+ agentId,
2447
+ chatId,
2448
+ kind,
2449
+ organizationId
2450
+ });
2451
+ } catch {}
2452
+ }
2453
+ }
2454
+ })).unlisten;
2455
+ unlistenRuntimeStateFn = (await listenClient.listen(RUNTIME_STATE_CHANNEL, (payload) => {
2456
+ if (payload) {
2457
+ const firstSep = payload.indexOf(":");
2458
+ const secondSep = payload.indexOf(":", firstSep + 1);
2459
+ if (firstSep > 0 && secondSep > firstSep) {
2460
+ const agentId = payload.slice(0, firstSep);
2461
+ const state = payload.slice(firstSep + 1, secondSep);
2462
+ const organizationId = payload.slice(secondSep + 1);
2463
+ for (const handler of runtimeStateChangeHandlers) handler({
2464
+ agentId,
2465
+ state,
2466
+ organizationId
2467
+ });
2468
+ }
2469
+ }
2470
+ })).unlisten;
2471
+ unlistenChatMessageFn = (await listenClient.listen(CHAT_MESSAGE_CHANNEL, (payload) => {
2472
+ if (!payload) return;
2473
+ const sep = payload.indexOf(":");
2474
+ if (sep <= 0) return;
2475
+ const chatId = payload.slice(0, sep);
2476
+ const messageId = payload.slice(sep + 1);
2477
+ for (const handler of chatMessageHandlers) try {
2478
+ handler({
2479
+ chatId,
2480
+ messageId
2481
+ });
2482
+ } catch {}
2483
+ })).unlisten;
2484
+ unlistenAdminBroadcastFn = (await listenClient.listen(ADMIN_BROADCAST_CHANNEL, (payload) => {
2485
+ if (!payload) return;
2486
+ let parsed;
2487
+ try {
2488
+ parsed = JSON.parse(payload);
2489
+ } catch {
2490
+ return;
2491
+ }
2492
+ if (typeof parsed !== "object" || parsed === null) return;
2493
+ const envelope = parsed;
2494
+ for (const handler of adminBroadcastHandlers) try {
2495
+ handler(envelope);
2496
+ } catch {}
2497
+ })).unlisten;
2498
+ },
2499
+ async stop() {
2500
+ if (unlistenInboxFn) {
2501
+ await unlistenInboxFn();
2502
+ unlistenInboxFn = null;
2503
+ }
2504
+ if (unlistenConfigFn) {
2505
+ await unlistenConfigFn();
2506
+ unlistenConfigFn = null;
2507
+ }
2508
+ if (unlistenSessionStateFn) {
2509
+ await unlistenSessionStateFn();
2510
+ unlistenSessionStateFn = null;
2511
+ }
2512
+ if (unlistenSessionEventFn) {
2513
+ await unlistenSessionEventFn();
2514
+ unlistenSessionEventFn = null;
2515
+ }
2516
+ if (unlistenRuntimeStateFn) {
2517
+ await unlistenRuntimeStateFn();
2518
+ unlistenRuntimeStateFn = null;
2519
+ }
2520
+ if (unlistenChatMessageFn) {
2521
+ await unlistenChatMessageFn();
2522
+ unlistenChatMessageFn = null;
2523
+ }
2524
+ if (unlistenAdminBroadcastFn) {
2525
+ await unlistenAdminBroadcastFn();
2526
+ unlistenAdminBroadcastFn = null;
2527
+ }
2528
+ }
2529
+ };
2530
+ }
2531
+ /** Fire-and-forget: notify all recipients that a new message is available. */
2532
+ function notifyRecipients(notifier, recipients, messageId) {
2533
+ for (const inboxId of recipients) notifier.notify(inboxId, messageId).catch(() => {});
2534
+ }
2535
+ const log = createLogger("questions");
2536
+ /**
2537
+ * Insert a `pending_questions` row inside the same transaction that wrote a
2538
+ * `format=question` message. Caller is `sendMessage` after the message INSERT
2539
+ * returns, so a rollback drops both rows together. No-op (returns silently)
2540
+ * if the message content is not a valid `QuestionMessageContent` — the caller
2541
+ * will already have rejected such input upstream, but we defend in depth so a
2542
+ * malformed write never leaves a dangling pending row.
2543
+ */
2544
+ async function recordPendingQuestionFromMessage(tx, args) {
2545
+ const parsed = questionMessageContentSchema.safeParse(args.content);
2546
+ if (!parsed.success) throw new BadRequestError("Invalid question message content", { "question.parse_error": parsed.error.message.slice(0, 200) });
2547
+ const { correlationId } = parsed.data;
2548
+ await tx.insert(pendingQuestions).values({
2549
+ id: correlationId,
2550
+ agentId: args.agentId,
2551
+ chatId: args.chatId,
2552
+ messageId: args.messageId,
2553
+ status: "pending"
2554
+ });
2555
+ }
2556
+ /**
2557
+ * Defensive write-side check: codex-runtime agents must never emit
2558
+ * `format=question` (Codex SDK 0.125 has no ask-user surface, so any such
2559
+ * message would be a runtime regression). Looks up the sender's
2560
+ * `runtime_provider` and rejects if it is `codex`. Throws `ForbiddenError`
2561
+ * (HTTP 403) so the bug surfaces loudly to the offending writer.
2562
+ *
2563
+ * Returns the runtime provider for telemetry / further checks (e.g. the
2564
+ * caller can attach it to the active span).
2565
+ */
2566
+ async function assertSenderMayEmitQuestion(tx, senderAgentId) {
2567
+ const [row] = await tx.select({ runtimeProvider: agents.runtimeProvider }).from(agents).where(eq(agents.uuid, senderAgentId)).limit(1);
2568
+ if (!row) throw new NotFoundError(`Sender agent "${senderAgentId}" not found`);
2569
+ if (row.runtimeProvider === "codex") {
2570
+ log.error({
2571
+ agentId: senderAgentId,
2572
+ runtimeProvider: row.runtimeProvider
2573
+ }, "rejected format=question emit from codex-runtime agent");
2574
+ throw new ForbiddenError("Codex runtime cannot emit ask-user questions", {
2575
+ "question.codex_emit_attempt": true,
2576
+ "agent.id": senderAgentId
2577
+ });
2578
+ }
2579
+ return row.runtimeProvider;
2580
+ }
2581
+ /**
2582
+ * User-side answer submission. Atomically:
2583
+ * 1. Lock the `pending_questions` row by correlationId.
2584
+ * 2. Refuse if status !== "pending" (409 if already-answered, 410-shaped
2585
+ * 400 if superseded — both surface as ConflictError so the caller knows
2586
+ * the question is no longer answerable).
2587
+ * 3. Validate that the answer keys match the original `questions[]`.
2588
+ * 4. Flip status to `answered` INSIDE the lock-tx, before releasing the
2589
+ * row lock. This is the linearisation point: the second concurrent
2590
+ * submitter (waiting on the same row lock) will, on its turn, see
2591
+ * status=answered and exit with ConflictError BEFORE it can write a
2592
+ * second `format=question_answer` message.
2593
+ * 5. Send the `format=question_answer` message OUTSIDE the lock-tx
2594
+ * (sendMessage opens its own transaction; nesting wasn't supported by
2595
+ * the existing call site). At this point we hold an exclusive logical
2596
+ * claim — only one submitter ever reaches this step per correlationId.
2597
+ *
2598
+ * `submitterAgentId` is the human agent on whose behalf the answer is
2599
+ * written (it must be a participant of the question's chat). Returns the
2600
+ * created `question_answer` message id so the route can include it in the
2601
+ * 201 response.
2602
+ *
2603
+ * Failure semantics: if step 5 (sendMessage) fails after status was flipped,
2604
+ * we revert the row to `pending` so the user can retry. This is best-effort —
2605
+ * the revert UPDATE is guarded by `status='answered'` to avoid clobbering a
2606
+ * supersede that might race in. If the revert itself fails, the row is
2607
+ * stranded as `answered` with no answer message; an operator would need to
2608
+ * intervene, but a sendMessage failure (local DB tx) is already
2609
+ * extraordinarily rare.
2610
+ */
2611
+ async function submitAnswer(db, notifier, args) {
2612
+ const questionRow = await db.transaction(async (tx) => {
2613
+ const [row] = await tx.select({
2614
+ id: pendingQuestions.id,
2615
+ status: pendingQuestions.status,
2616
+ chatId: pendingQuestions.chatId,
2617
+ agentId: pendingQuestions.agentId,
2618
+ messageId: pendingQuestions.messageId
2619
+ }).from(pendingQuestions).where(eq(pendingQuestions.id, args.correlationId)).for("update").limit(1);
2620
+ if (!row) throw new NotFoundError(`Question "${args.correlationId}" not found`);
2621
+ if (row.chatId !== args.chatId) throw new NotFoundError(`Question "${args.correlationId}" not found in this chat`);
2622
+ if (row.status !== "pending") throw new ConflictError(`Question "${args.correlationId}" is no longer pending`, { "question.status": row.status });
2623
+ const [msg] = await tx.select({ content: messages.content }).from(messages).where(eq(messages.id, row.messageId)).limit(1);
2624
+ if (!msg) throw new NotFoundError(`Question message "${row.messageId}" not found`);
2625
+ const parsedQuestion = questionMessageContentSchema.safeParse(msg.content);
2626
+ if (!parsedQuestion.success) throw new BadRequestError("Stored question content is malformed", { "question.parse_error": parsedQuestion.error.message.slice(0, 200) });
2627
+ const expectedKeys = new Set(parsedQuestion.data.questions.map((q) => q.question));
2628
+ for (const key of Object.keys(args.answers)) if (!expectedKeys.has(key)) throw new BadRequestError(`Answer key "${key}" does not match any question`, { "question.id": args.correlationId });
2629
+ for (const key of expectedKeys) if (!(key in args.answers)) throw new BadRequestError(`Answer missing for question "${key}"`, { "question.id": args.correlationId });
2630
+ await tx.update(pendingQuestions).set({
2631
+ status: "answered",
2632
+ answeredAt: /* @__PURE__ */ new Date()
2633
+ }).where(eq(pendingQuestions.id, args.correlationId));
2634
+ return row;
2635
+ });
2636
+ const answerContent = {
2637
+ correlationId: args.correlationId,
2638
+ answers: args.answers
2639
+ };
2640
+ questionAnswerMessageContentSchema.parse(answerContent);
2641
+ let result;
2642
+ try {
2643
+ result = await sendMessage(db, args.chatId, args.submitterAgentId, {
2644
+ format: "question_answer",
2645
+ content: answerContent,
2646
+ inReplyTo: questionRow.messageId,
2647
+ source: "hub_ui"
2648
+ });
2649
+ } catch (err) {
2650
+ log.error({
2651
+ correlationId: args.correlationId,
2652
+ chatId: args.chatId,
2653
+ err: err instanceof Error ? err.message : String(err)
2654
+ }, "sendMessage failed after status flip; reverting pending_questions row to 'pending'");
2655
+ try {
2656
+ await db.update(pendingQuestions).set({
2657
+ status: "pending",
2658
+ answeredAt: null
2659
+ }).where(and(eq(pendingQuestions.id, args.correlationId), eq(pendingQuestions.status, "answered")));
2660
+ } catch (revertErr) {
2661
+ log.error({
2662
+ correlationId: args.correlationId,
2663
+ chatId: args.chatId,
2664
+ revertErr: revertErr instanceof Error ? revertErr.message : String(revertErr)
2665
+ }, "revert UPDATE also failed; row may be stranded as 'answered' without an answer message");
2666
+ }
2667
+ throw err;
2668
+ }
2669
+ if (notifier) notifyRecipients(notifier, result.recipients, result.message.id);
2670
+ return {
2671
+ messageId: result.message.id,
2672
+ recipients: result.recipients
2673
+ };
2674
+ }
2675
+ /**
2676
+ * Mark every pending row whose chat is `chatId` as superseded. Used when a
2677
+ * chat session is archived — the agent runtime that emitted the question
2678
+ * may already be gone, so leaving the row pending would block forever.
2679
+ */
2680
+ async function markSupersededByChat(tx, chatId, reason = "chat_archived") {
2681
+ return (await tx.update(pendingQuestions).set({
2682
+ status: "superseded",
2683
+ supersededAt: /* @__PURE__ */ new Date(),
2684
+ supersededReason: reason
2685
+ }).where(and(eq(pendingQuestions.chatId, chatId), eq(pendingQuestions.status, "pending"))).returning({ id: pendingQuestions.id })).length;
2686
+ }
2687
+ /**
2688
+ * Mark every pending row owned by any of `agentIds` as superseded. Used when
2689
+ * the client carrying these agents is claimed by a new user — the previous
2690
+ * owner's runtime is detached and cannot deliver an answer back.
2691
+ */
2692
+ async function markSupersededByAgents(tx, agentIds, reason = "client_claimed") {
2693
+ if (agentIds.length === 0) return 0;
2694
+ return (await tx.update(pendingQuestions).set({
2695
+ status: "superseded",
2696
+ supersededAt: /* @__PURE__ */ new Date(),
2697
+ supersededReason: reason
2698
+ }).where(and(inArray(pendingQuestions.agentId, agentIds), eq(pendingQuestions.status, "pending"))).returning({ id: pendingQuestions.id })).length;
2699
+ }
2700
+ /** Extract a plain-text summary from a message's JSONB content field.
2701
+ * Used as the auto-title fallback in chat list rendering — see
2702
+ * `me-chat.ts:resolveChatTitle` and `admin/chats.ts:getChat`.
2703
+ *
2704
+ * - `@<name>` mention tokens are stripped before truncation: in the
2705
+ * chat-first model they're routing/audience metadata, not part of
2706
+ * the user's intent. Leaving them in produces noisy titles like
2707
+ * "@hub-agent-01 帮我重构这个文件" or "你好 @hub-agent-02 看看".
2708
+ * - Whitespace runs (including those left behind by mention removal)
2709
+ * collapse to single spaces.
2710
+ * - If the cleaned text is empty (e.g., a message that's only
2711
+ * `@hub-agent-01`), returns null so the caller falls through to
2712
+ * the participant-join fallback.
2713
+ * - Slicing is code-point-aware (`Array.from + join`) so emoji /
2714
+ * surrogate pairs aren't split into garbled half-characters. */
2715
+ function extractSummary(content, maxLen = 50) {
2716
+ let text = "";
2717
+ if (typeof content === "object" && content !== null && "text" in content) text = String(content.text ?? "");
2718
+ else if (typeof content === "string") text = content;
2719
+ if (!text) return null;
2720
+ const cleaned = stripCode(text).replace(MENTION_REGEX, "").replace(/\s+/g, " ").trim();
2721
+ if (!cleaned) return null;
2722
+ return Array.from(cleaned).slice(0, maxLen).join("");
2723
+ }
2724
+ /** List sessions for a specific agent, with optional state filters. */
2725
+ async function listAgentSessions(db, agentId, filters) {
2726
+ const conditions = [eq(agentChatSessions.agentId, agentId)];
2727
+ if (filters?.state) conditions.push(eq(agentChatSessions.state, filters.state));
2728
+ else conditions.push(ne(agentChatSessions.state, "evicted"));
2729
+ const rows = await db.select({
2730
+ agentId: agentChatSessions.agentId,
2731
+ chatId: agentChatSessions.chatId,
2732
+ state: agentChatSessions.state,
2733
+ updatedAt: agentChatSessions.updatedAt,
2734
+ chatCreatedAt: chats.createdAt,
2735
+ chatTopic: chats.topic
2736
+ }).from(agentChatSessions).innerJoin(chats, eq(agentChatSessions.chatId, chats.id)).where(and(...conditions)).orderBy(desc(agentChatSessions.updatedAt));
2737
+ const [presence] = await db.select({ runtimeState: agentPresence.runtimeState }).from(agentPresence).where(eq(agentPresence.agentId, agentId)).limit(1);
2738
+ const agentRuntimeState = presence?.runtimeState ?? null;
2739
+ if (filters?.runtimeState && agentRuntimeState !== filters.runtimeState) return [];
2740
+ const chatIds = rows.map((r) => r.chatId);
2741
+ const messageCounts = chatIds.length > 0 ? await db.select({
2742
+ chatId: inboxEntries.chatId,
2743
+ count: sql`count(*)::int`
2744
+ }).from(inboxEntries).where(and(eq(inboxEntries.inboxId, sql`(SELECT inbox_id FROM agents WHERE uuid = ${agentId})`), inArray(inboxEntries.chatId, chatIds))).groupBy(inboxEntries.chatId) : [];
2745
+ const countMap = new Map(messageCounts.map((r) => [r.chatId, r.count]));
2746
+ const firstMessages = chatIds.length > 0 ? await db.selectDistinctOn([messages.chatId], {
2747
+ chatId: messages.chatId,
2748
+ content: messages.content
2749
+ }).from(messages).where(inArray(messages.chatId, chatIds)).orderBy(messages.chatId, messages.createdAt) : [];
2750
+ const summaryMap = /* @__PURE__ */ new Map();
2751
+ for (const row of firstMessages) {
2752
+ const summary = extractSummary(row.content);
2753
+ if (summary) summaryMap.set(row.chatId, summary);
2754
+ }
2755
+ return rows.map((r) => ({
2756
+ agentId: r.agentId,
2757
+ chatId: r.chatId,
2758
+ state: r.state,
2759
+ runtimeState: agentRuntimeState,
2760
+ startedAt: r.chatCreatedAt.toISOString(),
2761
+ lastActivityAt: r.updatedAt.toISOString(),
2762
+ messageCount: countMap.get(r.chatId) ?? 0,
2763
+ summary: summaryMap.get(r.chatId) ?? null,
2764
+ topic: r.chatTopic ?? null
2765
+ }));
2766
+ }
2767
+ /** Get a single session's detail. */
2768
+ async function getSession(db, agentId, chatId) {
2769
+ const [row] = await db.select({
2770
+ agentId: agentChatSessions.agentId,
2771
+ chatId: agentChatSessions.chatId,
2772
+ state: agentChatSessions.state,
2773
+ updatedAt: agentChatSessions.updatedAt,
2774
+ chatCreatedAt: chats.createdAt,
2775
+ chatTopic: chats.topic
2776
+ }).from(agentChatSessions).innerJoin(chats, eq(agentChatSessions.chatId, chats.id)).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId))).limit(1);
2777
+ if (!row) throw new NotFoundError(`Session (${agentId}, ${chatId}) not found`);
2778
+ const [presence] = await db.select({ runtimeState: agentPresence.runtimeState }).from(agentPresence).where(eq(agentPresence.agentId, agentId)).limit(1);
2779
+ 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)));
2780
+ const firstMsg = (await db.execute(sql`SELECT content FROM messages WHERE chat_id = ${chatId} ORDER BY created_at ASC LIMIT 1`))[0];
2781
+ const summary = firstMsg ? extractSummary(firstMsg.content) : null;
2782
+ return {
2783
+ agentId: row.agentId,
2784
+ chatId: row.chatId,
2785
+ state: row.state,
2786
+ runtimeState: presence?.runtimeState ?? null,
2787
+ startedAt: row.chatCreatedAt.toISOString(),
2788
+ lastActivityAt: row.updatedAt.toISOString(),
2789
+ messageCount: countRow?.count ?? 0,
2790
+ summary,
2791
+ topic: row.chatTopic ?? null
2792
+ };
2793
+ }
2794
+ /** List all sessions across all agents, with pagination. Scoped to organization. */
2795
+ async function listAllSessions(db, limit, cursor, filters) {
2796
+ const conditions = [];
2797
+ if (filters?.state) conditions.push(eq(agentChatSessions.state, filters.state));
2798
+ else conditions.push(ne(agentChatSessions.state, "evicted"));
2799
+ if (filters?.agentId) conditions.push(eq(agentChatSessions.agentId, filters.agentId));
2800
+ if (filters?.organizationId) conditions.push(eq(agents.organizationId, filters.organizationId));
2801
+ if (cursor) conditions.push(sql`${agentChatSessions.updatedAt} < ${new Date(cursor)}`);
2802
+ const rows = await db.select({
2803
+ agentId: agentChatSessions.agentId,
2804
+ chatId: agentChatSessions.chatId,
2805
+ state: agentChatSessions.state,
2806
+ updatedAt: agentChatSessions.updatedAt,
2807
+ chatCreatedAt: chats.createdAt,
2808
+ chatTopic: chats.topic
2809
+ }).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);
2810
+ const hasMore = rows.length > limit;
2811
+ const items = hasMore ? rows.slice(0, limit) : rows;
2812
+ const agentIds = [...new Set(items.map((r) => r.agentId))];
2813
+ const presenceRows = agentIds.length > 0 ? await db.select({
2814
+ agentId: agentPresence.agentId,
2815
+ runtimeState: agentPresence.runtimeState
2816
+ }).from(agentPresence).where(inArray(agentPresence.agentId, agentIds)) : [];
2817
+ const runtimeMap = new Map(presenceRows.map((r) => [r.agentId, r.runtimeState]));
2818
+ const chatIds = [...new Set(items.map((r) => r.chatId))];
2819
+ const firstMessages = chatIds.length > 0 ? await db.selectDistinctOn([messages.chatId], {
2820
+ chatId: messages.chatId,
2821
+ content: messages.content
2822
+ }).from(messages).where(inArray(messages.chatId, chatIds)).orderBy(messages.chatId, messages.createdAt) : [];
2823
+ const summaryMap = /* @__PURE__ */ new Map();
2824
+ for (const row of firstMessages) {
2825
+ const summary = extractSummary(row.content);
2826
+ if (summary) summaryMap.set(row.chatId, summary);
2827
+ }
2828
+ const last = items[items.length - 1];
2829
+ const nextCursor = hasMore && last ? last.updatedAt.toISOString() : null;
2830
+ return {
2831
+ items: items.map((r) => ({
2832
+ agentId: r.agentId,
2833
+ chatId: r.chatId,
2834
+ state: r.state,
2835
+ runtimeState: runtimeMap.get(r.agentId) ?? null,
2836
+ startedAt: r.chatCreatedAt.toISOString(),
2837
+ lastActivityAt: r.updatedAt.toISOString(),
2838
+ messageCount: 0,
2839
+ summary: summaryMap.get(r.chatId) ?? null,
2840
+ topic: r.chatTopic ?? null
2841
+ })),
2842
+ nextCursor
2843
+ };
2844
+ }
2845
+ /** Commit `active → suspended`. No-op on suspended/evicted. Throws if row is missing. */
2846
+ async function suspendSession(db, agentId, chatId, organizationId, notifier) {
2847
+ return transitionSessionState(db, agentId, chatId, "suspended", ["active"], organizationId, notifier);
2848
+ }
2849
+ /** Commit `suspended → evicted` (terminal — listings hide it, revival defense blocks resurrection). */
2850
+ async function archiveSession(db, agentId, chatId, organizationId, notifier) {
2851
+ return transitionSessionState(db, agentId, chatId, "evicted", ["suspended"], organizationId, notifier);
2852
+ }
2853
+ async function transitionSessionState(db, agentId, chatId, target, from, organizationId, notifier) {
2854
+ const now = /* @__PURE__ */ new Date();
2855
+ let finalState = null;
2856
+ let transitioned = false;
2857
+ await db.transaction(async (tx) => {
2858
+ const [existing] = await tx.select({ state: agentChatSessions.state }).from(agentChatSessions).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId))).for("update");
2859
+ if (!existing) return;
2860
+ const current = existing.state;
2861
+ finalState = current;
2862
+ if (!from.includes(current)) return;
2863
+ await tx.update(agentChatSessions).set({
2864
+ state: target,
2865
+ updatedAt: now
2866
+ }).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId)));
2867
+ const [counts] = await tx.select({
2868
+ active: sql`count(*) FILTER (WHERE ${agentChatSessions.state} = 'active')::int`,
2869
+ total: sql`count(*) FILTER (WHERE ${agentChatSessions.state} != 'evicted')::int`
2870
+ }).from(agentChatSessions).where(eq(agentChatSessions.agentId, agentId));
2871
+ await tx.update(agentPresence).set({
2872
+ activeSessions: counts?.active ?? 0,
2873
+ totalSessions: counts?.total ?? 0,
2874
+ lastSeenAt: now
2875
+ }).where(eq(agentPresence.agentId, agentId));
2876
+ if (target === "evicted") await markSupersededByChat(tx, chatId, "chat_archived");
2877
+ finalState = target;
2878
+ transitioned = true;
2879
+ });
2880
+ if (finalState === null) throw new NotFoundError(`Session (${agentId}, ${chatId}) not found`);
2881
+ if (transitioned && notifier) notifier.notifySessionStateChange(agentId, chatId, target, organizationId).catch(() => {});
2882
+ return {
2883
+ state: finalState,
2884
+ transitioned
2885
+ };
2886
+ }
2887
+ /**
2888
+ * Filter sessions to only those where the given agent is also a participant in the chat.
2889
+ * Used when a non-manager views sessions of an org-visible agent — they should only see
2890
+ * sessions for chats they participate in.
2891
+ */
2892
+ async function filterSessionsByParticipant(db, sessions, participantAgentId) {
2893
+ if (sessions.length === 0) return [];
2894
+ const chatIds = sessions.map((s) => s.chatId);
2895
+ const participantRows = await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(inArray(chatMembership.chatId, chatIds), eq(chatMembership.agentId, participantAgentId), eq(chatMembership.accessMode, "speaker")));
2896
+ const allowedChatIds = new Set(participantRows.map((r) => r.chatId));
2897
+ return sessions.filter((s) => allowedChatIds.has(s.chatId));
2898
+ }
2899
+ /**
2900
+ * Member-facing chat service backing `/me/chats*` endpoints (chat-first
2901
+ * workspace).
2902
+ *
2903
+ * Responsibilities:
2904
+ * - Cursor-paginated conversation list (single-stream JOIN over the
2905
+ * unified `chat_membership` + `chat_user_state` tables).
2906
+ * - Create a new chat (no dedupe, runs `recomputeChatWatchers` after).
2907
+ * - Add participants (idempotent, UPSERT into `chat_membership`,
2908
+ * runs `recomputeChatWatchers` after).
2909
+ * - Mark-read (UPSERT into `chat_user_state`).
2910
+ * - Join → watcher to speaker (delegates to `watcher.ts`).
2911
+ * - Leave → speaker to watcher or detach (delegates to `watcher.ts`).
2912
+ *
2913
+ * See proposals/chat-data-model-restructure.20260512.md §8 (schema)
2914
+ * and §11.1 (per-route mapping).
2915
+ */
2916
+ function encodeCursor(lastMessageAt, chatId) {
2917
+ const payload = `${lastMessageAt ? lastMessageAt.toISOString() : ""}|${chatId}`;
2918
+ return Buffer.from(payload, "utf8").toString("base64url");
2919
+ }
2920
+ function decodeCursor(cursor) {
2921
+ try {
2922
+ const decoded = Buffer.from(cursor, "base64url").toString("utf8");
2923
+ const sep = decoded.indexOf("|");
2924
+ if (sep < 0) return null;
2925
+ const tsPart = decoded.slice(0, sep);
2926
+ const chatId = decoded.slice(sep + 1);
2927
+ if (!chatId) return null;
2928
+ const lastMessageAt = tsPart.length > 0 ? new Date(tsPart) : null;
2929
+ if (lastMessageAt && Number.isNaN(lastMessageAt.getTime())) return null;
2930
+ return {
2931
+ lastMessageAt,
2932
+ chatId
2933
+ };
2934
+ } catch {
2935
+ return null;
2936
+ }
2937
+ }
2938
+ const { ACTIVE, ARCHIVED, DELETED } = CHAT_ENGAGEMENT_STATUSES;
2939
+ /**
2940
+ * SQL predicate for each engagement view tab. `deleted` is never a valid view
2941
+ * value — deleted rows are reachable only through `GET /chats/:chatId` + the
2942
+ * Restore banner on the chat detail page.
2943
+ */
2944
+ const ENGAGEMENT_VIEW_PREDICATE = {
2945
+ active: sql`COALESCE(cus.engagement_status, ${ACTIVE}) = ${ACTIVE}`,
2946
+ archived: sql`COALESCE(cus.engagement_status, ${ACTIVE}) = ${ARCHIVED}`,
2947
+ all: sql`COALESCE(cus.engagement_status, ${ACTIVE}) IN (${ACTIVE}, ${ARCHIVED})`
2948
+ };
2949
+ /**
2950
+ * Write the caller's engagement state for this chat. UPSERT into
2951
+ * `chat_user_state` — the row may not yet exist (the user might not have
2952
+ * marked-read or been @-mentioned), so an INSERT with the engagement value
2953
+ * is the first write; subsequent transitions are UPDATEs.
2954
+ *
2955
+ * Idempotent. Mirrors the UPSERT shape used by `markMeChatRead`.
2956
+ */
2957
+ async function setChatEngagement(db, chatId, agentId, status) {
2958
+ await db.insert(chatUserState).values({
2959
+ chatId,
2960
+ agentId,
2961
+ unreadMentionCount: 0,
2962
+ engagementStatus: status
2963
+ }).onConflictDoUpdate({
2964
+ target: [chatUserState.chatId, chatUserState.agentId],
2965
+ set: { engagementStatus: status }
2966
+ });
2967
+ }
2968
+ /**
2969
+ * Read the caller's engagement state. Returns `'active'` when no
2970
+ * `chat_user_state` row exists yet (lazy-materialised; matches the SQL
2971
+ * `COALESCE(..., 'active')` used elsewhere).
2972
+ */
2973
+ async function getCallerEngagement(db, chatId, agentId) {
2974
+ const [row] = await db.select({ engagementStatus: chatUserState.engagementStatus }).from(chatUserState).where(and(eq(chatUserState.chatId, chatId), eq(chatUserState.agentId, agentId))).limit(1);
2975
+ return row?.engagementStatus ?? ACTIVE;
2976
+ }
2977
+ const KNOWN_NON_MANUAL_PREDICATE = sql`(
2978
+ (c.metadata->>'source' = 'github' AND c.metadata->>'entityType' IN (${sql.join(GITHUB_ENTITY_TYPES.map((t) => sql`${t}`), sql.raw(", "))}))
2979
+ OR c.metadata->>'source' = 'feishu'
2980
+ )`;
2981
+ const chatSourceSqlExpression = sql`CASE
2982
+ ${sql.join(GITHUB_ENTITY_TYPES.map((t) => sql`WHEN c.metadata->>'source' = 'github' AND c.metadata->>'entityType' = ${t} THEN ${`github_${t}`}`), sql.raw("\n "))}
2983
+ WHEN c.metadata->>'source' = 'feishu' THEN 'feishu'
2984
+ ELSE 'manual'
2985
+ END`;
2986
+ function sourceFilterSql(source) {
2987
+ switch (source) {
2988
+ case "manual": return sql`(${KNOWN_NON_MANUAL_PREDICATE}) IS NOT TRUE`;
2989
+ case "github_issue": return sql`(c.metadata->>'source' = 'github' AND c.metadata->>'entityType' = 'issue')`;
2990
+ case "github_pull_request": return sql`(c.metadata->>'source' = 'github' AND c.metadata->>'entityType' = 'pull_request')`;
2991
+ case "github_discussion": return sql`(c.metadata->>'source' = 'github' AND c.metadata->>'entityType' = 'discussion')`;
2992
+ case "github_commit": return sql`(c.metadata->>'source' = 'github' AND c.metadata->>'entityType' = 'commit')`;
2993
+ case "feishu": return sql`(c.metadata->>'source' = 'feishu')`;
2994
+ }
2995
+ }
2996
+ /**
2997
+ * GET /me/chats — cursor-paginated conversation list.
2998
+ *
2999
+ * SQL strategy:
3000
+ * - Single-stream query: `chats JOIN chat_membership LEFT JOIN
3001
+ * chat_user_state`. The membership row carries access_mode
3002
+ * (speaker → "participant" / watcher → "watching"); the user
3003
+ * state row supplies the unread counter (COALESCE → 0 when
3004
+ * row is missing).
3005
+ * - Filter `parent_chat_id IS NULL` (nested chats not surfaced in v1).
3006
+ * - Filter `c.organization_id = ?` to defend against historical
3007
+ * cross-org pollution rows that may still reference the caller
3008
+ * (see fix/cross-org-direct-chat-pollution).
3009
+ * - Sort `(last_message_at DESC NULLS LAST, chat_id DESC)`.
3010
+ * - Cursor narrows the result to rows STRICTLY before the cursor.
3011
+ * - Followed by a participants-list lookup for the page only.
3012
+ */
3013
+ async function listMeChats(db, humanAgentId, organizationId, query) {
3014
+ const limit = query.limit;
3015
+ const cursor = query.cursor ? decodeCursor(query.cursor) : null;
3016
+ if (query.cursor && !cursor) throw new BadRequestError("Invalid cursor");
3017
+ const filterUnreadOnly = query.filter === "unread";
3018
+ const filterWatchingOnly = query.filter === "watching";
3019
+ const engagementPredicate = ENGAGEMENT_VIEW_PREDICATE[query.engagement];
3020
+ const sourcePredicate = query.source ? sourceFilterSql(query.source) : sql`TRUE`;
3021
+ const cursorTsIso = cursor?.lastMessageAt ? cursor.lastMessageAt.toISOString() : null;
3022
+ 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
3023
+ OR c.last_message_at < ${cursorTsIso}::timestamptz
3024
+ OR (c.last_message_at = ${cursorTsIso}::timestamptz AND c.id < ${cursor.chatId}))`;
3025
+ const rawRows = await db.execute(sql`
3026
+ SELECT
3027
+ c.id AS chat_id,
3028
+ c.type AS type,
3029
+ c.topic AS topic,
3030
+ c.parent_chat_id AS parent_chat_id,
3031
+ c.last_message_at AS last_message_at,
3032
+ c.last_message_preview AS last_message_preview,
3033
+ (SELECT count(*) FROM chat_membership
3034
+ WHERE chat_id = c.id AND access_mode = 'speaker') AS participant_count,
3035
+ cm.access_mode AS access_mode,
3036
+ COALESCE(cus.unread_mention_count, 0) AS unread_mention_count,
3037
+ COALESCE(cus.engagement_status, ${ACTIVE}) AS engagement_status
3038
+ FROM chats c
3039
+ JOIN chat_membership cm
3040
+ ON cm.chat_id = c.id AND cm.agent_id = ${humanAgentId}
3041
+ LEFT JOIN chat_user_state cus
3042
+ ON cus.chat_id = c.id AND cus.agent_id = ${humanAgentId}
3043
+ WHERE c.parent_chat_id IS NULL
3044
+ /* Scope to the caller's org. Without this, cross-org dirty
3045
+ chats whose chat_membership still references the caller's
3046
+ human agent (historical pollution — see
3047
+ fix/cross-org-direct-chat-pollution) would leak into the
3048
+ list and 404 on click via requireChatAccess. */
3049
+ AND c.organization_id = ${organizationId}
3050
+ AND (${!filterUnreadOnly}::bool OR COALESCE(cus.unread_mention_count, 0) > 0)
3051
+ AND (${!filterWatchingOnly}::bool OR cm.access_mode = 'watcher')
3052
+ AND ${engagementPredicate}
3053
+ AND ${sourcePredicate}
3054
+ AND ${cursorPredicate}
3055
+ ORDER BY c.last_message_at DESC NULLS LAST, c.id DESC
3056
+ LIMIT ${limit + 1}
3057
+ `);
3058
+ const toDate = (v) => {
3059
+ if (v === null) return null;
3060
+ return v instanceof Date ? v : new Date(v);
3061
+ };
3062
+ const hasMore = rawRows.length > limit;
3063
+ const pageRaw = hasMore ? rawRows.slice(0, limit) : rawRows;
3064
+ const last = pageRaw[pageRaw.length - 1];
3065
+ const nextCursor = hasMore && last ? encodeCursor(toDate(last.last_message_at), last.chat_id) : null;
3066
+ if (pageRaw.length === 0) return {
3067
+ rows: [],
3068
+ nextCursor: null
3069
+ };
3070
+ const chatIds = pageRaw.map((r) => r.chat_id);
3071
+ const participantRows = await db.select({
3072
+ chatId: chatMembership.chatId,
3073
+ agentId: chatMembership.agentId,
3074
+ displayName: agents.displayName,
3075
+ type: agents.type,
3076
+ avatarColorToken: agents.avatarColorToken,
3077
+ avatarImageUpdatedAt: agents.avatarImageUpdatedAt,
3078
+ sessionState: agentChatSessions.state
3079
+ }).from(chatMembership).innerJoin(agents, eq(chatMembership.agentId, agents.uuid)).leftJoin(agentChatSessions, and(eq(agentChatSessions.agentId, chatMembership.agentId), eq(agentChatSessions.chatId, chatMembership.chatId))).where(and(inArray(chatMembership.chatId, chatIds), eq(chatMembership.accessMode, "speaker")));
3080
+ const participantsByChat = /* @__PURE__ */ new Map();
3081
+ const engagedByChat = /* @__PURE__ */ new Map();
3082
+ for (const p of participantRows) {
3083
+ const list = participantsByChat.get(p.chatId) ?? [];
3084
+ list.push({
3085
+ agentId: p.agentId,
3086
+ displayName: p.displayName,
3087
+ type: p.type,
3088
+ avatarColorToken: p.avatarColorToken,
3089
+ avatarImageUrl: agentAvatarImageUrl(p.agentId, p.avatarImageUpdatedAt)
3090
+ });
3091
+ participantsByChat.set(p.chatId, list);
3092
+ if (p.sessionState === "active") {
3093
+ const engaged = engagedByChat.get(p.chatId) ?? [];
3094
+ engaged.push(p.agentId);
3095
+ engagedByChat.set(p.chatId, engaged);
3096
+ }
3097
+ }
3098
+ const liveActivityByChat = await deriveLiveActivity(db, chatIds);
3099
+ const firstMessageRows = chatIds.length > 0 ? await db.selectDistinctOn([messages.chatId], {
3100
+ chatId: messages.chatId,
3101
+ content: messages.content
3102
+ }).from(messages).where(inArray(messages.chatId, chatIds)).orderBy(messages.chatId, messages.createdAt) : [];
3103
+ const firstMessageSummary = /* @__PURE__ */ new Map();
3104
+ for (const row of firstMessageRows) {
3105
+ const s = extractSummary(row.content);
3106
+ if (s) firstMessageSummary.set(row.chatId, s);
3107
+ }
3108
+ return {
3109
+ rows: pageRaw.map((r) => {
3110
+ const participants = participantsByChat.get(r.chat_id) ?? [];
3111
+ const title = resolveChatTitle(r.topic, firstMessageSummary.get(r.chat_id) ?? null, participants, humanAgentId);
3112
+ const isSpeaker = r.access_mode === "speaker";
3113
+ return {
3114
+ chatId: r.chat_id,
3115
+ type: r.type,
3116
+ membershipKind: isSpeaker ? "participant" : "watching",
3117
+ title,
3118
+ topic: r.topic,
3119
+ participants,
3120
+ participantCount: Number(r.participant_count),
3121
+ lastMessageAt: toDate(r.last_message_at)?.toISOString() ?? null,
3122
+ lastMessagePreview: r.last_message_preview,
3123
+ unreadMentionCount: r.unread_mention_count,
3124
+ canReply: isSpeaker,
3125
+ engagementStatus: r.engagement_status,
3126
+ engagedAgentIds: engagedByChat.get(r.chat_id) ?? [],
3127
+ liveActivity: liveActivityByChat.get(r.chat_id) ?? null
3128
+ };
3129
+ }),
3130
+ nextCursor
3131
+ };
3132
+ }
3133
+ /**
3134
+ * Per-chat live activity, derived from the most recent `session_events` row.
3135
+ *
3136
+ * Returns a chatId → LiveActivity map; chats with no activity (or where the
3137
+ * latest event is terminal / stale) are absent from the map (caller treats
3138
+ * absence as null).
3139
+ */
3140
+ async function deriveLiveActivity(db, chatIds) {
3141
+ if (chatIds.length === 0) return /* @__PURE__ */ new Map();
3142
+ const chatIdInClause = sql.join(chatIds.map((id) => sql`${id}`), sql`, `);
3143
+ const rows = (await db.execute(sql`
3144
+ SELECT acs.agent_id AS agent_id,
3145
+ acs.chat_id AS chat_id,
3146
+ e.kind AS kind,
3147
+ e.payload AS payload,
3148
+ e.created_at AS created_at
3149
+ FROM agent_chat_sessions acs
3150
+ CROSS JOIN LATERAL (
3151
+ SELECT kind, payload, created_at, seq
3152
+ FROM session_events se
3153
+ WHERE se.agent_id = acs.agent_id
3154
+ AND se.chat_id = acs.chat_id
3155
+ ORDER BY se.seq DESC
3156
+ LIMIT 1
3157
+ ) e
3158
+ WHERE acs.chat_id IN (${chatIdInClause})
3159
+ AND acs.state <> 'evicted'
3160
+ `)).map((r) => ({
3161
+ agent_id: r.agent_id,
3162
+ chat_id: r.chat_id,
3163
+ kind: r.kind,
3164
+ payload: r.payload,
3165
+ created_at: r.created_at
3166
+ }));
3167
+ const now = Date.now();
3168
+ const byChat = /* @__PURE__ */ new Map();
3169
+ for (const row of rows) {
3170
+ const activity = toLiveActivity(row);
3171
+ if (!activity) continue;
3172
+ const createdAtMs = new Date(row.created_at).getTime();
3173
+ if (now - createdAtMs > 6e4) continue;
3174
+ const existing = byChat.get(row.chat_id);
3175
+ if (!existing || createdAtMs > existing.createdAtMs) byChat.set(row.chat_id, {
3176
+ activity,
3177
+ createdAtMs
3178
+ });
3179
+ }
3180
+ const out = /* @__PURE__ */ new Map();
3181
+ for (const [chatId, { activity }] of byChat) out.set(chatId, activity);
3182
+ return out;
3183
+ }
3184
+ /**
3185
+ * Translate a `session_events` row into a `LiveActivity`, or null when the
3186
+ * kind is terminal (`turn_end` / `error`) or unrecognised. Pure & exported
3187
+ * for unit testing.
3188
+ */
3189
+ function toLiveActivity(row) {
3190
+ const startedAt = new Date(row.created_at).toISOString();
3191
+ switch (row.kind) {
3192
+ case "tool_call": {
3193
+ const payload = row.payload ?? {};
3194
+ const label = typeof payload.name === "string" && payload.name.length > 0 ? payload.name : "Tool";
3195
+ return {
3196
+ agentId: row.agent_id,
3197
+ kind: "tool_call",
3198
+ label,
3199
+ startedAt
3200
+ };
3201
+ }
3202
+ case "thinking": return {
3203
+ agentId: row.agent_id,
3204
+ kind: "thinking",
3205
+ label: "Thinking",
3206
+ startedAt
3207
+ };
3208
+ case "assistant_text": return {
3209
+ agentId: row.agent_id,
3210
+ kind: "assistant_text",
3211
+ label: "Writing",
3212
+ startedAt
3213
+ };
3214
+ default: return null;
3215
+ }
3216
+ }
3217
+ /**
3218
+ * Title resolution priority:
3219
+ *
3220
+ * 1. `chat.topic` (manual, set via `PATCH /chats/:chatId`)
3221
+ * 2. First message summary (auto, ≤ 50 chars from `extractSummary`)
3222
+ * 3. Participant join (fallback when chat has no messages yet)
3223
+ */
3224
+ function resolveChatTitle(topic, firstMessageSummary, participants, selfAgentId) {
3225
+ if (topic && topic.length > 0) return topic;
3226
+ if (firstMessageSummary && firstMessageSummary.length > 0) return firstMessageSummary;
3227
+ const others = participants.filter((p) => p.agentId !== selfAgentId);
3228
+ if (others.length === 0) return "Empty chat";
3229
+ if (others.length <= 3) return others.map((p) => p.displayName).join(", ");
3230
+ return `${others[0]?.displayName}, ${others[1]?.displayName} +${others.length - 2}`;
3231
+ }
3232
+ async function createMeChat(db, humanAgentId, organizationId, body) {
3233
+ const distinctIds = [...new Set(body.participantIds)].filter((id) => id !== humanAgentId);
3234
+ if (distinctIds.length === 0) throw new BadRequestError("At least one non-self participant required");
3235
+ const allIds = [humanAgentId, ...distinctIds];
3236
+ const found = await db.select({
3237
+ uuid: agents.uuid,
3238
+ organizationId: agents.organizationId,
3239
+ type: agents.type,
3240
+ visibility: agents.visibility,
3241
+ managerId: agents.managerId
3242
+ }).from(agents).where(inArray(agents.uuid, allIds));
3243
+ if (found.length !== allIds.length) {
3244
+ const foundSet = new Set(found.map((a) => a.uuid));
3245
+ throw new BadRequestError(`Agents not found: ${allIds.filter((id) => !foundSet.has(id)).join(", ")}`);
3246
+ }
3247
+ const crossOrg = found.filter((a) => a.organizationId !== organizationId);
3248
+ if (crossOrg.length > 0) throw new BadRequestError(`Cross-organization chat not allowed: ${crossOrg.map((a) => a.uuid).join(", ")}`);
3249
+ const caller = found.find((a) => a.uuid === humanAgentId);
3250
+ if (!caller) throw new BadRequestError("Caller agent not found in the chat's organization");
3251
+ const privateNotOwned = found.filter((a) => a.uuid !== humanAgentId && a.visibility === AGENT_VISIBILITY.PRIVATE && a.managerId !== caller.managerId);
3252
+ if (privateNotOwned.length > 0) throw new ForbiddenError(`Only the owner can add a private agent to a chat: ${privateNotOwned.map((a) => a.uuid).join(", ")}`);
3253
+ const chatType = distinctIds.length === 1 ? "direct" : "group";
3254
+ const chatId = randomUUID();
3255
+ const topic = body.topic ?? null;
3256
+ await db.transaction(async (tx) => {
3257
+ await tx.insert(chats).values({
3258
+ id: chatId,
3259
+ organizationId,
3260
+ type: chatType,
3261
+ topic
3262
+ });
3263
+ await addChatParticipants(tx, chatId, allIds.map((agentId) => ({
3264
+ agentId,
3265
+ role: agentId === humanAgentId ? "owner" : "member"
3266
+ })));
3267
+ await recomputeChatWatchers(tx, chatId);
3268
+ });
3269
+ invalidateChatAudience(chatId);
3270
+ return { chatId };
3271
+ }
3272
+ async function addMeChatParticipants(db, chatId, callerHumanAgentId, callerOrganizationId, body) {
3273
+ const distinct = [...new Set(body.participantIds)];
3274
+ if (distinct.length === 0) throw new BadRequestError("At least one participant required");
3275
+ const [chat] = await db.select({
3276
+ id: chats.id,
3277
+ organizationId: chats.organizationId,
3278
+ type: chats.type
3279
+ }).from(chats).where(eq(chats.id, chatId)).limit(1);
3280
+ if (!chat) throw new NotFoundError(`Chat "${chatId}" not found`);
3281
+ if (chat.organizationId !== callerOrganizationId) throw new NotFoundError(`Chat "${chatId}" not found`);
3282
+ const [callerRow] = await db.select({ ownerMemberId: agents.managerId }).from(chatMembership).innerJoin(agents, eq(agents.uuid, chatMembership.agentId)).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, callerHumanAgentId), eq(chatMembership.accessMode, "speaker"))).limit(1);
3283
+ if (!callerRow) throw new NotFoundError(`Chat "${chatId}" not found`);
3284
+ const callerMemberId = callerRow.ownerMemberId;
3285
+ const found = await db.select({
3286
+ uuid: agents.uuid,
3287
+ organizationId: agents.organizationId,
3288
+ type: agents.type,
3289
+ visibility: agents.visibility,
3290
+ managerId: agents.managerId
3291
+ }).from(agents).where(inArray(agents.uuid, distinct));
3292
+ if (found.length !== distinct.length) {
3293
+ const foundSet = new Set(found.map((a) => a.uuid));
3294
+ throw new BadRequestError(`Agents not found: ${distinct.filter((id) => !foundSet.has(id)).join(", ")}`);
3295
+ }
3296
+ const crossOrg = found.filter((a) => a.organizationId !== chat.organizationId);
3297
+ if (crossOrg.length > 0) throw new BadRequestError(`Cross-organization participant rejected: ${crossOrg.map((a) => a.uuid).join(", ")}`);
3298
+ const privateNotOwned = found.filter((a) => a.visibility === AGENT_VISIBILITY.PRIVATE && a.managerId !== callerMemberId);
3299
+ if (privateNotOwned.length > 0) throw new ForbiddenError(`Only the owner can add a private agent to a chat: ${privateNotOwned.map((a) => a.uuid).join(", ")}`);
3300
+ await db.transaction(async (tx) => {
3301
+ const existingSpeakers = await tx.select({ agentId: chatMembership.agentId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
3302
+ const existingSpeakerSet = new Set(existingSpeakers.map((e) => e.agentId));
3303
+ const toUpsert = distinct.filter((id) => !existingSpeakerSet.has(id));
3304
+ if (toUpsert.length === 0) {
3305
+ await recomputeChatWatchers(tx, chatId);
3306
+ return;
3307
+ }
3308
+ if (existingSpeakers.length + toUpsert.length >= 3 && chat.type === "direct") await changeChatType(tx, chatId, "group");
3309
+ await addChatParticipants(tx, chatId, toUpsert.map((agentId) => ({
3310
+ agentId,
3311
+ role: "member"
3312
+ })), { upgradeWatcherToSpeaker: true });
3313
+ await recomputeChatWatchers(tx, chatId);
3314
+ });
3315
+ invalidateChatAudience(chatId);
3316
+ }
3317
+ async function markMeChatRead(db, chatId, humanAgentId) {
3318
+ const now = /* @__PURE__ */ new Date();
3319
+ await db.insert(chatUserState).values({
3320
+ chatId,
3321
+ agentId: humanAgentId,
3322
+ lastReadAt: now,
3323
+ unreadMentionCount: 0
3324
+ }).onConflictDoUpdate({
3325
+ target: [chatUserState.chatId, chatUserState.agentId],
3326
+ set: {
3327
+ lastReadAt: now,
3328
+ unreadMentionCount: 0
3329
+ }
3330
+ });
3331
+ return {
3332
+ chatId,
3333
+ lastReadAt: now.toISOString(),
3334
+ unreadMentionCount: 0
3335
+ };
3336
+ }
3337
+ /**
3338
+ * Bump `unread_mention_count` to at least 1 so the chat shows up as unread
3339
+ * in the conversation list (and is matched by `?filter=unread`). Idempotent:
3340
+ * if the row already has a positive count, it stays as-is. `last_read_at`
3341
+ * is intentionally untouched — this is a UI affordance, not a "rewind the
3342
+ * read cursor" operation.
3343
+ *
3344
+ * Contract note — semantic overload: the column is named `unread_mention_count`
3345
+ * but is co-opted here as a generic "manual unread" flag. Every existing
3346
+ * consumer (conversation list bold styling, `?filter=unread`, source-counts,
3347
+ * the bell badge) only checks `> 0`, so the exact value carries no meaning
3348
+ * for callers. If a future feature ever renders the literal mention count
3349
+ * (e.g. a "N mentions" pill), it must NOT read this column directly — it
3350
+ * needs a separate mention-only counter, otherwise a manually-marked-unread
3351
+ * chat would show a fictitious "1 mention".
3352
+ */
3353
+ async function markMeChatUnread(db, chatId, humanAgentId) {
3354
+ await db.insert(chatUserState).values({
3355
+ chatId,
3356
+ agentId: humanAgentId,
3357
+ unreadMentionCount: 1
3358
+ }).onConflictDoUpdate({
3359
+ target: [chatUserState.chatId, chatUserState.agentId],
3360
+ set: { unreadMentionCount: sql`GREATEST(${chatUserState.unreadMentionCount}, 1)` }
3361
+ });
3362
+ const [row] = await db.select({ unreadMentionCount: chatUserState.unreadMentionCount }).from(chatUserState).where(and(eq(chatUserState.chatId, chatId), eq(chatUserState.agentId, humanAgentId))).limit(1);
3363
+ return {
3364
+ chatId,
3365
+ unreadMentionCount: row?.unreadMentionCount ?? 1
3366
+ };
3367
+ }
3368
+ async function joinMeChat(db, chatId, humanAgentId) {
3369
+ ensureCanJoin(await resolveChatMembership(db, chatId, humanAgentId));
3370
+ await joinAsParticipant(db, chatId, humanAgentId);
3371
+ invalidateChatAudience(chatId);
3372
+ }
3373
+ async function leaveMeChat(db, chatId, humanAgentId) {
3374
+ const result = await leaveAsParticipant(db, chatId, humanAgentId);
3375
+ invalidateChatAudience(chatId);
3376
+ return result;
3377
+ }
3378
+ /**
3379
+ * Used by future bell-badge / list-pill counts. The partial index
3380
+ * `idx_user_state_unread WHERE unread_mention_count > 0` bounds the
3381
+ * driving scan; we then join `chat_membership` + `chats` so the badge
3382
+ * stays consistent with `listMeChats`.
3383
+ *
3384
+ * Why the joins (not just a single-table count): per §11.4 a user's
3385
+ * `chat_user_state` row is **preserved on detach** so read state
3386
+ * survives a leave/rejoin cycle. Without the membership join, any
3387
+ * preserved row with `unread_mention_count > 0` would keep
3388
+ * contributing to the badge even though the chat no longer appears in
3389
+ * the list. The `chats` join applies the same org-scoping +
3390
+ * `parent_chat_id IS NULL` filter as `listMeChats` so the two counts
3391
+ * cannot drift in the cross-org pollution or nested-chat cases either.
3392
+ *
3393
+ * Engagement parity: deleted chats are excluded from `listMeChats`
3394
+ * (any `engagement` view), so the badge must exclude them too — otherwise
3395
+ * the user sees an unread red dot for a chat they've removed from view.
3396
+ */
3397
+ /**
3398
+ * Per-source aggregate for the conversation-list tag bar.
3399
+ *
3400
+ * Returns one row per source the caller has at least one chat for, plus an
3401
+ * always-present `manual` entry (zero counts when there are no manual chats —
3402
+ * the workspace UI uses `manual` as its default tab and must render it even
3403
+ * when empty).
3404
+ *
3405
+ * Filtering matches `listMeChats` for the corresponding tab so the badges
3406
+ * cannot drift from the list: same membership join, same `parent_chat_id IS
3407
+ * NULL` and `organization_id` scopes, same engagement view, same
3408
+ * `chat_user_state.unread_mention_count` source.
3409
+ */
3410
+ async function listMeChatSourceCounts(db, humanAgentId, organizationId, query) {
3411
+ const engagementPredicate = ENGAGEMENT_VIEW_PREDICATE[query.engagement];
3412
+ const rows = await db.execute(sql`
3413
+ SELECT
3414
+ ${chatSourceSqlExpression} AS source,
3415
+ count(*)::int AS chat_count,
3416
+ count(*) FILTER (WHERE COALESCE(cus.unread_mention_count, 0) > 0)::int AS unread_chat_count
3417
+ FROM chats c
3418
+ JOIN chat_membership cm
3419
+ ON cm.chat_id = c.id AND cm.agent_id = ${humanAgentId}
3420
+ LEFT JOIN chat_user_state cus
3421
+ ON cus.chat_id = c.id AND cus.agent_id = ${humanAgentId}
3422
+ WHERE c.parent_chat_id IS NULL
3423
+ AND c.organization_id = ${organizationId}
3424
+ AND ${engagementPredicate}
3425
+ GROUP BY 1
3426
+ `);
3427
+ const counts = {};
3428
+ for (const row of rows) counts[row.source] = {
3429
+ chatCount: Number(row.chat_count),
3430
+ unreadChatCount: Number(row.unread_chat_count)
3431
+ };
3432
+ if (!counts.manual) counts.manual = {
3433
+ chatCount: 0,
3434
+ unreadChatCount: 0
3435
+ };
3436
+ return { counts };
3437
+ }
3438
+ async function createChat(db, creatorId, data) {
3439
+ const chatId = randomUUID();
3440
+ const allParticipantIds = new Set([creatorId, ...data.participantIds]);
3441
+ const existingAgents = await db.select({
3442
+ id: agents.uuid,
3443
+ organizationId: agents.organizationId,
3444
+ type: agents.type,
3445
+ visibility: agents.visibility,
3446
+ managerId: agents.managerId
3447
+ }).from(agents).where(inArray(agents.uuid, [...allParticipantIds]));
3448
+ if (existingAgents.length !== allParticipantIds.size) {
3449
+ const found = new Set(existingAgents.map((a) => a.id));
3450
+ throw new BadRequestError(`Agents not found: ${[...allParticipantIds].filter((id) => !found.has(id)).join(", ")}`);
3451
+ }
3452
+ const creator = existingAgents.find((a) => a.id === creatorId);
3453
+ if (!creator) throw new Error("Unexpected: creator not in existingAgents");
3454
+ const orgId = creator.organizationId;
3455
+ const crossOrg = existingAgents.filter((a) => a.organizationId !== orgId);
3456
+ if (crossOrg.length > 0) throw new BadRequestError(`Cross-organization chat not allowed: ${crossOrg.map((a) => a.id).join(", ")}`);
3457
+ const privateNotOwned = existingAgents.filter((a) => a.id !== creatorId && a.visibility === AGENT_VISIBILITY.PRIVATE && a.managerId !== creator.managerId);
3458
+ if (privateNotOwned.length > 0) throw new ForbiddenError(`Only the owner can add a private agent to a chat: ${privateNotOwned.map((a) => a.id).join(", ")}`);
3459
+ return db.transaction(async (tx) => {
3460
+ const [chat] = await tx.insert(chats).values({
3461
+ id: chatId,
3462
+ organizationId: orgId,
3463
+ type: data.type,
3464
+ topic: data.topic ?? null,
3465
+ metadata: data.metadata ?? {}
3466
+ }).returning();
3467
+ await addChatParticipants(tx, chatId, [...allParticipantIds].map((agentId) => ({
3468
+ agentId,
3469
+ role: agentId === creatorId ? "owner" : "member"
3470
+ })));
3471
+ await recomputeChatWatchers(tx, chatId);
3472
+ const participants = await tx.select().from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
3473
+ if (!chat) throw new Error("Unexpected: INSERT RETURNING produced no row");
3474
+ return {
3475
+ ...chat,
3476
+ participants
3477
+ };
3478
+ });
3479
+ }
3480
+ async function getChat(db, chatId) {
3481
+ const [chat] = await db.select().from(chats).where(eq(chats.id, chatId)).limit(1);
3482
+ if (!chat) throw new NotFoundError(`Chat "${chatId}" not found`);
3483
+ return chat;
3484
+ }
3485
+ /**
3486
+ * Read a chat row + speaker participants + server-resolved display
3487
+ * metadata (`title`, `firstMessagePreview`) so the agent route can return
3488
+ * a payload that matches the wire `chatDetailSchema` contract.
3489
+ *
3490
+ * `selfAgentId` only affects the participant-join fallback in
3491
+ * `resolveChatTitle` (e.g. `"alice, bob"` excluding self when topic + first
3492
+ * message are both empty). Callers that don't have a self agent (admin
3493
+ * paths) can pass `null` — the fallback degrades to "all displayNames".
3494
+ */
3495
+ async function getChatDetail(db, chatId, selfAgentId = null) {
3496
+ const chat = await getChat(db, chatId);
3497
+ const participantRows = await db.select({
3498
+ agentId: chatMembership.agentId,
3499
+ role: chatMembership.role,
3500
+ mode: chatMembership.mode,
3501
+ joinedAt: chatMembership.joinedAt,
3502
+ name: agents.name,
3503
+ displayName: agents.displayName,
3504
+ type: agents.type
3505
+ }).from(chatMembership).innerJoin(agents, eq(chatMembership.agentId, agents.uuid)).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
3506
+ const [firstMessageRow] = await db.select({ content: messages.content }).from(messages).where(eq(messages.chatId, chatId)).orderBy(messages.createdAt, messages.id).limit(1);
3507
+ const firstMessagePreview = firstMessageRow ? extractSummary(firstMessageRow.content) : null;
3508
+ const title = resolveChatTitle(chat.topic, firstMessagePreview, participantRows, selfAgentId ?? "");
3509
+ const participants = participantRows.map((p) => ({
3510
+ chatId,
3511
+ agentId: p.agentId,
3512
+ role: p.role,
3513
+ mode: p.mode,
3514
+ joinedAt: p.joinedAt,
3515
+ name: p.name,
3516
+ displayName: p.displayName,
3517
+ type: p.type
3518
+ }));
3519
+ return {
3520
+ ...chat,
3521
+ participants,
3522
+ title,
3523
+ firstMessagePreview
3524
+ };
3525
+ }
3526
+ async function listChats(db, agentId, limit, cursor) {
3527
+ const chatIds = (await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(eq(chatMembership.agentId, agentId), eq(chatMembership.accessMode, "speaker")))).map((r) => r.chatId);
3528
+ if (chatIds.length === 0) return {
3529
+ items: [],
3530
+ nextCursor: null
3531
+ };
3532
+ const where = cursor ? and(inArray(chats.id, chatIds), lt(chats.updatedAt, new Date(cursor))) : inArray(chats.id, chatIds);
3533
+ const rows = await db.select().from(chats).where(where).orderBy(desc(chats.updatedAt)).limit(limit + 1);
3534
+ const hasMore = rows.length > limit;
3535
+ const items = hasMore ? rows.slice(0, limit) : rows;
3536
+ const last = items[items.length - 1];
3537
+ return {
3538
+ items,
3539
+ nextCursor: hasMore && last ? last.updatedAt.toISOString() : null
3540
+ };
3541
+ }
3542
+ /**
3543
+ * List participants of a chat with their agent names — used by the client
3544
+ * runtime to resolve `@<name>` mentions against the authoritative participant
3545
+ * set (see proposals/hub-agent-messaging-reply-and-mentions §4).
3546
+ */
3547
+ async function listChatParticipantsWithNames(db, chatId) {
3548
+ return await db.select({
3549
+ agentId: chatMembership.agentId,
3550
+ role: chatMembership.role,
3551
+ mode: chatMembership.mode,
3552
+ joinedAt: chatMembership.joinedAt,
3553
+ name: agents.name,
3554
+ displayName: agents.displayName,
3555
+ type: agents.type
3556
+ }).from(chatMembership).innerJoin(agents, eq(chatMembership.agentId, agents.uuid)).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
1588
3557
  }
1589
- /** Fire-and-forget: notify all recipients that a new message is available. */
1590
- function notifyRecipients(notifier, recipients, messageId) {
1591
- for (const inboxId of recipients) notifier.notify(inboxId, messageId).catch(() => {});
3558
+ async function assertParticipant(db, chatId, agentId) {
3559
+ const [row] = await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, agentId), eq(chatMembership.accessMode, "speaker"))).limit(1);
3560
+ if (!row) throw new ForbiddenError("Not a participant of this chat");
1592
3561
  }
1593
- const log$1 = createLogger("questions");
1594
3562
  /**
1595
- * Insert a `pending_questions` row inside the same transaction that wrote a
1596
- * `format=question` message. Caller is `sendMessage` after the message INSERT
1597
- * returns, so a rollback drops both rows together. No-op (returns silently)
1598
- * if the message content is not a valid `QuestionMessageContent` — the caller
1599
- * will already have rejected such input upstream, but we defend in depth so a
1600
- * malformed write never leaves a dangling pending row.
3563
+ * Non-throwing membership check. Used by routing logic that needs to fall
3564
+ * back to a different chat when the candidate target isn't a member of the
3565
+ * caller's current chat (see `sendToAgent`'s current-chat routing branch).
1601
3566
  */
1602
- async function recordPendingQuestionFromMessage(tx, args) {
1603
- const parsed = questionMessageContentSchema.safeParse(args.content);
1604
- if (!parsed.success) throw new BadRequestError("Invalid question message content", { "question.parse_error": parsed.error.message.slice(0, 200) });
1605
- const { correlationId } = parsed.data;
1606
- await tx.insert(pendingQuestions).values({
1607
- id: correlationId,
1608
- agentId: args.agentId,
1609
- chatId: args.chatId,
1610
- messageId: args.messageId,
1611
- status: "pending"
3567
+ async function isParticipant(db, chatId, agentId) {
3568
+ const [row] = await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, agentId), eq(chatMembership.accessMode, "speaker"))).limit(1);
3569
+ return Boolean(row);
3570
+ }
3571
+ /** Ensure an agent is a speaker of a chat. Silently adds them if not already. */
3572
+ async function ensureParticipant(db, chatId, agentId) {
3573
+ const [existing] = await db.select({ accessMode: chatMembership.accessMode }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, agentId))).limit(1);
3574
+ if (existing?.accessMode === "speaker") return;
3575
+ await db.transaction(async (tx) => {
3576
+ if (wouldUpgradeToGroup((await tx.select({ agentId: chatMembership.agentId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")))).length, 1)) await changeChatType(tx, chatId, "group");
3577
+ await addChatParticipants(tx, chatId, [{ agentId }], { upgradeWatcherToSpeaker: true });
3578
+ await recomputeChatWatchers(tx, chatId);
3579
+ });
3580
+ invalidateChatAudience(chatId);
3581
+ }
3582
+ async function addParticipant(db, chatId, requesterId, data) {
3583
+ const chat = await getChat(db, chatId);
3584
+ await assertParticipant(db, chatId, requesterId);
3585
+ const [targetAgent] = await db.select({
3586
+ id: agents.uuid,
3587
+ organizationId: agents.organizationId,
3588
+ inboxId: agents.inboxId,
3589
+ visibility: agents.visibility,
3590
+ managerId: agents.managerId
3591
+ }).from(agents).where(eq(agents.uuid, data.agentId)).limit(1);
3592
+ if (!targetAgent) throw new NotFoundError(`Agent "${data.agentId}" not found`);
3593
+ if (targetAgent.organizationId !== chat.organizationId) throw new BadRequestError("Cannot add agent from different organization");
3594
+ if (targetAgent.visibility === AGENT_VISIBILITY.PRIVATE && targetAgent.id !== requesterId) {
3595
+ const [requester] = await db.select({ managerId: agents.managerId }).from(agents).where(eq(agents.uuid, requesterId)).limit(1);
3596
+ if (!requester) throw new NotFoundError(`Agent "${requesterId}" not found`);
3597
+ if (requester.managerId !== targetAgent.managerId) throw new ForbiddenError(`Only the owner can add a private agent to a chat: ${targetAgent.id}`);
3598
+ }
3599
+ const [existing] = await db.select({ accessMode: chatMembership.accessMode }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, data.agentId))).limit(1);
3600
+ if (existing?.accessMode === "speaker") throw new ConflictError(`Agent "${data.agentId}" is already a participant`);
3601
+ await db.transaction(async (tx) => {
3602
+ if (wouldUpgradeToGroup((await tx.select({ agentId: chatMembership.agentId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")))).length, 1)) await changeChatType(tx, chatId, "group");
3603
+ await addChatParticipants(tx, chatId, [{ agentId: data.agentId }], { upgradeWatcherToSpeaker: true });
3604
+ await recomputeChatWatchers(tx, chatId);
3605
+ await backfillSilentContextForNewParticipants(tx, chatId, [{ inboxId: targetAgent.inboxId }]);
1612
3606
  });
3607
+ invalidateChatAudience(chatId);
3608
+ return db.select().from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
3609
+ }
3610
+ async function removeParticipant(db, chatId, requesterId, targetAgentId) {
3611
+ await assertParticipant(db, chatId, requesterId);
3612
+ if (requesterId === targetAgentId) throw new BadRequestError("Cannot remove yourself from a chat");
3613
+ const [removed] = await db.delete(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, targetAgentId), eq(chatMembership.accessMode, "speaker"))).returning();
3614
+ if (!removed) throw new NotFoundError(`Agent "${targetAgentId}" is not a participant of this chat`);
3615
+ await recomputeChatWatchers(db, chatId);
3616
+ invalidateChatAudience(chatId);
3617
+ return db.select().from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
1613
3618
  }
1614
3619
  /**
1615
- * Defensive write-side check: codex-runtime agents must never emit
1616
- * `format=question` (Codex SDK 0.125 has no ask-user surface, so any such
1617
- * message would be a runtime regression). Looks up the sender's
1618
- * `runtime_provider` and rejects if it is `codex`. Throws `ForbiddenError`
1619
- * (HTTP 403) so the bug surfaces loudly to the offending writer.
1620
- *
1621
- * Returns the runtime provider for telemetry / further checks (e.g. the
1622
- * caller can attach it to the active span).
3620
+ * List chats visible to a member, grouped by agent.
3621
+ * A member sees chats where:
3622
+ * 1. Their human agent is a participant, OR
3623
+ * 2. Any agent they manage (managerId = memberId) is a participant (supervision)
1623
3624
  */
1624
- async function assertSenderMayEmitQuestion(tx, senderAgentId) {
1625
- const [row] = await tx.select({ runtimeProvider: agents.runtimeProvider }).from(agents).where(eq(agents.uuid, senderAgentId)).limit(1);
1626
- if (!row) throw new NotFoundError(`Sender agent "${senderAgentId}" not found`);
1627
- if (row.runtimeProvider === "codex") {
1628
- log$1.error({
1629
- agentId: senderAgentId,
1630
- runtimeProvider: row.runtimeProvider
1631
- }, "rejected format=question emit from codex-runtime agent");
1632
- throw new ForbiddenError("Codex runtime cannot emit ask-user questions", {
1633
- "question.codex_emit_attempt": true,
1634
- "agent.id": senderAgentId
3625
+ async function listChatsForMember(db, memberId, humanAgentId) {
3626
+ const managedAgents = await db.select({
3627
+ uuid: agents.uuid,
3628
+ name: agents.name,
3629
+ type: agents.type,
3630
+ displayName: agents.displayName
3631
+ }).from(agents).where(eq(agents.managerId, memberId));
3632
+ const agentMap = /* @__PURE__ */ new Map();
3633
+ for (const a of managedAgents) agentMap.set(a.uuid, a);
3634
+ if (!agentMap.has(humanAgentId)) {
3635
+ const [ha] = await db.select({
3636
+ uuid: agents.uuid,
3637
+ name: agents.name,
3638
+ type: agents.type,
3639
+ displayName: agents.displayName
3640
+ }).from(agents).where(eq(agents.uuid, humanAgentId)).limit(1);
3641
+ if (ha) agentMap.set(ha.uuid, ha);
3642
+ }
3643
+ const agentIds = [...agentMap.keys()];
3644
+ if (agentIds.length === 0) return [];
3645
+ const participations = await db.select({
3646
+ chatId: chatMembership.chatId,
3647
+ agentId: chatMembership.agentId,
3648
+ role: chatMembership.role,
3649
+ mode: chatMembership.mode
3650
+ }).from(chatMembership).where(and(inArray(chatMembership.agentId, agentIds), eq(chatMembership.accessMode, "speaker")));
3651
+ if (participations.length === 0) return [];
3652
+ const chatIds = [...new Set(participations.map((p) => p.chatId))];
3653
+ const agentChatMap = /* @__PURE__ */ new Map();
3654
+ for (const p of participations) {
3655
+ const list = agentChatMap.get(p.agentId) ?? [];
3656
+ list.push(p.chatId);
3657
+ agentChatMap.set(p.agentId, list);
3658
+ }
3659
+ const chatRows = await db.select({
3660
+ id: chats.id,
3661
+ type: chats.type,
3662
+ topic: chats.topic,
3663
+ metadata: chats.metadata,
3664
+ createdAt: chats.createdAt,
3665
+ updatedAt: chats.updatedAt,
3666
+ participantCount: sql`(SELECT count(*)::int FROM chat_membership WHERE chat_id = ${chats.id} AND access_mode = 'speaker')`
3667
+ }).from(chats).where(inArray(chats.id, chatIds)).orderBy(desc(chats.updatedAt));
3668
+ const chatMap = new Map(chatRows.map((c) => [c.id, c]));
3669
+ const humanParticipantChatIds = new Set(participations.filter((p) => p.agentId === humanAgentId).map((p) => p.chatId));
3670
+ const result = [];
3671
+ for (const [agentId, agentChatIds] of agentChatMap) {
3672
+ const agentInfo = agentMap.get(agentId);
3673
+ if (!agentInfo) continue;
3674
+ const agentChats = agentChatIds.map((chatId) => {
3675
+ const chat = chatMap.get(chatId);
3676
+ if (!chat) return null;
3677
+ const isSupervisionOnly = agentId !== humanAgentId && !humanParticipantChatIds.has(chatId);
3678
+ return {
3679
+ id: chat.id,
3680
+ type: chat.type,
3681
+ topic: chat.topic,
3682
+ participantCount: chat.participantCount,
3683
+ isSupervisionOnly,
3684
+ createdAt: chat.createdAt.toISOString(),
3685
+ updatedAt: chat.updatedAt.toISOString()
3686
+ };
3687
+ }).filter((c) => c !== null);
3688
+ if (agentChats.length > 0) result.push({
3689
+ agent: agentInfo,
3690
+ chats: agentChats
1635
3691
  });
1636
3692
  }
1637
- return row.runtimeProvider;
3693
+ return result;
1638
3694
  }
1639
3695
  /**
1640
- * User-side answer submission. Atomically:
1641
- * 1. Lock the `pending_questions` row by correlationId.
1642
- * 2. Refuse if status !== "pending" (409 if already-answered, 410-shaped
1643
- * 400 if superseded — both surface as ConflictError so the caller knows
1644
- * the question is no longer answerable).
1645
- * 3. Validate that the answer keys match the original `questions[]`.
1646
- * 4. Flip status to `answered` INSIDE the lock-tx, before releasing the
1647
- * row lock. This is the linearisation point: the second concurrent
1648
- * submitter (waiting on the same row lock) will, on its turn, see
1649
- * status=answered and exit with ConflictError BEFORE it can write a
1650
- * second `format=question_answer` message.
1651
- * 5. Send the `format=question_answer` message OUTSIDE the lock-tx
1652
- * (sendMessage opens its own transaction; nesting wasn't supported by
1653
- * the existing call site). At this point we hold an exclusive logical
1654
- * claim — only one submitter ever reaches this step per correlationId.
1655
- *
1656
- * `submitterAgentId` is the human agent on whose behalf the answer is
1657
- * written (it must be a participant of the question's chat). Returns the
1658
- * created `question_answer` message id so the route can include it in the
1659
- * 201 response.
1660
- *
1661
- * Failure semantics: if step 5 (sendMessage) fails after status was flipped,
1662
- * we revert the row to `pending` so the user can retry. This is best-effort —
1663
- * the revert UPDATE is guarded by `status='answered'` to avoid clobbering a
1664
- * supersede that might race in. If the revert itself fails, the row is
1665
- * stranded as `answered` with no answer message; an operator would need to
1666
- * intervene, but a sendMessage failure (local DB tx) is already
1667
- * extraordinarily rare.
3696
+ * Manager joins a chat. Adds their human agent as a participant.
3697
+ * Requires the member to have supervision rights (manages at least one existing participant).
1668
3698
  */
1669
- async function submitAnswer(db, notifier, args) {
1670
- const questionRow = await db.transaction(async (tx) => {
1671
- const [row] = await tx.select({
1672
- id: pendingQuestions.id,
1673
- status: pendingQuestions.status,
1674
- chatId: pendingQuestions.chatId,
1675
- agentId: pendingQuestions.agentId,
1676
- messageId: pendingQuestions.messageId
1677
- }).from(pendingQuestions).where(eq(pendingQuestions.id, args.correlationId)).for("update").limit(1);
1678
- if (!row) throw new NotFoundError(`Question "${args.correlationId}" not found`);
1679
- if (row.chatId !== args.chatId) throw new NotFoundError(`Question "${args.correlationId}" not found in this chat`);
1680
- if (row.status !== "pending") throw new ConflictError(`Question "${args.correlationId}" is no longer pending`, { "question.status": row.status });
1681
- const [msg] = await tx.select({ content: messages.content }).from(messages).where(eq(messages.id, row.messageId)).limit(1);
1682
- if (!msg) throw new NotFoundError(`Question message "${row.messageId}" not found`);
1683
- const parsedQuestion = questionMessageContentSchema.safeParse(msg.content);
1684
- if (!parsedQuestion.success) throw new BadRequestError("Stored question content is malformed", { "question.parse_error": parsedQuestion.error.message.slice(0, 200) });
1685
- const expectedKeys = new Set(parsedQuestion.data.questions.map((q) => q.question));
1686
- for (const key of Object.keys(args.answers)) if (!expectedKeys.has(key)) throw new BadRequestError(`Answer key "${key}" does not match any question`, { "question.id": args.correlationId });
1687
- for (const key of expectedKeys) if (!(key in args.answers)) throw new BadRequestError(`Answer missing for question "${key}"`, { "question.id": args.correlationId });
1688
- await tx.update(pendingQuestions).set({
1689
- status: "answered",
1690
- answeredAt: /* @__PURE__ */ new Date()
1691
- }).where(eq(pendingQuestions.id, args.correlationId));
1692
- return row;
1693
- });
1694
- const answerContent = {
1695
- correlationId: args.correlationId,
1696
- answers: args.answers
1697
- };
1698
- questionAnswerMessageContentSchema.parse(answerContent);
1699
- let result;
1700
- try {
1701
- result = await sendMessage(db, args.chatId, args.submitterAgentId, {
1702
- format: "question_answer",
1703
- content: answerContent,
1704
- inReplyTo: questionRow.messageId,
1705
- source: "hub_ui"
1706
- });
1707
- } catch (err) {
1708
- log$1.error({
1709
- correlationId: args.correlationId,
1710
- chatId: args.chatId,
1711
- err: err instanceof Error ? err.message : String(err)
1712
- }, "sendMessage failed after status flip; reverting pending_questions row to 'pending'");
1713
- try {
1714
- await db.update(pendingQuestions).set({
1715
- status: "pending",
1716
- answeredAt: null
1717
- }).where(and(eq(pendingQuestions.id, args.correlationId), eq(pendingQuestions.status, "answered")));
1718
- } catch (revertErr) {
1719
- log$1.error({
1720
- correlationId: args.correlationId,
1721
- chatId: args.chatId,
1722
- revertErr: revertErr instanceof Error ? revertErr.message : String(revertErr)
1723
- }, "revert UPDATE also failed; row may be stranded as 'answered' without an answer message");
1724
- }
1725
- throw err;
3699
+ async function joinChat(db, chatId, memberId, humanAgentId) {
3700
+ const chat = await getChat(db, chatId);
3701
+ const participantAgentIds = (await db.select().from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")))).map((p) => p.agentId);
3702
+ if (participantAgentIds.length === 0) throw new NotFoundError("Chat has no participants");
3703
+ if (participantAgentIds.includes(humanAgentId)) throw new ConflictError("Already a participant in this chat");
3704
+ if ((await db.select({ uuid: agents.uuid }).from(agents).where(and(inArray(agents.uuid, participantAgentIds), eq(agents.managerId, memberId)))).length === 0) throw new ForbiddenError("You can only join chats where you manage at least one participant");
3705
+ const [humanAgent] = await db.select({ organizationId: agents.organizationId }).from(agents).where(eq(agents.uuid, humanAgentId)).limit(1);
3706
+ if (!humanAgent || humanAgent.organizationId !== chat.organizationId) throw new BadRequestError("Agent does not belong to the same organization as the chat");
3707
+ await db.transaction(async (tx) => {
3708
+ if (wouldUpgradeToGroup(participantAgentIds.length, 1)) await changeChatType(tx, chatId, "group");
3709
+ await addChatParticipants(tx, chatId, [{
3710
+ agentId: humanAgentId,
3711
+ role: "member"
3712
+ }], {
3713
+ assertHuman: true,
3714
+ upgradeWatcherToSpeaker: true
3715
+ });
3716
+ await recomputeChatWatchers(tx, chatId);
3717
+ });
3718
+ invalidateChatAudience(chatId);
3719
+ return db.select().from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
3720
+ }
3721
+ /**
3722
+ * Manager leaves a chat. Removes their human agent from participants.
3723
+ * Only allowed if the human agent is a participant.
3724
+ *
3725
+ * Delegates the participant→watcher transition to `leaveAsParticipant`
3726
+ * so admin-side and `/me/chats/:id/leave` share one canonical path. The
3727
+ * earlier "recompute then UPDATE-back state" variant violated the design
3728
+ * rule that recompute is only for set rebuild — never on a transition
3729
+ * path (review #228 issue #2). The returned participant list is fetched
3730
+ * after the tx commits, matching the admin route's existing contract.
3731
+ */
3732
+ async function leaveChat(db, chatId, humanAgentId) {
3733
+ await leaveAsParticipant(db, chatId, humanAgentId);
3734
+ invalidateChatAudience(chatId);
3735
+ return db.select().from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
3736
+ }
3737
+ async function findOrCreateDirectChat(db, agentAId, agentBId) {
3738
+ const ends = await db.select({
3739
+ uuid: agents.uuid,
3740
+ organizationId: agents.organizationId,
3741
+ type: agents.type,
3742
+ visibility: agents.visibility,
3743
+ managerId: agents.managerId
3744
+ }).from(agents).where(inArray(agents.uuid, [agentAId, agentBId]));
3745
+ const agentA = ends.find((a) => a.uuid === agentAId);
3746
+ if (!agentA) throw new NotFoundError(`Agent "${agentAId}" not found`);
3747
+ const agentB = ends.find((a) => a.uuid === agentBId);
3748
+ if (!agentB) throw new NotFoundError(`Agent "${agentBId}" not found`);
3749
+ if (agentA.organizationId !== agentB.organizationId) throw new BadRequestError(`Cannot create direct chat across organizations: agent "${agentAId}" (org "${agentA.organizationId}") vs agent "${agentBId}" (org "${agentB.organizationId}")`);
3750
+ const orgId = agentA.organizationId;
3751
+ const commonChatIds = (await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(inArray(chatMembership.agentId, [agentAId, agentBId]), eq(chatMembership.accessMode, "speaker"))).groupBy(chatMembership.chatId).having(sql`COUNT(DISTINCT ${chatMembership.agentId}) = 2`)).map((r) => r.chatId);
3752
+ if (commonChatIds.length > 0) {
3753
+ const directChats = await db.select().from(chats).where(and(inArray(chats.id, commonChatIds), eq(chats.type, "direct"), eq(chats.organizationId, orgId))).orderBy(chats.createdAt, chats.id).limit(1);
3754
+ if (directChats.length > 0 && directChats[0]) return directChats[0];
1726
3755
  }
1727
- if (notifier) notifyRecipients(notifier, result.recipients, result.message.id);
3756
+ if ((agentA.visibility === AGENT_VISIBILITY.PRIVATE || agentB.visibility === AGENT_VISIBILITY.PRIVATE) && agentA.managerId !== agentB.managerId) throw new ForbiddenError(`Cannot open a direct chat with a private agent across owners: "${agentAId}" ↔ "${agentBId}"`);
3757
+ const chatId = randomUUID();
3758
+ return db.transaction(async (tx) => {
3759
+ const [chat] = await tx.insert(chats).values({
3760
+ id: chatId,
3761
+ organizationId: orgId,
3762
+ type: "direct"
3763
+ }).returning();
3764
+ await addChatParticipants(tx, chatId, [{
3765
+ agentId: agentAId,
3766
+ role: "member"
3767
+ }, {
3768
+ agentId: agentBId,
3769
+ role: "member"
3770
+ }]);
3771
+ await recomputeChatWatchers(tx, chatId);
3772
+ if (!chat) throw new Error("Unexpected: INSERT RETURNING produced no row");
3773
+ return chat;
3774
+ });
3775
+ }
3776
+ /** Server instance heartbeat. Used to detect crashed instances and clean up associated agent_presence records. */
3777
+ const serverInstances = pgTable("server_instances", {
3778
+ instanceId: text("instance_id").primaryKey(),
3779
+ lastHeartbeat: timestamp("last_heartbeat", { withTimezone: true }).notNull().defaultNow()
3780
+ });
3781
+ /** Common field reset when agent goes offline or is unbound. */
3782
+ function runtimeFieldsReset(now) {
1728
3783
  return {
1729
- messageId: result.message.id,
1730
- recipients: result.recipients
3784
+ runtimeState: null,
3785
+ activeSessions: null,
3786
+ totalSessions: null,
3787
+ runtimeUpdatedAt: now,
3788
+ lastSeenAt: now
1731
3789
  };
1732
3790
  }
1733
- /**
1734
- * Mark every pending row whose chat is `chatId` as superseded. Used when a
1735
- * chat session is archived — the agent runtime that emitted the question
1736
- * may already be gone, so leaving the row pending would block forever.
1737
- */
1738
- async function markSupersededByChat(tx, chatId, reason = "chat_archived") {
1739
- return (await tx.update(pendingQuestions).set({
1740
- status: "superseded",
1741
- supersededAt: /* @__PURE__ */ new Date(),
1742
- supersededReason: reason
1743
- }).where(and(eq(pendingQuestions.chatId, chatId), eq(pendingQuestions.status, "pending"))).returning({ id: pendingQuestions.id })).length;
3791
+ async function setOffline(db, agentId) {
3792
+ const now = /* @__PURE__ */ new Date();
3793
+ await db.update(agentPresence).set({
3794
+ status: "offline",
3795
+ instanceId: null,
3796
+ ...runtimeFieldsReset(now)
3797
+ }).where(eq(agentPresence.agentId, agentId));
1744
3798
  }
1745
- /**
1746
- * Mark every pending row owned by any of `agentIds` as superseded. Used when
1747
- * the client carrying these agents is claimed by a new user — the previous
1748
- * owner's runtime is detached and cannot deliver an answer back.
1749
- */
1750
- async function markSupersededByAgents(tx, agentIds, reason = "client_claimed") {
1751
- if (agentIds.length === 0) return 0;
1752
- return (await tx.update(pendingQuestions).set({
1753
- status: "superseded",
1754
- supersededAt: /* @__PURE__ */ new Date(),
1755
- supersededReason: reason
1756
- }).where(and(inArray(pendingQuestions.agentId, agentIds), eq(pendingQuestions.status, "pending"))).returning({ id: pendingQuestions.id })).length;
3799
+ async function getPresence(db, agentId) {
3800
+ const [row] = await db.select().from(agentPresence).where(eq(agentPresence.agentId, agentId)).limit(1);
3801
+ return row ?? null;
1757
3802
  }
1758
- const log = createLogger("message");
1759
- async function sendMessage(db, chatId, senderId, data, options = {}) {
1760
- return withSpan("inbox.enqueue", messageAttrs({
1761
- chatId,
1762
- senderAgentId: senderId,
1763
- source: data.source ?? void 0
1764
- }), () => sendMessageInner(db, chatId, senderId, data, options));
3803
+ async function getOnlineCount(db) {
3804
+ const [result] = await db.select({ count: sql`count(*)::int` }).from(agentPresence).where(eq(agentPresence.status, "online"));
3805
+ return result?.count ?? 0;
1765
3806
  }
1766
- async function sendMessageInner(db, chatId, senderId, data, options) {
1767
- const txResult = await db.transaction(async (tx) => {
1768
- const [participants, [chatRow], [senderRow]] = await Promise.all([
1769
- tx.select({
1770
- agentId: chatMembership.agentId,
1771
- inboxId: agents.inboxId,
1772
- mode: chatMembership.mode,
1773
- name: agents.name
1774
- }).from(chatMembership).innerJoin(agents, eq(chatMembership.agentId, agents.uuid)).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker"))),
1775
- tx.select({ type: chats.type }).from(chats).where(eq(chats.id, chatId)).limit(1),
1776
- tx.select({
1777
- inboxId: agents.inboxId,
1778
- organizationId: agents.organizationId,
1779
- type: agents.type
1780
- }).from(agents).where(eq(agents.uuid, senderId)).limit(1)
1781
- ]);
1782
- const chatType = chatRow?.type ?? null;
1783
- if (!senderRow) throw new NotFoundError(`Sender agent "${senderId}" not found`);
1784
- let effectiveContent = data.content;
1785
- if (senderRow.type !== "human" && typeof effectiveContent === "string") {
1786
- const unwrapped = maybeUnwrapDoubleEncoded(effectiveContent);
1787
- if (unwrapped !== null) {
1788
- log.warn({
1789
- metric: "double_encoded_content_unwrapped_total",
1790
- chatId,
1791
- senderId
1792
- }, "agent sent JSON-encoded string content — unwrapping to restore markdown rendering");
1793
- effectiveContent = unwrapped;
1794
- }
1795
- }
1796
- if (data.replyToInbox !== void 0 && data.replyToInbox !== null) {
1797
- if (senderRow.inboxId !== data.replyToInbox) throw new BadRequestError("replyToInbox must reference the sender's own inbox");
1798
- }
1799
- const incomingMeta = data.metadata ?? {};
1800
- const explicitMentionsRaw = incomingMeta.mentions;
1801
- const explicitMentions = Array.isArray(explicitMentionsRaw) ? explicitMentionsRaw.filter((m) => typeof m === "string") : [];
1802
- const contentText = typeof effectiveContent === "string" ? effectiveContent : "";
1803
- const resolved = contentText ? extractMentions(contentText, participants) : [];
1804
- const mergedMentions = [...new Set([...explicitMentions, ...resolved])];
1805
- const metadataToStore = mergedMentions.length > 0 ? {
1806
- ...incomingMeta,
1807
- mentions: mergedMentions
1808
- } : incomingMeta;
1809
- const dmAutoProjection = chatType === "direct" ? [...new Set([...mergedMentions, ...participants.filter((p) => p.agentId !== senderId).map((p) => p.agentId)])] : mergedMentions;
1810
- if (options.enforceGroupMention && chatType === "group") {
1811
- if (mergedMentions.filter((id) => id !== senderId).length === 0) throw new BadRequestError("Sending to a group chat requires an explicit @mention. Use `first-tree-hub chat send <name>` to message a single agent, or @<name> in the content to address one or more group members.");
1812
- }
1813
- let outboundContent = effectiveContent;
1814
- if (options.normalizeMentionsInContent && typeof outboundContent === "string") {
1815
- const present = new Set(scanMentionTokens(outboundContent));
1816
- const missingNames = [];
1817
- for (const id of mergedMentions) {
1818
- if (id === senderId) continue;
1819
- const p = participants.find((q) => q.agentId === id);
1820
- if (!p?.name) continue;
1821
- if (present.has(p.name.toLowerCase())) continue;
1822
- missingNames.push(p.name);
1823
- }
1824
- if (missingNames.length > 0) {
1825
- const prefix = missingNames.map((n) => `@${n}`).join(" ");
1826
- outboundContent = outboundContent.length > 0 ? `${prefix} ${outboundContent}` : prefix;
1827
- }
1828
- }
1829
- if (data.format === "question") await assertSenderMayEmitQuestion(tx, senderId);
1830
- const isSilentSend = typeof outboundContent === "string" && outboundContent.replace(/^(@\S+\s*)+/, "").trim().length === 0;
1831
- if (isSilentSend) log.info({
1832
- chatId,
1833
- senderId,
1834
- source: data.source ?? null
1835
- }, "silent send: empty content after mention strip — no fan-out wake-up");
1836
- const projectionMentions = isSilentSend ? [] : dmAutoProjection;
1837
- const messageId = randomUUID();
1838
- const [msg] = await tx.insert(messages).values({
1839
- id: messageId,
1840
- chatId,
1841
- senderId,
1842
- format: data.format,
1843
- content: outboundContent,
1844
- metadata: metadataToStore,
1845
- replyToInbox: data.replyToInbox ?? null,
1846
- replyToChat: data.replyToChat ?? null,
1847
- inReplyTo: data.inReplyTo ?? null,
1848
- source: data.source ?? null
1849
- }).returning();
1850
- if (data.format === "question" && msg) await recordPendingQuestionFromMessage(tx, {
1851
- agentId: senderId,
1852
- chatId,
1853
- messageId: msg.id,
1854
- content: outboundContent
1855
- });
1856
- const mentionSet = new Set(mergedMentions);
1857
- const fanout = participants.filter((p) => p.agentId !== senderId).map((p) => ({
1858
- agentId: p.agentId,
1859
- inboxId: p.inboxId,
1860
- notify: !isSilentSend && (p.mode !== "mention_only" || mentionSet.has(p.agentId))
1861
- }));
1862
- if (fanout.length > 0) await tx.insert(inboxEntries).values(fanout.map((f) => ({
1863
- inboxId: f.inboxId,
1864
- messageId,
1865
- chatId,
1866
- notify: f.notify
1867
- })));
1868
- const notified = fanout.filter((f) => f.notify);
1869
- const recipients = notified.map((f) => f.inboxId);
1870
- const recipientAgentIds = notified.map((f) => f.agentId);
1871
- if (data.inReplyTo) {
1872
- const [original] = await tx.select({
1873
- replyToInbox: messages.replyToInbox,
1874
- replyToChat: messages.replyToChat
1875
- }).from(messages).where(eq(messages.id, data.inReplyTo)).limit(1);
1876
- if (original?.replyToInbox && original?.replyToChat) {
1877
- await tx.insert(inboxEntries).values({
1878
- inboxId: original.replyToInbox,
1879
- messageId,
1880
- chatId: original.replyToChat,
1881
- notify: !isSilentSend
1882
- }).onConflictDoNothing();
1883
- if (!isSilentSend && !recipients.includes(original.replyToInbox)) recipients.push(original.replyToInbox);
1884
- }
3807
+ async function bindAgent(db, agentId, data) {
3808
+ const now = /* @__PURE__ */ new Date();
3809
+ await db.insert(agentPresence).values({
3810
+ agentId,
3811
+ status: "online",
3812
+ instanceId: data.instanceId,
3813
+ clientId: data.clientId,
3814
+ runtimeType: data.runtimeType,
3815
+ runtimeVersion: data.runtimeVersion ?? null,
3816
+ runtimeState: "idle",
3817
+ connectedAt: now,
3818
+ lastSeenAt: now,
3819
+ runtimeUpdatedAt: now
3820
+ }).onConflictDoUpdate({
3821
+ target: agentPresence.agentId,
3822
+ set: {
3823
+ status: "online",
3824
+ instanceId: data.instanceId,
3825
+ clientId: data.clientId,
3826
+ runtimeType: data.runtimeType,
3827
+ runtimeVersion: data.runtimeVersion ?? null,
3828
+ runtimeState: "idle",
3829
+ activeSessions: null,
3830
+ totalSessions: null,
3831
+ connectedAt: now,
3832
+ lastSeenAt: now,
3833
+ runtimeUpdatedAt: now
1885
3834
  }
1886
- await tx.update(chats).set({ updatedAt: /* @__PURE__ */ new Date() }).where(eq(chats.id, chatId));
1887
- if (!msg) throw new Error("Unexpected: INSERT RETURNING produced no row");
1888
- const previewText = typeof outboundContent === "string" ? outboundContent.trim() : "";
1889
- await applyAfterFanOut(tx, {
1890
- chatId,
1891
- messageId: msg.id,
1892
- senderId,
1893
- mentionedAgentIds: projectionMentions,
1894
- contentPreview: previewText,
1895
- messageCreatedAt: msg.createdAt
1896
- });
1897
- return {
1898
- message: msg,
1899
- recipients,
1900
- recipientAgentIds,
1901
- organizationId: senderRow.organizationId
1902
- };
1903
- });
1904
- const settled = await Promise.allSettled(txResult.recipientAgentIds.map((agentId) => upsertSessionState(db, agentId, chatId, "active", txResult.organizationId, void 0, { touchPresenceLastSeen: false })));
1905
- for (let i = 0; i < settled.length; i++) {
1906
- const r = settled[i];
1907
- if (r?.status === "rejected") log.error({
1908
- err: r.reason,
1909
- chatId,
1910
- agentId: txResult.recipientAgentIds[i]
1911
- }, "predictive session activation failed");
1912
- }
1913
- fireChatMessageKick(chatId, txResult.message.id);
1914
- observeLoopPattern(db, chatId).catch((err) => {
1915
- log.error({
1916
- err,
1917
- chatId
1918
- }, "loop pattern observation failed");
1919
3835
  });
1920
- return {
1921
- message: txResult.message,
1922
- recipients: txResult.recipients
1923
- };
1924
3836
  }
1925
- /**
1926
- * Detect agent-sent content that was JSON.stringify-ed once before reaching
1927
- * the CLI / API. The bad shape is an outer `"..."` wrapper + interior `\n` /
1928
- * `\"` escape sequences, which the UI renders as a quoted literal instead of
1929
- * markdown (issue #389). Returns the unwrapped inner string on a confident
1930
- * match, or `null` to leave the content alone.
1931
- *
1932
- * Match conditions (all required) — kept strict so legitimate human content
1933
- * that happens to look like a quoted phrase is never touched. The caller is
1934
- * additionally responsible for restricting this to non-human senders.
3837
+ async function unbindAgent(db, agentId) {
3838
+ const now = /* @__PURE__ */ new Date();
3839
+ await db.update(agentPresence).set({
3840
+ status: "offline",
3841
+ clientId: null,
3842
+ ...runtimeFieldsReset(now)
3843
+ }).where(eq(agentPresence.agentId, agentId));
3844
+ }
3845
+ /** Set runtime state directly from client-reported value.
1935
3846
  *
1936
- * - first and last char are `"`
1937
- * - body contains at least one typical JSON escape sequence
1938
- * (`\n`, `\r`, `\t`, `\"`, or `\\`)
1939
- * - `JSON.parse` succeeds
1940
- * - the parse result is a `string` (excludes `{...}`, `[...]`, numbers)
1941
- */
1942
- function maybeUnwrapDoubleEncoded(content) {
1943
- if (content.length < 4) return null;
1944
- if (content.charCodeAt(0) !== 34) return null;
1945
- if (content.charCodeAt(content.length - 1) !== 34) return null;
1946
- if (!/\\[nrt"\\]/.test(content)) return null;
1947
- try {
1948
- const parsed = JSON.parse(content);
1949
- return typeof parsed === "string" ? parsed : null;
1950
- } catch {
1951
- return null;
1952
- }
3847
+ * When an org-scoped notifier is provided, emit a PG NOTIFY on the
3848
+ * `runtime_state_changes` channel so the pulse aggregator (and any future
3849
+ * admin-side consumers) can observe the transition. Fire-and-forget to match
3850
+ * notifier semantics elsewhere in this module. */
3851
+ async function setRuntimeState(db, agentId, runtimeState, options) {
3852
+ const now = /* @__PURE__ */ new Date();
3853
+ await db.update(agentPresence).set({
3854
+ runtimeState,
3855
+ runtimeUpdatedAt: now,
3856
+ lastSeenAt: now
3857
+ }).where(eq(agentPresence.agentId, agentId));
3858
+ if (options?.notifier && options.organizationId) options.notifier.notifyRuntimeStateChange(agentId, runtimeState, options.organizationId).catch(() => {});
1953
3859
  }
1954
- function stripMentionPrefix(content) {
1955
- return content.replace(/^(@\S+\s*)+/, "").trim();
3860
+ /** Touch agent last_seen_at on heartbeat (per-agent liveness). */
3861
+ async function touchAgent(db, agentId) {
3862
+ await db.update(agentPresence).set({ lastSeenAt: /* @__PURE__ */ new Date() }).where(eq(agentPresence.agentId, agentId));
3863
+ }
3864
+ async function heartbeatInstance(db, instanceId) {
3865
+ await db.insert(serverInstances).values({
3866
+ instanceId,
3867
+ lastHeartbeat: /* @__PURE__ */ new Date()
3868
+ }).onConflictDoUpdate({
3869
+ target: serverInstances.instanceId,
3870
+ set: { lastHeartbeat: /* @__PURE__ */ new Date() }
3871
+ });
1956
3872
  }
1957
- const defaultLoopObserver = (data) => {
1958
- log.warn({
1959
- metric: "loop_pattern_observed_total",
1960
- ...data
1961
- }, "loop pattern observed (not blocked) — prompt discipline may be drifting");
1962
- };
1963
3873
  /**
1964
- * Pure observation: detect short, fast, two-agent ping-pong reply chains and
1965
- * surface them via a structured log line. Does NOT modify the `notify` flag
1966
- * or otherwise interfere with delivery loop *prevention* lives client-side
1967
- * (prompt + silent-turn protocol in `result-sink`). Six conjunctive
1968
- * conditions (see design `agent-reply-loop-prevention-design.md` §3.4) so
1969
- * normal multi-agent collaboration is never flagged:
1970
- *
1971
- * C1 — every message is `format=text`
1972
- * C2 — no human sender in the window (any human reply resets the chain)
1973
- * C3 — exactly two senders, perfectly alternating
1974
- * C4 — strict `inReplyTo` chain across the whole window
1975
- * C5 — every message body (after stripping leading `@<name>` tokens) is
1976
- * ≤ `LOOP_OBSERVATION_SHORT_CHARS` characters
1977
- * C6 — the whole window spans ≤ `LOOP_OBSERVATION_TIME_WINDOW_MS` ms
3874
+ * M1: Mark agents as offline whose last_seen_at is older than staleSeconds.
3875
+ * Unlike cleanupStalePresence (which checks instance liveness), this checks
3876
+ * per-agent heartbeat liveness detecting agents that stopped heartbeating
3877
+ * while the client process may still be alive.
1978
3878
  *
1979
- * Exported for direct test coverage of the detection logic; the `sendMessage`
1980
- * call site uses the default observer.
3879
+ * Returns the list of agent IDs that were marked stale (for notification in Step 6).
1981
3880
  */
1982
- async function observeLoopPattern(db, chatId, observer = defaultLoopObserver) {
1983
- const window = await db.select({
1984
- id: messages.id,
1985
- senderId: messages.senderId,
1986
- content: messages.content,
1987
- inReplyTo: messages.inReplyTo,
1988
- createdAt: messages.createdAt,
1989
- format: messages.format,
1990
- senderType: agents.type
1991
- }).from(messages).innerJoin(agents, eq(messages.senderId, agents.uuid)).where(eq(messages.chatId, chatId)).orderBy(desc(messages.createdAt)).limit(4);
1992
- if (window.length < 4) return;
1993
- if (window.some((m) => m.format !== "text")) return;
1994
- if (window.some((m) => m.senderType === "human")) return;
1995
- if (new Set(window.map((m) => m.senderId)).size !== 2) return;
1996
- for (let i = 0; i < window.length - 1; i++) if (window[i]?.senderId === window[i + 1]?.senderId) return;
1997
- for (let i = 0; i < window.length - 1; i++) if (window[i]?.inReplyTo !== window[i + 1]?.id) return;
1998
- const lens = [];
1999
- for (const m of window) {
2000
- const text = typeof m.content === "string" ? m.content : null;
2001
- if (text === null) return;
2002
- const len = stripMentionPrefix(text).length;
2003
- if (len > 10) return;
2004
- lens.push(len);
2005
- }
2006
- const newest = window[0];
2007
- const oldest = window[window.length - 1];
2008
- if (!newest || !oldest) return;
2009
- const spanMs = newest.createdAt.getTime() - oldest.createdAt.getTime();
2010
- if (spanMs > 3e4) return;
2011
- observer({
2012
- chatId,
2013
- recentMessageIds: window.map((m) => m.id),
2014
- windowSpanMs: spanMs,
2015
- contentLengths: lens
2016
- });
2017
- }
2018
- async function sendToAgent(db, senderUuid, targetName, data) {
2019
- const [sender] = await db.select({
2020
- uuid: agents.uuid,
2021
- organizationId: agents.organizationId
2022
- }).from(agents).where(eq(agents.uuid, senderUuid)).limit(1);
2023
- if (!sender) throw new NotFoundError(`Agent "${senderUuid}" not found`);
2024
- const [target] = await db.select({ uuid: agents.uuid }).from(agents).where(and(eq(agents.organizationId, sender.organizationId), eq(agents.name, targetName), ne(agents.status, "deleted"))).limit(1);
2025
- if (!target) throw new NotFoundError(`Agent "${targetName}" not found${/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(targetName) ? " — `first-tree-hub chat send` expects an agent NAME, not a uuid. Run `first-tree-hub agent list` to see available names." : ""}`);
2026
- const incomingMeta = data.metadata ?? {};
2027
- const existingMentionsRaw = incomingMeta.mentions;
2028
- const existingMentions = Array.isArray(existingMentionsRaw) ? existingMentionsRaw.filter((m) => typeof m === "string") : [];
2029
- const mergedMentions = existingMentions.includes(target.uuid) ? existingMentions : [...existingMentions, target.uuid];
2030
- const metadata = {
2031
- ...incomingMeta,
2032
- mentions: mergedMentions
2033
- };
2034
- if (data.replyToChat) {
2035
- const [targetIsMember, senderIsMember] = await Promise.all([isParticipant(db, data.replyToChat, target.uuid), isParticipant(db, data.replyToChat, senderUuid)]);
2036
- if (targetIsMember && senderIsMember) return sendMessage(db, data.replyToChat, senderUuid, {
2037
- format: data.format,
2038
- content: data.content,
2039
- metadata,
2040
- replyToInbox: void 0,
2041
- replyToChat: void 0,
2042
- source: data.source
2043
- }, { normalizeMentionsInContent: true });
2044
- }
2045
- return sendMessage(db, (await findOrCreateDirectChat(db, senderUuid, target.uuid)).id, senderUuid, {
2046
- format: data.format,
2047
- content: data.content,
2048
- metadata,
2049
- replyToInbox: data.replyToInbox,
2050
- replyToChat: data.replyToChat,
2051
- source: data.source
2052
- }, { normalizeMentionsInContent: true });
2053
- }
2054
- async function editMessage(db, chatId, messageId, senderId, data) {
2055
- const [msg] = await db.select().from(messages).where(eq(messages.id, messageId)).limit(1);
2056
- if (!msg) throw new NotFoundError(`Message "${messageId}" not found`);
2057
- if (msg.chatId !== chatId) throw new NotFoundError(`Message "${messageId}" not found in this chat`);
2058
- if (msg.senderId !== senderId) throw new ForbiddenError("Only the sender can edit a message");
2059
- const setClause = {};
2060
- if (data.format !== void 0) setClause.format = data.format;
2061
- if (data.content !== void 0) setClause.content = data.content;
2062
- const meta = msg.metadata ?? {};
2063
- meta.editedAt = (/* @__PURE__ */ new Date()).toISOString();
2064
- setClause.metadata = meta;
2065
- const [updated] = await db.update(messages).set(setClause).where(eq(messages.id, messageId)).returning();
2066
- if (!updated) throw new Error("Unexpected: UPDATE RETURNING produced no row");
2067
- return updated;
3881
+ async function markStaleAgents(db, staleSeconds = 60) {
3882
+ return (await db.execute(sql`
3883
+ UPDATE agent_presence SET
3884
+ status = 'offline',
3885
+ client_id = NULL,
3886
+ runtime_state = NULL,
3887
+ active_sessions = NULL,
3888
+ total_sessions = NULL,
3889
+ runtime_updated_at = NOW()
3890
+ WHERE status = 'online'
3891
+ AND last_seen_at < NOW() - make_interval(secs => ${staleSeconds})
3892
+ RETURNING agent_id
3893
+ `)).map((r) => r.agent_id);
2068
3894
  }
2069
- async function listMessages(db, chatId, limit, cursor) {
2070
- const where = cursor ? and(eq(messages.chatId, chatId), lt(messages.createdAt, new Date(cursor))) : eq(messages.chatId, chatId);
2071
- const rows = await db.select().from(messages).where(where).orderBy(desc(messages.createdAt)).limit(limit + 1);
2072
- const hasMore = rows.length > limit;
2073
- const items = hasMore ? rows.slice(0, limit) : rows;
2074
- const last = items[items.length - 1];
2075
- return {
2076
- items,
2077
- nextCursor: hasMore && last ? last.createdAt.toISOString() : null
2078
- };
3895
+ async function cleanupStalePresence(db, staleSeconds = 60) {
3896
+ return (await db.execute(sql`
3897
+ UPDATE agent_presence SET status = 'offline', instance_id = NULL,
3898
+ runtime_state = NULL,
3899
+ active_sessions = NULL, total_sessions = NULL,
3900
+ runtime_updated_at = NOW()
3901
+ WHERE instance_id IN (
3902
+ SELECT instance_id FROM server_instances
3903
+ WHERE last_heartbeat < NOW() - make_interval(secs => ${staleSeconds})
3904
+ )
3905
+ AND status = 'online'
3906
+ RETURNING agent_id
3907
+ `)).length;
2079
3908
  }
2080
3909
  /**
2081
3910
  * Assert the caller can act on this client. Throws 404 for both "not found"
@@ -2381,4 +4210,4 @@ async function cleanupStaleClients(db, staleSeconds = 60) {
2381
4210
  return result.length;
2382
4211
  }
2383
4212
  //#endregion
2384
- export { notifyRecipients as $, getPresence as A, listAgentsManagedByUser as B, ensureParticipant as C, getChatDetail as D, getCachedAudience as E, joinAsParticipant as F, listClients as G, listChatParticipantsWithNames as H, joinChat as I, listMyPinnedAgents as J, listClientsForOrgAdmin as K, leaveAsParticipant as L, heartbeatInstance as M, inboxEntries as N, getClient as O, invalidateChatAudience as P, messages as Q, leaveChat as R, ensureCanJoin as S, getActivityOverview as T, listChats as U, listAgentsWithRuntime as V, listChatsForMember as W, markSupersededByChat as X, markStaleAgents as Y, members as Z, createChat as _, unbindAgent as _t, agentVisibilityCondition as a, registerClient as at, disconnectClient as b, assertParticipant as c, resolveChatMembership as ct, chatMembership as d, sendToAgent as dt, pendingQuestions as et, chats as f, serverInstances as ft, clients as g, touchAgent as gt, cleanupStalePresence as h, submitAnswer as ht, agentPresence as i, registerChatMessageDispatcher as it, heartbeatClient as j, getOnlineCount as k, bindAgent as l, retireClient as lt, cleanupStaleClients as m, setRuntimeState as mt, addParticipant as n, recomputeWatchersForAgent as nt, agents as o, removeParticipant as ot, claimClient as p, setOffline as pt, listMessages as q, agentChatSessions as r, recomputeWatchersForMember as rt, assertClientOwner as s, resetActivity as st, addChatParticipants as t, recomputeChatWatchers as tt, changeChatType as u, sendMessage as ut, createNotifier as v, updateClientCapabilities as vt, findOrCreateDirectChat as w, editMessage as x, deriveAuthState as y, upsertSessionState as yt, listActiveAgentsPinnedToClient as z };
4213
+ export { getSession as $, suspendSession as $t, createAgent as A, pollInbox as At, extractSummary as B, resetTimedOutEntries as Bt, claimAndBuildForPush as C, markMeChatRead as Ct, cleanupStalePresence as D, messages as Dt, cleanupStaleClients as E, members as Et, deriveAuthState as F, registerChatMessageDispatcher as Ft, getAgentAvatarImage as G, sendToAgent as Gt, findOrCreateDirectChat as H, resolveDefaultOrgId as Ht, disconnectClient as I, registerClient as It, getChatDetail as J, setChatEngagement as Jt, getCachedAudience as K, serverInstances as Kt, editMessage as L, removeParticipant as Lt, createMeChat as M, reactivateAgent as Mt, createNotifier as N, rebindAgent as Nt, clearAgentAvatarImage as O, notifyRecipients as Ot, deleteAgent as P, recomputeWatchersForMember as Pt, getPresence as Q, suspendAgent as Qt, ensureDefaultOrganization as R, renewEntry as Rt, checkAgentNameAvailability as S, listMyPinnedAgents as St, claimClient as T, markStaleAgents as Tt, getActivityOverview as U, retireClient as Ut, filterSessionsByParticipant as V, resolveChatTitle as Vt, getAgent as W, sendMessage as Wt, getOnlineCount as X, setRuntimeState as Xt, getClient as Y, setOffline as Yt, getOrganization as Z, submitAnswer as Zt, assertParticipant as _, listClients as _t, adapterAgentMappings as a, upsertSessionState as an, leaveChat as at, chatUserState as b, listMeChats as bt, addMeChatParticipants as c, listAgentSessions as ct, agentChatSessions as d, listAgentsManagedByUser as dt, touchAgent as en, heartbeatClient as et, agentConfigs as f, listAgentsWithRuntime as ft, assertClientOwner as g, listChatsForMember as gt, archiveSession as h, listChats as ht, ackEntryByIdForBoundAgents as i, updateOrganization as in, joinMeChat as it, createChat as j, pruneStaleSilentEntries as jt, clients as k, pendingQuestions as kt, addParticipant as l, listAgentsForAdmin as lt, agents as m, listChatParticipantsWithNames as mt, SUPPORTED_AVATAR_IMAGE_MIMES as n, updateAgent as nn, inboxEntries as nt, adapterConfigs as o, leaveMeChat as ot, agentPresence as p, listAllSessions as pt, getCallerEngagement as q, setAgentAvatarImage as qt, ackEntry as r, updateClientCapabilities as rn, joinChat as rt, addChatParticipants as s, listActiveAgentsPinnedToClient as st, MAX_AVATAR_IMAGE_BYTES as t, unbindAgent as tn, heartbeatInstance as tt, agentAvatarImageUrl as u, listAgentsForMember as ut, bindAgent as v, listClientsForOrgAdmin as vt, claimBacklogForPush as w, markMeChatUnread as wt, chats as x, listMessages as xt, chatMembership as y, listMeChatSourceCounts as yt, ensureParticipant as z, resetActivity as zt };