@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.
@@ -2,11 +2,11 @@ import { a as __toCommonJS, o as __toESM, t as __commonJSMin } from "./chunk-BSw
2
2
  import { A as FIRST_TREE_HUB_ATTR, C as stampOrgScope, D as untrustedAttrs, E as startWsConnectionSpan, M as require_pino, O as withSpan, S as stampChatResource, _ as rootLogger$1, a as buildRateLimitError, c as currentTraceId, g as reportErrorToRoot, i as bodyCaptureOnSendHook, j as redactUrl, k as withWsMessageSpan, l as decodeJwtForTrace, m as observabilityPlugin, n as applyLoggerConfig, o as classifyJoseError, r as attachRequestContext, s as createLogger$1, t as adapterAttrs, u as endWsConnectionSpan, x as stampAgentResource, y as setWsConnectionAttrs } from "./observability-BAScT_5S-BcW9HgkG.mjs";
3
3
  import { C as resetConfigMeta, E as setConfigValue, S as resetConfig, T as serverConfigSchema, b as migrateLegacyHome, c as resolveServerUrl, d as DEFAULT_CONFIG_DIR, f as DEFAULT_DATA_DIR$1, g as collectMissingPrompts, h as clientConfigSchema, i as ensureFreshAccessToken, l as saveAgentConfig, m as agentConfigSchema, o as loadCredentials, p as DEFAULT_HOME_DIR$1, u as saveCredentials, v as initConfig, w as resolveConfigReadonly, y as loadAgents } from "./bootstrap-C15ZBOCC.mjs";
4
4
  import { a as print, i as blank, n as CLI_USER_AGENT, r as COMMAND_VERSION, s as status, t as cliFetch } from "./cli-fetch--tiwKm5S.mjs";
5
- import { $ as listMeChatSourceCountsQuerySchema, A as createMeChatSchema, At as updateOrganizationSchema, B as githubAppInstallationClaimBodySchema, C as clientRegisterSchema, Ct as submitQuestionAnswerSchema, D as createAdapterMappingSchema, Dt as updateChatSchema, E as createAdapterConfigSchema, Et as updateAgentSchema, F as delegateFeishuUserSchema, G as imageInlineContentSchema, H as githubCallbackQuerySchema, I as dryRunAgentRuntimeConfigSchema, J as inboxPollQuerySchema, K as inboxAckFrameSchema, M as createOrgFromMeSchema, O as createAgentSchema, Ot as updateClientCapabilitiesSchema, P as defaultRuntimeConfigPayload, Q as joinByInvitationSchema, R as getMeDocResponseSchema, St as stripCode, T as contextTreeSnapshotSchema, Tt as updateAgentRuntimeConfigSchema, U as githubDevCallbackQuerySchema, V as githubAppInstallationPermissionsSchema$1, W as githubStartQuerySchema, X as isRedactedEnvValue, Y as isOrgSettingNamespace, Z as isReservedAgentName$1, _ as agentBindRequestSchema, _t as sendToAgentSchema, a as AGENT_TYPES, at as paginationQuerySchema, b as agentTypeSchema$1, bt as sessionReconcileRequestSchema, d as MENTION_REGEX, dt as refreshTokenSchema, et as listMeChatsQuerySchema, f as NOTIFICATION_TYPES, ft as runtimeStateMessageSchema, g as addParticipantSchema, gt as sendMessageSchema, h as addMeChatParticipantsSchema, ht as selfServiceFeishuBotSchema, i as AGENT_STATUSES, it as onboardingEventSchema, j as createMemberSchema, jt as wsAuthFrameSchema, k as createChatSchema, kt as updateMemberSchema, l as GITHUB_ENTITY_TYPES, m as WS_AUTH_FRAME_TIMEOUT_MS, n as AGENT_NAME_REGEX$1, nt as messageSourceSchema$1, o as AGENT_VISIBILITY, ot as patchChatEngagementSchema, p as ORG_SETTINGS_NAMESPACES$1, pt as safeRedirectPath, q as inboxDeliverFrameSchema$1, r as AGENT_SELECTOR_HEADER$1, rt as notificationQuerySchema, s as CHAT_ENGAGEMENT_STATUSES, st as patchOnboardingSchema, t as AGENT_BIND_REJECT_REASONS, tt as loginSchema, ut as rebindAgentSchema, v as agentPinnedMessageSchema$1, vt as sessionEventMessageSchema, w as connectTokenExchangeSchema, wt as updateAdapterConfigSchema, x as chatMetadataSchema$1, xt as sessionStateMessageSchema, y as agentRuntimeConfigPayloadSchema$1, yt as sessionEventSchema$1, z as getMeDocSchema } from "./dist-1XGLJMOq.mjs";
6
- import { a as ConflictError, c as UnauthorizedError, i as ClientUserMismatchError$1, l as organizations, n as BadRequestError, o as ForbiddenError, r as ClientOrgMismatchError$1, s as NotFoundError, t as AppError, u as users } from "./errors-LPcARA4K-Dbrptiyz.mjs";
5
+ import { $ as listMeChatSourceCountsQuerySchema, A as createMeChatSchema, At as updateOrganizationSchema, B as githubAppInstallationClaimBodySchema, C as clientRegisterSchema, Ct as submitQuestionAnswerSchema, D as createAdapterMappingSchema, Dt as updateChatSchema, E as createAdapterConfigSchema, Et as updateAgentSchema, F as delegateFeishuUserSchema, G as imageInlineContentSchema, H as githubCallbackQuerySchema, I as dryRunAgentRuntimeConfigSchema, J as inboxPollQuerySchema, K as inboxAckFrameSchema, M as createOrgFromMeSchema, O as createAgentSchema, Ot as updateClientCapabilitiesSchema, Q as joinByInvitationSchema, R as getMeDocResponseSchema, T as contextTreeSnapshotSchema, Tt as updateAgentRuntimeConfigSchema, U as githubDevCallbackQuerySchema, V as githubAppInstallationPermissionsSchema$1, W as githubStartQuerySchema, X as isRedactedEnvValue, Y as isOrgSettingNamespace, _ as agentBindRequestSchema, _t as sendToAgentSchema, at as paginationQuerySchema, b as agentTypeSchema$1, bt as sessionReconcileRequestSchema, dt as refreshTokenSchema, et as listMeChatsQuerySchema, f as NOTIFICATION_TYPES, ft as runtimeStateMessageSchema, g as addParticipantSchema, gt as sendMessageSchema, h as addMeChatParticipantsSchema, ht as selfServiceFeishuBotSchema, i as AGENT_STATUSES, it as onboardingEventSchema, j as createMemberSchema, jt as wsAuthFrameSchema, k as createChatSchema, kt as updateMemberSchema, m as WS_AUTH_FRAME_TIMEOUT_MS, o as AGENT_VISIBILITY, ot as patchChatEngagementSchema, p as ORG_SETTINGS_NAMESPACES$1, pt as safeRedirectPath, q as inboxDeliverFrameSchema$1, r as AGENT_SELECTOR_HEADER$1, rt as notificationQuerySchema, s as CHAT_ENGAGEMENT_STATUSES, st as patchOnboardingSchema, t as AGENT_BIND_REJECT_REASONS, tt as loginSchema, ut as rebindAgentSchema, v as agentPinnedMessageSchema$1, vt as sessionEventMessageSchema, w as connectTokenExchangeSchema, wt as updateAdapterConfigSchema, x as chatMetadataSchema$1, xt as sessionStateMessageSchema, y as agentRuntimeConfigPayloadSchema$1, yt as sessionEventSchema$1, z as getMeDocSchema } from "./dist-DmYxT5Kb.mjs";
6
+ import { a as ClientUserMismatchError$1, c as NotFoundError, d as users, f as uuidv7, i as ClientOrgMismatchError$1, l as UnauthorizedError, n as AppError, o as ConflictError, r as BadRequestError, s as ForbiddenError, u as organizations } from "./uuid-DbS_4vFh-iFghv4zA.mjs";
7
7
  import { n as init_esm, r as trace, t as esm_exports } from "./esm-iadMkGbV.mjs";
8
- import { $ as notifyRecipients, A as getPresence, B as listAgentsManagedByUser, C as ensureParticipant, D as getChatDetail, E as getCachedAudience, F as joinAsParticipant, G as listClients, H as listChatParticipantsWithNames, I as joinChat, K as listClientsForOrgAdmin, L as leaveAsParticipant, M as heartbeatInstance, N as inboxEntries, O as getClient, P as invalidateChatAudience, Q as messages, R as leaveChat, S as ensureCanJoin, T as getActivityOverview, U as listChats, V as listAgentsWithRuntime, W as listChatsForMember, X as markSupersededByChat, Y as markStaleAgents, Z as members, _ as createChat, _t as unbindAgent, a as agentVisibilityCondition, at as registerClient, b as disconnectClient, c as assertParticipant, ct as resolveChatMembership, d as chatMembership, dt as sendToAgent$1, et as pendingQuestions, f as chats, ft as serverInstances, g as clients, gt as touchAgent, h as cleanupStalePresence, ht as submitAnswer, i as agentPresence, it as registerChatMessageDispatcher, j as heartbeatClient, k as getOnlineCount, l as bindAgent, lt as retireClient, m as cleanupStaleClients, mt as setRuntimeState, n as addParticipant, nt as recomputeWatchersForAgent, o as agents, ot as removeParticipant, p as claimClient, pt as setOffline, q as listMessages, r as agentChatSessions, rt as recomputeWatchersForMember, s as assertClientOwner, st as resetActivity, t as addChatParticipants, tt as recomputeChatWatchers, u as changeChatType, ut as sendMessage, v as createNotifier, vt as updateClientCapabilities, w as findOrCreateDirectChat, x as editMessage, y as deriveAuthState, yt as upsertSessionState, z as listActiveAgentsPinnedToClient } from "./client-CzXmweS9-DhUiuQvL.mjs";
9
- import { a as invitationRedemptions, c as recordRedemption, i as getActiveInvitation, l as rotateInvitation, n as ensureActiveInvitation, o as invitations, r as findActiveByToken, t as buildInviteUrl, u as uuidv7 } from "./invitation-DZO4NX3P-BPxTeHf-.mjs";
8
+ import { $ as getSession, $t as suspendSession, A as createAgent, At as pollInbox, B as extractSummary, Bt as resetTimedOutEntries, C as claimAndBuildForPush, Ct as markMeChatRead, D as cleanupStalePresence, Dt as messages, E as cleanupStaleClients, Et as members, F as deriveAuthState, Ft as registerChatMessageDispatcher, G as getAgentAvatarImage, Gt as sendToAgent$1, H as findOrCreateDirectChat, Ht as resolveDefaultOrgId$1, I as disconnectClient, It as registerClient, J as getChatDetail, Jt as setChatEngagement, K as getCachedAudience, Kt as serverInstances, L as editMessage, Lt as removeParticipant, M as createMeChat, Mt as reactivateAgent, N as createNotifier, Nt as rebindAgent, O as clearAgentAvatarImage, Ot as notifyRecipients, P as deleteAgent, Pt as recomputeWatchersForMember, Q as getPresence, Qt as suspendAgent, R as ensureDefaultOrganization, Rt as renewEntry, S as checkAgentNameAvailability, T as claimClient, Tt as markStaleAgents, U as getActivityOverview, Ut as retireClient, V as filterSessionsByParticipant, Vt as resolveChatTitle, W as getAgent, Wt as sendMessage, X as getOnlineCount, Xt as setRuntimeState, Y as getClient, Yt as setOffline, Z as getOrganization, Zt as submitAnswer, _ as assertParticipant, _t as listClients, a as adapterAgentMappings, an as upsertSessionState, at as leaveChat, b as chatUserState, bt as listMeChats, c as addMeChatParticipants, ct as listAgentSessions, d as agentChatSessions, dt as listAgentsManagedByUser, en as touchAgent, et as heartbeatClient, f as agentConfigs, ft as listAgentsWithRuntime, g as assertClientOwner, gt as listChatsForMember, h as archiveSession, ht as listChats, i as ackEntryByIdForBoundAgents, in as updateOrganization, it as joinMeChat, j as createChat, jt as pruneStaleSilentEntries, k as clients, kt as pendingQuestions, l as addParticipant, lt as listAgentsForAdmin, m as agents, mt as listChatParticipantsWithNames, n as SUPPORTED_AVATAR_IMAGE_MIMES, nn as updateAgent, nt as inboxEntries, o as adapterConfigs, ot as leaveMeChat, p as agentPresence, pt as listAllSessions, q as getCallerEngagement, qt as setAgentAvatarImage, r as ackEntry$2, rn as updateClientCapabilities, rt as joinChat, s as addChatParticipants, st as listActiveAgentsPinnedToClient, t as MAX_AVATAR_IMAGE_BYTES, tn as unbindAgent, tt as heartbeatInstance, u as agentAvatarImageUrl, ut as listAgentsForMember, v as bindAgent, vt as listClientsForOrgAdmin, w as claimBacklogForPush, wt as markMeChatUnread, x as chats, xt as listMessages, y as chatMembership, yt as listMeChatSourceCounts, z as ensureParticipant, zt as resetActivity } from "./client-CZ_VnbEc-CBF46cJd.mjs";
9
+ import { a as invitationRedemptions, c as recordRedemption, i as getActiveInvitation, l as rotateInvitation, n as ensureActiveInvitation, o as invitations, r as findActiveByToken, t as buildInviteUrl } from "./invitation-D_ENPHyj-5ETiae5r.mjs";
10
10
  import { createRequire } from "node:module";
11
11
  import { ZodError, z } from "zod";
