@agent-team-foundation/first-tree-hub 0.14.0 → 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-RM_03B_l-DiEIa9xe.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) {
@@ -3436,70 +3474,154 @@ function installFirstTreeIntegration(options) {
3436
3474
  function generateToolsDoc() {
3437
3475
  return `# Agent Hub SDK
3438
3476
 
3439
- ## How You Communicate
3440
-
3441
3477
  You are running inside **Agent Hub**, a messaging platform for agent teams.
3442
3478
 
3443
- - Messages from other team members arrive as your prompt input
3444
- - Each message includes a \`[From: <agent-name>]\` header — that name is also
3445
- what you pass back to \`chat send\` to reply to or address that agent
3446
- - **Your final text response is automatically delivered** to the chat just respond normally
3447
- - For **proactive communication** (sending to other agents, other chats, or structured data),
3448
- use the \`first-tree-hub\` CLI below
3449
- - **Use your judgment about when to respond.** Not every message requires
3450
- a reply if you have nothing new for the recipient, output nothing and
3451
- the runtime will end the turn silently.
3452
- Your role and responsibilities are injected via the Hub-managed system prompt.
3479
+ - Messages from other team members arrive as your prompt input. Each message has a
3480
+ \`[From: <agent-name>]\` header — that name is what you pass back to \`chat send\`.
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).
3484
+ - **Stay silent when you have nothing to add.** Not every message needs a reply.
3485
+ If you have nothing new for the recipient, output nothing and the runtime ends the turn.
3486
+ - For **proactive communication** (other agents, other chats, or different format),
3487
+ use the \`first-tree-hub\` CLI below.
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.
3453
3493
 
3454
- ## Environment Variables
3494
+ To make another agent take action, you MUST explicitly call:
3455
3495
 
3456
- These are injected automatically when the agent process starts:
3496
+ first-tree-hub chat send <name> "..."
3457
3497
 
3458
- | Variable | Description |
3459
- |----------|-------------|
3460
- | \`FIRST_TREE_HUB_SERVER_URL\` | Server address for API calls |
3461
- | \`FIRST_TREE_HUB_ACCESS_TOKEN\` | User member access JWT (short-lived) |
3462
- | \`FIRST_TREE_HUB_AGENT_ID\` | YOUR own agent UUID. The CLI reads it to identify you as the sender — never pass it as a \`send\` target. |
3463
- | \`FIRST_TREE_HUB_CHAT_ID\` | The chat this session is currently bound to. The CLI uses it to route messages — you don't need to pass it manually. |
3498
+ Decision guide (based on participant \`type\` in the Current Chat Context block):
3464
3499
 
3465
- The \`first-tree-hub\` CLI reads these automatically no extra setup needed.
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.
3466
3511
 
3467
3512
  ## Sending Messages
3468
3513
 
3469
- Use the \`first-tree-hub chat send\` CLIit reads the env vars above and
3470
- attaches the \`Authorization\` + \`X-Agent-Id\` headers automatically:
3514
+ The CLI auto-reads its config from env no setup needed.
3471
3515
 
3472
3516
  \`\`\`bash
3473
- # Send to another agent — first positional argument is the recipient's NAME
3474
- # (NOT a uuid; uuids in chat history / participant lists are not accepted).
3475
- # Run \`first-tree-hub agent list\` to see available names.
3476
- #
3477
- # Routing: if the recipient is a participant of your current chat (typically
3478
- # the case in a group chat where someone @-mentioned you to talk to them),
3479
- # the message stays in that chat. Otherwise it falls back to a direct chat
3480
- # between you and the recipient. You don't need to think about which.
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.
3481
3521
  first-tree-hub chat send <agentName> "your message"
3482
3522
 
3483
- # Send into a specific chat by id use this only when you explicitly want
3484
- # to address a chat your current session is NOT bound to.
3485
- 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"
3486
3526
 
3487
- # Send markdown (default format is text)
3488
- first-tree-hub chat send <agentName> -f markdown "**bold** message"
3527
+ # Markdown format (default is text)
3528
+ first-tree-hub chat send <agentName> -f markdown "**bold**"
3489
3529
 
3490
- # Reply to a specific message
3491
- first-tree-hub chat send <agentName> --reply-to <messageId> "reply content"
3530
+ # Reply to a message
3531
+ first-tree-hub chat send <agentName> --reply-to <messageId> "reply"
3492
3532
 
3493
- # Pipe long content via stdin (recommended for special characters)
3494
- echo "long message body" | first-tree-hub chat send <agentName>
3533
+ # Pipe long / multiline content via stdin
3534
+ echo "long body" | first-tree-hub chat send <agentName>
3495
3535
  \`\`\`
3496
3536
 
3497
- > Agent uuids appear in \`chat list\`, chat history, and participant lists,
3498
- > but they are NOT accepted by \`chat send\` — always use the name.
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
+
3548
+ **Content rules (important):**
3499
3549
 
3500
- For content with quotes, \`$\`, backticks, or newlines, prefer stdin to avoid shell escaping issues.
3550
+ - Pass content as a **raw string** never \`JSON.stringify\` it first. Wrapping in
3551
+ outer quotes + \`\\n\` escapes produces a literal \`"@x ...\\n..."\` that the UI
3552
+ cannot render as markdown.
3553
+ - For multi-line / markdown / special chars (quotes, \`$\`, backticks, newlines),
3554
+ use **stdin** with real newlines, plus \`-f markdown\`.
3501
3555
  `;
3502
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
+ }
3503
3625
  function resolveGitRepoTargetPath(workspace, localPath) {
3504
3626
  const safetyError = getRepoLocalPathSafetyError(localPath);
3505
3627
  if (safetyError) throw new Error(`Unsafe git repo localPath "${localPath}": ${safetyError}`);
@@ -4021,6 +4143,7 @@ function createGitMirrorManager(opts) {
4021
4143
  }, "worktree create conflict");
4022
4144
  throw new GitMirrorWorktreeConflictError(`Worktree target "${absTarget}" is already occupied by ${classifyOccupant(absTarget)} — aborting (D13)`);
4023
4145
  }
4146
+ await gitOk(["worktree", "prune"], mirror, 1e4);
4024
4147
  const pathExists = existsSync(absTarget);
4025
4148
  const hasBranch = await branchExists(mirror, branchName);
4026
4149
  mkdirSync(dirname(absTarget), { recursive: true });
@@ -4921,7 +5044,8 @@ const createClaudeCodeHandler = (config) => {
4921
5044
  try {
4922
5045
  await sessionCtx.sdk.sendMessage(sessionCtx.chatId, {
4923
5046
  format: "question",
4924
- content: questionContent
5047
+ content: questionContent,
5048
+ purpose: "agent-final-text"
4925
5049
  });
4926
5050
  } catch (err) {
4927
5051
  const reason = err instanceof Error ? err.message : String(err);
@@ -5191,16 +5315,45 @@ const createClaudeCodeHandler = (config) => {
5191
5315
  }
5192
5316
  }
5193
5317
  }
5194
- /** Bootstrap workspace and generate CLAUDE.md. */
5195
- 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) {
5196
5342
  bootstrapWorkspace({
5197
5343
  workspacePath: workspace,
5198
5344
  identity: sessionCtx.agent,
5199
5345
  contextTreePath,
5200
5346
  serverUrl: sessionCtx.sdk.serverUrl,
5201
- chatId: sessionCtx.chatId
5347
+ chatId: sessionCtx.chatId,
5348
+ chatContext
5202
5349
  });
5203
- 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);
5204
5357
  if (contextTreePath) installFirstTreeIntegration({
5205
5358
  workspacePath: workspace,
5206
5359
  contextTreePath,
@@ -5214,7 +5367,8 @@ const createClaudeCodeHandler = (config) => {
5214
5367
  ctx = sessionCtx;
5215
5368
  claudeSessionId = randomUUID();
5216
5369
  cwd = acquireWorkspace(workspaceRoot, sessionCtx.chatId);
5217
- runBootstrap(cwd, sessionCtx);
5370
+ const chatContext = await fetchChatContextOrLog(sessionCtx);
5371
+ runBootstrap(cwd, sessionCtx, chatContext);
5218
5372
  const payload = agentConfigCache?.get(sessionCtx.agent.agentId)?.payload;
5219
5373
  await prepareGitWorktrees(cwd, payload, sessionCtx);
5220
5374
  markWorkspaceInitComplete(cwd);
@@ -5230,7 +5384,9 @@ const createClaudeCodeHandler = (config) => {
5230
5384
  claudeSessionId = sessionId;
5231
5385
  retryCount = 0;
5232
5386
  cwd = acquireWorkspace(workspaceRoot, sessionCtx.chatId);
5233
- 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);
5234
5390
  const payload = agentConfigCache?.get(sessionCtx.agent.agentId)?.payload;
5235
5391
  await prepareGitWorktrees(cwd, payload, sessionCtx);
5236
5392
  markWorkspaceInitComplete(cwd);
@@ -5323,12 +5479,14 @@ function isHubWorktreeMarker(path) {
5323
5479
  * `agent_configs.payload.prompt.append` and are passed to the Claude SDK via
5324
5480
  * `systemPrompt.append` — not through this file.
5325
5481
  */
5326
- function generateClaudeMd(workspacePath, identity, contextTreePath) {
5482
+ function generateClaudeMd(workspacePath, identity, contextTreePath, chatContext) {
5327
5483
  const sections = [];
5328
5484
  const contextDir = join(workspacePath, ".agent", "context");
5329
5485
  const name = identity.displayName ?? identity.agentId;
5330
5486
  if (identity.type === "personal_assistant") sections.push(`# Agent Identity\n\nYou are ${name}, a personal assistant agent.\n`);
5331
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);
5332
5490
  const agentInstructionsPath = join(contextDir, "agent-instructions.md");
5333
5491
  if (existsSync(agentInstructionsPath)) {
5334
5492
  const instructions = readFileSync(agentInstructionsPath, "utf-8");
@@ -5434,7 +5592,7 @@ const createCodexHandler = (config) => {
5434
5592
  cfg.mcp_servers = mcpServers;
5435
5593
  return cfg;
5436
5594
  }
5437
- function buildAgentBriefing(payload) {
5595
+ function buildAgentBriefing(payload, chatContext) {
5438
5596
  const lines = [];
5439
5597
  lines.push("# Agent Briefing");
5440
5598
  lines.push("");
@@ -5442,11 +5600,28 @@ const createCodexHandler = (config) => {
5442
5600
  lines.push(payload.prompt.append.trim());
5443
5601
  lines.push("");
5444
5602
  }
5603
+ const chatContextSection = renderChatContextSection(chatContext);
5604
+ if (chatContextSection) {
5605
+ lines.push(chatContextSection.trimEnd());
5606
+ lines.push("");
5607
+ }
5445
5608
  lines.push("Refer to `.agent/identity.json` for your agent identity, `.agent/tools.md` for the");
5446
5609
  lines.push("first-tree-hub SDK reference, and `.agent/context/` for organisational context");
5447
5610
  lines.push("(when configured).");
5448
5611
  return lines.join("\n").concat("\n");
5449
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
+ }
5450
5625
  function toCodexInput(message, sessionCtx) {
5451
5626
  return sessionCtx.formatInboundContent(message).then((text) => text);
5452
5627
  }
@@ -5706,15 +5881,17 @@ const createCodexHandler = (config) => {
5706
5881
  env: [],
5707
5882
  gitRepos: []
5708
5883
  };
5884
+ const chatContext = await fetchChatContextOrLog(sessionCtx);
5709
5885
  bootstrapWorkspace({
5710
5886
  workspacePath: cwd,
5711
5887
  identity: sessionCtx.agent,
5712
5888
  contextTreePath,
5713
5889
  serverUrl: sessionCtx.sdk.serverUrl,
5714
5890
  chatId: sessionCtx.chatId,
5891
+ chatContext,
5715
5892
  briefing: {
5716
5893
  format: "agents-md",
5717
- content: buildAgentBriefing(payload)
5894
+ content: buildAgentBriefing(payload, chatContext)
5718
5895
  }
5719
5896
  });
5720
5897
  ensureFirstTreeBinding(cwd, sessionCtx);
@@ -5743,20 +5920,20 @@ const createCodexHandler = (config) => {
5743
5920
  env: [],
5744
5921
  gitRepos: []
5745
5922
  };
5746
- if (!existsSync(join(cwd, INIT_COMPLETE_SENTINEL_REL))) {
5747
- bootstrapWorkspace({
5748
- workspacePath: cwd,
5749
- identity: sessionCtx.agent,
5750
- contextTreePath,
5751
- serverUrl: sessionCtx.sdk.serverUrl,
5752
- chatId: sessionCtx.chatId,
5753
- briefing: {
5754
- format: "agents-md",
5755
- content: buildAgentBriefing(payload)
5756
- }
5757
- });
5758
- ensureFirstTreeBinding(cwd, sessionCtx);
5759
- }
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);
5760
5937
  await prepareGitWorktrees(payload, cwd, sessionCtx);
5761
5938
  markWorkspaceInitComplete(cwd);
5762
5939
  codex = new Codex({
@@ -6072,17 +6249,10 @@ var Deduplicator = class {
6072
6249
  }
6073
6250
  };
6074
6251
  function createResultSink(deps) {
6075
- async function buildMetadata(trigger) {
6252
+ async function buildMetadata() {
6076
6253
  const metadata = {};
6077
6254
  const documentBasePath = await deps.getDocumentBasePath?.();
6078
6255
  if (documentBasePath) metadata.documentContext = documentContextSchema.parse({ basePath: documentBasePath });
6079
- if (trigger && trigger.senderId !== deps.agent.agentId) {
6080
- const participants = await deps.participants.get();
6081
- if (participants.length <= 2) {
6082
- const peer = participants.find((p) => p.agentId === trigger.senderId);
6083
- if (!peer || peer.mode === "mention_only") metadata.mentions = [trigger.senderId];
6084
- } else metadata.mentions = [trigger.senderId];
6085
- }
6086
6256
  return Object.keys(metadata).length > 0 ? metadata : void 0;
6087
6257
  }
6088
6258
  return async function forwardResult(text) {
@@ -6093,10 +6263,11 @@ function createResultSink(deps) {
6093
6263
  }
6094
6264
  const trigger = deps.getTrigger();
6095
6265
  deps.clearTrigger();
6096
- const metadata = await buildMetadata(trigger);
6266
+ const metadata = await buildMetadata();
6097
6267
  await deps.sdk.sendMessage(deps.chatId, {
6098
6268
  format: "text",
6099
6269
  content: text,
6270
+ purpose: "agent-final-text",
6100
6271
  ...trigger ? { inReplyTo: trigger.messageId } : {},
6101
6272
  ...metadata ? { metadata } : {}
6102
6273
  });
@@ -6673,7 +6844,6 @@ var SessionManager = class {
6673
6844
  this.currentTrigger.delete(chatId);
6674
6845
  },
6675
6846
  log,
6676
- participants,
6677
6847
  getDocumentBasePath: () => this.resolveDocumentBasePath(log)
6678
6848
  });
6679
6849
  const envCtx = {
@@ -9611,7 +9781,7 @@ function formatCheckReport(items) {
9611
9781
  }
9612
9782
  return lines.join("\n");
9613
9783
  }
9614
- async function resolveDefaultOrgId$1(serverUrl, accessToken) {
9784
+ async function resolveDefaultOrgId(serverUrl, accessToken) {
9615
9785
  const res = await cliFetch(`${serverUrl}/api/v1/me`, {
9616
9786
  headers: { Authorization: `Bearer ${accessToken}` },
9617
9787
  signal: AbortSignal.timeout(1e4)
@@ -9646,7 +9816,7 @@ async function onboardCreate(args) {
9646
9816
  if (args.role) metadata.role = args.role;
9647
9817
  if (args.domains) metadata.domains = args.domains.split(",").map((d) => d.trim());
9648
9818
  print.line(`Creating agent "${args.id}"...\n`);
9649
- const orgId = await resolveDefaultOrgId$1(serverUrl, accessToken);
9819
+ const orgId = await resolveDefaultOrgId(serverUrl, accessToken);
9650
9820
  const primary = await createAgentViaAdmin(serverUrl, accessToken, orgId, {
9651
9821
  name: args.id,
9652
9822
  type: args.type,
@@ -9683,7 +9853,7 @@ async function onboardCreate(args) {
9683
9853
  }
9684
9854
  const runtimeAgent = args.type === "human" ? args.assistant : args.id;
9685
9855
  if (args.feishuBotAppId && args.feishuBotAppSecret) {
9686
- const { bindFeishuBot } = await import("./feishu-BGx71p5s.mjs").then((n) => n.r);
9856
+ const { bindFeishuBot } = await import("./feishu-CCWd-JE4.mjs").then((n) => n.r);
9687
9857
  const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
9688
9858
  if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
9689
9859
  else {
@@ -10896,7 +11066,7 @@ function createFeedbackHandler(config) {
10896
11066
  return { handle };
10897
11067
  }
10898
11068
  //#endregion
10899
- //#region ../server/dist/app-l2iy80P2.mjs
11069
+ //#region ../server/dist/app-Cv337jed.mjs
10900
11070
  var import_fastify_opentelemetry = /* @__PURE__ */ __toESM(require_fastify_opentelemetry(), 1);
10901
11071
  init_esm();
10902
11072
  var __defProp = Object.defineProperty;
@@ -10909,17 +11079,6 @@ var __exportAll = (all, no_symbols) => {
10909
11079
  if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
10910
11080
  return target;
10911
11081
  };
10912
- /** Maps external user identities to internal Agents. */
10913
- const adapterAgentMappings = pgTable("adapter_agent_mappings", {
10914
- id: serial("id").primaryKey(),
10915
- platform: text("platform").notNull(),
10916
- externalUserId: text("external_user_id").notNull(),
10917
- agentId: text("agent_id").notNull().references(() => agents.uuid),
10918
- boundVia: text("bound_via"),
10919
- displayName: text("display_name"),
10920
- metadata: jsonb("metadata").$type().notNull().default({}),
10921
- createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
10922
- }, (table) => [unique("uq_adapter_agent_mapping").on(table.platform, table.externalUserId)]);
10923
11082
  /**
10924
11083
  * Pull the JWT-verified `UserScope` off the request. The `userAuthHook`
10925
11084
  * middleware populates `request.user` synchronously before any handler
@@ -10980,10 +11139,23 @@ async function requireAgentAccess(request, db, kind) {
10980
11139
  };
10981
11140
  }
10982
11141
  /**
10983
- * Gate access to a chat. Allowed if the caller's HUMAN agent is a
10984
- * participant, OR any agent the caller manages (via members.id) is a
10985
- * participant. Admin role does NOT auto-grant chat access — chat content
10986
- * 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.
10987
11159
  *
10988
11160
  * The Params type is generic so routes that mount on a path with extra
10989
11161
  * params (e.g. `/agents/:uuid/sessions/:chatId/...` for compound checks)
@@ -11003,7 +11175,7 @@ async function requireChatAccess(request, db) {
11003
11175
  role: caller.role,
11004
11176
  humanAgentId: caller.humanAgentId
11005
11177
  };
11006
- 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);
11007
11179
  if (direct) {
11008
11180
  stampOrgScope(request, scope);
11009
11181
  stampChatResource(request, chat);
@@ -11092,16 +11264,6 @@ async function adapterMappingRoutes(app) {
11092
11264
  return reply.status(204).send();
11093
11265
  });
11094
11266
  }
11095
- /** Bot credentials for external platform adapters. Credentials are encrypted at application layer (AES-256-GCM). */
11096
- const adapterConfigs = pgTable("adapter_configs", {
11097
- id: serial("id").primaryKey(),
11098
- platform: text("platform").notNull(),
11099
- agentId: text("agent_id").notNull().references(() => agents.uuid),
11100
- credentials: jsonb("credentials").$type().notNull(),
11101
- status: text("status").notNull().default("active"),
11102
- createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
11103
- updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
11104
- }, (t) => [unique("uq_adapter_configs_agent_platform").on(t.agentId, t.platform)]);
11105
11267
  const ALGORITHM = "aes-256-gcm";
11106
11268
  const IV_LENGTH = 12;
11107
11269
  const AUTH_TAG_LENGTH = 16;
@@ -11370,7 +11532,7 @@ async function agentChatRoutes(app) {
11370
11532
  app.get("/:chatId", async (request) => {
11371
11533
  const identity = requireAgent(request);
11372
11534
  await assertParticipant(app.db, request.params.chatId, identity.uuid);
11373
- const detail = await getChatDetail(app.db, request.params.chatId);
11535
+ const detail = await getChatDetail(app.db, request.params.chatId, identity.uuid);
11374
11536
  return {
11375
11537
  ...serializeChat(detail),
11376
11538
  participants: detail.participants.map((p) => ({
@@ -11435,592 +11597,6 @@ async function agentConfigRoutes$1(app) {
11435
11597
  return await app.configService.getDecrypted(identity.uuid);
11436
11598
  });
11437
11599
  }
11438
- /**
11439
- * Per-agent runtime configuration (Hub-managed; not the local YAML config).
11440
- *
11441
- * One row per agent. `version` increments on every successful UPDATE
11442
- * (optimistic locking via WHERE version = :expected). Sensitive env values
11443
- * inside `payload.env[*]` are AES-256-GCM encrypted at write time and
11444
- * masked when echoed via the Admin API (see Step 2).
11445
- *
11446
- * Integrity is enforced by the service layer per project convention:
11447
- * no FK / CHECK / triggers on this table.
11448
- */
11449
- const agentConfigs = pgTable("agent_configs", {
11450
- agentId: text("agent_id").primaryKey(),
11451
- version: integer("version").notNull().default(1),
11452
- payload: jsonb("payload").$type().notNull(),
11453
- updatedBy: text("updated_by").notNull(),
11454
- updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
11455
- });
11456
- /**
11457
- * Resolve the UUID of the "default" organization. Internal use only —
11458
- * webhooks, fallbacks, etc. The HTTP API layer no longer falls back to
11459
- * the JWT default org.
11460
- */
11461
- async function resolveDefaultOrgId(db) {
11462
- const [org] = await db.select({ id: organizations.id }).from(organizations).where(eq(organizations.name, "default")).limit(1);
11463
- if (!org) throw new Error("Default organization not found. Ensure the server has started and ensureDefaultOrganization() ran.");
11464
- return org.id;
11465
- }
11466
- async function getOrganization(db, id) {
11467
- const [org] = await db.select().from(organizations).where(eq(organizations.id, id)).limit(1);
11468
- if (!org) throw new NotFoundError(`Organization "${id}" not found`);
11469
- return org;
11470
- }
11471
- async function updateOrganization(db, id, data) {
11472
- const updates = { updatedAt: /* @__PURE__ */ new Date() };
11473
- if (data.name !== void 0) updates.name = data.name;
11474
- if (data.displayName !== void 0) updates.displayName = data.displayName;
11475
- if (data.maxAgents !== void 0) updates.maxAgents = data.maxAgents;
11476
- if (data.maxMessagesPerMinute !== void 0) updates.maxMessagesPerMinute = data.maxMessagesPerMinute;
11477
- if (data.features !== void 0) updates.features = data.features;
11478
- try {
11479
- const [org] = await db.update(organizations).set(updates).where(eq(organizations.id, id)).returning();
11480
- if (!org) throw new NotFoundError(`Organization "${id}" not found`);
11481
- return org;
11482
- } catch (err) {
11483
- if ((err?.code ?? err?.cause?.code ?? "") === "23505") throw new ConflictError(`Organization name "${data.name}" is already taken`);
11484
- throw err;
11485
- }
11486
- }
11487
- /**
11488
- * Ensure the default organization exists. Called on server startup.
11489
- * Uses a fixed UUID for the default org to ensure idempotency.
11490
- */
11491
- async function ensureDefaultOrganization(db) {
11492
- const [existing] = await db.select({ id: organizations.id }).from(organizations).where(eq(organizations.name, "default")).limit(1);
11493
- if (existing) return existing;
11494
- const id = uuidv7();
11495
- const [org] = await db.insert(organizations).values({
11496
- id,
11497
- name: "default",
11498
- displayName: "Default Organization"
11499
- }).onConflictDoNothing().returning();
11500
- return org ?? existing;
11501
- }
11502
- /**
11503
- * Names beginning with `__` are reserved for Hub-internal pseudo agents.
11504
- * User-facing creation must not be able to squat on them, otherwise
11505
- * internal traffic could be routed through a real account.
11506
- */
11507
- const RESERVED_AGENT_NAME_PREFIX = "__";
11508
- /**
11509
- * Derive the relative URL clients should use to fetch a manager-uploaded
11510
- * avatar image. Returns `null` when no image is set. Embeds the upload
11511
- * timestamp as `?v=<epoch>` so a fresh upload busts any browser cache
11512
- * that may have memoised the previous version.
11513
- *
11514
- * Auth: the image route is intentionally public read — the URL leaks no
11515
- * more than the agent's UUID, which is already required to address it.
11516
- * Keeping it unauthenticated lets `<img src>` render without bespoke
11517
- * fetch-and-blob plumbing.
11518
- */
11519
- function agentAvatarImageUrl(uuid, updatedAt) {
11520
- if (!updatedAt) return null;
11521
- return `/api/v1/agents/${uuid}/avatar?v=${updatedAt.getTime()}`;
11522
- }
11523
- /**
11524
- * True iff `clients.metadata.capabilities` is a non-empty object — i.e. the
11525
- * client has reported at least one runtime probe result. Used to distinguish
11526
- * "we don't know what's installed yet" (empty / never reported) from
11527
- * "client explicitly reports this provider is missing".
11528
- */
11529
- function clientCapabilitiesReported(metadata) {
11530
- if (!metadata || typeof metadata !== "object") return false;
11531
- const caps = metadata.capabilities;
11532
- if (!caps || typeof caps !== "object") return false;
11533
- return Object.keys(caps).length > 0;
11534
- }
11535
- /**
11536
- * Inspect a `clients.metadata.capabilities` blob (jsonb) for a specific
11537
- * runtime provider entry. Capabilities live under the `metadata.capabilities`
11538
- * subkey (Option C); the column is unstructured at the DB layer, so we
11539
- * defensively narrow before key access.
11540
- *
11541
- * "Supports" requires the entry's SDK to be **available** — `state: "ok"` or
11542
- * `state: "unauthenticated"`. A `missing` or `error` entry is *reported* but
11543
- * not usable, so we explicitly reject those rather than treating mere key
11544
- * presence as support. Auth state is left to the user to fix at runtime
11545
- * (the re-bind dialog surfaces an `unauthenticated` hint).
11546
- */
11547
- function clientSupportsRuntimeProvider(metadata, provider) {
11548
- if (!metadata || typeof metadata !== "object") return false;
11549
- const caps = metadata.capabilities;
11550
- if (!caps || typeof caps !== "object") return false;
11551
- const entry = caps[provider];
11552
- if (!entry || typeof entry !== "object") return false;
11553
- return entry.available === true;
11554
- }
11555
- /** Default visibility per agent type. */
11556
- function defaultVisibility(type) {
11557
- switch (type) {
11558
- case "human":
11559
- case "autonomous_agent": return AGENT_VISIBILITY.ORGANIZATION;
11560
- case "personal_assistant": return AGENT_VISIBILITY.PRIVATE;
11561
- default: return AGENT_VISIBILITY.PRIVATE;
11562
- }
11563
- }
11564
- /**
11565
- * Resolve + validate the client that will own the new agent.
11566
- *
11567
- * Rule (unified-user-token, post-first-bind relaxation):
11568
- * - Human agents represent the member themselves and have no runtime; a
11569
- * missing `clientId` is required and the column stays NULL.
11570
- * - Non-human agents MAY omit `clientId` at creation; the row stays NULL
11571
- * and is claimed on the first WS bind (see `api/agent/ws-client.ts`).
11572
- * - When a non-human agent IS created with a `clientId`, the pinned client
11573
- * must already be owned by the manager's user (Rule R-RUN).
11574
- */
11575
- /**
11576
- * Check that a client's reported capabilities show the given runtime provider
11577
- * as **available** (SDK installed, regardless of auth state).
11578
- *
11579
- * Tri-state semantics by `clients.metadata.capabilities` shape:
11580
- * - empty / absent — client hasn't probed yet (newly registered or pre-P2
11581
- * install). Treat as "unknown" and allow; the in-band repair path
11582
- * (RUNTIME_PROVIDER_MISMATCH on bind) catches actual incompatibility.
11583
- * - reported, entry shows `state: ok | unauthenticated` (i.e. `available:
11584
- * true`) — allow.
11585
- * - reported, entry missing OR `state: missing | error` — block unless
11586
- * `force` is set. We deliberately do NOT treat mere key presence as
11587
- * support: probeCapabilities() always emits an entry per built-in
11588
- * provider, including `{ state: "missing" }` for absent SDKs.
11589
- *
11590
- * Skipped entirely for human agents (no clientId) and when `force` is set
11591
- * (e.g. operator overrides for an offline client).
11592
- */
11593
- async function ensureClientSupportsRuntimeProvider(db, clientId, runtimeProvider, options = {}) {
11594
- if (clientId === null) return;
11595
- if (options.force) return;
11596
- const [client] = await db.select({ metadata: clients.metadata }).from(clients).where(eq(clients.id, clientId)).limit(1);
11597
- if (!client) return;
11598
- if (!clientCapabilitiesReported(client.metadata)) return;
11599
- 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.`);
11600
- }
11601
- async function resolveAgentClient(db, data) {
11602
- if (data.type === "human") {
11603
- if (data.clientId) throw new BadRequestError("Human agents cannot be pinned to a client");
11604
- return null;
11605
- }
11606
- if (!data.clientId) return null;
11607
- const [manager] = await db.select({ userId: members.userId }).from(members).where(eq(members.id, data.managerId)).limit(1);
11608
- if (!manager) throw new BadRequestError(`Manager "${data.managerId}" not found`);
11609
- const [client] = await db.select({
11610
- id: clients.id,
11611
- userId: clients.userId
11612
- }).from(clients).where(eq(clients.id, data.clientId)).limit(1);
11613
- if (!client) throw new BadRequestError(`Client "${data.clientId}" not found`);
11614
- 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.`);
11615
- 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.`);
11616
- return client.id;
11617
- }
11618
- /**
11619
- * Validate a `delegateMention` write at the service layer. Two checks:
11620
- * 1. Target uuid must resolve to an existing agent — dangling references
11621
- * would silently break webhook delegation at runtime.
11622
- * 2. Target must belong to the same organization as the source agent —
11623
- * cross-org delegate links are rejected here at the source so the
11624
- * database never accumulates dirty rows. The webhook router has a
11625
- * defense-in-depth check that filters them at fan-out time, but this
11626
- * keeps the data clean and gives the admin UI an immediate 422 instead
11627
- * of a silent runtime drop.
11628
- *
11629
- * `null` clears the field — handled by the caller; we are only invoked when
11630
- * the caller wrote a non-null uuid.
11631
- */
11632
- async function validateDelegateMentionTarget(db, targetUuid, sourceOrgId) {
11633
- const [target] = await db.select({
11634
- uuid: agents.uuid,
11635
- organizationId: agents.organizationId
11636
- }).from(agents).where(eq(agents.uuid, targetUuid)).limit(1);
11637
- if (!target) throw new BadRequestError(`delegateMention target "${targetUuid}" not found`);
11638
- if (target.organizationId !== sourceOrgId) throw new BadRequestError("delegateMention target must belong to the same organization as the agent");
11639
- }
11640
- /**
11641
- * Service-layer guard: `delegateMention` is only available for `human` agents.
11642
- * Mirrors the Web UI in `identity-section.tsx`, which only renders the
11643
- * delegate-mention selector when `agent.type === "human"`. Without this
11644
- * server-side check, CLI / Admin API / internal scripts could write
11645
- * delegateMention onto non-human rows, silently re-enabling the
11646
- * autonomous-agent-self-mention path that resolveAudience would then fan
11647
- * out. Called from `createAgent` / `updateAgent` before
11648
- * `validateDelegateMentionTarget` so a wrong source type fails fast without
11649
- * the target lookup round-trip.
11650
- */
11651
- function assertDelegateMentionAllowed(sourceType) {
11652
- if (sourceType !== AGENT_TYPES.HUMAN) throw new BadRequestError("delegateMention can only be set on human agents");
11653
- }
11654
- /**
11655
- * Pick the first admin member in the org for internal system agents. Throws
11656
- * if the org has no admin — the caller should surface the error so an admin
11657
- * is created before the system tries to register more agents.
11658
- */
11659
- async function resolveFallbackManagerId(db, orgId) {
11660
- 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);
11661
- 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\`).`);
11662
- return row.id;
11663
- }
11664
- async function createAgent(db, data, options = {}) {
11665
- const uuid = uuidv7();
11666
- const name = data.name ?? null;
11667
- const runtimeProvider = data.runtimeProvider ?? "claude-code";
11668
- 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`);
11669
- if (name && isReservedAgentName$1(name)) throw new BadRequestError(`Agent name "${name}" is reserved — pick a different one.`);
11670
- const inboxId = `inbox_${uuid}`;
11671
- let orgId;
11672
- let managerId;
11673
- if (data.managerId && data.organizationId) {
11674
- orgId = data.organizationId;
11675
- managerId = data.managerId;
11676
- } else if (data.managerId) {
11677
- const [manager] = await db.select({
11678
- id: members.id,
11679
- organizationId: members.organizationId
11680
- }).from(members).where(eq(members.id, data.managerId)).limit(1);
11681
- if (!manager) throw new BadRequestError(`Manager "${data.managerId}" not found`);
11682
- orgId = manager.organizationId;
11683
- managerId = manager.id;
11684
- } else {
11685
- orgId = data.organizationId ?? await resolveDefaultOrgId(db);
11686
- managerId = await resolveFallbackManagerId(db, orgId);
11687
- }
11688
- const clientId = await resolveAgentClient(db, {
11689
- clientId: data.clientId,
11690
- managerId,
11691
- type: data.type
11692
- });
11693
- await ensureClientSupportsRuntimeProvider(db, clientId, runtimeProvider, { force: options.force });
11694
- if (data.delegateMention) {
11695
- assertDelegateMentionAllowed(data.type);
11696
- await validateDelegateMentionTarget(db, data.delegateMention, orgId);
11697
- }
11698
- const [org] = await db.select({ maxAgents: organizations.maxAgents }).from(organizations).where(eq(organizations.id, orgId)).limit(1);
11699
- if (org && org.maxAgents > 0) {
11700
- 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.`);
11701
- }
11702
- const resolvedDisplayName = data.displayName?.trim() || name || "Unnamed Agent";
11703
- try {
11704
- return await db.transaction(async (tx) => {
11705
- const [row] = await tx.insert(agents).values({
11706
- uuid,
11707
- name,
11708
- organizationId: orgId,
11709
- type: data.type,
11710
- displayName: resolvedDisplayName,
11711
- delegateMention: data.delegateMention ?? null,
11712
- inboxId,
11713
- source: data.source ?? null,
11714
- visibility: data.visibility ?? defaultVisibility(data.type),
11715
- metadata: data.metadata ?? {},
11716
- managerId,
11717
- clientId,
11718
- runtimeProvider
11719
- }).returning();
11720
- if (!row) throw new Error("Unexpected: INSERT RETURNING produced no row");
11721
- const initialPayload = defaultRuntimeConfigPayload(runtimeProvider);
11722
- if (data.gitRepos && data.gitRepos.length > 0) initialPayload.gitRepos = data.gitRepos;
11723
- await tx.insert(agentConfigs).values({
11724
- agentId: row.uuid,
11725
- version: 1,
11726
- payload: initialPayload,
11727
- updatedBy: "system"
11728
- }).onConflictDoNothing();
11729
- return row;
11730
- });
11731
- } catch (err) {
11732
- if ((err?.code ?? err?.cause?.code ?? "") === "23505" && name) throw new ConflictError(`Agent name "${name}" already exists in organization "${orgId}"`);
11733
- throw err;
11734
- }
11735
- }
11736
- async function checkAgentNameAvailability(db, orgId, name) {
11737
- if (!AGENT_NAME_REGEX$1.test(name)) return {
11738
- available: false,
11739
- reason: "invalid"
11740
- };
11741
- if (isReservedAgentName$1(name) || name.startsWith(RESERVED_AGENT_NAME_PREFIX)) return {
11742
- available: false,
11743
- reason: "reserved"
11744
- };
11745
- 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);
11746
- return existing ? {
11747
- available: false,
11748
- reason: "taken"
11749
- } : { available: true };
11750
- }
11751
- async function getAgent(db, uuid) {
11752
- const [agent] = await db.select().from(agents).where(and(eq(agents.uuid, uuid), ne(agents.status, AGENT_STATUSES.DELETED))).limit(1);
11753
- if (!agent) throw new NotFoundError(`Agent "${uuid}" not found`);
11754
- return agent;
11755
- }
11756
- /**
11757
- * Admin-only variant: return every non-deleted agent in the org, ignoring
11758
- * the visibility filter. Used by the `/admin` "All Agents" view so a team
11759
- * admin can see and act on private agents owned by other members. The
11760
- * route layer is responsible for gating this to admin callers — the
11761
- * service does not enforce role by itself, but it does enforce org scope
11762
- * and the not-deleted predicate.
11763
- */
11764
- async function listAgentsForAdmin(db, scope, limit, cursor) {
11765
- const conditions = [eq(agents.organizationId, scope.organizationId), ne(agents.status, AGENT_STATUSES.DELETED)];
11766
- if (cursor) conditions.push(lt(agents.createdAt, new Date(cursor)));
11767
- const where = and(...conditions);
11768
- const rows = await db.select({
11769
- uuid: agents.uuid,
11770
- name: agents.name,
11771
- organizationId: agents.organizationId,
11772
- type: agents.type,
11773
- displayName: agents.displayName,
11774
- delegateMention: agents.delegateMention,
11775
- inboxId: agents.inboxId,
11776
- status: agents.status,
11777
- visibility: agents.visibility,
11778
- metadata: agents.metadata,
11779
- managerId: agents.managerId,
11780
- clientId: agents.clientId,
11781
- runtimeProvider: agents.runtimeProvider,
11782
- avatarColorToken: agents.avatarColorToken,
11783
- avatarImageUpdatedAt: agents.avatarImageUpdatedAt,
11784
- createdAt: agents.createdAt,
11785
- updatedAt: agents.updatedAt,
11786
- presenceStatus: agentPresence.status,
11787
- runtimeType: agentPresence.runtimeType,
11788
- runtimeState: agentPresence.runtimeState,
11789
- activeSessions: agentPresence.activeSessions
11790
- }).from(agents).leftJoin(agentPresence, eq(agents.uuid, agentPresence.agentId)).where(where).orderBy(desc(agents.createdAt)).limit(limit + 1);
11791
- const hasMore = rows.length > limit;
11792
- const items = hasMore ? rows.slice(0, limit) : rows;
11793
- const last = items[items.length - 1];
11794
- return {
11795
- items,
11796
- nextCursor: hasMore && last ? last.createdAt.toISOString() : null
11797
- };
11798
- }
11799
- /**
11800
- * List agents visible to a specific member.
11801
- * Uses agentVisibilityCondition from access-control (same rules for all roles).
11802
- */
11803
- async function listAgentsForMember(db, scope, limit, cursor, type) {
11804
- const conditions = [agentVisibilityCondition(scope.organizationId, scope.memberId)];
11805
- if (cursor) conditions.push(lt(agents.createdAt, new Date(cursor)));
11806
- if (type) conditions.push(eq(agents.type, type));
11807
- const where = and(...conditions);
11808
- const rows = await db.select({
11809
- uuid: agents.uuid,
11810
- name: agents.name,
11811
- organizationId: agents.organizationId,
11812
- type: agents.type,
11813
- displayName: agents.displayName,
11814
- delegateMention: agents.delegateMention,
11815
- inboxId: agents.inboxId,
11816
- status: agents.status,
11817
- visibility: agents.visibility,
11818
- metadata: agents.metadata,
11819
- managerId: agents.managerId,
11820
- clientId: agents.clientId,
11821
- runtimeProvider: agents.runtimeProvider,
11822
- avatarColorToken: agents.avatarColorToken,
11823
- avatarImageUpdatedAt: agents.avatarImageUpdatedAt,
11824
- createdAt: agents.createdAt,
11825
- updatedAt: agents.updatedAt,
11826
- presenceStatus: agentPresence.status,
11827
- runtimeType: agentPresence.runtimeType,
11828
- runtimeState: agentPresence.runtimeState,
11829
- activeSessions: agentPresence.activeSessions
11830
- }).from(agents).leftJoin(agentPresence, eq(agents.uuid, agentPresence.agentId)).where(where).orderBy(desc(agents.createdAt)).limit(limit + 1);
11831
- const hasMore = rows.length > limit;
11832
- const items = hasMore ? rows.slice(0, limit) : rows;
11833
- const last = items[items.length - 1];
11834
- return {
11835
- items,
11836
- nextCursor: hasMore && last ? last.createdAt.toISOString() : null
11837
- };
11838
- }
11839
- async function updateAgent(db, uuid, data) {
11840
- const agent = await getAgent(db, uuid);
11841
- if (data.clientId !== void 0) {
11842
- if (data.clientId === null) throw new BadRequestError("clientId cannot be cleared — once bound, an agent stays bound to its client");
11843
- 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.");
11844
- }
11845
- const updates = { updatedAt: /* @__PURE__ */ new Date() };
11846
- if (data.type !== void 0) {
11847
- 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.");
11848
- updates.type = data.type;
11849
- }
11850
- if (data.displayName !== void 0) updates.displayName = data.displayName;
11851
- if (data.delegateMention !== void 0) {
11852
- if (data.delegateMention !== null) {
11853
- assertDelegateMentionAllowed(data.type ?? agent.type);
11854
- await validateDelegateMentionTarget(db, data.delegateMention, agent.organizationId);
11855
- }
11856
- updates.delegateMention = data.delegateMention;
11857
- }
11858
- if (data.visibility !== void 0) updates.visibility = data.visibility;
11859
- if (data.metadata !== void 0) updates.metadata = data.metadata;
11860
- if (data.avatarColorToken !== void 0) updates.avatarColorToken = data.avatarColorToken;
11861
- if (data.managerId !== void 0) {
11862
- if (data.managerId === null) throw new BadRequestError("managerId cannot be cleared — every agent must have a manager");
11863
- const [manager] = await db.select({
11864
- id: members.id,
11865
- organizationId: members.organizationId
11866
- }).from(members).where(eq(members.id, data.managerId)).limit(1);
11867
- if (!manager) throw new BadRequestError(`Manager "${data.managerId}" not found`);
11868
- if (manager.organizationId !== agent.organizationId) throw new BadRequestError("Manager must belong to the same organization as the agent");
11869
- updates.managerId = data.managerId;
11870
- }
11871
- if (data.clientId !== void 0 && data.clientId !== null && agent.clientId === null) {
11872
- const resolvedClientId = await resolveAgentClient(db, {
11873
- clientId: data.clientId,
11874
- managerId: updates.managerId ?? agent.managerId,
11875
- type: agent.type
11876
- });
11877
- if (resolvedClientId !== null) updates.clientId = resolvedClientId;
11878
- }
11879
- const [updated] = await db.update(agents).set(updates).where(eq(agents.uuid, agent.uuid)).returning();
11880
- if (!updated) throw new Error("Unexpected: UPDATE RETURNING produced no row");
11881
- if (data.managerId !== void 0 && data.managerId !== agent.managerId) await recomputeWatchersForAgent(db, agent.uuid);
11882
- return updated;
11883
- }
11884
- /**
11885
- * Atomically re-bind an agent to a new client and/or runtime provider.
11886
- *
11887
- * Validations: agent must exist and not be human; new client must belong to
11888
- * the same owner (manager.userId) and same organization; client must report
11889
- * the requested runtime provider in its capabilities (skipped under `force`).
11890
- *
11891
- * Intended caller: PATCH /agents/:uuid/rebind. The Web "Re-bind"
11892
- * dialog routes both same-client runtime-only switches and cross-client
11893
- * moves through this single entry.
11894
- *
11895
- * NOTE: active sessions on the previous client are not auto-suspended in P1.
11896
- * P3 will wire in cross-service coordination (inbox + presence + session)
11897
- * so the destination client can resume cleanly.
11898
- */
11899
- async function rebindAgent(db, uuid, data) {
11900
- const agent = await getAgent(db, uuid);
11901
- if (agent.type === "human") throw new BadRequestError("Human agents have no runtime — they cannot be re-bound to a client.");
11902
- const newClientId = await resolveAgentClient(db, {
11903
- clientId: data.clientId,
11904
- managerId: agent.managerId,
11905
- type: agent.type
11906
- });
11907
- if (newClientId === null) throw new BadRequestError("Rebind requires a non-null clientId.");
11908
- await ensureClientSupportsRuntimeProvider(db, newClientId, data.runtimeProvider, { force: data.force });
11909
- const [updated] = await db.update(agents).set({
11910
- clientId: newClientId,
11911
- runtimeProvider: data.runtimeProvider,
11912
- updatedAt: /* @__PURE__ */ new Date()
11913
- }).where(eq(agents.uuid, uuid)).returning();
11914
- if (!updated) throw new Error("Unexpected: UPDATE RETURNING produced no row");
11915
- return updated;
11916
- }
11917
- /**
11918
- * Reactivate a suspended agent.
11919
- */
11920
- async function reactivateAgent(db, uuid) {
11921
- const [existing] = await db.select({
11922
- uuid: agents.uuid,
11923
- status: agents.status
11924
- }).from(agents).where(eq(agents.uuid, uuid)).limit(1);
11925
- if (!existing || existing.status === AGENT_STATUSES.DELETED) throw new NotFoundError(`Agent "${uuid}" not found`);
11926
- if (existing.status !== AGENT_STATUSES.SUSPENDED) throw new BadRequestError("Only suspended agents can be reactivated.");
11927
- const [agent] = await db.update(agents).set({
11928
- status: AGENT_STATUSES.ACTIVE,
11929
- updatedAt: /* @__PURE__ */ new Date()
11930
- }).where(eq(agents.uuid, uuid)).returning();
11931
- if (!agent) throw new Error("Unexpected: UPDATE RETURNING produced no row");
11932
- return agent;
11933
- }
11934
- /**
11935
- * Suspend an agent. Once suspended, Rule R-RUN refuses every runtime bind
11936
- * and every agent-selector-authorised HTTP call.
11937
- */
11938
- async function suspendAgent(db, uuid) {
11939
- const [agent] = await db.update(agents).set({
11940
- status: AGENT_STATUSES.SUSPENDED,
11941
- updatedAt: /* @__PURE__ */ new Date()
11942
- }).where(and(eq(agents.uuid, uuid), ne(agents.status, AGENT_STATUSES.DELETED))).returning();
11943
- if (!agent) throw new NotFoundError(`Agent "${uuid}" not found`);
11944
- return agent;
11945
- }
11946
- /**
11947
- * Delete an agent. Only allowed when status is "suspended". Sets name to NULL
11948
- * so the name becomes reusable.
11949
- */
11950
- async function deleteAgent(db, uuid) {
11951
- const [existing] = await db.select({
11952
- uuid: agents.uuid,
11953
- status: agents.status
11954
- }).from(agents).where(eq(agents.uuid, uuid)).limit(1);
11955
- if (!existing || existing.status === AGENT_STATUSES.DELETED) throw new NotFoundError(`Agent "${uuid}" not found`);
11956
- if (existing.status !== AGENT_STATUSES.SUSPENDED) throw new BadRequestError("Only suspended agents can be deleted. Suspend the agent first.");
11957
- const [agent] = await db.update(agents).set({
11958
- status: AGENT_STATUSES.DELETED,
11959
- name: null,
11960
- updatedAt: /* @__PURE__ */ new Date()
11961
- }).where(eq(agents.uuid, uuid)).returning();
11962
- await db.delete(adapterConfigs).where(eq(adapterConfigs.agentId, uuid));
11963
- await db.delete(adapterAgentMappings).where(eq(adapterAgentMappings.agentId, uuid));
11964
- if (!agent) throw new Error("Unexpected: UPDATE RETURNING produced no row");
11965
- return agent;
11966
- }
11967
- /**
11968
- * Supported avatar-image MIME types. The web client always uploads WEBP after
11969
- * its own resize step; we accept PNG/JPEG too so a caller using the raw HTTP
11970
- * API (curl, scripts) doesn't have to re-encode. Anything else is rejected at
11971
- * the boundary — we never store an unknown content type.
11972
- */
11973
- const SUPPORTED_AVATAR_IMAGE_MIMES = [
11974
- "image/webp",
11975
- "image/png",
11976
- "image/jpeg"
11977
- ];
11978
- /** Hard server-side ceiling for the stored bytea blob. Client pre-resizes to ~50KB. */
11979
- const MAX_AVATAR_IMAGE_BYTES = 512 * 1024;
11980
- function isSupportedAvatarMime(mime) {
11981
- return SUPPORTED_AVATAR_IMAGE_MIMES.find((m) => m === mime) !== void 0;
11982
- }
11983
- /**
11984
- * Fetch the avatar image blob for an agent. Returns `null` when no image
11985
- * is set (the column is NULL). The data + mime pair is always coherent
11986
- * (set/cleared together by the service writes below).
11987
- */
11988
- async function getAgentAvatarImage(db, uuid) {
11989
- const [row] = await db.select({
11990
- data: agents.avatarImageData,
11991
- mime: agents.avatarImageMime,
11992
- updatedAt: agents.avatarImageUpdatedAt
11993
- }).from(agents).where(and(eq(agents.uuid, uuid), ne(agents.status, AGENT_STATUSES.DELETED))).limit(1);
11994
- if (!row || !row.data || !row.mime || !row.updatedAt) return null;
11995
- return {
11996
- data: row.data,
11997
- mime: row.mime,
11998
- updatedAt: row.updatedAt
11999
- };
12000
- }
12001
- /** Replace (or set) an agent's avatar image. Validates mime + size. */
12002
- async function setAgentAvatarImage(db, uuid, data, mime) {
12003
- if (!isSupportedAvatarMime(mime)) throw new BadRequestError(`Unsupported avatar image type "${mime}". Use PNG, JPEG, or WEBP.`);
12004
- if (data.length === 0) throw new BadRequestError("Avatar image payload is empty.");
12005
- if (data.length > 524288) throw new BadRequestError(`Avatar image is too large (${data.length} bytes; max ${MAX_AVATAR_IMAGE_BYTES}).`);
12006
- const now = /* @__PURE__ */ new Date();
12007
- if ((await db.update(agents).set({
12008
- avatarImageData: data,
12009
- avatarImageMime: mime,
12010
- avatarImageUpdatedAt: now,
12011
- updatedAt: now
12012
- }).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`);
12013
- return now;
12014
- }
12015
- /** Clear an agent's avatar image (falls back to color + initial). */
12016
- async function clearAgentAvatarImage(db, uuid) {
12017
- if ((await db.update(agents).set({
12018
- avatarImageData: null,
12019
- avatarImageMime: null,
12020
- avatarImageUpdatedAt: null,
12021
- updatedAt: /* @__PURE__ */ new Date()
12022
- }).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`);
12023
- }
12024
11600
  const log$5 = createLogger$1("AgentFeishuBot");
12025
11601
  async function agentFeishuBotRoutes(app) {
12026
11602
  /**
@@ -12171,7 +11747,7 @@ async function findOrCreateChatForChannel(db, data) {
12171
11747
  const internalType = data.chatType === "p2p" ? "direct" : "group";
12172
11748
  return db.transaction(async (tx) => {
12173
11749
  const [botAgent] = await tx.select({ organizationId: agents.organizationId }).from(agents).where(eq(agents.uuid, data.botAgentId)).limit(1);
12174
- const orgId = botAgent?.organizationId ?? await resolveDefaultOrgId(db);
11750
+ const orgId = botAgent?.organizationId ?? await resolveDefaultOrgId$1(db);
12175
11751
  const metadata = chatMetadataSchema$1.parse({
12176
11752
  source: data.platform,
12177
11753
  externalChannelId: data.externalChannelId
@@ -12262,338 +11838,6 @@ async function agentFeishuUserRoutes(app) {
12262
11838
  return reply.status(204).send();
12263
11839
  });
12264
11840
  }
12265
- function normaliseSource(source) {
12266
- if (source === null) return null;
12267
- const parsed = messageSourceSchema$1.safeParse(source);
12268
- return parsed.success ? parsed.data : null;
12269
- }
12270
- function normaliseMode(mode) {
12271
- return mode === "mention_only" ? "mention_only" : "full";
12272
- }
12273
- /**
12274
- * Batch variant — builds all payloads with a single DB lookup per agent plus
12275
- * batched lookups for participant modes and inReplyTo snapshots.
12276
- */
12277
- async function buildClientMessagePayloadsForInbox(db, inboxId, items) {
12278
- if (items.length === 0) return [];
12279
- const agentId = await resolveAgentId(db, {
12280
- kind: "inboxId",
12281
- inboxId
12282
- });
12283
- const [cfg] = await db.select({ version: agentConfigs.version }).from(agentConfigs).where(eq(agentConfigs.agentId, agentId)).limit(1);
12284
- const version = cfg?.version ?? 1;
12285
- const chatIds = [...new Set(items.map((it) => it.entryChatId ?? it.message.chatId).filter((id) => id !== null))];
12286
- const modeByChat = /* @__PURE__ */ new Map();
12287
- if (chatIds.length > 0) {
12288
- const rows = await db.select({
12289
- chatId: chatMembership.chatId,
12290
- mode: chatMembership.mode
12291
- }).from(chatMembership).where(and(eq(chatMembership.agentId, agentId), inArray(chatMembership.chatId, chatIds), eq(chatMembership.accessMode, "speaker")));
12292
- for (const r of rows) modeByChat.set(r.chatId, normaliseMode(r.mode));
12293
- }
12294
- const inReplyToIds = [...new Set(items.map((it) => it.message.inReplyTo).filter((id) => id !== null))];
12295
- const snapshotById = /* @__PURE__ */ new Map();
12296
- if (inReplyToIds.length > 0) {
12297
- const origs = await db.select({
12298
- id: messages.id,
12299
- senderId: messages.senderId,
12300
- chatId: messages.chatId,
12301
- replyToChat: messages.replyToChat
12302
- }).from(messages).where(inArray(messages.id, inReplyToIds));
12303
- for (const o of origs) snapshotById.set(o.id, {
12304
- senderId: o.senderId,
12305
- chatId: o.chatId,
12306
- replyToChat: o.replyToChat
12307
- });
12308
- }
12309
- return items.map(({ entryChatId, message: m, precedingMessages = [] }) => ({
12310
- id: m.id,
12311
- chatId: m.chatId,
12312
- senderId: m.senderId,
12313
- format: m.format,
12314
- content: m.content,
12315
- metadata: m.metadata,
12316
- replyToInbox: m.replyToInbox,
12317
- replyToChat: m.replyToChat,
12318
- inReplyTo: m.inReplyTo,
12319
- source: normaliseSource(m.source),
12320
- createdAt: m.createdAt,
12321
- configVersion: version,
12322
- recipientMode: modeByChat.get(entryChatId ?? m.chatId) ?? "full",
12323
- inReplyToSnapshot: m.inReplyTo ? snapshotById.get(m.inReplyTo) ?? null : null,
12324
- precedingMessages
12325
- }));
12326
- }
12327
- async function resolveAgentId(db, source) {
12328
- if (source.kind === "agentId") return source.agentId;
12329
- const [agent] = await db.select({ uuid: agents.uuid }).from(agents).where(eq(agents.inboxId, source.inboxId)).limit(1);
12330
- if (!agent) throw new Error(`No agent owns inbox "${source.inboxId}"`);
12331
- return agent.uuid;
12332
- }
12333
- const DEFAULT_INBOX_TIMEOUT_SECONDS = 300;
12334
- const DEFAULT_MAX_RETRY_COUNT = 3;
12335
- const PRECEDING_CONTEXT_WINDOW_SECONDS = 1440 * 60;
12336
- async function pollInbox(db, inboxId, limit) {
12337
- return withSpan("inbox.deliver", {
12338
- "inbox.id": inboxId,
12339
- "inbox.poll.limit": limit
12340
- }, () => pollInboxInner(db, inboxId, limit));
12341
- }
12342
- async function pollInboxInner(db, inboxId, limit) {
12343
- return db.transaction(async (tx) => {
12344
- 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 });
12345
- return bundleDeliveryWithSilentContext(tx, inboxId, await tx.update(inboxEntries).set({
12346
- status: "delivered",
12347
- deliveredAt: /* @__PURE__ */ new Date()
12348
- }).where(inArray(inboxEntries.id, targetIds)).returning());
12349
- });
12350
- }
12351
- /**
12352
- * Shared payload assembler for already-claimed `inbox_entries` rows.
12353
- *
12354
- * Both the HTTP poll path (`pollInbox`) and the WS push path
12355
- * (`claimAndBuildForPush`) call this with rows they have just `UPDATE`d to
12356
- * `status='delivered'`. Keeping the silent-context bundling in one place is
12357
- * the only way to keep the two paths from drifting (proposal
12358
- * hub-inbox-ws-data-plane §3.2 risk #1).
12359
- *
12360
- * Steps:
12361
- * 1. Sort by `createdAt` ASC (PG `RETURNING` does not guarantee order).
12362
- * 2. For each trigger, collect silent context & bulk-ack stale silent rows.
12363
- * 3. Fetch the trigger messages.
12364
- * 4. Build wire payloads via the single dispatcher.
12365
- *
12366
- * Returns `[]` if `claimed` is empty.
12367
- */
12368
- async function bundleDeliveryWithSilentContext(tx, inboxId, claimed) {
12369
- if (claimed.length === 0) return [];
12370
- claimed.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
12371
- const precedingByEntryId = await collectPrecedingContext(tx, inboxId, claimed);
12372
- const messageIds = claimed.map((e) => e.messageId);
12373
- const msgs = await tx.select().from(messages).where(inArray(messages.id, messageIds));
12374
- const msgMap = new Map(msgs.map((m) => [m.id, m]));
12375
- const payloads = await buildClientMessagePayloadsForInbox(tx, inboxId, claimed.map((entry) => {
12376
- const msg = msgMap.get(entry.messageId);
12377
- if (!msg) throw new Error(`Unexpected: message ${entry.messageId} not found`);
12378
- return {
12379
- entryChatId: entry.chatId,
12380
- precedingMessages: precedingByEntryId.get(entry.id) ?? [],
12381
- message: {
12382
- id: msg.id,
12383
- chatId: msg.chatId,
12384
- senderId: msg.senderId,
12385
- format: msg.format,
12386
- content: msg.content,
12387
- metadata: msg.metadata,
12388
- replyToInbox: msg.replyToInbox,
12389
- replyToChat: msg.replyToChat,
12390
- inReplyTo: msg.inReplyTo,
12391
- source: msg.source,
12392
- createdAt: msg.createdAt.toISOString()
12393
- }
12394
- };
12395
- }));
12396
- return claimed.map((entry, idx) => {
12397
- const payload = payloads[idx];
12398
- if (!payload) throw new Error(`Unexpected: payload for entry ${entry.id} not built`);
12399
- return {
12400
- id: entry.id,
12401
- inboxId: entry.inboxId,
12402
- messageId: entry.messageId,
12403
- chatId: entry.chatId,
12404
- status: entry.status,
12405
- retryCount: entry.retryCount,
12406
- createdAt: entry.createdAt.toISOString(),
12407
- deliveredAt: entry.deliveredAt?.toISOString() ?? null,
12408
- ackedAt: entry.ackedAt?.toISOString() ?? null,
12409
- message: payload
12410
- };
12411
- });
12412
- }
12413
- /**
12414
- * Realistic upper bound on rows a single NOTIFY references. The unique
12415
- * constraint `(inbox_id, message_id, chat_id)` caps a `(inbox, message)`
12416
- * pair at one row per chatId; the only way to exceed 1 today is the replyTo
12417
- * cross-chat path (`message.ts` writes a second row keyed by the original's
12418
- * `replyToChat`). 8 leaves headroom for any future fan-out variant without
12419
- * requiring a schema change here.
12420
- */
12421
- const PUSH_CLAIM_BATCH_LIMIT = 8;
12422
- /**
12423
- * WS-push path: atomically claim every pending entry the just-fired
12424
- * `NOTIFY (inboxId:messageId)` references and assemble their wire payloads.
12425
- *
12426
- * Returns `[]` if no row matches — benign race with HTTP poll or another
12427
- * server instance that already claimed the entry. NOTIFY is fire-and-forget
12428
- * (proposal §3.2).
12429
- *
12430
- * Why an array, not a single row: `sendMessage` can write **two** rows for
12431
- * the same `(inbox, messageId)` pair when the recipient is both a chat
12432
- * participant and the `replyToInbox` of an earlier message — the unique key
12433
- * is `(inbox_id, message_id, chat_id)`, so the rows differ by chatId. The
12434
- * old `LIMIT 1` shape would only push the first; the second sat `pending`
12435
- * until reconnect. Aligning with `pollInboxInner`'s `LIMIT N` shape closes
12436
- * that gap and keeps push/poll behaviour interchangeable.
12437
- */
12438
- async function claimAndBuildForPush(db, inboxId, messageId) {
12439
- return withSpan("inbox.deliver.push", {
12440
- "inbox.id": inboxId,
12441
- "message.id": messageId
12442
- }, () => db.transaction(async (tx) => {
12443
- 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 });
12444
- return bundleDeliveryWithSilentContext(tx, inboxId, await tx.update(inboxEntries).set({
12445
- status: "delivered",
12446
- deliveredAt: /* @__PURE__ */ new Date()
12447
- }).where(inArray(inboxEntries.id, targetIds)).returning());
12448
- }));
12449
- }
12450
- /**
12451
- * WS-push backlog path: on agent rebind (or once an in-flight slot frees up
12452
- * after an ack), drain up to `limit` pending `notify=true` entries oldest-
12453
- * first and assemble wire payloads. Identical claim shape to the HTTP poll
12454
- * path — they are intentionally interchangeable so a hot-path bug fixed in
12455
- * one shows up in the other (proposal §3.3 / §3.5).
12456
- */
12457
- async function claimBacklogForPush(db, inboxId, limit) {
12458
- return withSpan("inbox.deliver.backlog", {
12459
- "inbox.id": inboxId,
12460
- "inbox.backlog.limit": limit
12461
- }, () => pollInboxInner(db, inboxId, limit));
12462
- }
12463
- /**
12464
- * Per claimed trigger: SELECT silent (notify=false) pending rows in the same
12465
- * chat that occurred between the previous trigger in this batch (or beginning
12466
- * of time) and this trigger, capped by `PRECEDING_CONTEXT_MAX_ENTRIES` and
12467
- * `PRECEDING_CONTEXT_WINDOW_SECONDS`. Returned messages are oldest-first.
12468
- *
12469
- * Side effect: bulk-ack ALL silent pending rows in each chat with
12470
- * createdAt < latest_trigger.createdAt — including ones that fell outside
12471
- * the window/cap. Otherwise stale silent rows would accumulate and re-load
12472
- * on every poll.
12473
- */
12474
- async function collectPrecedingContext(tx, inboxId, triggers) {
12475
- const result = /* @__PURE__ */ new Map();
12476
- const byChat = /* @__PURE__ */ new Map();
12477
- for (const t of triggers) {
12478
- if (t.chatId === null) continue;
12479
- const list = byChat.get(t.chatId) ?? [];
12480
- list.push(t);
12481
- byChat.set(t.chatId, list);
12482
- }
12483
- for (const [chatId, chatTriggers] of byChat) {
12484
- chatTriggers.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
12485
- let prevCreatedAt = null;
12486
- for (const trigger of chatTriggers) {
12487
- const preceding = (await tx.select({
12488
- messageId: messages.id,
12489
- senderId: messages.senderId,
12490
- format: messages.format,
12491
- content: messages.content,
12492
- metadata: messages.metadata,
12493
- createdAt: messages.createdAt
12494
- }).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", {
12495
- of: inboxEntries,
12496
- skipLocked: true
12497
- })).map((r) => ({
12498
- id: r.messageId,
12499
- senderId: r.senderId,
12500
- format: r.format,
12501
- content: r.content,
12502
- metadata: r.metadata ?? {},
12503
- createdAt: r.createdAt.toISOString()
12504
- })).reverse();
12505
- result.set(trigger.id, preceding);
12506
- prevCreatedAt = trigger.createdAt;
12507
- }
12508
- const latestTrigger = chatTriggers[chatTriggers.length - 1];
12509
- if (latestTrigger) await tx.update(inboxEntries).set({
12510
- status: "acked",
12511
- ackedAt: /* @__PURE__ */ new Date()
12512
- }).where(and(eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.chatId, chatId), eq(inboxEntries.status, "pending"), eq(inboxEntries.notify, false), lt(inboxEntries.createdAt, latestTrigger.createdAt)));
12513
- }
12514
- return result;
12515
- }
12516
- async function ackEntry$2(db, entryId, inboxId) {
12517
- return withSpan("inbox.ack", {
12518
- [FIRST_TREE_HUB_ATTR.INBOX_ENTRY_ID]: String(entryId),
12519
- "inbox.id": inboxId
12520
- }, async () => {
12521
- const [entry] = await db.update(inboxEntries).set({
12522
- status: "acked",
12523
- ackedAt: /* @__PURE__ */ new Date()
12524
- }).where(and(eq(inboxEntries.id, entryId), eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.status, "delivered"))).returning();
12525
- if (!entry) throw new NotFoundError("Inbox entry not found or not in delivered status");
12526
- return entry;
12527
- });
12528
- }
12529
- /**
12530
- * Ack a delivered entry from the WS data plane, scoped to the inboxes the
12531
- * connected socket has bound. Returns the acked row on success, `null` if no
12532
- * row matches — a benign outcome the caller should ignore (the entry may
12533
- * have already been acked, timed out, or never belonged to this socket).
12534
- *
12535
- * Distinct from {@link ackEntry} so the WS path can ack without trusting an
12536
- * `inboxId` from the wire — only entries whose `inboxId` is in `inboxIds`
12537
- * are eligible. Empty `inboxIds` short-circuits to `null`.
12538
- */
12539
- async function ackEntryByIdForBoundAgents(db, entryId, inboxIds) {
12540
- if (inboxIds.length === 0) return null;
12541
- return withSpan("inbox.ack.ws", { [FIRST_TREE_HUB_ATTR.INBOX_ENTRY_ID]: String(entryId) }, async () => {
12542
- const [entry] = await db.update(inboxEntries).set({
12543
- status: "acked",
12544
- ackedAt: /* @__PURE__ */ new Date()
12545
- }).where(and(eq(inboxEntries.id, entryId), inArray(inboxEntries.inboxId, inboxIds), eq(inboxEntries.status, "delivered"))).returning();
12546
- return entry ?? null;
12547
- });
12548
- }
12549
- async function renewEntry(db, entryId, inboxId) {
12550
- 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();
12551
- if (!entry) throw new NotFoundError("Inbox entry not found or not in delivered status");
12552
- return entry;
12553
- }
12554
- async function resetTimedOutEntries(db, timeoutSeconds = DEFAULT_INBOX_TIMEOUT_SECONDS, maxRetries = DEFAULT_MAX_RETRY_COUNT) {
12555
- const reset = await db.update(inboxEntries).set({
12556
- status: "pending",
12557
- retryCount: sql`${inboxEntries.retryCount} + 1`
12558
- }).where(and(eq(inboxEntries.status, "delivered"), sql`${inboxEntries.deliveredAt} < NOW() - make_interval(secs => ${timeoutSeconds})`, lt(inboxEntries.retryCount, maxRetries))).returning({ id: inboxEntries.id });
12559
- 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 });
12560
- return {
12561
- reset: reset.length,
12562
- failed: failed.length
12563
- };
12564
- }
12565
- /** Default age (30 days) past which silent rows that no notify-true delivery
12566
- * ever picked up are physically deleted. */
12567
- const SILENT_ROW_GC_MAX_AGE_SECONDS = 720 * 60 * 60;
12568
- /**
12569
- * Garbage-collect silent inbox rows so the table doesn't grow forever in
12570
- * chats where a `mention_only` agent is never @mentioned.
12571
- *
12572
- * Two cleanup paths:
12573
- *
12574
- * 1. `notify=false AND status='acked'` of any age — these are fully
12575
- * consumed (either bundled into a previous trigger or aged out via the
12576
- * bulk-ack in `collectPrecedingContext`); keep them only as long as
12577
- * the corresponding message rows we link to. The unique constraint
12578
- * `(inbox_id, message_id, chat_id)` means leaving them around blocks
12579
- * legitimate retries with the same key.
12580
- *
12581
- * 2. `notify=false AND status='pending' AND createdAt < NOW() - maxAge` —
12582
- * stale silent rows that no trigger ever caught up with. After 30
12583
- * days they're useless as preceding context (the @mention almost
12584
- * certainly already happened or the chat went dormant).
12585
- *
12586
- * Returns the number of rows deleted in each bucket so the background task
12587
- * can log meaningful counts.
12588
- */
12589
- async function pruneStaleSilentEntries(db, maxAgeSeconds = SILENT_ROW_GC_MAX_AGE_SECONDS) {
12590
- const ackedDeleted = await db.delete(inboxEntries).where(and(eq(inboxEntries.notify, false), eq(inboxEntries.status, "acked"))).returning({ id: inboxEntries.id });
12591
- 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 });
12592
- return {
12593
- ackedDeleted: ackedDeleted.length,
12594
- stalePendingDeleted: stalePendingDeleted.length
12595
- };
12596
- }
12597
11841
  async function agentInboxRoutes(app) {
12598
11842
  app.get("/", async (request) => {
12599
11843
  const identity = requireAgent(request);
@@ -15789,743 +15033,6 @@ async function bootstrapConfigRoutes(_app) {
15789
15033
  });
15790
15034
  }
15791
15035
  /**
15792
- * Per-(chat, agent) user state — independent from membership structure.
15793
- *
15794
- * This is the third layer of the chat data model: while `chats` owns
15795
- * the entity and `chat_membership` owns the structural relation
15796
- * (who can speak, who watches), this table owns the user's private
15797
- * state about a chat. The reason it lives apart: structural changes
15798
- * (speaker ↔ watcher, manager rebind, recompute) must never overwrite
15799
- * user-private state — physical separation makes that an invariant
15800
- * rather than a service-layer discipline.
15801
- *
15802
- * Columns evolve incrementally as new per-user state is needed.
15803
- * Currently:
15804
- * - `last_read_at`, `unread_mention_count` — seeded by PR-A from
15805
- * the legacy `chat_participants` / `chat_subscriptions` columns.
15806
- * - `engagement_status` — added in 0040; per-(chat, user) view
15807
- * state (active / archived / deleted). Auto-revives archived →
15808
- * active on new message; deleted is sticky (only the user can
15809
- * restore from the chat detail page).
15810
- *
15811
- * Future fields slated for this table: pinned, mute_until, draft,
15812
- * custom_title, last_seen_at — each as a separate change.
15813
- *
15814
- * Rows are lazy-upserted on first user write (markRead / mention
15815
- * counter bump / engagement transition). Reads use COALESCE for
15816
- * defaults so callers see `'active'` etc. even when no row exists.
15817
- * Service-layer integrity (no FK / CHECK / trigger).
15818
- *
15819
- * See proposals/chat-data-model-restructure.20260512.md §8.6.
15820
- */
15821
- const chatUserState = pgTable("chat_user_state", {
15822
- chatId: text("chat_id").notNull(),
15823
- agentId: text("agent_id").notNull(),
15824
- lastReadAt: timestamp("last_read_at", { withTimezone: true }),
15825
- unreadMentionCount: integer("unread_mention_count").notNull().default(0),
15826
- engagementStatus: text("engagement_status").notNull().default("active")
15827
- }, (table) => [
15828
- primaryKey({ columns: [table.chatId, table.agentId] }),
15829
- index("idx_user_state_agent").on(table.agentId),
15830
- index("idx_user_state_unread").on(table.agentId).where(sql`unread_mention_count > 0`)
15831
- ]);
15832
- /** Extract a plain-text summary from a message's JSONB content field.
15833
- * Used as the auto-title fallback in chat list rendering — see
15834
- * `me-chat.ts:resolveChatTitle` and `admin/chats.ts:getChat`.
15835
- *
15836
- * - `@<name>` mention tokens are stripped before truncation: in the
15837
- * chat-first model they're routing/audience metadata, not part of
15838
- * the user's intent. Leaving them in produces noisy titles like
15839
- * "@hub-agent-01 帮我重构这个文件" or "你好 @hub-agent-02 看看".
15840
- * - Whitespace runs (including those left behind by mention removal)
15841
- * collapse to single spaces.
15842
- * - If the cleaned text is empty (e.g., a message that's only
15843
- * `@hub-agent-01`), returns null so the caller falls through to
15844
- * the participant-join fallback.
15845
- * - Slicing is code-point-aware (`Array.from + join`) so emoji /
15846
- * surrogate pairs aren't split into garbled half-characters. */
15847
- function extractSummary(content, maxLen = 50) {
15848
- let text = "";
15849
- if (typeof content === "object" && content !== null && "text" in content) text = String(content.text ?? "");
15850
- else if (typeof content === "string") text = content;
15851
- if (!text) return null;
15852
- const cleaned = stripCode(text).replace(MENTION_REGEX, "").replace(/\s+/g, " ").trim();
15853
- if (!cleaned) return null;
15854
- return Array.from(cleaned).slice(0, maxLen).join("");
15855
- }
15856
- /** List sessions for a specific agent, with optional state filters. */
15857
- async function listAgentSessions(db, agentId, filters) {
15858
- const conditions = [eq(agentChatSessions.agentId, agentId)];
15859
- if (filters?.state) conditions.push(eq(agentChatSessions.state, filters.state));
15860
- else conditions.push(ne(agentChatSessions.state, "evicted"));
15861
- const rows = await db.select({
15862
- agentId: agentChatSessions.agentId,
15863
- chatId: agentChatSessions.chatId,
15864
- state: agentChatSessions.state,
15865
- updatedAt: agentChatSessions.updatedAt,
15866
- chatCreatedAt: chats.createdAt,
15867
- chatTopic: chats.topic
15868
- }).from(agentChatSessions).innerJoin(chats, eq(agentChatSessions.chatId, chats.id)).where(and(...conditions)).orderBy(desc(agentChatSessions.updatedAt));
15869
- const [presence] = await db.select({ runtimeState: agentPresence.runtimeState }).from(agentPresence).where(eq(agentPresence.agentId, agentId)).limit(1);
15870
- const agentRuntimeState = presence?.runtimeState ?? null;
15871
- if (filters?.runtimeState && agentRuntimeState !== filters.runtimeState) return [];
15872
- const chatIds = rows.map((r) => r.chatId);
15873
- const messageCounts = chatIds.length > 0 ? await db.select({
15874
- chatId: inboxEntries.chatId,
15875
- count: sql`count(*)::int`
15876
- }).from(inboxEntries).where(and(eq(inboxEntries.inboxId, sql`(SELECT inbox_id FROM agents WHERE uuid = ${agentId})`), inArray(inboxEntries.chatId, chatIds))).groupBy(inboxEntries.chatId) : [];
15877
- const countMap = new Map(messageCounts.map((r) => [r.chatId, r.count]));
15878
- const firstMessages = chatIds.length > 0 ? await db.selectDistinctOn([messages.chatId], {
15879
- chatId: messages.chatId,
15880
- content: messages.content
15881
- }).from(messages).where(inArray(messages.chatId, chatIds)).orderBy(messages.chatId, messages.createdAt) : [];
15882
- const summaryMap = /* @__PURE__ */ new Map();
15883
- for (const row of firstMessages) {
15884
- const summary = extractSummary(row.content);
15885
- if (summary) summaryMap.set(row.chatId, summary);
15886
- }
15887
- return rows.map((r) => ({
15888
- agentId: r.agentId,
15889
- chatId: r.chatId,
15890
- state: r.state,
15891
- runtimeState: agentRuntimeState,
15892
- startedAt: r.chatCreatedAt.toISOString(),
15893
- lastActivityAt: r.updatedAt.toISOString(),
15894
- messageCount: countMap.get(r.chatId) ?? 0,
15895
- summary: summaryMap.get(r.chatId) ?? null,
15896
- topic: r.chatTopic ?? null
15897
- }));
15898
- }
15899
- /** Get a single session's detail. */
15900
- async function getSession(db, agentId, chatId) {
15901
- const [row] = await db.select({
15902
- agentId: agentChatSessions.agentId,
15903
- chatId: agentChatSessions.chatId,
15904
- state: agentChatSessions.state,
15905
- updatedAt: agentChatSessions.updatedAt,
15906
- chatCreatedAt: chats.createdAt,
15907
- chatTopic: chats.topic
15908
- }).from(agentChatSessions).innerJoin(chats, eq(agentChatSessions.chatId, chats.id)).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId))).limit(1);
15909
- if (!row) throw new NotFoundError(`Session (${agentId}, ${chatId}) not found`);
15910
- const [presence] = await db.select({ runtimeState: agentPresence.runtimeState }).from(agentPresence).where(eq(agentPresence.agentId, agentId)).limit(1);
15911
- 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)));
15912
- const firstMsg = (await db.execute(sql`SELECT content FROM messages WHERE chat_id = ${chatId} ORDER BY created_at ASC LIMIT 1`))[0];
15913
- const summary = firstMsg ? extractSummary(firstMsg.content) : null;
15914
- return {
15915
- agentId: row.agentId,
15916
- chatId: row.chatId,
15917
- state: row.state,
15918
- runtimeState: presence?.runtimeState ?? null,
15919
- startedAt: row.chatCreatedAt.toISOString(),
15920
- lastActivityAt: row.updatedAt.toISOString(),
15921
- messageCount: countRow?.count ?? 0,
15922
- summary,
15923
- topic: row.chatTopic ?? null
15924
- };
15925
- }
15926
- /** List all sessions across all agents, with pagination. Scoped to organization. */
15927
- async function listAllSessions(db, limit, cursor, filters) {
15928
- const conditions = [];
15929
- if (filters?.state) conditions.push(eq(agentChatSessions.state, filters.state));
15930
- else conditions.push(ne(agentChatSessions.state, "evicted"));
15931
- if (filters?.agentId) conditions.push(eq(agentChatSessions.agentId, filters.agentId));
15932
- if (filters?.organizationId) conditions.push(eq(agents.organizationId, filters.organizationId));
15933
- if (cursor) conditions.push(sql`${agentChatSessions.updatedAt} < ${new Date(cursor)}`);
15934
- const rows = await db.select({
15935
- agentId: agentChatSessions.agentId,
15936
- chatId: agentChatSessions.chatId,
15937
- state: agentChatSessions.state,
15938
- updatedAt: agentChatSessions.updatedAt,
15939
- chatCreatedAt: chats.createdAt,
15940
- chatTopic: chats.topic
15941
- }).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);
15942
- const hasMore = rows.length > limit;
15943
- const items = hasMore ? rows.slice(0, limit) : rows;
15944
- const agentIds = [...new Set(items.map((r) => r.agentId))];
15945
- const presenceRows = agentIds.length > 0 ? await db.select({
15946
- agentId: agentPresence.agentId,
15947
- runtimeState: agentPresence.runtimeState
15948
- }).from(agentPresence).where(inArray(agentPresence.agentId, agentIds)) : [];
15949
- const runtimeMap = new Map(presenceRows.map((r) => [r.agentId, r.runtimeState]));
15950
- const chatIds = [...new Set(items.map((r) => r.chatId))];
15951
- const firstMessages = chatIds.length > 0 ? await db.selectDistinctOn([messages.chatId], {
15952
- chatId: messages.chatId,
15953
- content: messages.content
15954
- }).from(messages).where(inArray(messages.chatId, chatIds)).orderBy(messages.chatId, messages.createdAt) : [];
15955
- const summaryMap = /* @__PURE__ */ new Map();
15956
- for (const row of firstMessages) {
15957
- const summary = extractSummary(row.content);
15958
- if (summary) summaryMap.set(row.chatId, summary);
15959
- }
15960
- const last = items[items.length - 1];
15961
- const nextCursor = hasMore && last ? last.updatedAt.toISOString() : null;
15962
- return {
15963
- items: items.map((r) => ({
15964
- agentId: r.agentId,
15965
- chatId: r.chatId,
15966
- state: r.state,
15967
- runtimeState: runtimeMap.get(r.agentId) ?? null,
15968
- startedAt: r.chatCreatedAt.toISOString(),
15969
- lastActivityAt: r.updatedAt.toISOString(),
15970
- messageCount: 0,
15971
- summary: summaryMap.get(r.chatId) ?? null,
15972
- topic: r.chatTopic ?? null
15973
- })),
15974
- nextCursor
15975
- };
15976
- }
15977
- /** Commit `active → suspended`. No-op on suspended/evicted. Throws if row is missing. */
15978
- async function suspendSession(db, agentId, chatId, organizationId, notifier) {
15979
- return transitionSessionState(db, agentId, chatId, "suspended", ["active"], organizationId, notifier);
15980
- }
15981
- /** Commit `suspended → evicted` (terminal — listings hide it, revival defense blocks resurrection). */
15982
- async function archiveSession(db, agentId, chatId, organizationId, notifier) {
15983
- return transitionSessionState(db, agentId, chatId, "evicted", ["suspended"], organizationId, notifier);
15984
- }
15985
- async function transitionSessionState(db, agentId, chatId, target, from, organizationId, notifier) {
15986
- const now = /* @__PURE__ */ new Date();
15987
- let finalState = null;
15988
- let transitioned = false;
15989
- await db.transaction(async (tx) => {
15990
- const [existing] = await tx.select({ state: agentChatSessions.state }).from(agentChatSessions).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId))).for("update");
15991
- if (!existing) return;
15992
- const current = existing.state;
15993
- finalState = current;
15994
- if (!from.includes(current)) return;
15995
- await tx.update(agentChatSessions).set({
15996
- state: target,
15997
- updatedAt: now
15998
- }).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId)));
15999
- const [counts] = await tx.select({
16000
- active: sql`count(*) FILTER (WHERE ${agentChatSessions.state} = 'active')::int`,
16001
- total: sql`count(*) FILTER (WHERE ${agentChatSessions.state} != 'evicted')::int`
16002
- }).from(agentChatSessions).where(eq(agentChatSessions.agentId, agentId));
16003
- await tx.update(agentPresence).set({
16004
- activeSessions: counts?.active ?? 0,
16005
- totalSessions: counts?.total ?? 0,
16006
- lastSeenAt: now
16007
- }).where(eq(agentPresence.agentId, agentId));
16008
- if (target === "evicted") await markSupersededByChat(tx, chatId, "chat_archived");
16009
- finalState = target;
16010
- transitioned = true;
16011
- });
16012
- if (finalState === null) throw new NotFoundError(`Session (${agentId}, ${chatId}) not found`);
16013
- if (transitioned && notifier) notifier.notifySessionStateChange(agentId, chatId, target, organizationId).catch(() => {});
16014
- return {
16015
- state: finalState,
16016
- transitioned
16017
- };
16018
- }
16019
- /**
16020
- * Filter sessions to only those where the given agent is also a participant in the chat.
16021
- * Used when a non-manager views sessions of an org-visible agent — they should only see
16022
- * sessions for chats they participate in.
16023
- */
16024
- async function filterSessionsByParticipant(db, sessions, participantAgentId) {
16025
- if (sessions.length === 0) return [];
16026
- const chatIds = sessions.map((s) => s.chatId);
16027
- const participantRows = await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(inArray(chatMembership.chatId, chatIds), eq(chatMembership.agentId, participantAgentId), eq(chatMembership.accessMode, "speaker")));
16028
- const allowedChatIds = new Set(participantRows.map((r) => r.chatId));
16029
- return sessions.filter((s) => allowedChatIds.has(s.chatId));
16030
- }
16031
- /**
16032
- * Member-facing chat service backing `/me/chats*` endpoints (chat-first
16033
- * workspace).
16034
- *
16035
- * Responsibilities:
16036
- * - Cursor-paginated conversation list (single-stream JOIN over the
16037
- * unified `chat_membership` + `chat_user_state` tables).
16038
- * - Create a new chat (no dedupe, runs `recomputeChatWatchers` after).
16039
- * - Add participants (idempotent, UPSERT into `chat_membership`,
16040
- * runs `recomputeChatWatchers` after).
16041
- * - Mark-read (UPSERT into `chat_user_state`).
16042
- * - Join → watcher to speaker (delegates to `watcher.ts`).
16043
- * - Leave → speaker to watcher or detach (delegates to `watcher.ts`).
16044
- *
16045
- * See proposals/chat-data-model-restructure.20260512.md §8 (schema)
16046
- * and §11.1 (per-route mapping).
16047
- */
16048
- function encodeCursor(lastMessageAt, chatId) {
16049
- const payload = `${lastMessageAt ? lastMessageAt.toISOString() : ""}|${chatId}`;
16050
- return Buffer.from(payload, "utf8").toString("base64url");
16051
- }
16052
- function decodeCursor(cursor) {
16053
- try {
16054
- const decoded = Buffer.from(cursor, "base64url").toString("utf8");
16055
- const sep = decoded.indexOf("|");
16056
- if (sep < 0) return null;
16057
- const tsPart = decoded.slice(0, sep);
16058
- const chatId = decoded.slice(sep + 1);
16059
- if (!chatId) return null;
16060
- const lastMessageAt = tsPart.length > 0 ? new Date(tsPart) : null;
16061
- if (lastMessageAt && Number.isNaN(lastMessageAt.getTime())) return null;
16062
- return {
16063
- lastMessageAt,
16064
- chatId
16065
- };
16066
- } catch {
16067
- return null;
16068
- }
16069
- }
16070
- const { ACTIVE: ACTIVE$1, ARCHIVED: ARCHIVED$1, DELETED } = CHAT_ENGAGEMENT_STATUSES;
16071
- /**
16072
- * SQL predicate for each engagement view tab. `deleted` is never a valid view
16073
- * value — deleted rows are reachable only through `GET /chats/:chatId` + the
16074
- * Restore banner on the chat detail page.
16075
- */
16076
- const ENGAGEMENT_VIEW_PREDICATE = {
16077
- active: sql`COALESCE(cus.engagement_status, ${ACTIVE$1}) = ${ACTIVE$1}`,
16078
- archived: sql`COALESCE(cus.engagement_status, ${ACTIVE$1}) = ${ARCHIVED$1}`,
16079
- all: sql`COALESCE(cus.engagement_status, ${ACTIVE$1}) IN (${ACTIVE$1}, ${ARCHIVED$1})`
16080
- };
16081
- /**
16082
- * Write the caller's engagement state for this chat. UPSERT into
16083
- * `chat_user_state` — the row may not yet exist (the user might not have
16084
- * marked-read or been @-mentioned), so an INSERT with the engagement value
16085
- * is the first write; subsequent transitions are UPDATEs.
16086
- *
16087
- * Idempotent. Mirrors the UPSERT shape used by `markMeChatRead`.
16088
- */
16089
- async function setChatEngagement(db, chatId, agentId, status) {
16090
- await db.insert(chatUserState).values({
16091
- chatId,
16092
- agentId,
16093
- unreadMentionCount: 0,
16094
- engagementStatus: status
16095
- }).onConflictDoUpdate({
16096
- target: [chatUserState.chatId, chatUserState.agentId],
16097
- set: { engagementStatus: status }
16098
- });
16099
- }
16100
- /**
16101
- * Read the caller's engagement state. Returns `'active'` when no
16102
- * `chat_user_state` row exists yet (lazy-materialised; matches the SQL
16103
- * `COALESCE(..., 'active')` used elsewhere).
16104
- */
16105
- async function getCallerEngagement(db, chatId, agentId) {
16106
- const [row] = await db.select({ engagementStatus: chatUserState.engagementStatus }).from(chatUserState).where(and(eq(chatUserState.chatId, chatId), eq(chatUserState.agentId, agentId))).limit(1);
16107
- return row?.engagementStatus ?? ACTIVE$1;
16108
- }
16109
- const KNOWN_NON_MANUAL_PREDICATE = sql`(
16110
- (c.metadata->>'source' = 'github' AND c.metadata->>'entityType' IN (${sql.join(GITHUB_ENTITY_TYPES.map((t) => sql`${t}`), sql.raw(", "))}))
16111
- OR c.metadata->>'source' = 'feishu'
16112
- )`;
16113
- const chatSourceSqlExpression = sql`CASE
16114
- ${sql.join(GITHUB_ENTITY_TYPES.map((t) => sql`WHEN c.metadata->>'source' = 'github' AND c.metadata->>'entityType' = ${t} THEN ${`github_${t}`}`), sql.raw("\n "))}
16115
- WHEN c.metadata->>'source' = 'feishu' THEN 'feishu'
16116
- ELSE 'manual'
16117
- END`;
16118
- function sourceFilterSql(source) {
16119
- switch (source) {
16120
- case "manual": return sql`(${KNOWN_NON_MANUAL_PREDICATE}) IS NOT TRUE`;
16121
- case "github_issue": return sql`(c.metadata->>'source' = 'github' AND c.metadata->>'entityType' = 'issue')`;
16122
- case "github_pull_request": return sql`(c.metadata->>'source' = 'github' AND c.metadata->>'entityType' = 'pull_request')`;
16123
- case "github_discussion": return sql`(c.metadata->>'source' = 'github' AND c.metadata->>'entityType' = 'discussion')`;
16124
- case "github_commit": return sql`(c.metadata->>'source' = 'github' AND c.metadata->>'entityType' = 'commit')`;
16125
- case "feishu": return sql`(c.metadata->>'source' = 'feishu')`;
16126
- }
16127
- }
16128
- /**
16129
- * GET /me/chats — cursor-paginated conversation list.
16130
- *
16131
- * SQL strategy:
16132
- * - Single-stream query: `chats JOIN chat_membership LEFT JOIN
16133
- * chat_user_state`. The membership row carries access_mode
16134
- * (speaker → "participant" / watcher → "watching"); the user
16135
- * state row supplies the unread counter (COALESCE → 0 when
16136
- * row is missing).
16137
- * - Filter `parent_chat_id IS NULL` (nested chats not surfaced in v1).
16138
- * - Filter `c.organization_id = ?` to defend against historical
16139
- * cross-org pollution rows that may still reference the caller
16140
- * (see fix/cross-org-direct-chat-pollution).
16141
- * - Sort `(last_message_at DESC NULLS LAST, chat_id DESC)`.
16142
- * - Cursor narrows the result to rows STRICTLY before the cursor.
16143
- * - Followed by a participants-list lookup for the page only.
16144
- */
16145
- async function listMeChats(db, humanAgentId, organizationId, query) {
16146
- const limit = query.limit;
16147
- const cursor = query.cursor ? decodeCursor(query.cursor) : null;
16148
- if (query.cursor && !cursor) throw new BadRequestError("Invalid cursor");
16149
- const filterUnreadOnly = query.filter === "unread";
16150
- const filterWatchingOnly = query.filter === "watching";
16151
- const engagementPredicate = ENGAGEMENT_VIEW_PREDICATE[query.engagement];
16152
- const sourcePredicate = query.source ? sourceFilterSql(query.source) : sql`TRUE`;
16153
- const cursorTsIso = cursor?.lastMessageAt ? cursor.lastMessageAt.toISOString() : null;
16154
- 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
16155
- OR c.last_message_at < ${cursorTsIso}::timestamptz
16156
- OR (c.last_message_at = ${cursorTsIso}::timestamptz AND c.id < ${cursor.chatId}))`;
16157
- const rawRows = await db.execute(sql`
16158
- SELECT
16159
- c.id AS chat_id,
16160
- c.type AS type,
16161
- c.topic AS topic,
16162
- c.parent_chat_id AS parent_chat_id,
16163
- c.last_message_at AS last_message_at,
16164
- c.last_message_preview AS last_message_preview,
16165
- (SELECT count(*) FROM chat_membership
16166
- WHERE chat_id = c.id AND access_mode = 'speaker') AS participant_count,
16167
- cm.access_mode AS access_mode,
16168
- COALESCE(cus.unread_mention_count, 0) AS unread_mention_count,
16169
- COALESCE(cus.engagement_status, ${ACTIVE$1}) AS engagement_status
16170
- FROM chats c
16171
- JOIN chat_membership cm
16172
- ON cm.chat_id = c.id AND cm.agent_id = ${humanAgentId}
16173
- LEFT JOIN chat_user_state cus
16174
- ON cus.chat_id = c.id AND cus.agent_id = ${humanAgentId}
16175
- WHERE c.parent_chat_id IS NULL
16176
- /* Scope to the caller's org. Without this, cross-org dirty
16177
- chats whose chat_membership still references the caller's
16178
- human agent (historical pollution — see
16179
- fix/cross-org-direct-chat-pollution) would leak into the
16180
- list and 404 on click via requireChatAccess. */
16181
- AND c.organization_id = ${organizationId}
16182
- AND (${!filterUnreadOnly}::bool OR COALESCE(cus.unread_mention_count, 0) > 0)
16183
- AND (${!filterWatchingOnly}::bool OR cm.access_mode = 'watcher')
16184
- AND ${engagementPredicate}
16185
- AND ${sourcePredicate}
16186
- AND ${cursorPredicate}
16187
- ORDER BY c.last_message_at DESC NULLS LAST, c.id DESC
16188
- LIMIT ${limit + 1}
16189
- `);
16190
- const toDate = (v) => {
16191
- if (v === null) return null;
16192
- return v instanceof Date ? v : new Date(v);
16193
- };
16194
- const hasMore = rawRows.length > limit;
16195
- const pageRaw = hasMore ? rawRows.slice(0, limit) : rawRows;
16196
- const last = pageRaw[pageRaw.length - 1];
16197
- const nextCursor = hasMore && last ? encodeCursor(toDate(last.last_message_at), last.chat_id) : null;
16198
- if (pageRaw.length === 0) return {
16199
- rows: [],
16200
- nextCursor: null
16201
- };
16202
- const chatIds = pageRaw.map((r) => r.chat_id);
16203
- const participantRows = await db.select({
16204
- chatId: chatMembership.chatId,
16205
- agentId: chatMembership.agentId,
16206
- displayName: agents.displayName,
16207
- type: agents.type,
16208
- avatarColorToken: agents.avatarColorToken,
16209
- avatarImageUpdatedAt: agents.avatarImageUpdatedAt,
16210
- sessionState: agentChatSessions.state
16211
- }).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")));
16212
- const participantsByChat = /* @__PURE__ */ new Map();
16213
- const engagedByChat = /* @__PURE__ */ new Map();
16214
- for (const p of participantRows) {
16215
- const list = participantsByChat.get(p.chatId) ?? [];
16216
- list.push({
16217
- agentId: p.agentId,
16218
- displayName: p.displayName,
16219
- type: p.type,
16220
- avatarColorToken: p.avatarColorToken,
16221
- avatarImageUrl: agentAvatarImageUrl(p.agentId, p.avatarImageUpdatedAt)
16222
- });
16223
- participantsByChat.set(p.chatId, list);
16224
- if (p.sessionState === "active") {
16225
- const engaged = engagedByChat.get(p.chatId) ?? [];
16226
- engaged.push(p.agentId);
16227
- engagedByChat.set(p.chatId, engaged);
16228
- }
16229
- }
16230
- const liveActivityByChat = await deriveLiveActivity(db, chatIds);
16231
- const firstMessageRows = chatIds.length > 0 ? await db.selectDistinctOn([messages.chatId], {
16232
- chatId: messages.chatId,
16233
- content: messages.content
16234
- }).from(messages).where(inArray(messages.chatId, chatIds)).orderBy(messages.chatId, messages.createdAt) : [];
16235
- const firstMessageSummary = /* @__PURE__ */ new Map();
16236
- for (const row of firstMessageRows) {
16237
- const s = extractSummary(row.content);
16238
- if (s) firstMessageSummary.set(row.chatId, s);
16239
- }
16240
- return {
16241
- rows: pageRaw.map((r) => {
16242
- const participants = participantsByChat.get(r.chat_id) ?? [];
16243
- const title = resolveChatTitle(r.topic, firstMessageSummary.get(r.chat_id) ?? null, participants, humanAgentId);
16244
- const isSpeaker = r.access_mode === "speaker";
16245
- return {
16246
- chatId: r.chat_id,
16247
- type: r.type,
16248
- membershipKind: isSpeaker ? "participant" : "watching",
16249
- title,
16250
- topic: r.topic,
16251
- participants,
16252
- participantCount: Number(r.participant_count),
16253
- lastMessageAt: toDate(r.last_message_at)?.toISOString() ?? null,
16254
- lastMessagePreview: r.last_message_preview,
16255
- unreadMentionCount: r.unread_mention_count,
16256
- canReply: isSpeaker,
16257
- engagementStatus: r.engagement_status,
16258
- engagedAgentIds: engagedByChat.get(r.chat_id) ?? [],
16259
- liveActivity: liveActivityByChat.get(r.chat_id) ?? null
16260
- };
16261
- }),
16262
- nextCursor
16263
- };
16264
- }
16265
- /**
16266
- * Per-chat live activity, derived from the most recent `session_events` row.
16267
- *
16268
- * Returns a chatId → LiveActivity map; chats with no activity (or where the
16269
- * latest event is terminal / stale) are absent from the map (caller treats
16270
- * absence as null).
16271
- */
16272
- async function deriveLiveActivity(db, chatIds) {
16273
- if (chatIds.length === 0) return /* @__PURE__ */ new Map();
16274
- const chatIdInClause = sql.join(chatIds.map((id) => sql`${id}`), sql`, `);
16275
- const rows = (await db.execute(sql`
16276
- SELECT acs.agent_id AS agent_id,
16277
- acs.chat_id AS chat_id,
16278
- e.kind AS kind,
16279
- e.payload AS payload,
16280
- e.created_at AS created_at
16281
- FROM agent_chat_sessions acs
16282
- CROSS JOIN LATERAL (
16283
- SELECT kind, payload, created_at, seq
16284
- FROM session_events se
16285
- WHERE se.agent_id = acs.agent_id
16286
- AND se.chat_id = acs.chat_id
16287
- ORDER BY se.seq DESC
16288
- LIMIT 1
16289
- ) e
16290
- WHERE acs.chat_id IN (${chatIdInClause})
16291
- AND acs.state <> 'evicted'
16292
- `)).map((r) => ({
16293
- agent_id: r.agent_id,
16294
- chat_id: r.chat_id,
16295
- kind: r.kind,
16296
- payload: r.payload,
16297
- created_at: r.created_at
16298
- }));
16299
- const now = Date.now();
16300
- const byChat = /* @__PURE__ */ new Map();
16301
- for (const row of rows) {
16302
- const activity = toLiveActivity(row);
16303
- if (!activity) continue;
16304
- const createdAtMs = new Date(row.created_at).getTime();
16305
- if (now - createdAtMs > 6e4) continue;
16306
- const existing = byChat.get(row.chat_id);
16307
- if (!existing || createdAtMs > existing.createdAtMs) byChat.set(row.chat_id, {
16308
- activity,
16309
- createdAtMs
16310
- });
16311
- }
16312
- const out = /* @__PURE__ */ new Map();
16313
- for (const [chatId, { activity }] of byChat) out.set(chatId, activity);
16314
- return out;
16315
- }
16316
- /**
16317
- * Translate a `session_events` row into a `LiveActivity`, or null when the
16318
- * kind is terminal (`turn_end` / `error`) or unrecognised. Pure & exported
16319
- * for unit testing.
16320
- */
16321
- function toLiveActivity(row) {
16322
- const startedAt = new Date(row.created_at).toISOString();
16323
- switch (row.kind) {
16324
- case "tool_call": {
16325
- const payload = row.payload ?? {};
16326
- const label = typeof payload.name === "string" && payload.name.length > 0 ? payload.name : "Tool";
16327
- return {
16328
- agentId: row.agent_id,
16329
- kind: "tool_call",
16330
- label,
16331
- startedAt
16332
- };
16333
- }
16334
- case "thinking": return {
16335
- agentId: row.agent_id,
16336
- kind: "thinking",
16337
- label: "Thinking",
16338
- startedAt
16339
- };
16340
- case "assistant_text": return {
16341
- agentId: row.agent_id,
16342
- kind: "assistant_text",
16343
- label: "Writing",
16344
- startedAt
16345
- };
16346
- default: return null;
16347
- }
16348
- }
16349
- /**
16350
- * Title resolution priority:
16351
- *
16352
- * 1. `chat.topic` (manual, set via `PATCH /chats/:chatId`)
16353
- * 2. First message summary (auto, ≤ 50 chars from `extractSummary`)
16354
- * 3. Participant join (fallback when chat has no messages yet)
16355
- */
16356
- function resolveChatTitle(topic, firstMessageSummary, participants, selfAgentId) {
16357
- if (topic && topic.length > 0) return topic;
16358
- if (firstMessageSummary && firstMessageSummary.length > 0) return firstMessageSummary;
16359
- const others = participants.filter((p) => p.agentId !== selfAgentId);
16360
- if (others.length === 0) return "Empty chat";
16361
- if (others.length <= 3) return others.map((p) => p.displayName).join(", ");
16362
- return `${others[0]?.displayName}, ${others[1]?.displayName} +${others.length - 2}`;
16363
- }
16364
- async function createMeChat(db, humanAgentId, organizationId, body) {
16365
- const distinctIds = [...new Set(body.participantIds)].filter((id) => id !== humanAgentId);
16366
- if (distinctIds.length === 0) throw new BadRequestError("At least one non-self participant required");
16367
- const allIds = [humanAgentId, ...distinctIds];
16368
- const found = await db.select({
16369
- uuid: agents.uuid,
16370
- organizationId: agents.organizationId,
16371
- type: agents.type
16372
- }).from(agents).where(inArray(agents.uuid, allIds));
16373
- if (found.length !== allIds.length) {
16374
- const foundSet = new Set(found.map((a) => a.uuid));
16375
- throw new BadRequestError(`Agents not found: ${allIds.filter((id) => !foundSet.has(id)).join(", ")}`);
16376
- }
16377
- const crossOrg = found.filter((a) => a.organizationId !== organizationId);
16378
- if (crossOrg.length > 0) throw new BadRequestError(`Cross-organization chat not allowed: ${crossOrg.map((a) => a.uuid).join(", ")}`);
16379
- const chatType = distinctIds.length === 1 ? "direct" : "group";
16380
- const chatId = randomUUID();
16381
- const topic = body.topic ?? null;
16382
- await db.transaction(async (tx) => {
16383
- await tx.insert(chats).values({
16384
- id: chatId,
16385
- organizationId,
16386
- type: chatType,
16387
- topic
16388
- });
16389
- await addChatParticipants(tx, chatId, allIds.map((agentId) => ({
16390
- agentId,
16391
- role: agentId === humanAgentId ? "owner" : "member"
16392
- })));
16393
- await recomputeChatWatchers(tx, chatId);
16394
- });
16395
- invalidateChatAudience(chatId);
16396
- return { chatId };
16397
- }
16398
- async function addMeChatParticipants(db, chatId, callerHumanAgentId, callerOrganizationId, body) {
16399
- const distinct = [...new Set(body.participantIds)];
16400
- if (distinct.length === 0) throw new BadRequestError("At least one participant required");
16401
- const [chat] = await db.select({
16402
- id: chats.id,
16403
- organizationId: chats.organizationId,
16404
- type: chats.type
16405
- }).from(chats).where(eq(chats.id, chatId)).limit(1);
16406
- if (!chat) throw new NotFoundError(`Chat "${chatId}" not found`);
16407
- if (chat.organizationId !== callerOrganizationId) throw new NotFoundError(`Chat "${chatId}" not found`);
16408
- 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);
16409
- if (!callerRow) throw new NotFoundError(`Chat "${chatId}" not found`);
16410
- const found = await db.select({
16411
- uuid: agents.uuid,
16412
- organizationId: agents.organizationId,
16413
- type: agents.type
16414
- }).from(agents).where(inArray(agents.uuid, distinct));
16415
- if (found.length !== distinct.length) {
16416
- const foundSet = new Set(found.map((a) => a.uuid));
16417
- throw new BadRequestError(`Agents not found: ${distinct.filter((id) => !foundSet.has(id)).join(", ")}`);
16418
- }
16419
- const crossOrg = found.filter((a) => a.organizationId !== chat.organizationId);
16420
- if (crossOrg.length > 0) throw new BadRequestError(`Cross-organization participant rejected: ${crossOrg.map((a) => a.uuid).join(", ")}`);
16421
- await db.transaction(async (tx) => {
16422
- const existingSpeakers = await tx.select({ agentId: chatMembership.agentId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
16423
- const existingSpeakerSet = new Set(existingSpeakers.map((e) => e.agentId));
16424
- const toUpsert = distinct.filter((id) => !existingSpeakerSet.has(id));
16425
- if (toUpsert.length === 0) {
16426
- await recomputeChatWatchers(tx, chatId);
16427
- return;
16428
- }
16429
- if (existingSpeakers.length + toUpsert.length >= 3 && chat.type === "direct") await changeChatType(tx, chatId, "group");
16430
- await addChatParticipants(tx, chatId, toUpsert.map((agentId) => ({
16431
- agentId,
16432
- role: "member"
16433
- })), { upgradeWatcherToSpeaker: true });
16434
- await recomputeChatWatchers(tx, chatId);
16435
- });
16436
- invalidateChatAudience(chatId);
16437
- }
16438
- async function markMeChatRead(db, chatId, humanAgentId) {
16439
- const now = /* @__PURE__ */ new Date();
16440
- await db.insert(chatUserState).values({
16441
- chatId,
16442
- agentId: humanAgentId,
16443
- lastReadAt: now,
16444
- unreadMentionCount: 0
16445
- }).onConflictDoUpdate({
16446
- target: [chatUserState.chatId, chatUserState.agentId],
16447
- set: {
16448
- lastReadAt: now,
16449
- unreadMentionCount: 0
16450
- }
16451
- });
16452
- return {
16453
- chatId,
16454
- lastReadAt: now.toISOString(),
16455
- unreadMentionCount: 0
16456
- };
16457
- }
16458
- async function joinMeChat(db, chatId, humanAgentId) {
16459
- ensureCanJoin(await resolveChatMembership(db, chatId, humanAgentId));
16460
- await joinAsParticipant(db, chatId, humanAgentId);
16461
- invalidateChatAudience(chatId);
16462
- }
16463
- async function leaveMeChat(db, chatId, humanAgentId) {
16464
- const result = await leaveAsParticipant(db, chatId, humanAgentId);
16465
- invalidateChatAudience(chatId);
16466
- return result;
16467
- }
16468
- /**
16469
- * Used by future bell-badge / list-pill counts. The partial index
16470
- * `idx_user_state_unread WHERE unread_mention_count > 0` bounds the
16471
- * driving scan; we then join `chat_membership` + `chats` so the badge
16472
- * stays consistent with `listMeChats`.
16473
- *
16474
- * Why the joins (not just a single-table count): per §11.4 a user's
16475
- * `chat_user_state` row is **preserved on detach** so read state
16476
- * survives a leave/rejoin cycle. Without the membership join, any
16477
- * preserved row with `unread_mention_count > 0` would keep
16478
- * contributing to the badge even though the chat no longer appears in
16479
- * the list. The `chats` join applies the same org-scoping +
16480
- * `parent_chat_id IS NULL` filter as `listMeChats` so the two counts
16481
- * cannot drift in the cross-org pollution or nested-chat cases either.
16482
- *
16483
- * Engagement parity: deleted chats are excluded from `listMeChats`
16484
- * (any `engagement` view), so the badge must exclude them too — otherwise
16485
- * the user sees an unread red dot for a chat they've removed from view.
16486
- */
16487
- /**
16488
- * Per-source aggregate for the conversation-list tag bar.
16489
- *
16490
- * Returns one row per source the caller has at least one chat for, plus an
16491
- * always-present `manual` entry (zero counts when there are no manual chats —
16492
- * the workspace UI uses `manual` as its default tab and must render it even
16493
- * when empty).
16494
- *
16495
- * Filtering matches `listMeChats` for the corresponding tab so the badges
16496
- * cannot drift from the list: same membership join, same `parent_chat_id IS
16497
- * NULL` and `organization_id` scopes, same engagement view, same
16498
- * `chat_user_state.unread_mention_count` source.
16499
- */
16500
- async function listMeChatSourceCounts(db, humanAgentId, organizationId, query) {
16501
- const engagementPredicate = ENGAGEMENT_VIEW_PREDICATE[query.engagement];
16502
- const rows = await db.execute(sql`
16503
- SELECT
16504
- ${chatSourceSqlExpression} AS source,
16505
- count(*)::int AS chat_count,
16506
- count(*) FILTER (WHERE COALESCE(cus.unread_mention_count, 0) > 0)::int AS unread_chat_count
16507
- FROM chats c
16508
- JOIN chat_membership cm
16509
- ON cm.chat_id = c.id AND cm.agent_id = ${humanAgentId}
16510
- LEFT JOIN chat_user_state cus
16511
- ON cus.chat_id = c.id AND cus.agent_id = ${humanAgentId}
16512
- WHERE c.parent_chat_id IS NULL
16513
- AND c.organization_id = ${organizationId}
16514
- AND ${engagementPredicate}
16515
- GROUP BY 1
16516
- `);
16517
- const counts = {};
16518
- for (const row of rows) counts[row.source] = {
16519
- chatCount: Number(row.chat_count),
16520
- unreadChatCount: Number(row.unread_chat_count)
16521
- };
16522
- if (!counts.manual) counts.manual = {
16523
- chatCount: 0,
16524
- unreadChatCount: 0
16525
- };
16526
- return { counts };
16527
- }
16528
- /**
16529
15036
  * Class C — resource-scoped chat routes. Mounted at
16530
15037
  * `/api/v1/chats/:chatId/...`. The chat's `organizationId` locates its
16531
15038
  * org; `requireChatAccess` resolves the caller's membership in that org
@@ -16534,7 +15041,17 @@ async function listMeChatSourceCounts(db, humanAgentId, organizationId, query) {
16534
15041
  async function chatRoutes(app) {
16535
15042
  app.get("/:chatId", async (request) => {
16536
15043
  const { chat, scope } = await requireChatAccess(request, app.db);
16537
- 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")));
16538
15055
  const firstMsgRows = await app.db.execute(sql`
16539
15056
  SELECT content FROM messages
16540
15057
  WHERE chat_id = ${chat.id}
@@ -16542,25 +15059,13 @@ async function chatRoutes(app) {
16542
15059
  LIMIT 1
16543
15060
  `);
16544
15061
  const firstMessagePreview = firstMsgRows[0] ? extractSummary(firstMsgRows[0].content) : null;
16545
- const participantAgentIds = participants.map((p) => p.agentId);
16546
- const agentRows = participantAgentIds.length > 0 ? await app.db.select({
16547
- agentId: agents.uuid,
16548
- displayName: agents.displayName,
16549
- type: agents.type,
16550
- avatarColorToken: agents.avatarColorToken,
16551
- avatarImageUpdatedAt: agents.avatarImageUpdatedAt
16552
- }).from(agents).where(inArray(agents.uuid, participantAgentIds)) : [];
16553
- const agentMeta = new Map(agentRows.map((a) => [a.agentId, a]));
16554
- const participantsForTitle = participants.map((p) => {
16555
- const meta = agentMeta.get(p.agentId);
16556
- return {
16557
- agentId: p.agentId,
16558
- displayName: meta?.displayName ?? p.agentId,
16559
- type: meta?.type ?? "unknown",
16560
- avatarColorToken: meta?.avatarColorToken ?? null,
16561
- avatarImageUrl: agentAvatarImageUrl(p.agentId, meta?.avatarImageUpdatedAt ?? null)
16562
- };
16563
- });
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
+ }));
16564
15069
  const title = resolveChatTitle(chat.topic, firstMessagePreview, participantsForTitle, scope.humanAgentId);
16565
15070
  const engagementStatus = await getCallerEngagement(app.db, chat.id, scope.humanAgentId);
16566
15071
  return {
@@ -16574,6 +15079,9 @@ async function chatRoutes(app) {
16574
15079
  agentId: p.agentId,
16575
15080
  role: p.role,
16576
15081
  mode: p.mode,
15082
+ name: p.name,
15083
+ displayName: p.displayName,
15084
+ type: p.type,
16577
15085
  joinedAt: p.joinedAt.toISOString()
16578
15086
  }))
16579
15087
  };
@@ -16723,6 +15231,11 @@ async function chatRoutes(app) {
16723
15231
  const { scope } = await requireChatAccess(request, app.db);
16724
15232
  return markMeChatRead(app.db, request.params.chatId, scope.humanAgentId);
16725
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
+ });
16726
15239
  /** POST /chats/:chatId/participants — add speaking participants. Idempotent. */
16727
15240
  app.post("/:chatId/participants", async (request, reply) => {
16728
15241
  const { scope } = await requireChatAccess(request, app.db);
@@ -18089,7 +16602,7 @@ async function healthzRoutes(app) {
18089
16602
  * `api/orgs/invitations.ts` (Class B, admin-gated).
18090
16603
  */
18091
16604
  async function publicInvitationRoutes(app) {
18092
- const { previewInvitation } = await import("./invitation-CNv7gfFF-DOFZ75wb.mjs");
16605
+ const { previewInvitation } = await import("./invitation-C9m2gQx4-CkwWteA3.mjs");
18093
16606
  app.get("/:token/preview", async (request, reply) => {
18094
16607
  if (!request.params.token) throw new UnauthorizedError("Token required");
18095
16608
  const preview = await previewInvitation(app.db, request.params.token);
@@ -18378,7 +16891,7 @@ async function meRoutes(app) {
18378
16891
  */
18379
16892
  app.get("/me/pinned-agents", async (request) => {
18380
16893
  const { userId } = requireUser(request);
18381
- const { listMyPinnedAgents } = await import("./client-gSnsRu5W-v_mC1sRY.mjs");
16894
+ const { listMyPinnedAgents } = await import("./client-CDw0f-kN-BPzOVd8L.mjs");
18382
16895
  return listMyPinnedAgents(app.db, { userId });
18383
16896
  });
18384
16897
  /**