12
12
  import { basename, delimiter, dirname, extname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
@@ -25,7 +25,7 @@ import { fileURLToPath } from "node:url";
25
25
  import * as semver from "semver";
26
26
  import { confirm, input, password, select } from "@inquirer/prompts";
27
27
  import bcrypt from "bcrypt";
28
- import { and, asc, count, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, ne, or, sql } from "drizzle-orm";
28
+ import { and, asc, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, ne, or, sql } from "drizzle-orm";
29
29
  import { drizzle } from "drizzle-orm/postgres-js";
30
30
  import postgres from "postgres";
31
31
  import { migrate } from "drizzle-orm/postgres-js/migrator";
@@ -822,13 +822,17 @@ z.object({
822
822
  participantIds: z.array(z.string()).min(1),
823
823
  metadata: optionalChatMetadataSchema.optional()
824
824
  });
825
- const chatParticipantSchema = z.object({
825
+ /**
826
+ * Participant row with the agent's public-ish metadata resolved — used by the
827
+ * client runtime for `@<name>` mention extraction against the authoritative
828
+ * participant set (see proposals/hub-agent-messaging-reply-and-mentions §4).
829
+ */
830
+ const chatParticipantDetailSchema = z.object({
826
831
  agentId: z.string(),
827
832
  role: z.string(),
828
833
  mode: z.string(),
829
834
  joinedAt: z.string()
830
- });
831
- chatParticipantSchema.extend({
835
+ }).extend({
832
836
  name: z.string().nullable(),
833
837
  displayName: z.string(),
834
838
  type: z.string()
@@ -843,7 +847,7 @@ z.object({
843
847
  createdAt: z.string(),
844
848
  updatedAt: z.string()
845
849
  }).extend({
846
- participants: z.array(chatParticipantSchema),
850
+ participants: z.array(chatParticipantDetailSchema),
847
851
  title: z.string(),
848
852
  firstMessagePreview: z.string().nullable(),
849
853
  engagementStatus: chatEngagementStatusSchema
@@ -1142,6 +1146,23 @@ const messageFormatSchema = z.enum([
1142
1146
  "question",
1143
1147
  "question_answer"
1144
1148
  ]);
1149
+ /**
1150
+ * Optional intent tag set by the client when posting through
1151
+ * `POST /agent/chats/:id/messages`. Tells the server *why* this write is
1152
+ * happening so it can pick the right enforcement profile.
1153
+ *
1154
+ * - `"agent-final-text"`: handler-initiated forward of an agent's final
1155
+ * reply text (today: `runtime/result-sink.ts`) OR an `AskUserQuestion`
1156
+ * payload posted via the canUseTool bridge. Both should land in chat
1157
+ * history so human observers in the web UI can see what the agent is
1158
+ * doing, but neither should wake other agents and neither should be
1159
+ * subject to the group-chat `@mention required` guard — they are not
1160
+ * a user-typed group broadcast. v1 §四 改造 4 (b) bypass channel.
1161
+ *
1162
+ * Default-`undefined` means a regular agent-initiated send (CLI `chat send`,
1163
+ * adapter, etc.) and goes through the normal enforcement profile.
1164
+ */
1165
+ const messagePurposeSchema = z.enum(["agent-final-text"]);
1145
1166
  z.object({
1146
1167
  format: messageFormatSchema.default("text"),
1147
1168
  content: z.unknown(),
@@ -1149,7 +1170,8 @@ z.object({
1149
1170
  inReplyTo: z.string().optional(),
1150
1171
  replyToInbox: z.string().optional(),
1151
1172
  replyToChat: z.string().optional(),
1152
- source: messageSourceSchema.optional()
1173
+ source: messageSourceSchema.optional(),
1174
+ purpose: messagePurposeSchema.optional()
1153
1175
  });
1154
1176
  z.object({
1155
1177
  format: messageFormatSchema.default("text"),
@@ -1157,7 +1179,8 @@ z.object({
1157
1179
  metadata: z.record(z.string(), z.unknown()).optional(),
1158
1180
  replyToInbox: z.string().optional(),
1159
1181
  replyToChat: z.string().optional(),
1160
- source: messageSourceSchema.optional()
1182
+ source: messageSourceSchema.optional(),
1183
+ direct: z.boolean().optional()
1161
1184
  });
1162
1185
  const messageSchema = z.object({
1163
1186
  id: z.string(),
@@ -1370,6 +1393,10 @@ z.object({
1370
1393
  lastReadAt: z.string(),
1371
1394
  unreadMentionCount: z.number().int()
1372
1395
  });
1396
+ z.object({
1397
+ chatId: z.string(),
1398
+ unreadMentionCount: z.number().int()
1399
+ });
1373
1400
  z.object({
1374
1401
  chatId: z.string(),
1375
1402
  membershipKind: meChatMembershipKindSchema.nullable()
@@ -2404,6 +2431,16 @@ var FirstTreeHubSDK = class {
2404
2431
  async listChats(options) {
2405
2432
  return this.requestJson(`/api/v1/agent/chats${this.queryString(options)}`);
2406
2433
  }
2434
+ /**
2435
+ * Fetch full chat detail (topic + participant membership rows). Used by the
2436
+ * runtime bootstrap path to assemble a chat-level identity block injected
2437
+ * into CLAUDE.md / AGENTS.md so the agent knows the chat's topic and who
2438
+ * else is in the room. Participant rows here lack name/displayName/type —
2439
+ * call `listChatParticipants` for that.
2440
+ */
2441
+ async getChatDetail(chatId) {
2442
+ return this.requestJson(`/api/v1/agent/chats/${chatId}`);
2443
+ }
2407
2444
  async listMessages(chatId, options) {
2408
2445
  return this.requestJson(`/api/v1/agent/chats/${chatId}/messages${this.queryString(options)}`);
2409
2446
  }
@@ -3332,7 +3369,7 @@ const FIRST_TREE_WORKSPACE_MARKER = ".first-tree-workspace";
3332
3369
  * and on resume().
3333
3370
  */
3334
3371
  function bootstrapWorkspace(options) {
3335
- const { workspacePath, identity, contextTreePath, serverUrl, chatId, briefing } = options;
3372
+ const { workspacePath, identity, contextTreePath, serverUrl, chatId, chatContext, briefing } = options;
3336
3373
  const agentDir = join(workspacePath, ".agent");
3337
3374
  const contextDir = join(agentDir, "context");
3338
3375
  if (existsSync(contextDir)) rmSync(contextDir, {
@@ -3348,7 +3385,8 @@ function bootstrapWorkspace(options) {
3348
3385
  metadata: identity.metadata,
3349
3386
  chatId,
3350
3387
  serverUrl,
3351
- contextTreePath
3388
+ contextTreePath,
3389
+ ...chatContext ? { chatContext } : {}
3352
3390
  };
3353
3391
  writeFileSync(join(agentDir, "identity.json"), JSON.stringify(identityData, null, 2), "utf-8");
3354
3392
  if (contextTreePath) {
@@ -3440,22 +3478,51 @@ You are running inside **Agent Hub**, a messaging platform for agent teams.
3440
3478
 
3441
3479
  - Messages from other team members arrive as your prompt input. Each message has a
3442
3480
  \`[From: <agent-name>]\` header — that name is what you pass back to \`chat send\`.
3443
- - **Your final text response is automatically delivered** to the chat just respond normally.
3481
+ - **Your final response text is delivered to the chat for human observers to read.
3482
+ It does NOT wake other agents.** To make another agent take action, use
3483
+ \`first-tree-hub chat send <name>\` explicitly (see "Communication Rules" below).
3444
3484
  - **Stay silent when you have nothing to add.** Not every message needs a reply.
3445
3485
  If you have nothing new for the recipient, output nothing and the runtime ends the turn.
3446
3486
  - For **proactive communication** (other agents, other chats, or different format),
3447
3487
  use the \`first-tree-hub\` CLI below.
3448
3488
 
3489
+ ## Communication Rules
3490
+
3491
+ Your final response text is delivered to the chat for **human observers**
3492
+ to read. It does NOT wake other agents.
3493
+
3494
+ To make another agent take action, you MUST explicitly call:
3495
+
3496
+ first-tree-hub chat send <name> "..."
3497
+
3498
+ Decision guide (based on participant \`type\` in the Current Chat Context block):
3499
+
3500
+ - Target is a **human** in this chat → your final text is enough; do not
3501
+ redundantly chat send (it just adds noise).
3502
+ - Target is an **agent** in this chat → they will NOT see your final text
3503
+ as a wake signal. You MUST chat send <name> if you need them to act.
3504
+ - No specific target (just narrating progress / thinking aloud) → final
3505
+ text only; no send needed.
3506
+
3507
+ **Fallback** (if Current Chat Context block is missing — context injection
3508
+ may have failed): use conservative mode — all cross-agent collaboration
3509
+ goes through explicit \`chat send\`; do not rely on final text to wake
3510
+ anyone.
3511
+
3449
3512
  ## Sending Messages
3450
3513
 
3451
3514
  The CLI auto-reads its config from env — no setup needed.
3452
3515
 
3453
3516
  \`\`\`bash
3454
- # Send to an agent by NAME (uuids are NOT accepted — run \`first-tree-hub agent list\` for names)
3517
+ # Send to an agent by NAME (uuids are NOT accepted — run \`first-tree-hub agent list\` for names).
3518
+ # Routing: the recipient MUST be a participant of your current chat — the message
3519
+ # lands in that chat. If they are NOT a member the call ERRORS with a hint. To open
3520
+ # a side-conversation with a non-member, use the --direct flag explicitly.
3455
3521
  first-tree-hub chat send <agentName> "your message"
3456
3522
 
3457
- # Address a specific chat (only when not your current chat)
3458
- first-tree-hub chat send --chat <chatId> "your message"
3523
+ # Open or reuse a direct chat with the recipient (bypass the member check).
3524
+ # Use only when intentionally starting a side conversation with a non-member.
3525
+ first-tree-hub chat send --direct <agentName> "your message"
3459
3526
 
3460
3527
  # Markdown format (default is text)
3461
3528
  first-tree-hub chat send <agentName> -f markdown "**bold**"
@@ -3467,6 +3534,17 @@ first-tree-hub chat send <agentName> --reply-to <messageId> "reply"
3467
3534
  echo "long body" | first-tree-hub chat send <agentName>
3468
3535
  \`\`\`
3469
3536
 
3537
+ **Reaching another agent — pick the right flag**:
3538
+
3539
+ - **Same chat member** → \`chat send <agentName> "..."\` (no flag).
3540
+ - **Non-member** → \`chat send --direct <agentName> "..."\`. The CLI opens (or
3541
+ reuses) the direct chat AND auto-mentions the recipient so they actually wake.
3542
+
3543
+ The CLI **only addresses agents by name**. You cannot route by chat-id from
3544
+ this command, and \`@<name>\` in your content only resolves against the chat
3545
+ you are currently in — naming someone who is not a member is rejected so a
3546
+ silent-drop misroute is impossible.
3547
+
3470
3548
  **Content rules (important):**
3471
3549
 
3472
3550
  - Pass content as a **raw string** — never \`JSON.stringify\` it first. Wrapping in
@@ -3476,6 +3554,74 @@ echo "long body" | first-tree-hub chat send <agentName>
3476
3554
  use **stdin** with real newlines, plus \`-f markdown\`.
3477
3555
  `;
3478
3556
  }
3557
+ /**
3558
+ * Build a narrow `ChatContext` snapshot for the current session.
3559
+ *
3560
+ * Calls the two existing agent-scoped endpoints in parallel:
3561
+ * - `GET /agent/chats/:chatId` — chat detail (topic)
3562
+ * - `GET /agent/chats/:chatId/participants` — participant rows with names
3563
+ *
3564
+ * Throws on either HTTP failure so the caller (handler) can log + degrade
3565
+ * to the no-context path. The bootstrap branch then writes neither the
3566
+ * identity.json `chatContext` field nor the CLAUDE.md / AGENTS.md section.
3567
+ */
3568
+ async function fetchChatContext(sdk, chatId, identity) {
3569
+ const [detail, participants] = await Promise.all([sdk.getChatDetail(chatId), sdk.listChatParticipants(chatId)]);
3570
+ const filteredParticipants = participants.filter((p) => p.name !== null && p.name.length > 0).map((p) => ({
3571
+ name: p.name,
3572
+ displayName: p.displayName,
3573
+ type: p.type === "human" ? "human" : "agent"
3574
+ }));
3575
+ const selfOwner = resolveSelfOwner(identity, participants);
3576
+ return {
3577
+ chatId,
3578
+ title: detail.title,
3579
+ topic: detail.topic,
3580
+ ...selfOwner ? { selfOwner } : {},
3581
+ participants: filteredParticipants
3582
+ };
3583
+ }
3584
+ /**
3585
+ * For delegate agents (personal_assistant whose `delegateMention` points at
3586
+ * a chat participant) return `{name, displayName}` of the human owner; for
3587
+ * autonomous agents return `undefined`.
3588
+ *
3589
+ * `delegateMention` holds the OWNER'S `name` slug — see
3590
+ * web/.../identity-section.tsx ("delegate <AgentChip ...>").
3591
+ */
3592
+ function resolveSelfOwner(identity, participants) {
3593
+ if (identity.type !== "personal_assistant") return void 0;
3594
+ if (!identity.delegateMention) return void 0;
3595
+ const owner = participants.find((p) => p.name === identity.delegateMention && p.type === "human");
3596
+ if (!owner || !owner.name) return void 0;
3597
+ return {
3598
+ name: owner.name,
3599
+ displayName: owner.displayName
3600
+ };
3601
+ }
3602
+ /**
3603
+ * Render the "Current Chat Context" markdown section that both Claude Code
3604
+ * (CLAUDE.md) and Codex (AGENTS.md) inject into the agent's prompt context.
3605
+ *
3606
+ * Shared so the two handlers never drift on field shape or wording. Returns
3607
+ * `null` when there's no context to render — caller skips the section.
3608
+ *
3609
+ * See proposals/hub-chat-message-v1-design §四 改造 3.
3610
+ */
3611
+ function renderChatContextSection(chatContext) {
3612
+ if (!chatContext) return null;
3613
+ const lines = [];
3614
+ lines.push("## Current Chat Context");
3615
+ lines.push("");
3616
+ lines.push(`- Chat ID: ${chatContext.chatId}`);
3617
+ if (chatContext.title && chatContext.title.trim().length > 0) lines.push(`- Title: ${chatContext.title}`);
3618
+ if (chatContext.topic && chatContext.topic.trim().length > 0 && chatContext.topic !== chatContext.title) lines.push(`- Topic: ${chatContext.topic}`);
3619
+ if (chatContext.selfOwner) lines.push(`- Your owner: ${chatContext.selfOwner.displayName} (@${chatContext.selfOwner.name})`);
3620
+ lines.push("- Participants:");
3621
+ if (chatContext.participants.length === 0) lines.push(" - (none)");
3622
+ else for (const p of chatContext.participants) lines.push(` - @${p.name} (${p.displayName}, type=${p.type})`);
3623
+ return `${lines.join("\n")}\n`;
3624
+ }
3479
3625
  function resolveGitRepoTargetPath(workspace, localPath) {
3480
3626
  const safetyError = getRepoLocalPathSafetyError(localPath);
3481
3627
  if (safetyError) throw new Error(`Unsafe git repo localPath "${localPath}": ${safetyError}`);
@@ -3997,6 +4143,7 @@ function createGitMirrorManager(opts) {
3997
4143
  }, "worktree create conflict");
3998
4144
  throw new GitMirrorWorktreeConflictError(`Worktree target "${absTarget}" is already occupied by ${classifyOccupant(absTarget)} — aborting (D13)`);
3999
4145
  }
4146
+ await gitOk(["worktree", "prune"], mirror, 1e4);
4000
4147
  const pathExists = existsSync(absTarget);
4001
4148
  const hasBranch = await branchExists(mirror, branchName);
4002
4149
  mkdirSync(dirname(absTarget), { recursive: true });
@@ -4897,7 +5044,8 @@ const createClaudeCodeHandler = (config) => {
4897
5044
  try {
4898
5045
  await sessionCtx.sdk.sendMessage(sessionCtx.chatId, {
4899
5046
  format: "question",
4900
- content: questionContent
5047
+ content: questionContent,
5048
+ purpose: "agent-final-text"
4901
5049
  });
4902
5050
  } catch (err) {
4903
5051
  const reason = err instanceof Error ? err.message : String(err);
@@ -5167,16 +5315,45 @@ const createClaudeCodeHandler = (config) => {
5167
5315
  }
5168
5316
  }
5169
5317
  }
5170
- /** Bootstrap workspace and generate CLAUDE.md. */
5171
- function runBootstrap(workspace, sessionCtx) {
5318
+ /**
5319
+ * Best-effort chat-context fetch for the identity-injection path. Failures
5320
+ * are logged but never bubble — bootstrap continues with `undefined` and
5321
+ * the agent simply loses the "Current Chat Context" block (graceful
5322
+ * degradation; the Communication Rules in tools.md still tell it to fall
5323
+ * back to conservative mode).
5324
+ */
5325
+ async function fetchChatContextOrLog(sessionCtx) {
5326
+ try {
5327
+ return await fetchChatContext(sessionCtx.sdk, sessionCtx.chatId, sessionCtx.agent);
5328
+ } catch (err) {
5329
+ sessionCtx.log(`fetchChatContext failed: ${err instanceof Error ? err.message : String(err)}`);
5330
+ return;
5331
+ }
5332
+ }
5333
+ /**
5334
+ * Refresh the workspace's identity.json + CLAUDE.md from the latest chat
5335
+ * context. v1.7 fix: chat-context must NOT be frozen at first bootstrap.
5336
+ * When new participants join later, every resume re-fetches and rewrites
5337
+ * the "Current Chat Context" section so the agent sees the live roster.
5338
+ * Skips the expensive `first-tree tree integrate` shell-out — that part
5339
+ * stays sentinel-protected and runs only on a fresh bootstrap.
5340
+ */
5341
+ function refreshIdentityAndPrompt(workspace, sessionCtx, chatContext) {
5172
5342
  bootstrapWorkspace({
5173
5343
  workspacePath: workspace,
5174
5344
  identity: sessionCtx.agent,
5175
5345
  contextTreePath,
5176
5346
  serverUrl: sessionCtx.sdk.serverUrl,
5177
- chatId: sessionCtx.chatId
5347
+ chatId: sessionCtx.chatId,
5348
+ chatContext
5178
5349
  });
5179
- generateClaudeMd(workspace, sessionCtx.agent, contextTreePath);
5350
+ generateClaudeMd(workspace, sessionCtx.agent, contextTreePath, chatContext);
5351
+ }
5352
+ /** Full bootstrap: refresh identity/prompt + run the expensive
5353
+ * `first-tree tree integrate` shell-out. Used on `start()` and on
5354
+ * `resume()` when the stage-2 sentinel is missing. */
5355
+ function runBootstrap(workspace, sessionCtx, chatContext) {
5356
+ refreshIdentityAndPrompt(workspace, sessionCtx, chatContext);
5180
5357
  if (contextTreePath) installFirstTreeIntegration({
5181
5358
  workspacePath: workspace,
5182
5359
  contextTreePath,
@@ -5190,7 +5367,8 @@ const createClaudeCodeHandler = (config) => {
5190
5367
  ctx = sessionCtx;
5191
5368
  claudeSessionId = randomUUID();
5192
5369
  cwd = acquireWorkspace(workspaceRoot, sessionCtx.chatId);
5193
- runBootstrap(cwd, sessionCtx);
5370
+ const chatContext = await fetchChatContextOrLog(sessionCtx);
5371
+ runBootstrap(cwd, sessionCtx, chatContext);
5194
5372
  const payload = agentConfigCache?.get(sessionCtx.agent.agentId)?.payload;
5195
5373
  await prepareGitWorktrees(cwd, payload, sessionCtx);
5196
5374
  markWorkspaceInitComplete(cwd);
@@ -5206,7 +5384,9 @@ const createClaudeCodeHandler = (config) => {
5206
5384
  claudeSessionId = sessionId;
5207
5385
  retryCount = 0;
5208
5386
  cwd = acquireWorkspace(workspaceRoot, sessionCtx.chatId);
5209
- if (!existsSync(join(cwd, INIT_COMPLETE_SENTINEL_REL))) runBootstrap(cwd, sessionCtx);
5387
+ const chatContext = await fetchChatContextOrLog(sessionCtx);
5388
+ if (!existsSync(join(cwd, INIT_COMPLETE_SENTINEL_REL))) runBootstrap(cwd, sessionCtx, chatContext);
5389
+ else refreshIdentityAndPrompt(cwd, sessionCtx, chatContext);
5210
5390
  const payload = agentConfigCache?.get(sessionCtx.agent.agentId)?.payload;
5211
5391
  await prepareGitWorktrees(cwd, payload, sessionCtx);
5212
5392
  markWorkspaceInitComplete(cwd);
@@ -5299,12 +5479,14 @@ function isHubWorktreeMarker(path) {
5299
5479
  * `agent_configs.payload.prompt.append` and are passed to the Claude SDK via
5300
5480
  * `systemPrompt.append` — not through this file.
5301
5481
  */
5302
- function generateClaudeMd(workspacePath, identity, contextTreePath) {
5482
+ function generateClaudeMd(workspacePath, identity, contextTreePath, chatContext) {
5303
5483
  const sections = [];
5304
5484
  const contextDir = join(workspacePath, ".agent", "context");
5305
5485
  const name = identity.displayName ?? identity.agentId;
5306
5486
  if (identity.type === "personal_assistant") sections.push(`# Agent Identity\n\nYou are ${name}, a personal assistant agent.\n`);
5307
5487
  else sections.push(`# Agent Identity\n\nYou are ${name}, an autonomous agent.\n`);
5488
+ const chatContextSection = renderChatContextSection(chatContext);
5489
+ if (chatContextSection) sections.push(chatContextSection);
5308
5490
  const agentInstructionsPath = join(contextDir, "agent-instructions.md");
5309
5491
  if (existsSync(agentInstructionsPath)) {
5310
5492
  const instructions = readFileSync(agentInstructionsPath, "utf-8");
@@ -5410,7 +5592,7 @@ const createCodexHandler = (config) => {
5410
5592
  cfg.mcp_servers = mcpServers;
5411
5593
  return cfg;
5412
5594
  }
5413
- function buildAgentBriefing(payload) {
5595
+ function buildAgentBriefing(payload, chatContext) {
5414
5596
  const lines = [];
5415
5597
  lines.push("# Agent Briefing");
5416
5598
  lines.push("");
@@ -5418,11 +5600,28 @@ const createCodexHandler = (config) => {
5418
5600
  lines.push(payload.prompt.append.trim());
5419
5601
  lines.push("");
5420
5602
  }
5603
+ const chatContextSection = renderChatContextSection(chatContext);
5604
+ if (chatContextSection) {
5605
+ lines.push(chatContextSection.trimEnd());
5606
+ lines.push("");
5607
+ }
5421
5608
  lines.push("Refer to `.agent/identity.json` for your agent identity, `.agent/tools.md` for the");
5422
5609
  lines.push("first-tree-hub SDK reference, and `.agent/context/` for organisational context");
5423
5610
  lines.push("(when configured).");
5424
5611
  return lines.join("\n").concat("\n");
5425
5612
  }
5613
+ /**
5614
+ * Best-effort chat-context fetch for the identity-injection path. Failures
5615
+ * are logged but never bubble — bootstrap continues with `undefined`.
5616
+ */
5617
+ async function fetchChatContextOrLog(sessionCtx) {
5618
+ try {
5619
+ return await fetchChatContext(sessionCtx.sdk, sessionCtx.chatId, sessionCtx.agent);
5620
+ } catch (err) {
5621
+ sessionCtx.log(`fetchChatContext failed: ${err instanceof Error ? err.message : String(err)}`);
5622
+ return;
5623
+ }
5624
+ }
5426
5625
  function toCodexInput(message, sessionCtx) {
5427
5626
  return sessionCtx.formatInboundContent(message).then((text) => text);
5428
5627
  }
@@ -5682,15 +5881,17 @@ const createCodexHandler = (config) => {
5682
5881
  env: [],
5683
5882
  gitRepos: []
5684
5883
  };
5884
+ const chatContext = await fetchChatContextOrLog(sessionCtx);
5685
5885
  bootstrapWorkspace({
5686
5886
  workspacePath: cwd,
5687
5887
  identity: sessionCtx.agent,
5688
5888
  contextTreePath,
5689
5889
  serverUrl: sessionCtx.sdk.serverUrl,
5690
5890
  chatId: sessionCtx.chatId,
5891
+ chatContext,
5691
5892
  briefing: {
5692
5893
  format: "agents-md",
5693
- content: buildAgentBriefing(payload)
5894
+ content: buildAgentBriefing(payload, chatContext)
5694
5895
  }
5695
5896
  });
5696
5897
  ensureFirstTreeBinding(cwd, sessionCtx);
@@ -5719,20 +5920,20 @@ const createCodexHandler = (config) => {
5719
5920
  env: [],
5720
5921
  gitRepos: []
5721
5922
  };
5722
- if (!existsSync(join(cwd, INIT_COMPLETE_SENTINEL_REL))) {
5723
- bootstrapWorkspace({
5724
- workspacePath: cwd,
5725
- identity: sessionCtx.agent,
5726
- contextTreePath,
5727
- serverUrl: sessionCtx.sdk.serverUrl,
5728
- chatId: sessionCtx.chatId,
5729
- briefing: {
5730
- format: "agents-md",
5731
- content: buildAgentBriefing(payload)
5732
- }
5733
- });
5734
- ensureFirstTreeBinding(cwd, sessionCtx);
5735
- }
5923
+ const chatContext = await fetchChatContextOrLog(sessionCtx);
5924
+ bootstrapWorkspace({
5925
+ workspacePath: cwd,
5926
+ identity: sessionCtx.agent,
5927
+ contextTreePath,
5928
+ serverUrl: sessionCtx.sdk.serverUrl,
5929
+ chatId: sessionCtx.chatId,
5930
+ chatContext,
5931
+ briefing: {
5932
+ format: "agents-md",
5933
+ content: buildAgentBriefing(payload, chatContext)
5934
+ }
5935
+ });
5936
+ if (!existsSync(join(cwd, INIT_COMPLETE_SENTINEL_REL))) ensureFirstTreeBinding(cwd, sessionCtx);
5736
5937
  await prepareGitWorktrees(payload, cwd, sessionCtx);
5737
5938
  markWorkspaceInitComplete(cwd);
5738
5939
  codex = new Codex({
@@ -6048,17 +6249,10 @@ var Deduplicator = class {
6048
6249
  }
6049
6250
  };
6050
6251
  function createResultSink(deps) {
6051
- async function buildMetadata(trigger) {
6252
+ async function buildMetadata() {
6052
6253
  const metadata = {};
6053
6254
  const documentBasePath = await deps.getDocumentBasePath?.();
6054
6255
  if (documentBasePath) metadata.documentContext = documentContextSchema.parse({ basePath: documentBasePath });
6055
- if (trigger && trigger.senderId !== deps.agent.agentId) {
6056
- const participants = await deps.participants.get();
6057
- if (participants.length <= 2) {
6058
- const peer = participants.find((p) => p.agentId === trigger.senderId);
6059
- if (!peer || peer.mode === "mention_only") metadata.mentions = [trigger.senderId];
6060
- } else metadata.mentions = [trigger.senderId];
6061
- }
6062
6256
  return Object.keys(metadata).length > 0 ? metadata : void 0;
6063
6257
  }
6064
6258
  return async function forwardResult(text) {
@@ -6069,10 +6263,11 @@ function createResultSink(deps) {
6069
6263
  }
6070
6264
  const trigger = deps.getTrigger();
6071
6265
  deps.clearTrigger();
6072
- const metadata = await buildMetadata(trigger);
6266
+ const metadata = await buildMetadata();
6073
6267
  await deps.sdk.sendMessage(deps.chatId, {
6074
6268
  format: "text",
6075
6269
  content: text,
6270
+ purpose: "agent-final-text",
6076
6271
  ...trigger ? { inReplyTo: trigger.messageId } : {},
6077
6272
  ...metadata ? { metadata } : {}
6078
6273
  });
@@ -6649,7 +6844,6 @@ var SessionManager = class {
6649
6844
  this.currentTrigger.delete(chatId);
6650
6845
  },
6651
6846
  log,
6652
- participants,
6653
6847
  getDocumentBasePath: () => this.resolveDocumentBasePath(log)
6654
6848
  });
6655
6849
  const envCtx = {
@@ -9587,7 +9781,7 @@ function formatCheckReport(items) {
9587
9781
  }
9588
9782
  return lines.join("\n");
9589
9783
  }
9590
- async function resolveDefaultOrgId$1(serverUrl, accessToken) {
9784
+ async function resolveDefaultOrgId(serverUrl, accessToken) {
9591
9785
  const res = await cliFetch(`${serverUrl}/api/v1/me`, {
9592
9786
  headers: { Authorization: `Bearer ${accessToken}` },
9593
9787
  signal: AbortSignal.timeout(1e4)
@@ -9622,7 +9816,7 @@ async function onboardCreate(args) {
9622
9816
  if (args.role) metadata.role = args.role;
9623
9817
  if (args.domains) metadata.domains = args.domains.split(",").map((d) => d.trim());
9624
9818
  print.line(`Creating agent "${args.id}"...\n`);
9625
- const orgId = await resolveDefaultOrgId$1(serverUrl, accessToken);
9819
+ const orgId = await resolveDefaultOrgId(serverUrl, accessToken);
9626
9820
  const primary = await createAgentViaAdmin(serverUrl, accessToken, orgId, {
9627
9821
  name: args.id,
9628
9822
  type: args.type,
@@ -9659,7 +9853,7 @@ async function onboardCreate(args) {
9659
9853
  }
9660
9854
  const runtimeAgent = args.type === "human" ? args.assistant : args.id;
9661
9855
  if (args.feishuBotAppId && args.feishuBotAppSecret) {
9662
- const { bindFeishuBot } = await import("./feishu-BGx71p5s.mjs").then((n) => n.r);
9856
+ const { bindFeishuBot } = await import("./feishu-CCWd-JE4.mjs").then((n) => n.r);
9663
9857
  const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
9664
9858
  if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
9665
9859
  else {
@@ -10872,7 +11066,7 @@ function createFeedbackHandler(config) {
10872
11066
  return { handle };
10873
11067
  }
10874
11068
  //#endregion
10875
- //#region ../server/dist/app-BcZq1C1l.mjs
11069
+ //#region ../server/dist/app-Cv337jed.mjs
10876
11070
  var import_fastify_opentelemetry = /* @__PURE__ */ __toESM(require_fastify_opentelemetry(), 1);
10877
11071
  init_esm();
10878
11072
  var __defProp = Object.defineProperty;
@@ -10885,17 +11079,6 @@ var __exportAll = (all, no_symbols) => {
10885
11079
  if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
10886
11080
  return target;
10887
11081
  };
10888
- /** Maps external user identities to internal Agents. */
10889
- const adapterAgentMappings = pgTable("adapter_agent_mappings", {
10890
- id: serial("id").primaryKey(),
10891
- platform: text("platform").notNull(),
10892
- externalUserId: text("external_user_id").notNull(),
10893
- agentId: text("agent_id").notNull().references(() => agents.uuid),
10894
- boundVia: text("bound_via"),
10895
- displayName: text("display_name"),
10896
- metadata: jsonb("metadata").$type().notNull().default({}),
10897
- createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
10898
- }, (table) => [unique("uq_adapter_agent_mapping").on(table.platform, table.externalUserId)]);
10899
11082
  /**
10900
11083
  * Pull the JWT-verified `UserScope` off the request. The `userAuthHook`
10901
11084
  * middleware populates `request.user` synchronously before any handler
@@ -10956,10 +11139,23 @@ async function requireAgentAccess(request, db, kind) {
10956
11139
  };
10957
11140
  }
10958
11141
  /**
10959
- * Gate access to a chat. Allowed if the caller's HUMAN agent is a
10960
- * participant, OR any agent the caller manages (via members.id) is a
10961
- * participant. Admin role does NOT auto-grant chat access — chat content
10962
- * remains private to participants and supervisors (their managers).
11142
+ * Gate access to a chat. Allowed if the caller's HUMAN agent has any
11143
+ * `chat_membership` row (speaker OR watcher), OR any agent the caller
11144
+ * manages (via members.id) is a speaker. Admin role does NOT auto-grant
11145
+ * chat access — chat content remains private to members and supervisors
11146
+ * (their managers).
11147
+ *
11148
+ * Watchers are allowed on the direct-membership branch because they
11149
+ * surface in `listMeChats` with their own unread badge and engagement
11150
+ * state; chat-scoped per-user operations like read-cursor and
11151
+ * watcher→speaker upgrade must be reachable from that surface. Write
11152
+ * endpoints that need to refuse watchers rely on `ensureParticipant`
11153
+ * or service-layer checks, not on this guard.
11154
+ *
11155
+ * The supervisor branch is a fallback for callers whose human agent
11156
+ * has no direct row but who manage a speaker — e.g. before
11157
+ * `recomputeChatWatchers` has materialised the watcher row, or when a
11158
+ * member's human agent and managed agent diverge in cross-org chats.
10963
11159
  *
10964
11160
  * The Params type is generic so routes that mount on a path with extra
10965
11161
  * params (e.g. `/agents/:uuid/sessions/:chatId/...` for compound checks)
@@ -10979,7 +11175,7 @@ async function requireChatAccess(request, db) {
10979
11175
  role: caller.role,
10980
11176
  humanAgentId: caller.humanAgentId
10981
11177
  };
10982
- const [direct] = await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, caller.humanAgentId), eq(chatMembership.accessMode, "speaker"))).limit(1);
11178
+ const [direct] = await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, caller.humanAgentId))).limit(1);
10983
11179
  if (direct) {
10984
11180
  stampOrgScope(request, scope);
10985
11181
  stampChatResource(request, chat);
@@ -11068,16 +11264,6 @@ async function adapterMappingRoutes(app) {
11068
11264
  return reply.status(204).send();
11069
11265
  });
11070
11266
  }
11071
- /** Bot credentials for external platform adapters. Credentials are encrypted at application layer (AES-256-GCM). */
11072
- const adapterConfigs = pgTable("adapter_configs", {
11073
- id: serial("id").primaryKey(),
11074
- platform: text("platform").notNull(),
11075
- agentId: text("agent_id").notNull().references(() => agents.uuid),
11076
- credentials: jsonb("credentials").$type().notNull(),
11077
- status: text("status").notNull().default("active"),
11078
- createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
11079
- updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
11080
- }, (t) => [unique("uq_adapter_configs_agent_platform").on(t.agentId, t.platform)]);
11081
11267
  const ALGORITHM = "aes-256-gcm";
11082
11268
  const IV_LENGTH = 12;
11083
11269
  const AUTH_TAG_LENGTH = 16;
@@ -11346,7 +11532,7 @@ async function agentChatRoutes(app) {
11346
11532
  app.get("/:chatId", async (request) => {
11347
11533
  const identity = requireAgent(request);
11348
11534
  await assertParticipant(app.db, request.params.chatId, identity.uuid);
11349
- const detail = await getChatDetail(app.db, request.params.chatId);
11535
+ const detail = await getChatDetail(app.db, request.params.chatId, identity.uuid);
11350
11536
  return {
11351
11537
  ...serializeChat(detail),
11352
11538
  participants: detail.participants.map((p) => ({
@@ -11411,592 +11597,6 @@ async function agentConfigRoutes$1(app) {
11411
11597
  return await app.configService.getDecrypted(identity.uuid);
11412
11598
  });
11413
11599
  }
11414
- /**
11415
- * Per-agent runtime configuration (Hub-managed; not the local YAML config).
11416
- *
11417
- * One row per agent. `version` increments on every successful UPDATE
11418
- * (optimistic locking via WHERE version = :expected). Sensitive env values
11419
- * inside `payload.env[*]` are AES-256-GCM encrypted at write time and
11420
- * masked when echoed via the Admin API (see Step 2).
11421
- *
11422
- * Integrity is enforced by the service layer per project convention:
11423
- * no FK / CHECK / triggers on this table.
11424
- */
11425
- const agentConfigs = pgTable("agent_configs", {
11426
- agentId: text("agent_id").primaryKey(),
11427
- version: integer("version").notNull().default(1),
11428
- payload: jsonb("payload").$type().notNull(),
11429
- updatedBy: text("updated_by").notNull(),
11430
- updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
11431
- });
11432
- /**
11433
- * Resolve the UUID of the "default" organization. Internal use only —
11434
- * webhooks, fallbacks, etc. The HTTP API layer no longer falls back to
11435
- * the JWT default org.
11436
- */
11437
- async function resolveDefaultOrgId(db) {
11438
- const [org] = await db.select({ id: organizations.id }).from(organizations).where(eq(organizations.name, "default")).limit(1);
11439
- if (!org) throw new Error("Default organization not found. Ensure the server has started and ensureDefaultOrganization() ran.");
11440
- return org.id;
11441
- }
11442
- async function getOrganization(db, id) {
11443
- const [org] = await db.select().from(organizations).where(eq(organizations.id, id)).limit(1);
11444
- if (!org) throw new NotFoundError(`Organization "${id}" not found`);
11445
- return org;
11446
- }
11447
- async function updateOrganization(db, id, data) {
11448
- const updates = { updatedAt: /* @__PURE__ */ new Date() };
11449
- if (data.name !== void 0) updates.name = data.name;
11450
- if (data.displayName !== void 0) updates.displayName = data.displayName;
11451
- if (data.maxAgents !== void 0) updates.maxAgents = data.maxAgents;
11452
- if (data.maxMessagesPerMinute !== void 0) updates.maxMessagesPerMinute = data.maxMessagesPerMinute;
11453
- if (data.features !== void 0) updates.features = data.features;
11454
- try {
11455
- const [org] = await db.update(organizations).set(updates).where(eq(organizations.id, id)).returning();
11456
- if (!org) throw new NotFoundError(`Organization "${id}" not found`);
11457
- return org;
11458
- } catch (err) {
11459
- if ((err?.code ?? err?.cause?.code ?? "") === "23505") throw new ConflictError(`Organization name "${data.name}" is already taken`);
11460
- throw err;
11461
- }
11462
- }
11463
- /**
11464
- * Ensure the default organization exists. Called on server startup.
11465
- * Uses a fixed UUID for the default org to ensure idempotency.
11466
- */
11467
- async function ensureDefaultOrganization(db) {
11468
- const [existing] = await db.select({ id: organizations.id }).from(organizations).where(eq(organizations.name, "default")).limit(1);
11469
- if (existing) return existing;
11470
- const id = uuidv7();
11471
- const [org] = await db.insert(organizations).values({
11472
- id,
11473
- name: "default",
11474
- displayName: "Default Organization"
11475
- }).onConflictDoNothing().returning();
11476
- return org ?? existing;
11477
- }
11478
- /**
11479
- * Names beginning with `__` are reserved for Hub-internal pseudo agents.
11480
- * User-facing creation must not be able to squat on them, otherwise
11481
- * internal traffic could be routed through a real account.
11482
- */
11483
- const RESERVED_AGENT_NAME_PREFIX = "__";
11484
- /**
11485
- * Derive the relative URL clients should use to fetch a manager-uploaded
11486
- * avatar image. Returns `null` when no image is set. Embeds the upload
11487
- * timestamp as `?v=<epoch>` so a fresh upload busts any browser cache
11488
- * that may have memoised the previous version.
11489
- *
11490
- * Auth: the image route is intentionally public read — the URL leaks no
11491
- * more than the agent's UUID, which is already required to address it.
11492
- * Keeping it unauthenticated lets `<img src>` render without bespoke
11493
- * fetch-and-blob plumbing.
11494
- */
11495
- function agentAvatarImageUrl(uuid, updatedAt) {
11496
- if (!updatedAt) return null;
11497
- return `/api/v1/agents/${uuid}/avatar?v=${updatedAt.getTime()}`;
11498
- }
11499
- /**
11500
- * True iff `clients.metadata.capabilities` is a non-empty object — i.e. the
11501
- * client has reported at least one runtime probe result. Used to distinguish
11502
- * "we don't know what's installed yet" (empty / never reported) from
11503
- * "client explicitly reports this provider is missing".
11504
- */
11505
- function clientCapabilitiesReported(metadata) {
11506
- if (!metadata || typeof metadata !== "object") return false;
11507
- const caps = metadata.capabilities;
11508
- if (!caps || typeof caps !== "object") return false;
11509
- return Object.keys(caps).length > 0;
11510
- }
11511
- /**
11512
- * Inspect a `clients.metadata.capabilities` blob (jsonb) for a specific
11513
- * runtime provider entry. Capabilities live under the `metadata.capabilities`
11514
- * subkey (Option C); the column is unstructured at the DB layer, so we
11515
- * defensively narrow before key access.
11516
- *
11517
- * "Supports" requires the entry's SDK to be **available** — `state: "ok"` or
11518
- * `state: "unauthenticated"`. A `missing` or `error` entry is *reported* but
11519
- * not usable, so we explicitly reject those rather than treating mere key
11520
- * presence as support. Auth state is left to the user to fix at runtime
11521
- * (the re-bind dialog surfaces an `unauthenticated` hint).
11522
- */
11523
- function clientSupportsRuntimeProvider(metadata, provider) {
11524
- if (!metadata || typeof metadata !== "object") return false;
11525
- const caps = metadata.capabilities;
11526
- if (!caps || typeof caps !== "object") return false;
11527
- const entry = caps[provider];
11528
- if (!entry || typeof entry !== "object") return false;
11529
- return entry.available === true;
11530
- }
11531
- /** Default visibility per agent type. */
11532
- function defaultVisibility(type) {
11533
- switch (type) {
11534
- case "human":
11535
- case "autonomous_agent": return AGENT_VISIBILITY.ORGANIZATION;
11536
- case "personal_assistant": return AGENT_VISIBILITY.PRIVATE;
11537
- default: return AGENT_VISIBILITY.PRIVATE;
11538
- }
11539
- }
11540
- /**
11541
- * Resolve + validate the client that will own the new agent.
11542
- *
11543
- * Rule (unified-user-token, post-first-bind relaxation):
11544
- * - Human agents represent the member themselves and have no runtime; a
11545
- * missing `clientId` is required and the column stays NULL.
11546
- * - Non-human agents MAY omit `clientId` at creation; the row stays NULL
11547
- * and is claimed on the first WS bind (see `api/agent/ws-client.ts`).
11548
- * - When a non-human agent IS created with a `clientId`, the pinned client
11549
- * must already be owned by the manager's user (Rule R-RUN).
11550
- */
11551
- /**
11552
- * Check that a client's reported capabilities show the given runtime provider
11553
- * as **available** (SDK installed, regardless of auth state).
11554
- *
11555
- * Tri-state semantics by `clients.metadata.capabilities` shape:
11556
- * - empty / absent — client hasn't probed yet (newly registered or pre-P2
11557
- * install). Treat as "unknown" and allow; the in-band repair path
11558
- * (RUNTIME_PROVIDER_MISMATCH on bind) catches actual incompatibility.
11559
- * - reported, entry shows `state: ok | unauthenticated` (i.e. `available:
11560
- * true`) — allow.
11561
- * - reported, entry missing OR `state: missing | error` — block unless
11562
- * `force` is set. We deliberately do NOT treat mere key presence as
11563
- * support: probeCapabilities() always emits an entry per built-in
11564
- * provider, including `{ state: "missing" }` for absent SDKs.
11565
- *
11566
- * Skipped entirely for human agents (no clientId) and when `force` is set
11567
- * (e.g. operator overrides for an offline client).
11568
- */
11569
- async function ensureClientSupportsRuntimeProvider(db, clientId, runtimeProvider, options = {}) {
11570
- if (clientId === null) return;
11571
- if (options.force) return;
11572
- const [client] = await db.select({ metadata: clients.metadata }).from(clients).where(eq(clients.id, clientId)).limit(1);
11573
- if (!client) return;
11574
- if (!clientCapabilitiesReported(client.metadata)) return;
11575
- 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.`);
11576
- }
11577
- async function resolveAgentClient(db, data) {
11578
- if (data.type === "human") {
11579
- if (data.clientId) throw new BadRequestError("Human agents cannot be pinned to a client");
11580
- return null;
11581
- }
11582
- if (!data.clientId) return null;
11583
- const [manager] = await db.select({ userId: members.userId }).from(members).where(eq(members.id, data.managerId)).limit(1);
11584
- if (!manager) throw new BadRequestError(`Manager "${data.managerId}" not found`);
11585
- const [client] = await db.select({
11586
- id: clients.id,
11587
- userId: clients.userId
11588
- }).from(clients).where(eq(clients.id, data.clientId)).limit(1);
11589
- if (!client) throw new BadRequestError(`Client "${data.clientId}" not found`);
11590
- 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.`);
11591
- 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.`);
11592
- return client.id;
11593
- }
11594
- /**
11595
- * Validate a `delegateMention` write at the service layer. Two checks:
11596
- * 1. Target uuid must resolve to an existing agent — dangling references
11597
- * would silently break webhook delegation at runtime.
11598
- * 2. Target must belong to the same organization as the source agent —
11599
- * cross-org delegate links are rejected here at the source so the
11600
- * database never accumulates dirty rows. The webhook router has a
11601
- * defense-in-depth check that filters them at fan-out time, but this
11602
- * keeps the data clean and gives the admin UI an immediate 422 instead
11603
- * of a silent runtime drop.
11604
- *
11605
- * `null` clears the field — handled by the caller; we are only invoked when
11606
- * the caller wrote a non-null uuid.
11607
- */
11608
- async function validateDelegateMentionTarget(db, targetUuid, sourceOrgId) {
11609
- const [target] = await db.select({
11610
- uuid: agents.uuid,
11611
- organizationId: agents.organizationId
11612
- }).from(agents).where(eq(agents.uuid, targetUuid)).limit(1);
11613
- if (!target) throw new BadRequestError(`delegateMention target "${targetUuid}" not found`);
11614
- if (target.organizationId !== sourceOrgId) throw new BadRequestError("delegateMention target must belong to the same organization as the agent");
11615
- }
11616
- /**
11617
- * Service-layer guard: `delegateMention` is only available for `human` agents.
11618
- * Mirrors the Web UI in `identity-section.tsx`, which only renders the
11619
- * delegate-mention selector when `agent.type === "human"`. Without this
11620
- * server-side check, CLI / Admin API / internal scripts could write
11621
- * delegateMention onto non-human rows, silently re-enabling the
11622
- * autonomous-agent-self-mention path that resolveAudience would then fan
11623
- * out. Called from `createAgent` / `updateAgent` before
11624
- * `validateDelegateMentionTarget` so a wrong source type fails fast without
11625
- * the target lookup round-trip.
11626
- */
11627
- function assertDelegateMentionAllowed(sourceType) {
11628
- if (sourceType !== AGENT_TYPES.HUMAN) throw new BadRequestError("delegateMention can only be set on human agents");
11629
- }
11630
- /**
11631
- * Pick the first admin member in the org for internal system agents. Throws
11632
- * if the org has no admin — the caller should surface the error so an admin
11633
- * is created before the system tries to register more agents.
11634
- */
11635
- async function resolveFallbackManagerId(db, orgId) {
11636
- 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);
11637
- 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\`).`);
11638
- return row.id;
11639
- }
11640
- async function createAgent(db, data, options = {}) {
11641
- const uuid = uuidv7();
11642
- const name = data.name ?? null;
11643
- const runtimeProvider = data.runtimeProvider ?? "claude-code";
11644
- 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`);
11645
- if (name && isReservedAgentName$1(name)) throw new BadRequestError(`Agent name "${name}" is reserved — pick a different one.`);
11646
- const inboxId = `inbox_${uuid}`;
11647
- let orgId;
11648
- let managerId;
11649
- if (data.managerId && data.organizationId) {
11650
- orgId = data.organizationId;
11651
- managerId = data.managerId;
11652
- } else if (data.managerId) {
11653
- const [manager] = await db.select({
11654
- id: members.id,
11655
- organizationId: members.organizationId
11656
- }).from(members).where(eq(members.id, data.managerId)).limit(1);
11657
- if (!manager) throw new BadRequestError(`Manager "${data.managerId}" not found`);
11658
- orgId = manager.organizationId;
11659
- managerId = manager.id;
11660
- } else {
11661
- orgId = data.organizationId ?? await resolveDefaultOrgId(db);
11662
- managerId = await resolveFallbackManagerId(db, orgId);
11663
- }
11664
- const clientId = await resolveAgentClient(db, {
11665
- clientId: data.clientId,
11666
- managerId,
11667
- type: data.type
11668
- });
11669
- await ensureClientSupportsRuntimeProvider(db, clientId, runtimeProvider, { force: options.force });
11670
- if (data.delegateMention) {
11671
- assertDelegateMentionAllowed(data.type);
11672
- await validateDelegateMentionTarget(db, data.delegateMention, orgId);
11673
- }
11674
- const [org] = await db.select({ maxAgents: organizations.maxAgents }).from(organizations).where(eq(organizations.id, orgId)).limit(1);
11675
- if (org && org.maxAgents > 0) {
11676
- 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.`);
11677
- }
11678
- const resolvedDisplayName = data.displayName?.trim() || name || "Unnamed Agent";
11679
- try {
11680
- return await db.transaction(async (tx) => {
11681
- const [row] = await tx.insert(agents).values({
11682
- uuid,
11683
- name,
11684
- organizationId: orgId,
11685
- type: data.type,
11686
- displayName: resolvedDisplayName,
11687
- delegateMention: data.delegateMention ?? null,
11688
- inboxId,
11689
- source: data.source ?? null,
11690
- visibility: data.visibility ?? defaultVisibility(data.type),
11691
- metadata: data.metadata ?? {},
11692
- managerId,
11693
- clientId,
11694
- runtimeProvider
11695
- }).returning();
11696
- if (!row) throw new Error("Unexpected: INSERT RETURNING produced no row");
11697
- const initialPayload = defaultRuntimeConfigPayload(runtimeProvider);
11698
- if (data.gitRepos && data.gitRepos.length > 0) initialPayload.gitRepos = data.gitRepos;
11699
- await tx.insert(agentConfigs).values({
11700
- agentId: row.uuid,
11701
- version: 1,
11702
- payload: initialPayload,
11703
- updatedBy: "system"
11704
- }).onConflictDoNothing();
11705
- return row;
11706
- });
11707
- } catch (err) {
11708
- if ((err?.code ?? err?.cause?.code ?? "") === "23505" && name) throw new ConflictError(`Agent name "${name}" already exists in organization "${orgId}"`);
11709
- throw err;
11710
- }
11711
- }
11712
- async function checkAgentNameAvailability(db, orgId, name) {
11713
- if (!AGENT_NAME_REGEX$1.test(name)) return {
11714
- available: false,
11715
- reason: "invalid"
11716
- };
11717
- if (isReservedAgentName$1(name) || name.startsWith(RESERVED_AGENT_NAME_PREFIX)) return {
11718
- available: false,
11719
- reason: "reserved"
11720
- };
11721
- 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);
11722
- return existing ? {
11723
- available: false,
11724
- reason: "taken"
11725
- } : { available: true };
11726
- }
11727
- async function getAgent(db, uuid) {
11728
- const [agent] = await db.select().from(agents).where(and(eq(agents.uuid, uuid), ne(agents.status, AGENT_STATUSES.DELETED))).limit(1);
11729
- if (!agent) throw new NotFoundError(`Agent "${uuid}" not found`);
11730
- return agent;
11731
- }
11732
- /**
11733
- * Admin-only variant: return every non-deleted agent in the org, ignoring
11734
- * the visibility filter. Used by the `/admin` "All Agents" view so a team
11735
- * admin can see and act on private agents owned by other members. The
11736
- * route layer is responsible for gating this to admin callers — the
11737
- * service does not enforce role by itself, but it does enforce org scope
11738
- * and the not-deleted predicate.
11739
- */
11740
- async function listAgentsForAdmin(db, scope, limit, cursor) {
11741
- const conditions = [eq(agents.organizationId, scope.organizationId), ne(agents.status, AGENT_STATUSES.DELETED)];
11742
- if (cursor) conditions.push(lt(agents.createdAt, new Date(cursor)));
11743
- const where = and(...conditions);
11744
- const rows = await db.select({
11745
- uuid: agents.uuid,
11746
- name: agents.name,
11747
- organizationId: agents.organizationId,
11748
- type: agents.type,
11749
- displayName: agents.displayName,
11750
- delegateMention: agents.delegateMention,
11751
- inboxId: agents.inboxId,
11752
- status: agents.status,
11753
- visibility: agents.visibility,
11754
- metadata: agents.metadata,
11755
- managerId: agents.managerId,
11756
- clientId: agents.clientId,
11757
- runtimeProvider: agents.runtimeProvider,
11758
- avatarColorToken: agents.avatarColorToken,
11759
- avatarImageUpdatedAt: agents.avatarImageUpdatedAt,
11760
- createdAt: agents.createdAt,
11761
- updatedAt: agents.updatedAt,
11762
- presenceStatus: agentPresence.status,
11763
- runtimeType: agentPresence.runtimeType,
11764
- runtimeState: agentPresence.runtimeState,
11765
- activeSessions: agentPresence.activeSessions
11766
- }).from(agents).leftJoin(agentPresence, eq(agents.uuid, agentPresence.agentId)).where(where).orderBy(desc(agents.createdAt)).limit(limit + 1);
11767
- const hasMore = rows.length > limit;
11768
- const items = hasMore ? rows.slice(0, limit) : rows;
11769
- const last = items[items.length - 1];
11770
- return {
11771
- items,
11772
- nextCursor: hasMore && last ? last.createdAt.toISOString() : null
11773
- };
11774
- }
11775
- /**
11776
- * List agents visible to a specific member.
11777
- * Uses agentVisibilityCondition from access-control (same rules for all roles).
11778
- */
11779
- async function listAgentsForMember(db, scope, limit, cursor, type) {
11780
- const conditions = [agentVisibilityCondition(scope.organizationId, scope.memberId)];
11781
- if (cursor) conditions.push(lt(agents.createdAt, new Date(cursor)));
11782
- if (type) conditions.push(eq(agents.type, type));
11783
- const where = and(...conditions);
11784
- const rows = await db.select({
11785
- uuid: agents.uuid,
11786
- name: agents.name,
11787
- organizationId: agents.organizationId,
11788
- type: agents.type,
11789
- displayName: agents.displayName,
11790
- delegateMention: agents.delegateMention,
11791
- inboxId: agents.inboxId,
11792
- status: agents.status,
11793
- visibility: agents.visibility,
11794
- metadata: agents.metadata,
11795
- managerId: agents.managerId,
11796
- clientId: agents.clientId,
11797
- runtimeProvider: agents.runtimeProvider,
11798
- avatarColorToken: agents.avatarColorToken,
11799
- avatarImageUpdatedAt: agents.avatarImageUpdatedAt,
11800
- createdAt: agents.createdAt,
11801
- updatedAt: agents.updatedAt,
11802
- presenceStatus: agentPresence.status,
11803
- runtimeType: agentPresence.runtimeType,
11804
- runtimeState: agentPresence.runtimeState,
11805
- activeSessions: agentPresence.activeSessions
11806
- }).from(agents).leftJoin(agentPresence, eq(agents.uuid, agentPresence.agentId)).where(where).orderBy(desc(agents.createdAt)).limit(limit + 1);
11807
- const hasMore = rows.length > limit;
11808
- const items = hasMore ? rows.slice(0, limit) : rows;
11809
- const last = items[items.length - 1];
11810
- return {
11811
- items,
11812
- nextCursor: hasMore && last ? last.createdAt.toISOString() : null
11813
- };
11814
- }
11815
- async function updateAgent(db, uuid, data) {
11816
- const agent = await getAgent(db, uuid);
11817
- if (data.clientId !== void 0) {
11818
- if (data.clientId === null) throw new BadRequestError("clientId cannot be cleared — once bound, an agent stays bound to its client");
11819
- 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.");
11820
- }
11821
- const updates = { updatedAt: /* @__PURE__ */ new Date() };
11822
- if (data.type !== void 0) {
11823
- 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.");
11824
- updates.type = data.type;
11825
- }
11826
- if (data.displayName !== void 0) updates.displayName = data.displayName;
11827
- if (data.delegateMention !== void 0) {
11828
- if (data.delegateMention !== null) {
11829
- assertDelegateMentionAllowed(data.type ?? agent.type);
11830
- await validateDelegateMentionTarget(db, data.delegateMention, agent.organizationId);
11831
- }
11832
- updates.delegateMention = data.delegateMention;
11833
- }
11834
- if (data.visibility !== void 0) updates.visibility = data.visibility;
11835
- if (data.metadata !== void 0) updates.metadata = data.metadata;
11836
- if (data.avatarColorToken !== void 0) updates.avatarColorToken = data.avatarColorToken;
11837
- if (data.managerId !== void 0) {
11838
- if (data.managerId === null) throw new BadRequestError("managerId cannot be cleared — every agent must have a manager");
11839
- const [manager] = await db.select({
11840
- id: members.id,
11841
- organizationId: members.organizationId
11842
- }).from(members).where(eq(members.id, data.managerId)).limit(1);
11843
- if (!manager) throw new BadRequestError(`Manager "${data.managerId}" not found`);
11844
- if (manager.organizationId !== agent.organizationId) throw new BadRequestError("Manager must belong to the same organization as the agent");
11845
- updates.managerId = data.managerId;
11846
- }
11847
- if (data.clientId !== void 0 && data.clientId !== null && agent.clientId === null) {
11848
- const resolvedClientId = await resolveAgentClient(db, {
11849
- clientId: data.clientId,
11850
- managerId: updates.managerId ?? agent.managerId,
11851
- type: agent.type
11852
- });
11853
- if (resolvedClientId !== null) updates.clientId = resolvedClientId;
11854
- }
11855
- const [updated] = await db.update(agents).set(updates).where(eq(agents.uuid, agent.uuid)).returning();
11856
- if (!updated) throw new Error("Unexpected: UPDATE RETURNING produced no row");
11857
- if (data.managerId !== void 0 && data.managerId !== agent.managerId) await recomputeWatchersForAgent(db, agent.uuid);
11858
- return updated;
11859
- }
11860
- /**
11861
- * Atomically re-bind an agent to a new client and/or runtime provider.
11862
- *
11863
- * Validations: agent must exist and not be human; new client must belong to
11864
- * the same owner (manager.userId) and same organization; client must report
11865
- * the requested runtime provider in its capabilities (skipped under `force`).
11866
- *
11867
- * Intended caller: PATCH /agents/:uuid/rebind. The Web "Re-bind"
11868
- * dialog routes both same-client runtime-only switches and cross-client
11869
- * moves through this single entry.
11870
- *
11871
- * NOTE: active sessions on the previous client are not auto-suspended in P1.
11872
- * P3 will wire in cross-service coordination (inbox + presence + session)
11873
- * so the destination client can resume cleanly.
11874
- */
11875
- async function rebindAgent(db, uuid, data) {
11876
- const agent = await getAgent(db, uuid);
11877
- if (agent.type === "human") throw new BadRequestError("Human agents have no runtime — they cannot be re-bound to a client.");
11878
- const newClientId = await resolveAgentClient(db, {
11879
- clientId: data.clientId,
11880
- managerId: agent.managerId,
11881
- type: agent.type
11882
- });
11883
- if (newClientId === null) throw new BadRequestError("Rebind requires a non-null clientId.");
11884
- await ensureClientSupportsRuntimeProvider(db, newClientId, data.runtimeProvider, { force: data.force });
11885
- const [updated] = await db.update(agents).set({
11886
- clientId: newClientId,
11887
- runtimeProvider: data.runtimeProvider,
11888
- updatedAt: /* @__PURE__ */ new Date()
11889
- }).where(eq(agents.uuid, uuid)).returning();
11890
- if (!updated) throw new Error("Unexpected: UPDATE RETURNING produced no row");
11891
- return updated;
11892
- }
11893
- /**
11894
- * Reactivate a suspended agent.
11895
- */
11896
- async function reactivateAgent(db, uuid) {
11897
- const [existing] = await db.select({
11898
- uuid: agents.uuid,
11899
- status: agents.status
11900
- }).from(agents).where(eq(agents.uuid, uuid)).limit(1);
11901
- if (!existing || existing.status === AGENT_STATUSES.DELETED) throw new NotFoundError(`Agent "${uuid}" not found`);
11902
- if (existing.status !== AGENT_STATUSES.SUSPENDED) throw new BadRequestError("Only suspended agents can be reactivated.");
11903
- const [agent] = await db.update(agents).set({
11904
- status: AGENT_STATUSES.ACTIVE,
11905
- updatedAt: /* @__PURE__ */ new Date()
11906
- }).where(eq(agents.uuid, uuid)).returning();
11907
- if (!agent) throw new Error("Unexpected: UPDATE RETURNING produced no row");
11908
- return agent;
11909
- }
11910
- /**
11911
- * Suspend an agent. Once suspended, Rule R-RUN refuses every runtime bind
11912
- * and every agent-selector-authorised HTTP call.
11913
- */
11914
- async function suspendAgent(db, uuid) {
11915
- const [agent] = await db.update(agents).set({
11916
- status: AGENT_STATUSES.SUSPENDED,
11917
- updatedAt: /* @__PURE__ */ new Date()
11918
- }).where(and(eq(agents.uuid, uuid), ne(agents.status, AGENT_STATUSES.DELETED))).returning();
11919
- if (!agent) throw new NotFoundError(`Agent "${uuid}" not found`);
11920
- return agent;
11921
- }
11922
- /**
11923
- * Delete an agent. Only allowed when status is "suspended". Sets name to NULL
11924
- * so the name becomes reusable.
11925
- */
11926
- async function deleteAgent(db, uuid) {
11927
- const [existing] = await db.select({
11928
- uuid: agents.uuid,
11929
- status: agents.status
11930
- }).from(agents).where(eq(agents.uuid, uuid)).limit(1);
11931
- if (!existing || existing.status === AGENT_STATUSES.DELETED) throw new NotFoundError(`Agent "${uuid}" not found`);
11932
- if (existing.status !== AGENT_STATUSES.SUSPENDED) throw new BadRequestError("Only suspended agents can be deleted. Suspend the agent first.");
11933
- const [agent] = await db.update(agents).set({
11934
- status: AGENT_STATUSES.DELETED,
11935
- name: null,
11936
- updatedAt: /* @__PURE__ */ new Date()
11937
- }).where(eq(agents.uuid, uuid)).returning();
11938
- await db.delete(adapterConfigs).where(eq(adapterConfigs.agentId, uuid));
11939
- await db.delete(adapterAgentMappings).where(eq(adapterAgentMappings.agentId, uuid));
11940
- if (!agent) throw new Error("Unexpected: UPDATE RETURNING produced no row");
11941
- return agent;
11942
- }
11943
- /**
11944
- * Supported avatar-image MIME types. The web client always uploads WEBP after
11945
- * its own resize step; we accept PNG/JPEG too so a caller using the raw HTTP
11946
- * API (curl, scripts) doesn't have to re-encode. Anything else is rejected at
11947
- * the boundary — we never store an unknown content type.
11948
- */
11949
- const SUPPORTED_AVATAR_IMAGE_MIMES = [
11950
- "image/webp",
11951
- "image/png",
11952
- "image/jpeg"
11953
- ];
11954
- /** Hard server-side ceiling for the stored bytea blob. Client pre-resizes to ~50KB. */
11955
- const MAX_AVATAR_IMAGE_BYTES = 512 * 1024;
11956
- function isSupportedAvatarMime(mime) {
11957
- return SUPPORTED_AVATAR_IMAGE_MIMES.find((m) => m === mime) !== void 0;
11958
- }
11959
- /**
11960
- * Fetch the avatar image blob for an agent. Returns `null` when no image
11961
- * is set (the column is NULL). The data + mime pair is always coherent
11962
- * (set/cleared together by the service writes below).
11963
- */
11964
- async function getAgentAvatarImage(db, uuid) {
11965
- const [row] = await db.select({
11966
- data: agents.avatarImageData,
11967
- mime: agents.avatarImageMime,
11968
- updatedAt: agents.avatarImageUpdatedAt
11969
- }).from(agents).where(and(eq(agents.uuid, uuid), ne(agents.status, AGENT_STATUSES.DELETED))).limit(1);
11970
- if (!row || !row.data || !row.mime || !row.updatedAt) return null;
11971
- return {
11972
- data: row.data,
11973
- mime: row.mime,
11974
- updatedAt: row.updatedAt
11975
- };
11976
- }
11977
- /** Replace (or set) an agent's avatar image. Validates mime + size. */
11978
- async function setAgentAvatarImage(db, uuid, data, mime) {
11979
- if (!isSupportedAvatarMime(mime)) throw new BadRequestError(`Unsupported avatar image type "${mime}". Use PNG, JPEG, or WEBP.`);
11980
- if (data.length === 0) throw new BadRequestError("Avatar image payload is empty.");
11981
- if (data.length > 524288) throw new BadRequestError(`Avatar image is too large (${data.length} bytes; max ${MAX_AVATAR_IMAGE_BYTES}).`);
11982
- const now = /* @__PURE__ */ new Date();
11983
- if ((await db.update(agents).set({
11984
- avatarImageData: data,
11985
- avatarImageMime: mime,
11986
- avatarImageUpdatedAt: now,
11987
- updatedAt: now
11988
- }).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`);
11989
- return now;
11990
- }
11991
- /** Clear an agent's avatar image (falls back to color + initial). */
11992
- async function clearAgentAvatarImage(db, uuid) {
11993
- if ((await db.update(agents).set({
11994
- avatarImageData: null,
11995
- avatarImageMime: null,
11996
- avatarImageUpdatedAt: null,
11997
- updatedAt: /* @__PURE__ */ new Date()
11998
- }).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`);
11999
- }
12000
11600
  const log$5 = createLogger$1("AgentFeishuBot");
12001
11601
  async function agentFeishuBotRoutes(app) {
12002
11602
  /**
@@ -12147,7 +11747,7 @@ async function findOrCreateChatForChannel(db, data) {
12147
11747
  const internalType = data.chatType === "p2p" ? "direct" : "group";
12148
11748
  return db.transaction(async (tx) => {
12149
11749
  const [botAgent] = await tx.select({ organizationId: agents.organizationId }).from(agents).where(eq(agents.uuid, data.botAgentId)).limit(1);
12150
- const orgId = botAgent?.organizationId ?? await resolveDefaultOrgId(db);
11750
+ const orgId = botAgent?.organizationId ?? await resolveDefaultOrgId$1(db);
12151
11751
  const metadata = chatMetadataSchema$1.parse({
12152
11752
  source: data.platform,
12153
11753
  externalChannelId: data.externalChannelId
@@ -12238,338 +11838,6 @@ async function agentFeishuUserRoutes(app) {
12238
11838
  return reply.status(204).send();
12239
11839
  });
12240
11840
  }
12241
- function normaliseSource(source) {
12242
- if (source === null) return null;
12243
- const parsed = messageSourceSchema$1.safeParse(source);
12244
- return parsed.success ? parsed.data : null;
12245
- }
12246
- function normaliseMode(mode) {
12247
- return mode === "mention_only" ? "mention_only" : "full";
12248
- }
12249
- /**
12250
- * Batch variant — builds all payloads with a single DB lookup per agent plus
12251
- * batched lookups for participant modes and inReplyTo snapshots.
12252
- */
12253
- async function buildClientMessagePayloadsForInbox(db, inboxId, items) {
12254
- if (items.length === 0) return [];
12255
- const agentId = await resolveAgentId(db, {
12256
- kind: "inboxId",
12257
- inboxId
12258
- });
12259
- const [cfg] = await db.select({ version: agentConfigs.version }).from(agentConfigs).where(eq(agentConfigs.agentId, agentId)).limit(1);
12260
- const version = cfg?.version ?? 1;
12261
- const chatIds = [...new Set(items.map((it) => it.entryChatId ?? it.message.chatId).filter((id) => id !== null))];
12262
- const modeByChat = /* @__PURE__ */ new Map();
12263
- if (chatIds.length > 0) {
12264
- const rows = await db.select({
12265
- chatId: chatMembership.chatId,
12266
- mode: chatMembership.mode
12267
- }).from(chatMembership).where(and(eq(chatMembership.agentId, agentId), inArray(chatMembership.chatId, chatIds), eq(chatMembership.accessMode, "speaker")));
12268
- for (const r of rows) modeByChat.set(r.chatId, normaliseMode(r.mode));
12269
- }
12270
- const inReplyToIds = [...new Set(items.map((it) => it.message.inReplyTo).filter((id) => id !== null))];
12271
- const snapshotById = /* @__PURE__ */ new Map();
12272
- if (inReplyToIds.length > 0) {
12273
- const origs = await db.select({
12274
- id: messages.id,
12275
- senderId: messages.senderId,
12276
- chatId: messages.chatId,
12277
- replyToChat: messages.replyToChat
12278
- }).from(messages).where(inArray(messages.id, inReplyToIds));
12279
- for (const o of origs) snapshotById.set(o.id, {
12280
- senderId: o.senderId,
12281
- chatId: o.chatId,
12282
- replyToChat: o.replyToChat
12283
- });
12284
- }
12285
- return items.map(({ entryChatId, message: m, precedingMessages = [] }) => ({
12286
- id: m.id,
12287
- chatId: m.chatId,
12288
- senderId: m.senderId,
12289
- format: m.format,
12290
- content: m.content,
12291
- metadata: m.metadata,
12292
- replyToInbox: m.replyToInbox,
12293
- replyToChat: m.replyToChat,
12294
- inReplyTo: m.inReplyTo,
12295
- source: normaliseSource(m.source),
12296
- createdAt: m.createdAt,
12297
- configVersion: version,
12298
- recipientMode: modeByChat.get(entryChatId ?? m.chatId) ?? "full",
12299
- inReplyToSnapshot: m.inReplyTo ? snapshotById.get(m.inReplyTo) ?? null : null,
12300
- precedingMessages
12301
- }));
12302
- }
12303
- async function resolveAgentId(db, source) {
12304
- if (source.kind === "agentId") return source.agentId;
12305
- const [agent] = await db.select({ uuid: agents.uuid }).from(agents).where(eq(agents.inboxId, source.inboxId)).limit(1);
12306
- if (!agent) throw new Error(`No agent owns inbox "${source.inboxId}"`);
12307
- return agent.uuid;
12308
- }
12309
- const DEFAULT_INBOX_TIMEOUT_SECONDS = 300;
12310
- const DEFAULT_MAX_RETRY_COUNT = 3;
12311
- const PRECEDING_CONTEXT_WINDOW_SECONDS = 1440 * 60;
12312
- async function pollInbox(db, inboxId, limit) {
12313
- return withSpan("inbox.deliver", {
12314
- "inbox.id": inboxId,
12315
- "inbox.poll.limit": limit
12316
- }, () => pollInboxInner(db, inboxId, limit));
12317
- }
12318
- async function pollInboxInner(db, inboxId, limit) {
12319
- return db.transaction(async (tx) => {
12320
- 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 });
12321
- return bundleDeliveryWithSilentContext(tx, inboxId, await tx.update(inboxEntries).set({
12322
- status: "delivered",
12323
- deliveredAt: /* @__PURE__ */ new Date()
12324
- }).where(inArray(inboxEntries.id, targetIds)).returning());
12325
- });
12326
- }
12327
- /**
12328
- * Shared payload assembler for already-claimed `inbox_entries` rows.
12329
- *
12330
- * Both the HTTP poll path (`pollInbox`) and the WS push path
12331
- * (`claimAndBuildForPush`) call this with rows they have just `UPDATE`d to
12332
- * `status='delivered'`. Keeping the silent-context bundling in one place is
12333
- * the only way to keep the two paths from drifting (proposal
12334
- * hub-inbox-ws-data-plane §3.2 risk #1).
12335
- *
12336
- * Steps:
12337
- * 1. Sort by `createdAt` ASC (PG `RETURNING` does not guarantee order).
12338
- * 2. For each trigger, collect silent context & bulk-ack stale silent rows.
12339
- * 3. Fetch the trigger messages.
12340
- * 4. Build wire payloads via the single dispatcher.
12341
- *
12342
- * Returns `[]` if `claimed` is empty.
12343
- */
12344
- async function bundleDeliveryWithSilentContext(tx, inboxId, claimed) {
12345
- if (claimed.length === 0) return [];
12346
- claimed.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
12347
- const precedingByEntryId = await collectPrecedingContext(tx, inboxId, claimed);
12348
- const messageIds = claimed.map((e) => e.messageId);
12349
- const msgs = await tx.select().from(messages).where(inArray(messages.id, messageIds));
12350
- const msgMap = new Map(msgs.map((m) => [m.id, m]));
12351
- const payloads = await buildClientMessagePayloadsForInbox(tx, inboxId, claimed.map((entry) => {
12352
- const msg = msgMap.get(entry.messageId);
12353
- if (!msg) throw new Error(`Unexpected: message ${entry.messageId} not found`);
12354
- return {
12355
- entryChatId: entry.chatId,
12356
- precedingMessages: precedingByEntryId.get(entry.id) ?? [],
12357
- message: {
12358
- id: msg.id,
12359
- chatId: msg.chatId,
12360
- senderId: msg.senderId,
12361
- format: msg.format,
12362
- content: msg.content,
12363
- metadata: msg.metadata,
12364
- replyToInbox: msg.replyToInbox,
12365
- replyToChat: msg.replyToChat,
12366
- inReplyTo: msg.inReplyTo,
12367
- source: msg.source,
12368
- createdAt: msg.createdAt.toISOString()
12369
- }
12370
- };
12371
- }));
12372
- return claimed.map((entry, idx) => {
12373
- const payload = payloads[idx];
12374
- if (!payload) throw new Error(`Unexpected: payload for entry ${entry.id} not built`);
12375
- return {
12376
- id: entry.id,
12377
- inboxId: entry.inboxId,
12378
- messageId: entry.messageId,
12379
- chatId: entry.chatId,
12380
- status: entry.status,
12381
- retryCount: entry.retryCount,
12382
- createdAt: entry.createdAt.toISOString(),
12383
- deliveredAt: entry.deliveredAt?.toISOString() ?? null,
12384
- ackedAt: entry.ackedAt?.toISOString() ?? null,
12385
- message: payload
12386
- };
12387
- });
12388
- }
12389
- /**
12390
- * Realistic upper bound on rows a single NOTIFY references. The unique
12391
- * constraint `(inbox_id, message_id, chat_id)` caps a `(inbox, message)`
12392
- * pair at one row per chatId; the only way to exceed 1 today is the replyTo
12393
- * cross-chat path (`message.ts` writes a second row keyed by the original's
12394
- * `replyToChat`). 8 leaves headroom for any future fan-out variant without
12395
- * requiring a schema change here.
12396
- */
12397
- const PUSH_CLAIM_BATCH_LIMIT = 8;
12398
- /**
12399
- * WS-push path: atomically claim every pending entry the just-fired
12400
- * `NOTIFY (inboxId:messageId)` references and assemble their wire payloads.
12401
- *
12402
- * Returns `[]` if no row matches — benign race with HTTP poll or another
12403
- * server instance that already claimed the entry. NOTIFY is fire-and-forget
12404
- * (proposal §3.2).
12405
- *
12406
- * Why an array, not a single row: `sendMessage` can write **two** rows for
12407
- * the same `(inbox, messageId)` pair when the recipient is both a chat
12408
- * participant and the `replyToInbox` of an earlier message — the unique key
12409
- * is `(inbox_id, message_id, chat_id)`, so the rows differ by chatId. The
12410
- * old `LIMIT 1` shape would only push the first; the second sat `pending`
12411
- * until reconnect. Aligning with `pollInboxInner`'s `LIMIT N` shape closes
12412
- * that gap and keeps push/poll behaviour interchangeable.
12413
- */
12414
- async function claimAndBuildForPush(db, inboxId, messageId) {
12415
- return withSpan("inbox.deliver.push", {
12416
- "inbox.id": inboxId,
12417
- "message.id": messageId
12418
- }, () => db.transaction(async (tx) => {
12419
- 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 });
12420
- return bundleDeliveryWithSilentContext(tx, inboxId, await tx.update(inboxEntries).set({
12421
- status: "delivered",
12422
- deliveredAt: /* @__PURE__ */ new Date()
12423
- }).where(inArray(inboxEntries.id, targetIds)).returning());
12424
- }));
12425
- }
12426
- /**
12427
- * WS-push backlog path: on agent rebind (or once an in-flight slot frees up
12428
- * after an ack), drain up to `limit` pending `notify=true` entries oldest-
12429
- * first and assemble wire payloads. Identical claim shape to the HTTP poll
12430
- * path — they are intentionally interchangeable so a hot-path bug fixed in
12431
- * one shows up in the other (proposal §3.3 / §3.5).
12432
- */
12433
- async function claimBacklogForPush(db, inboxId, limit) {
12434
- return withSpan("inbox.deliver.backlog", {
12435
- "inbox.id": inboxId,
12436
- "inbox.backlog.limit": limit
12437
- }, () => pollInboxInner(db, inboxId, limit));
12438
- }
12439
- /**
12440
- * Per claimed trigger: SELECT silent (notify=false) pending rows in the same
12441
- * chat that occurred between the previous trigger in this batch (or beginning
12442
- * of time) and this trigger, capped by `PRECEDING_CONTEXT_MAX_ENTRIES` and
12443
- * `PRECEDING_CONTEXT_WINDOW_SECONDS`. Returned messages are oldest-first.
12444
- *
12445
- * Side effect: bulk-ack ALL silent pending rows in each chat with
12446
- * createdAt < latest_trigger.createdAt — including ones that fell outside
12447
- * the window/cap. Otherwise stale silent rows would accumulate and re-load
12448
- * on every poll.
12449
- */
12450
- async function collectPrecedingContext(tx, inboxId, triggers) {
12451
- const result = /* @__PURE__ */ new Map();
12452
- const byChat = /* @__PURE__ */ new Map();
12453
- for (const t of triggers) {
12454
- if (t.chatId === null) continue;
12455
- const list = byChat.get(t.chatId) ?? [];
12456
- list.push(t);
12457
- byChat.set(t.chatId, list);
12458
- }
12459
- for (const [chatId, chatTriggers] of byChat) {
12460
- chatTriggers.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
12461
- let prevCreatedAt = null;
12462
- for (const trigger of chatTriggers) {
12463
- const preceding = (await tx.select({
12464
- messageId: messages.id,
12465
- senderId: messages.senderId,
12466
- format: messages.format,
12467
- content: messages.content,
12468
- metadata: messages.metadata,
12469
- createdAt: messages.createdAt
12470
- }).from(inboxEntries).innerJoin(messages, eq(messages.id, inboxEntries.messageId)).where(and(eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.chatId, chatId), eq(inboxEntries.status, "pending"), eq(inboxEntries.notify, false), lt(inboxEntries.createdAt, trigger.createdAt), prevCreatedAt === null ? void 0 : gt(inboxEntries.createdAt, prevCreatedAt), sql`${inboxEntries.createdAt} > NOW() - make_interval(secs => ${PRECEDING_CONTEXT_WINDOW_SECONDS})`)).orderBy(desc(inboxEntries.createdAt)).limit(50).for("update", {
12471
- of: inboxEntries,
12472
- skipLocked: true
12473
- })).map((r) => ({
12474
- id: r.messageId,
12475
- senderId: r.senderId,
12476
- format: r.format,
12477
- content: r.content,
12478
- metadata: r.metadata ?? {},
12479
- createdAt: r.createdAt.toISOString()
12480
- })).reverse();
12481
- result.set(trigger.id, preceding);
12482
- prevCreatedAt = trigger.createdAt;
12483
- }
12484
- const latestTrigger = chatTriggers[chatTriggers.length - 1];
12485
- if (latestTrigger) await tx.update(inboxEntries).set({
12486
- status: "acked",
12487
- ackedAt: /* @__PURE__ */ new Date()
12488
- }).where(and(eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.chatId, chatId), eq(inboxEntries.status, "pending"), eq(inboxEntries.notify, false), lt(inboxEntries.createdAt, latestTrigger.createdAt)));
12489
- }
12490
- return result;
12491
- }
12492
- async function ackEntry$2(db, entryId, inboxId) {
12493
- return withSpan("inbox.ack", {
12494
- [FIRST_TREE_HUB_ATTR.INBOX_ENTRY_ID]: String(entryId),
12495
- "inbox.id": inboxId
12496
- }, async () => {
12497
- const [entry] = await db.update(inboxEntries).set({
12498
- status: "acked",
12499
- ackedAt: /* @__PURE__ */ new Date()
12500
- }).where(and(eq(inboxEntries.id, entryId), eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.status, "delivered"))).returning();
12501
- if (!entry) throw new NotFoundError("Inbox entry not found or not in delivered status");
12502
- return entry;
12503
- });
12504
- }
12505
- /**
12506
- * Ack a delivered entry from the WS data plane, scoped to the inboxes the
12507
- * connected socket has bound. Returns the acked row on success, `null` if no
12508
- * row matches — a benign outcome the caller should ignore (the entry may
12509
- * have already been acked, timed out, or never belonged to this socket).
12510
- *
12511
- * Distinct from {@link ackEntry} so the WS path can ack without trusting an
12512
- * `inboxId` from the wire — only entries whose `inboxId` is in `inboxIds`
12513
- * are eligible. Empty `inboxIds` short-circuits to `null`.
12514
- */
12515
- async function ackEntryByIdForBoundAgents(db, entryId, inboxIds) {
12516
- if (inboxIds.length === 0) return null;
12517
- return withSpan("inbox.ack.ws", { [FIRST_TREE_HUB_ATTR.INBOX_ENTRY_ID]: String(entryId) }, async () => {
12518
- const [entry] = await db.update(inboxEntries).set({
12519
- status: "acked",
12520
- ackedAt: /* @__PURE__ */ new Date()
12521
- }).where(and(eq(inboxEntries.id, entryId), inArray(inboxEntries.inboxId, inboxIds), eq(inboxEntries.status, "delivered"))).returning();
12522
- return entry ?? null;
12523
- });
12524
- }
12525
- async function renewEntry(db, entryId, inboxId) {
12526
- 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();
12527
- if (!entry) throw new NotFoundError("Inbox entry not found or not in delivered status");
12528
- return entry;
12529
- }
12530
- async function resetTimedOutEntries(db, timeoutSeconds = DEFAULT_INBOX_TIMEOUT_SECONDS, maxRetries = DEFAULT_MAX_RETRY_COUNT) {
12531
- const reset = await db.update(inboxEntries).set({
12532
- status: "pending",
12533
- retryCount: sql`${inboxEntries.retryCount} + 1`
12534
- }).where(and(eq(inboxEntries.status, "delivered"), sql`${inboxEntries.deliveredAt} < NOW() - make_interval(secs => ${timeoutSeconds})`, lt(inboxEntries.retryCount, maxRetries))).returning({ id: inboxEntries.id });
12535
- 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 });
12536
- return {
12537
- reset: reset.length,
12538
- failed: failed.length
12539
- };
12540
- }
12541
- /** Default age (30 days) past which silent rows that no notify-true delivery
12542
- * ever picked up are physically deleted. */
12543
- const SILENT_ROW_GC_MAX_AGE_SECONDS = 720 * 60 * 60;
12544
- /**
12545
- * Garbage-collect silent inbox rows so the table doesn't grow forever in
12546
- * chats where a `mention_only` agent is never @mentioned.
12547
- *
12548
- * Two cleanup paths:
12549
- *
12550
- * 1. `notify=false AND status='acked'` of any age — these are fully
12551
- * consumed (either bundled into a previous trigger or aged out via the
12552
- * bulk-ack in `collectPrecedingContext`); keep them only as long as
12553
- * the corresponding message rows we link to. The unique constraint
12554
- * `(inbox_id, message_id, chat_id)` means leaving them around blocks
12555
- * legitimate retries with the same key.
12556
- *
12557
- * 2. `notify=false AND status='pending' AND createdAt < NOW() - maxAge` —
12558
- * stale silent rows that no trigger ever caught up with. After 30
12559
- * days they're useless as preceding context (the @mention almost
12560
- * certainly already happened or the chat went dormant).
12561
- *
12562
- * Returns the number of rows deleted in each bucket so the background task
12563
- * can log meaningful counts.
12564
- */
12565
- async function pruneStaleSilentEntries(db, maxAgeSeconds = SILENT_ROW_GC_MAX_AGE_SECONDS) {
12566
- const ackedDeleted = await db.delete(inboxEntries).where(and(eq(inboxEntries.notify, false), eq(inboxEntries.status, "acked"))).returning({ id: inboxEntries.id });
12567
- 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 });
12568
- return {
12569
- ackedDeleted: ackedDeleted.length,
12570
- stalePendingDeleted: stalePendingDeleted.length
12571
- };
12572
- }
12573
11841
  async function agentInboxRoutes(app) {
12574
11842
  app.get("/", async (request) => {
12575
11843
  const identity = requireAgent(request);
@@ -15765,743 +15033,6 @@ async function bootstrapConfigRoutes(_app) {
15765
15033
  });
15766
15034
  }
15767
15035
  /**
15768
- * Per-(chat, agent) user state — independent from membership structure.
15769
- *
15770
- * This is the third layer of the chat data model: while `chats` owns
15771
- * the entity and `chat_membership` owns the structural relation
15772
- * (who can speak, who watches), this table owns the user's private
15773
- * state about a chat. The reason it lives apart: structural changes
15774
- * (speaker ↔ watcher, manager rebind, recompute) must never overwrite
15775
- * user-private state — physical separation makes that an invariant
15776
- * rather than a service-layer discipline.
15777
- *
15778
- * Columns evolve incrementally as new per-user state is needed.
15779
- * Currently:
15780
- * - `last_read_at`, `unread_mention_count` — seeded by PR-A from
15781
- * the legacy `chat_participants` / `chat_subscriptions` columns.
15782
- * - `engagement_status` — added in 0040; per-(chat, user) view
15783
- * state (active / archived / deleted). Auto-revives archived →
15784
- * active on new message; deleted is sticky (only the user can
15785
- * restore from the chat detail page).
15786
- *
15787
- * Future fields slated for this table: pinned, mute_until, draft,
15788
- * custom_title, last_seen_at — each as a separate change.
15789
- *
15790
- * Rows are lazy-upserted on first user write (markRead / mention
15791
- * counter bump / engagement transition). Reads use COALESCE for
15792
- * defaults so callers see `'active'` etc. even when no row exists.
15793
- * Service-layer integrity (no FK / CHECK / trigger).
15794
- *
15795
- * See proposals/chat-data-model-restructure.20260512.md §8.6.
15796
- */
15797
- const chatUserState = pgTable("chat_user_state", {
15798
- chatId: text("chat_id").notNull(),
15799
- agentId: text("agent_id").notNull(),
15800
- lastReadAt: timestamp("last_read_at", { withTimezone: true }),
15801
- unreadMentionCount: integer("unread_mention_count").notNull().default(0),
15802
- engagementStatus: text("engagement_status").notNull().default("active")
15803
- }, (table) => [
15804
- primaryKey({ columns: [table.chatId, table.agentId] }),
15805
- index("idx_user_state_agent").on(table.agentId),
15806
- index("idx_user_state_unread").on(table.agentId).where(sql`unread_mention_count > 0`)
15807
- ]);
15808
- /** Extract a plain-text summary from a message's JSONB content field.
15809
- * Used as the auto-title fallback in chat list rendering — see
15810
- * `me-chat.ts:resolveChatTitle` and `admin/chats.ts:getChat`.
15811
- *
15812
- * - `@<name>` mention tokens are stripped before truncation: in the
15813
- * chat-first model they're routing/audience metadata, not part of
15814
- * the user's intent. Leaving them in produces noisy titles like
15815
- * "@hub-agent-01 帮我重构这个文件" or "你好 @hub-agent-02 看看".
15816
- * - Whitespace runs (including those left behind by mention removal)
15817
- * collapse to single spaces.
15818
- * - If the cleaned text is empty (e.g., a message that's only
15819
- * `@hub-agent-01`), returns null so the caller falls through to
15820
- * the participant-join fallback.
15821
- * - Slicing is code-point-aware (`Array.from + join`) so emoji /
15822
- * surrogate pairs aren't split into garbled half-characters. */
15823
- function extractSummary(content, maxLen = 50) {
15824
- let text = "";
15825
- if (typeof content === "object" && content !== null && "text" in content) text = String(content.text ?? "");
15826
- else if (typeof content === "string") text = content;
15827
- if (!text) return null;
15828
- const cleaned = stripCode(text).replace(MENTION_REGEX, "").replace(/\s+/g, " ").trim();
15829
- if (!cleaned) return null;
15830
- return Array.from(cleaned).slice(0, maxLen).join("");
15831
- }
15832
- /** List sessions for a specific agent, with optional state filters. */
15833
- async function listAgentSessions(db, agentId, filters) {
15834
- const conditions = [eq(agentChatSessions.agentId, agentId)];
15835
- if (filters?.state) conditions.push(eq(agentChatSessions.state, filters.state));
15836
- else conditions.push(ne(agentChatSessions.state, "evicted"));
15837
- const rows = await db.select({
15838
- agentId: agentChatSessions.agentId,
15839
- chatId: agentChatSessions.chatId,
15840
- state: agentChatSessions.state,
15841
- updatedAt: agentChatSessions.updatedAt,
15842
- chatCreatedAt: chats.createdAt,
15843
- chatTopic: chats.topic
15844
- }).from(agentChatSessions).innerJoin(chats, eq(agentChatSessions.chatId, chats.id)).where(and(...conditions)).orderBy(desc(agentChatSessions.updatedAt));
15845
- const [presence] = await db.select({ runtimeState: agentPresence.runtimeState }).from(agentPresence).where(eq(agentPresence.agentId, agentId)).limit(1);
15846
- const agentRuntimeState = presence?.runtimeState ?? null;
15847
- if (filters?.runtimeState && agentRuntimeState !== filters.runtimeState) return [];
15848
- const chatIds = rows.map((r) => r.chatId);
15849
- const messageCounts = chatIds.length > 0 ? await db.select({
15850
- chatId: inboxEntries.chatId,
15851
- count: sql`count(*)::int`
15852
- }).from(inboxEntries).where(and(eq(inboxEntries.inboxId, sql`(SELECT inbox_id FROM agents WHERE uuid = ${agentId})`), inArray(inboxEntries.chatId, chatIds))).groupBy(inboxEntries.chatId) : [];
15853
- const countMap = new Map(messageCounts.map((r) => [r.chatId, r.count]));
15854
- const firstMessages = chatIds.length > 0 ? await db.selectDistinctOn([messages.chatId], {
15855
- chatId: messages.chatId,
15856
- content: messages.content
15857
- }).from(messages).where(inArray(messages.chatId, chatIds)).orderBy(messages.chatId, messages.createdAt) : [];
15858
- const summaryMap = /* @__PURE__ */ new Map();
15859
- for (const row of firstMessages) {
15860
- const summary = extractSummary(row.content);
15861
- if (summary) summaryMap.set(row.chatId, summary);
15862
- }
15863
- return rows.map((r) => ({
15864
- agentId: r.agentId,
15865
- chatId: r.chatId,
15866
- state: r.state,
15867
- runtimeState: agentRuntimeState,
15868
- startedAt: r.chatCreatedAt.toISOString(),
15869
- lastActivityAt: r.updatedAt.toISOString(),
15870
- messageCount: countMap.get(r.chatId) ?? 0,
15871
- summary: summaryMap.get(r.chatId) ?? null,
15872
- topic: r.chatTopic ?? null
15873
- }));
15874
- }
15875
- /** Get a single session's detail. */
15876
- async function getSession(db, agentId, chatId) {
15877
- const [row] = await db.select({
15878
- agentId: agentChatSessions.agentId,
15879
- chatId: agentChatSessions.chatId,
15880
- state: agentChatSessions.state,
15881
- updatedAt: agentChatSessions.updatedAt,
15882
- chatCreatedAt: chats.createdAt,
15883
- chatTopic: chats.topic
15884
- }).from(agentChatSessions).innerJoin(chats, eq(agentChatSessions.chatId, chats.id)).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId))).limit(1);
15885
- if (!row) throw new NotFoundError(`Session (${agentId}, ${chatId}) not found`);
15886
- const [presence] = await db.select({ runtimeState: agentPresence.runtimeState }).from(agentPresence).where(eq(agentPresence.agentId, agentId)).limit(1);
15887
- 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)));
15888
- const firstMsg = (await db.execute(sql`SELECT content FROM messages WHERE chat_id = ${chatId} ORDER BY created_at ASC LIMIT 1`))[0];
15889
- const summary = firstMsg ? extractSummary(firstMsg.content) : null;
15890
- return {
15891
- agentId: row.agentId,
15892
- chatId: row.chatId,
15893
- state: row.state,
15894
- runtimeState: presence?.runtimeState ?? null,
15895
- startedAt: row.chatCreatedAt.toISOString(),
15896
- lastActivityAt: row.updatedAt.toISOString(),
15897
- messageCount: countRow?.count ?? 0,
15898
- summary,
15899
- topic: row.chatTopic ?? null
15900
- };
15901
- }
15902
- /** List all sessions across all agents, with pagination. Scoped to organization. */
15903
- async function listAllSessions(db, limit, cursor, filters) {
15904
- const conditions = [];
15905
- if (filters?.state) conditions.push(eq(agentChatSessions.state, filters.state));
15906
- else conditions.push(ne(agentChatSessions.state, "evicted"));
15907
- if (filters?.agentId) conditions.push(eq(agentChatSessions.agentId, filters.agentId));
15908
- if (filters?.organizationId) conditions.push(eq(agents.organizationId, filters.organizationId));
15909
- if (cursor) conditions.push(sql`${agentChatSessions.updatedAt} < ${new Date(cursor)}`);
15910
- const rows = await db.select({
15911
- agentId: agentChatSessions.agentId,
15912
- chatId: agentChatSessions.chatId,
15913
- state: agentChatSessions.state,
15914
- updatedAt: agentChatSessions.updatedAt,
15915
- chatCreatedAt: chats.createdAt,
15916
- chatTopic: chats.topic
15917
- }).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);
15918
- const hasMore = rows.length > limit;
15919
- const items = hasMore ? rows.slice(0, limit) : rows;
15920
- const agentIds = [...new Set(items.map((r) => r.agentId))];
15921
- const presenceRows = agentIds.length > 0 ? await db.select({
15922
- agentId: agentPresence.agentId,
15923
- runtimeState: agentPresence.runtimeState
15924
- }).from(agentPresence).where(inArray(agentPresence.agentId, agentIds)) : [];
15925
- const runtimeMap = new Map(presenceRows.map((r) => [r.agentId, r.runtimeState]));
15926
- const chatIds = [...new Set(items.map((r) => r.chatId))];
15927
- const firstMessages = chatIds.length > 0 ? await db.selectDistinctOn([messages.chatId], {
15928
- chatId: messages.chatId,
15929
- content: messages.content
15930
- }).from(messages).where(inArray(messages.chatId, chatIds)).orderBy(messages.chatId, messages.createdAt) : [];
15931
- const summaryMap = /* @__PURE__ */ new Map();
15932
- for (const row of firstMessages) {
15933
- const summary = extractSummary(row.content);
15934
- if (summary) summaryMap.set(row.chatId, summary);
15935
- }
15936
- const last = items[items.length - 1];
15937
- const nextCursor = hasMore && last ? last.updatedAt.toISOString() : null;
15938
- return {
15939
- items: items.map((r) => ({
15940
- agentId: r.agentId,
15941
- chatId: r.chatId,
15942
- state: r.state,
15943
- runtimeState: runtimeMap.get(r.agentId) ?? null,
15944
- startedAt: r.chatCreatedAt.toISOString(),
15945
- lastActivityAt: r.updatedAt.toISOString(),
15946
- messageCount: 0,
15947
- summary: summaryMap.get(r.chatId) ?? null,
15948
- topic: r.chatTopic ?? null
15949
- })),
15950
- nextCursor
15951
- };
15952
- }
15953
- /** Commit `active → suspended`. No-op on suspended/evicted. Throws if row is missing. */
15954
- async function suspendSession(db, agentId, chatId, organizationId, notifier) {
15955
- return transitionSessionState(db, agentId, chatId, "suspended", ["active"], organizationId, notifier);
15956
- }
15957
- /** Commit `suspended → evicted` (terminal — listings hide it, revival defense blocks resurrection). */
15958
- async function archiveSession(db, agentId, chatId, organizationId, notifier) {
15959
- return transitionSessionState(db, agentId, chatId, "evicted", ["suspended"], organizationId, notifier);
15960
- }
15961
- async function transitionSessionState(db, agentId, chatId, target, from, organizationId, notifier) {
15962
- const now = /* @__PURE__ */ new Date();
15963
- let finalState = null;
15964
- let transitioned = false;
15965
- await db.transaction(async (tx) => {
15966
- const [existing] = await tx.select({ state: agentChatSessions.state }).from(agentChatSessions).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId))).for("update");
15967
- if (!existing) return;
15968
- const current = existing.state;
15969
- finalState = current;
15970
- if (!from.includes(current)) return;
15971
- await tx.update(agentChatSessions).set({
15972
- state: target,
15973
- updatedAt: now
15974
- }).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId)));
15975
- const [counts] = await tx.select({
15976
- active: sql`count(*) FILTER (WHERE ${agentChatSessions.state} = 'active')::int`,
15977
- total: sql`count(*) FILTER (WHERE ${agentChatSessions.state} != 'evicted')::int`
15978
- }).from(agentChatSessions).where(eq(agentChatSessions.agentId, agentId));
15979
- await tx.update(agentPresence).set({
15980
- activeSessions: counts?.active ?? 0,
15981
- totalSessions: counts?.total ?? 0,
15982
- lastSeenAt: now
15983
- }).where(eq(agentPresence.agentId, agentId));
15984
- if (target === "evicted") await markSupersededByChat(tx, chatId, "chat_archived");
15985
- finalState = target;
15986
- transitioned = true;
15987
- });
15988
- if (finalState === null) throw new NotFoundError(`Session (${agentId}, ${chatId}) not found`);
15989
- if (transitioned && notifier) notifier.notifySessionStateChange(agentId, chatId, target, organizationId).catch(() => {});
15990
- return {
15991
- state: finalState,
15992
- transitioned
15993
- };
15994
- }
15995
- /**
15996
- * Filter sessions to only those where the given agent is also a participant in the chat.
15997
- * Used when a non-manager views sessions of an org-visible agent — they should only see
15998
- * sessions for chats they participate in.
15999
- */
16000
- async function filterSessionsByParticipant(db, sessions, participantAgentId) {
16001
- if (sessions.length === 0) return [];
16002
- const chatIds = sessions.map((s) => s.chatId);
16003
- const participantRows = await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(inArray(chatMembership.chatId, chatIds), eq(chatMembership.agentId, participantAgentId), eq(chatMembership.accessMode, "speaker")));
16004
- const allowedChatIds = new Set(participantRows.map((r) => r.chatId));
16005
- return sessions.filter((s) => allowedChatIds.has(s.chatId));
16006
- }
16007
- /**
16008
- * Member-facing chat service backing `/me/chats*` endpoints (chat-first
16009
- * workspace).
16010
- *
16011
- * Responsibilities:
16012
- * - Cursor-paginated conversation list (single-stream JOIN over the
16013
- * unified `chat_membership` + `chat_user_state` tables).
16014
- * - Create a new chat (no dedupe, runs `recomputeChatWatchers` after).
16015
- * - Add participants (idempotent, UPSERT into `chat_membership`,
16016
- * runs `recomputeChatWatchers` after).
16017
- * - Mark-read (UPSERT into `chat_user_state`).
16018
- * - Join → watcher to speaker (delegates to `watcher.ts`).
16019
- * - Leave → speaker to watcher or detach (delegates to `watcher.ts`).
16020
- *
16021
- * See proposals/chat-data-model-restructure.20260512.md §8 (schema)
16022
- * and §11.1 (per-route mapping).
16023
- */
16024
- function encodeCursor(lastMessageAt, chatId) {
16025
- const payload = `${lastMessageAt ? lastMessageAt.toISOString() : ""}|${chatId}`;
16026
- return Buffer.from(payload, "utf8").toString("base64url");
16027
- }
16028
- function decodeCursor(cursor) {
16029
- try {
16030
- const decoded = Buffer.from(cursor, "base64url").toString("utf8");
16031
- const sep = decoded.indexOf("|");
16032
- if (sep < 0) return null;
16033
- const tsPart = decoded.slice(0, sep);
16034
- const chatId = decoded.slice(sep + 1);
16035
- if (!chatId) return null;
16036
- const lastMessageAt = tsPart.length > 0 ? new Date(tsPart) : null;
16037
- if (lastMessageAt && Number.isNaN(lastMessageAt.getTime())) return null;
16038
- return {
16039
- lastMessageAt,
16040
- chatId
16041
- };
16042
- } catch {
16043
- return null;
16044
- }
16045
- }
16046
- const { ACTIVE: ACTIVE$1, ARCHIVED: ARCHIVED$1, DELETED } = CHAT_ENGAGEMENT_STATUSES;
16047
- /**
16048
- * SQL predicate for each engagement view tab. `deleted` is never a valid view
16049
- * value — deleted rows are reachable only through `GET /chats/:chatId` + the
16050
- * Restore banner on the chat detail page.
16051
- */
16052
- const ENGAGEMENT_VIEW_PREDICATE = {
16053
- active: sql`COALESCE(cus.engagement_status, ${ACTIVE$1}) = ${ACTIVE$1}`,
16054
- archived: sql`COALESCE(cus.engagement_status, ${ACTIVE$1}) = ${ARCHIVED$1}`,
16055
- all: sql`COALESCE(cus.engagement_status, ${ACTIVE$1}) IN (${ACTIVE$1}, ${ARCHIVED$1})`
16056
- };
16057
- /**
16058
- * Write the caller's engagement state for this chat. UPSERT into
16059
- * `chat_user_state` — the row may not yet exist (the user might not have
16060
- * marked-read or been @-mentioned), so an INSERT with the engagement value
16061
- * is the first write; subsequent transitions are UPDATEs.
16062
- *
16063
- * Idempotent. Mirrors the UPSERT shape used by `markMeChatRead`.
16064
- */
16065
- async function setChatEngagement(db, chatId, agentId, status) {
16066
- await db.insert(chatUserState).values({
16067
- chatId,
16068
- agentId,
16069
- unreadMentionCount: 0,
16070
- engagementStatus: status
16071
- }).onConflictDoUpdate({
16072
- target: [chatUserState.chatId, chatUserState.agentId],
16073
- set: { engagementStatus: status }
16074
- });
16075
- }
16076
- /**
16077
- * Read the caller's engagement state. Returns `'active'` when no
16078
- * `chat_user_state` row exists yet (lazy-materialised; matches the SQL
16079
- * `COALESCE(..., 'active')` used elsewhere).
16080
- */
16081
- async function getCallerEngagement(db, chatId, agentId) {
16082
- const [row] = await db.select({ engagementStatus: chatUserState.engagementStatus }).from(chatUserState).where(and(eq(chatUserState.chatId, chatId), eq(chatUserState.agentId, agentId))).limit(1);
16083
- return row?.engagementStatus ?? ACTIVE$1;
16084
- }
16085
- const KNOWN_NON_MANUAL_PREDICATE = sql`(
16086
- (c.metadata->>'source' = 'github' AND c.metadata->>'entityType' IN (${sql.join(GITHUB_ENTITY_TYPES.map((t) => sql`${t}`), sql.raw(", "))}))
16087
- OR c.metadata->>'source' = 'feishu'
16088
- )`;
16089
- const chatSourceSqlExpression = sql`CASE
16090
- ${sql.join(GITHUB_ENTITY_TYPES.map((t) => sql`WHEN c.metadata->>'source' = 'github' AND c.metadata->>'entityType' = ${t} THEN ${`github_${t}`}`), sql.raw("\n "))}
16091
- WHEN c.metadata->>'source' = 'feishu' THEN 'feishu'
16092
- ELSE 'manual'
16093
- END`;
16094
- function sourceFilterSql(source) {
16095
- switch (source) {
16096
- case "manual": return sql`(${KNOWN_NON_MANUAL_PREDICATE}) IS NOT TRUE`;
16097
- case "github_issue": return sql`(c.metadata->>'source' = 'github' AND c.metadata->>'entityType' = 'issue')`;
16098
- case "github_pull_request": return sql`(c.metadata->>'source' = 'github' AND c.metadata->>'entityType' = 'pull_request')`;
16099
- case "github_discussion": return sql`(c.metadata->>'source' = 'github' AND c.metadata->>'entityType' = 'discussion')`;
16100
- case "github_commit": return sql`(c.metadata->>'source' = 'github' AND c.metadata->>'entityType' = 'commit')`;
16101
- case "feishu": return sql`(c.metadata->>'source' = 'feishu')`;
16102
- }
16103
- }
16104
- /**
16105
- * GET /me/chats — cursor-paginated conversation list.
16106
- *
16107
- * SQL strategy:
16108
- * - Single-stream query: `chats JOIN chat_membership LEFT JOIN
16109
- * chat_user_state`. The membership row carries access_mode
16110
- * (speaker → "participant" / watcher → "watching"); the user
16111
- * state row supplies the unread counter (COALESCE → 0 when
16112
- * row is missing).
16113
- * - Filter `parent_chat_id IS NULL` (nested chats not surfaced in v1).
16114
- * - Filter `c.organization_id = ?` to defend against historical
16115
- * cross-org pollution rows that may still reference the caller
16116
- * (see fix/cross-org-direct-chat-pollution).
16117
- * - Sort `(last_message_at DESC NULLS LAST, chat_id DESC)`.
16118
- * - Cursor narrows the result to rows STRICTLY before the cursor.
16119
- * - Followed by a participants-list lookup for the page only.
16120
- */
16121
- async function listMeChats(db, humanAgentId, organizationId, query) {
16122
- const limit = query.limit;
16123
- const cursor = query.cursor ? decodeCursor(query.cursor) : null;
16124
- if (query.cursor && !cursor) throw new BadRequestError("Invalid cursor");
16125
- const filterUnreadOnly = query.filter === "unread";
16126
- const filterWatchingOnly = query.filter === "watching";
16127
- const engagementPredicate = ENGAGEMENT_VIEW_PREDICATE[query.engagement];
16128
- const sourcePredicate = query.source ? sourceFilterSql(query.source) : sql`TRUE`;
16129
- const cursorTsIso = cursor?.lastMessageAt ? cursor.lastMessageAt.toISOString() : null;
16130
- 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
16131
- OR c.last_message_at < ${cursorTsIso}::timestamptz
16132
- OR (c.last_message_at = ${cursorTsIso}::timestamptz AND c.id < ${cursor.chatId}))`;
16133
- const rawRows = await db.execute(sql`
16134
- SELECT
16135
- c.id AS chat_id,
16136
- c.type AS type,
16137
- c.topic AS topic,
16138
- c.parent_chat_id AS parent_chat_id,
16139
- c.last_message_at AS last_message_at,
16140
- c.last_message_preview AS last_message_preview,
16141
- (SELECT count(*) FROM chat_membership
16142
- WHERE chat_id = c.id AND access_mode = 'speaker') AS participant_count,
16143
- cm.access_mode AS access_mode,
16144
- COALESCE(cus.unread_mention_count, 0) AS unread_mention_count,
16145
- COALESCE(cus.engagement_status, ${ACTIVE$1}) AS engagement_status
16146
- FROM chats c
16147
- JOIN chat_membership cm
16148
- ON cm.chat_id = c.id AND cm.agent_id = ${humanAgentId}
16149
- LEFT JOIN chat_user_state cus
16150
- ON cus.chat_id = c.id AND cus.agent_id = ${humanAgentId}
16151
- WHERE c.parent_chat_id IS NULL
16152
- /* Scope to the caller's org. Without this, cross-org dirty
16153
- chats whose chat_membership still references the caller's
16154
- human agent (historical pollution — see
16155
- fix/cross-org-direct-chat-pollution) would leak into the
16156
- list and 404 on click via requireChatAccess. */
16157
- AND c.organization_id = ${organizationId}
16158
- AND (${!filterUnreadOnly}::bool OR COALESCE(cus.unread_mention_count, 0) > 0)
16159
- AND (${!filterWatchingOnly}::bool OR cm.access_mode = 'watcher')
16160
- AND ${engagementPredicate}
16161
- AND ${sourcePredicate}
16162
- AND ${cursorPredicate}
16163
- ORDER BY c.last_message_at DESC NULLS LAST, c.id DESC
16164
- LIMIT ${limit + 1}
16165
- `);
16166
- const toDate = (v) => {
16167
- if (v === null) return null;
16168
- return v instanceof Date ? v : new Date(v);
16169
- };
16170
- const hasMore = rawRows.length > limit;
16171
- const pageRaw = hasMore ? rawRows.slice(0, limit) : rawRows;
16172
- const last = pageRaw[pageRaw.length - 1];
16173
- const nextCursor = hasMore && last ? encodeCursor(toDate(last.last_message_at), last.chat_id) : null;
16174
- if (pageRaw.length === 0) return {
16175
- rows: [],
16176
- nextCursor: null
16177
- };
16178
- const chatIds = pageRaw.map((r) => r.chat_id);
16179
- const participantRows = await db.select({
16180
- chatId: chatMembership.chatId,
16181
- agentId: chatMembership.agentId,
16182
- displayName: agents.displayName,
16183
- type: agents.type,
16184
- avatarColorToken: agents.avatarColorToken,
16185
- avatarImageUpdatedAt: agents.avatarImageUpdatedAt,
16186
- sessionState: agentChatSessions.state
16187
- }).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")));
16188
- const participantsByChat = /* @__PURE__ */ new Map();
16189
- const engagedByChat = /* @__PURE__ */ new Map();
16190
- for (const p of participantRows) {
16191
- const list = participantsByChat.get(p.chatId) ?? [];
16192
- list.push({
16193
- agentId: p.agentId,
16194
- displayName: p.displayName,
16195
- type: p.type,
16196
- avatarColorToken: p.avatarColorToken,
16197
- avatarImageUrl: agentAvatarImageUrl(p.agentId, p.avatarImageUpdatedAt)
16198
- });
16199
- participantsByChat.set(p.chatId, list);
16200
- if (p.sessionState === "active") {
16201
- const engaged = engagedByChat.get(p.chatId) ?? [];
16202
- engaged.push(p.agentId);
16203
- engagedByChat.set(p.chatId, engaged);
16204
- }
16205
- }
16206
- const liveActivityByChat = await deriveLiveActivity(db, chatIds);
16207
- const firstMessageRows = chatIds.length > 0 ? await db.selectDistinctOn([messages.chatId], {
16208
- chatId: messages.chatId,
16209
- content: messages.content
16210
- }).from(messages).where(inArray(messages.chatId, chatIds)).orderBy(messages.chatId, messages.createdAt) : [];
16211
- const firstMessageSummary = /* @__PURE__ */ new Map();
16212
- for (const row of firstMessageRows) {
16213
- const s = extractSummary(row.content);
16214
- if (s) firstMessageSummary.set(row.chatId, s);
16215
- }
16216
- return {
16217
- rows: pageRaw.map((r) => {
16218
- const participants = participantsByChat.get(r.chat_id) ?? [];
16219
- const title = resolveChatTitle(r.topic, firstMessageSummary.get(r.chat_id) ?? null, participants, humanAgentId);
16220
- const isSpeaker = r.access_mode === "speaker";
16221
- return {
16222
- chatId: r.chat_id,
16223
- type: r.type,
16224
- membershipKind: isSpeaker ? "participant" : "watching",
16225
- title,
16226
- topic: r.topic,
16227
- participants,
16228
- participantCount: Number(r.participant_count),
16229
- lastMessageAt: toDate(r.last_message_at)?.toISOString() ?? null,
16230
- lastMessagePreview: r.last_message_preview,
16231
- unreadMentionCount: r.unread_mention_count,
16232
- canReply: isSpeaker,
16233
- engagementStatus: r.engagement_status,
16234
- engagedAgentIds: engagedByChat.get(r.chat_id) ?? [],
16235
- liveActivity: liveActivityByChat.get(r.chat_id) ?? null
16236
- };
16237
- }),
16238
- nextCursor
16239
- };
16240
- }
16241
- /**
16242
- * Per-chat live activity, derived from the most recent `session_events` row.
16243
- *
16244
- * Returns a chatId → LiveActivity map; chats with no activity (or where the
16245
- * latest event is terminal / stale) are absent from the map (caller treats
16246
- * absence as null).
16247
- */
16248
- async function deriveLiveActivity(db, chatIds) {
16249
- if (chatIds.length === 0) return /* @__PURE__ */ new Map();
16250
- const chatIdInClause = sql.join(chatIds.map((id) => sql`${id}`), sql`, `);
16251
- const rows = (await db.execute(sql`
16252
- SELECT acs.agent_id AS agent_id,
16253
- acs.chat_id AS chat_id,
16254
- e.kind AS kind,
16255
- e.payload AS payload,
16256
- e.created_at AS created_at
16257
- FROM agent_chat_sessions acs
16258
- CROSS JOIN LATERAL (
16259
- SELECT kind, payload, created_at, seq
16260
- FROM session_events se
16261
- WHERE se.agent_id = acs.agent_id
16262
- AND se.chat_id = acs.chat_id
16263
- ORDER BY se.seq DESC
16264
- LIMIT 1
16265
- ) e
16266
- WHERE acs.chat_id IN (${chatIdInClause})
16267
- AND acs.state <> 'evicted'
16268
- `)).map((r) => ({
16269
- agent_id: r.agent_id,
16270
- chat_id: r.chat_id,
16271
- kind: r.kind,
16272
- payload: r.payload,
16273
- created_at: r.created_at
16274
- }));
16275
- const now = Date.now();
16276
- const byChat = /* @__PURE__ */ new Map();
16277
- for (const row of rows) {
16278
- const activity = toLiveActivity(row);
16279
- if (!activity) continue;
16280
- const createdAtMs = new Date(row.created_at).getTime();
16281
- if (now - createdAtMs > 6e4) continue;
16282
- const existing = byChat.get(row.chat_id);
16283
- if (!existing || createdAtMs > existing.createdAtMs) byChat.set(row.chat_id, {
16284
- activity,
16285
- createdAtMs
16286
- });
16287
- }
16288
- const out = /* @__PURE__ */ new Map();
16289
- for (const [chatId, { activity }] of byChat) out.set(chatId, activity);
16290
- return out;
16291
- }
16292
- /**
16293
- * Translate a `session_events` row into a `LiveActivity`, or null when the
16294
- * kind is terminal (`turn_end` / `error`) or unrecognised. Pure & exported
16295
- * for unit testing.
16296
- */
16297
- function toLiveActivity(row) {
16298
- const startedAt = new Date(row.created_at).toISOString();
16299
- switch (row.kind) {
16300
- case "tool_call": {
16301
- const payload = row.payload ?? {};
16302
- const label = typeof payload.name === "string" && payload.name.length > 0 ? payload.name : "Tool";
16303
- return {
16304
- agentId: row.agent_id,
16305
- kind: "tool_call",
16306
- label,
16307
- startedAt
16308
- };
16309
- }
16310
- case "thinking": return {
16311
- agentId: row.agent_id,
16312
- kind: "thinking",
16313
- label: "Thinking",
16314
- startedAt
16315
- };
16316
- case "assistant_text": return {
16317
- agentId: row.agent_id,
16318
- kind: "assistant_text",
16319
- label: "Writing",
16320
- startedAt
16321
- };
16322
- default: return null;
16323
- }
16324
- }
16325
- /**
16326
- * Title resolution priority:
16327
- *
16328
- * 1. `chat.topic` (manual, set via `PATCH /chats/:chatId`)
16329
- * 2. First message summary (auto, ≤ 50 chars from `extractSummary`)
16330
- * 3. Participant join (fallback when chat has no messages yet)
16331
- */
16332
- function resolveChatTitle(topic, firstMessageSummary, participants, selfAgentId) {
16333
- if (topic && topic.length > 0) return topic;
16334
- if (firstMessageSummary && firstMessageSummary.length > 0) return firstMessageSummary;
16335
- const others = participants.filter((p) => p.agentId !== selfAgentId);
16336
- if (others.length === 0) return "Empty chat";
16337
- if (others.length <= 3) return others.map((p) => p.displayName).join(", ");
16338
- return `${others[0]?.displayName}, ${others[1]?.displayName} +${others.length - 2}`;
16339
- }
16340
- async function createMeChat(db, humanAgentId, organizationId, body) {
16341
- const distinctIds = [...new Set(body.participantIds)].filter((id) => id !== humanAgentId);
16342
- if (distinctIds.length === 0) throw new BadRequestError("At least one non-self participant required");
16343
- const allIds = [humanAgentId, ...distinctIds];
16344
- const found = await db.select({
16345
- uuid: agents.uuid,
16346
- organizationId: agents.organizationId,
16347
- type: agents.type
16348
- }).from(agents).where(inArray(agents.uuid, allIds));
16349
- if (found.length !== allIds.length) {
16350
- const foundSet = new Set(found.map((a) => a.uuid));
16351
- throw new BadRequestError(`Agents not found: ${allIds.filter((id) => !foundSet.has(id)).join(", ")}`);
16352
- }
16353
- const crossOrg = found.filter((a) => a.organizationId !== organizationId);
16354
- if (crossOrg.length > 0) throw new BadRequestError(`Cross-organization chat not allowed: ${crossOrg.map((a) => a.uuid).join(", ")}`);
16355
- const chatType = distinctIds.length === 1 ? "direct" : "group";
16356
- const chatId = randomUUID();
16357
- const topic = body.topic ?? null;
16358
- await db.transaction(async (tx) => {
16359
- await tx.insert(chats).values({
16360
- id: chatId,
16361
- organizationId,
16362
- type: chatType,
16363
- topic
16364
- });
16365
- await addChatParticipants(tx, chatId, allIds.map((agentId) => ({
16366
- agentId,
16367
- role: agentId === humanAgentId ? "owner" : "member"
16368
- })));
16369
- await recomputeChatWatchers(tx, chatId);
16370
- });
16371
- invalidateChatAudience(chatId);
16372
- return { chatId };
16373
- }
16374
- async function addMeChatParticipants(db, chatId, callerHumanAgentId, callerOrganizationId, body) {
16375
- const distinct = [...new Set(body.participantIds)];
16376
- if (distinct.length === 0) throw new BadRequestError("At least one participant required");
16377
- const [chat] = await db.select({
16378
- id: chats.id,
16379
- organizationId: chats.organizationId,
16380
- type: chats.type
16381
- }).from(chats).where(eq(chats.id, chatId)).limit(1);
16382
- if (!chat) throw new NotFoundError(`Chat "${chatId}" not found`);
16383
- if (chat.organizationId !== callerOrganizationId) throw new NotFoundError(`Chat "${chatId}" not found`);
16384
- const [callerRow] = await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, callerHumanAgentId), eq(chatMembership.accessMode, "speaker"))).limit(1);
16385
- if (!callerRow) throw new NotFoundError(`Chat "${chatId}" not found`);
16386
- const found = await db.select({
16387
- uuid: agents.uuid,
16388
- organizationId: agents.organizationId,
16389
- type: agents.type
16390
- }).from(agents).where(inArray(agents.uuid, distinct));
16391
- if (found.length !== distinct.length) {
16392
- const foundSet = new Set(found.map((a) => a.uuid));
16393
- throw new BadRequestError(`Agents not found: ${distinct.filter((id) => !foundSet.has(id)).join(", ")}`);
16394
- }
16395
- const crossOrg = found.filter((a) => a.organizationId !== chat.organizationId);
16396
- if (crossOrg.length > 0) throw new BadRequestError(`Cross-organization participant rejected: ${crossOrg.map((a) => a.uuid).join(", ")}`);
16397
- await db.transaction(async (tx) => {
16398
- const existingSpeakers = await tx.select({ agentId: chatMembership.agentId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
16399
- const existingSpeakerSet = new Set(existingSpeakers.map((e) => e.agentId));
16400
- const toUpsert = distinct.filter((id) => !existingSpeakerSet.has(id));
16401
- if (toUpsert.length === 0) {
16402
- await recomputeChatWatchers(tx, chatId);
16403
- return;
16404
- }
16405
- if (existingSpeakers.length + toUpsert.length >= 3 && chat.type === "direct") await changeChatType(tx, chatId, "group");
16406
- await addChatParticipants(tx, chatId, toUpsert.map((agentId) => ({
16407
- agentId,
16408
- role: "member"
16409
- })), { upgradeWatcherToSpeaker: true });
16410
- await recomputeChatWatchers(tx, chatId);
16411
- });
16412
- invalidateChatAudience(chatId);
16413
- }
16414
- async function markMeChatRead(db, chatId, humanAgentId) {
16415
- const now = /* @__PURE__ */ new Date();
16416
- await db.insert(chatUserState).values({
16417
- chatId,
16418
- agentId: humanAgentId,
16419
- lastReadAt: now,
16420
- unreadMentionCount: 0
16421
- }).onConflictDoUpdate({
16422
- target: [chatUserState.chatId, chatUserState.agentId],
16423
- set: {
16424
- lastReadAt: now,
16425
- unreadMentionCount: 0
16426
- }
16427
- });
16428
- return {
16429
- chatId,
16430
- lastReadAt: now.toISOString(),
16431
- unreadMentionCount: 0
16432
- };
16433
- }
16434
- async function joinMeChat(db, chatId, humanAgentId) {
16435
- ensureCanJoin(await resolveChatMembership(db, chatId, humanAgentId));
16436
- await joinAsParticipant(db, chatId, humanAgentId);
16437
- invalidateChatAudience(chatId);
16438
- }
16439
- async function leaveMeChat(db, chatId, humanAgentId) {
16440
- const result = await leaveAsParticipant(db, chatId, humanAgentId);
16441
- invalidateChatAudience(chatId);
16442
- return result;
16443
- }
16444
- /**
16445
- * Used by future bell-badge / list-pill counts. The partial index
16446
- * `idx_user_state_unread WHERE unread_mention_count > 0` bounds the
16447
- * driving scan; we then join `chat_membership` + `chats` so the badge
16448
- * stays consistent with `listMeChats`.
16449
- *
16450
- * Why the joins (not just a single-table count): per §11.4 a user's
16451
- * `chat_user_state` row is **preserved on detach** so read state
16452
- * survives a leave/rejoin cycle. Without the membership join, any
16453
- * preserved row with `unread_mention_count > 0` would keep
16454
- * contributing to the badge even though the chat no longer appears in
16455
- * the list. The `chats` join applies the same org-scoping +
16456
- * `parent_chat_id IS NULL` filter as `listMeChats` so the two counts
16457
- * cannot drift in the cross-org pollution or nested-chat cases either.
16458
- *
16459
- * Engagement parity: deleted chats are excluded from `listMeChats`
16460
- * (any `engagement` view), so the badge must exclude them too — otherwise
16461
- * the user sees an unread red dot for a chat they've removed from view.
16462
- */
16463
- /**
16464
- * Per-source aggregate for the conversation-list tag bar.
16465
- *
16466
- * Returns one row per source the caller has at least one chat for, plus an
16467
- * always-present `manual` entry (zero counts when there are no manual chats —
16468
- * the workspace UI uses `manual` as its default tab and must render it even
16469
- * when empty).
16470
- *
16471
- * Filtering matches `listMeChats` for the corresponding tab so the badges
16472
- * cannot drift from the list: same membership join, same `parent_chat_id IS
16473
- * NULL` and `organization_id` scopes, same engagement view, same
16474
- * `chat_user_state.unread_mention_count` source.
16475
- */
16476
- async function listMeChatSourceCounts(db, humanAgentId, organizationId, query) {
16477
- const engagementPredicate = ENGAGEMENT_VIEW_PREDICATE[query.engagement];
16478
- const rows = await db.execute(sql`
16479
- SELECT
16480
- ${chatSourceSqlExpression} AS source,
16481
- count(*)::int AS chat_count,
16482
- count(*) FILTER (WHERE COALESCE(cus.unread_mention_count, 0) > 0)::int AS unread_chat_count
16483
- FROM chats c
16484
- JOIN chat_membership cm
16485
- ON cm.chat_id = c.id AND cm.agent_id = ${humanAgentId}
16486
- LEFT JOIN chat_user_state cus
16487
- ON cus.chat_id = c.id AND cus.agent_id = ${humanAgentId}
16488
- WHERE c.parent_chat_id IS NULL
16489
- AND c.organization_id = ${organizationId}
16490
- AND ${engagementPredicate}
16491
- GROUP BY 1
16492
- `);
16493
- const counts = {};
16494
- for (const row of rows) counts[row.source] = {
16495
- chatCount: Number(row.chat_count),
16496
- unreadChatCount: Number(row.unread_chat_count)
16497
- };
16498
- if (!counts.manual) counts.manual = {
16499
- chatCount: 0,
16500
- unreadChatCount: 0
16501
- };
16502
- return { counts };
16503
- }
16504
- /**
16505
15036
  * Class C — resource-scoped chat routes. Mounted at
16506
15037
  * `/api/v1/chats/:chatId/...`. The chat's `organizationId` locates its
16507
15038
  * org; `requireChatAccess` resolves the caller's membership in that org
@@ -16510,7 +15041,17 @@ async function listMeChatSourceCounts(db, humanAgentId, organizationId, query) {
16510
15041
  async function chatRoutes(app) {
16511
15042
  app.get("/:chatId", async (request) => {
16512
15043
  const { chat, scope } = await requireChatAccess(request, app.db);
16513
- const participants = await app.db.select().from(chatMembership).where(and(eq(chatMembership.chatId, chat.id), eq(chatMembership.accessMode, "speaker")));
15044
+ const participants = await app.db.select({
15045
+ agentId: chatMembership.agentId,
15046
+ role: chatMembership.role,
15047
+ mode: chatMembership.mode,
15048
+ joinedAt: chatMembership.joinedAt,
15049
+ name: agents.name,
15050
+ displayName: agents.displayName,
15051
+ type: agents.type,
15052
+ avatarColorToken: agents.avatarColorToken,
15053
+ avatarImageUpdatedAt: agents.avatarImageUpdatedAt
15054
+ }).from(chatMembership).innerJoin(agents, eq(chatMembership.agentId, agents.uuid)).where(and(eq(chatMembership.chatId, chat.id), eq(chatMembership.accessMode, "speaker")));
16514
15055
  const firstMsgRows = await app.db.execute(sql`
16515
15056
  SELECT content FROM messages
16516
15057
  WHERE chat_id = ${chat.id}
@@ -16518,25 +15059,13 @@ async function chatRoutes(app) {
16518
15059
  LIMIT 1
16519
15060
  `);
16520
15061
  const firstMessagePreview = firstMsgRows[0] ? extractSummary(firstMsgRows[0].content) : null;
16521
- const participantAgentIds = participants.map((p) => p.agentId);
16522
- const agentRows = participantAgentIds.length > 0 ? await app.db.select({
16523
- agentId: agents.uuid,
16524
- displayName: agents.displayName,
16525
- type: agents.type,
16526
- avatarColorToken: agents.avatarColorToken,
16527
- avatarImageUpdatedAt: agents.avatarImageUpdatedAt
16528
- }).from(agents).where(inArray(agents.uuid, participantAgentIds)) : [];
16529
- const agentMeta = new Map(agentRows.map((a) => [a.agentId, a]));
16530
- const participantsForTitle = participants.map((p) => {
16531
- const meta = agentMeta.get(p.agentId);
16532
- return {
16533
- agentId: p.agentId,
16534
- displayName: meta?.displayName ?? p.agentId,
16535
- type: meta?.type ?? "unknown",
16536
- avatarColorToken: meta?.avatarColorToken ?? null,
16537
- avatarImageUrl: agentAvatarImageUrl(p.agentId, meta?.avatarImageUpdatedAt ?? null)
16538
- };
16539
- });
15062
+ const participantsForTitle = participants.map((p) => ({
15063
+ agentId: p.agentId,
15064
+ displayName: p.displayName,
15065
+ type: p.type,
15066
+ avatarColorToken: p.avatarColorToken ?? null,
15067
+ avatarImageUrl: agentAvatarImageUrl(p.agentId, p.avatarImageUpdatedAt ?? null)
15068
+ }));
16540
15069
  const title = resolveChatTitle(chat.topic, firstMessagePreview, participantsForTitle, scope.humanAgentId);
16541
15070
  const engagementStatus = await getCallerEngagement(app.db, chat.id, scope.humanAgentId);
16542
15071
  return {
@@ -16550,6 +15079,9 @@ async function chatRoutes(app) {
16550
15079
  agentId: p.agentId,
16551
15080
  role: p.role,
16552
15081
  mode: p.mode,
15082
+ name: p.name,
15083
+ displayName: p.displayName,
15084
+ type: p.type,
16553
15085
  joinedAt: p.joinedAt.toISOString()
16554
15086
  }))
16555
15087
  };
@@ -16699,6 +15231,11 @@ async function chatRoutes(app) {
16699
15231
  const { scope } = await requireChatAccess(request, app.db);
16700
15232
  return markMeChatRead(app.db, request.params.chatId, scope.humanAgentId);
16701
15233
  });
15234
+ /** POST /chats/:chatId/unread — manual "mark as unread" affordance. Idempotent. */
15235
+ app.post("/:chatId/unread", async (request) => {
15236
+ const { scope } = await requireChatAccess(request, app.db);
15237
+ return markMeChatUnread(app.db, request.params.chatId, scope.humanAgentId);
15238
+ });
16702
15239
  /** POST /chats/:chatId/participants — add speaking participants. Idempotent. */
16703
15240
  app.post("/:chatId/participants", async (request, reply) => {
16704
15241
  const { scope } = await requireChatAccess(request, app.db);
@@ -18065,7 +16602,7 @@ async function healthzRoutes(app) {
18065
16602
  * `api/orgs/invitations.ts` (Class B, admin-gated).
18066
16603
  */
18067
16604
  async function publicInvitationRoutes(app) {
18068
- const { previewInvitation } = await import("./invitation-CNv7gfFF-DOFZ75wb.mjs");
16605
+ const { previewInvitation } = await import("./invitation-C9m2gQx4-CkwWteA3.mjs");
18069
16606
  app.get("/:token/preview", async (request, reply) => {
18070
16607
  if (!request.params.token) throw new UnauthorizedError("Token required");
18071
16608
  const preview = await previewInvitation(app.db, request.params.token);
@@ -18354,7 +16891,7 @@ async function meRoutes(app) {
18354
16891
  */
18355
16892
  app.get("/me/pinned-agents", async (request) => {
18356
16893
  const { userId } = requireUser(request);
18357
- const { listMyPinnedAgents } = await import("./client-CREn8bJ0-C5fHJir6.mjs");
16894
+ const { listMyPinnedAgents } = await import("./client-CDw0f-kN-BPzOVd8L.mjs");
18358
16895
  return listMyPinnedAgents(app.db, { userId });
18359
16896
  });
18360
16897
  /**