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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,11 @@
1
1
  import { a as __toCommonJS, o as __toESM, t as __commonJSMin } from "./chunk-BSw8zbkd.mjs";
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
- 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-Cya2OoHz.mjs";
3
+ import { C as resetConfigMeta, E as setConfigValue, S as resetConfig, T as serverConfigSchema, b as migrateLegacyHome, c as resolveServerUrl, d as DEFAULT_CONFIG_DIR, f as DEFAULT_DATA_DIR$1, g as collectMissingPrompts, h as clientConfigSchema, i as ensureFreshAccessToken, l as saveAgentConfig, m as agentConfigSchema, o as loadCredentials, p as DEFAULT_HOME_DIR$1, u as saveCredentials, v as initConfig, w as resolveConfigReadonly, y as loadAgents } from "./bootstrap-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 notificationQuerySchema, A as createMemberSchema, B as githubDevCallbackQuerySchema, C as connectTokenExchangeSchema, Ct as updateChatSchema, D as createAgentSchema, Dt as wsAuthFrameSchema, E as createAdapterMappingSchema, Et as updateOrganizationSchema, F as dryRunAgentRuntimeConfigSchema, G as inboxPollQuerySchema, H as imageInlineContentSchema, J as isReservedAgentName$1, K as isOrgSettingNamespace, L as githubAppInstallationClaimBodySchema, N as defaultRuntimeConfigPayload, O as createChatSchema, P as delegateFeishuUserSchema, Q as messageSourceSchema$1, R as githubAppInstallationPermissionsSchema$1, S as clientRegisterSchema, St as updateAgentSchema, T as createAdapterConfigSchema, Tt as updateMemberSchema, U as inboxAckFrameSchema, V as githubStartQuerySchema, W as inboxDeliverFrameSchema$1, X as listMeChatsQuerySchema, Y as joinByInvitationSchema, Z as loginSchema, _ as agentPinnedMessageSchema$1, _t as sessionStateMessageSchema, a as AGENT_TYPES, b as chatMetadataSchema$1, bt as updateAdapterConfigSchema, ct as runtimeStateMessageSchema, d as NOTIFICATION_TYPES, dt as selfServiceFeishuBotSchema, et as onboardingEventSchema, f as ORG_SETTINGS_NAMESPACES$1, ft as sendMessageSchema, g as agentBindRequestSchema, gt as sessionReconcileRequestSchema, h as addParticipantSchema, ht as sessionEventSchema$1, i as AGENT_STATUSES, j as createOrgFromMeSchema, k as createMeChatSchema, lt as safeRedirectPath, m as addMeChatParticipantsSchema, mt as sessionEventMessageSchema, n as AGENT_NAME_REGEX$1, nt as patchChatEngagementSchema, o as AGENT_VISIBILITY, ot as rebindAgentSchema, p as WS_AUTH_FRAME_TIMEOUT_MS, pt as sendToAgentSchema, q as isRedactedEnvValue, r as AGENT_SELECTOR_HEADER$1, rt as patchOnboardingSchema, s as CHAT_ENGAGEMENT_STATUSES, st as refreshTokenSchema, t as AGENT_BIND_REJECT_REASONS, tt as paginationQuerySchema, u as MENTION_REGEX, v as agentRuntimeConfigPayloadSchema$1, vt as stripCode, w as contextTreeSnapshotSchema, wt as updateClientCapabilitiesSchema, xt as updateAgentRuntimeConfigSchema, y as agentTypeSchema$1, yt as submitQuestionAnswerSchema, z as githubCallbackQuerySchema } from "./dist-C8yStx2L.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
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";
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-BH4CmUL0-CybE3kuP.mjs";
8
+ import { $ as notifyRecipients, A as getPresence, B as listAgentsManagedByUser, C as ensureParticipant, D as getChatDetail, E as getCachedAudience, F as joinAsParticipant, G as listClients, H as listChatParticipantsWithNames, I as joinChat, K as listClientsForOrgAdmin, L as leaveAsParticipant, M as heartbeatInstance, N as inboxEntries, O as getClient, P as invalidateChatAudience, Q as messages, R as leaveChat, S as ensureCanJoin, T as getActivityOverview, U as listChats, V as listAgentsWithRuntime, W as listChatsForMember, X as markSupersededByChat, Y as markStaleAgents, Z as members, _ as createChat, _t as unbindAgent, a as agentVisibilityCondition, at as registerClient, b as disconnectClient, c as assertParticipant, ct as resolveChatMembership, d as chatMembership, dt as sendToAgent$1, et as pendingQuestions, f as chats, ft as serverInstances, g as clients, gt as touchAgent, h as cleanupStalePresence, ht as submitAnswer, i as agentPresence, it as registerChatMessageDispatcher, j as heartbeatClient, k as getOnlineCount, l as bindAgent, lt as retireClient, m as cleanupStaleClients, mt as setRuntimeState, n as addParticipant, nt as recomputeWatchersForAgent, o as agents, ot as removeParticipant, p as claimClient, pt as setOffline, q as listMessages, r as agentChatSessions, rt as recomputeWatchersForMember, s as assertClientOwner, st as resetActivity, t as addChatParticipants, tt as recomputeChatWatchers, u as changeChatType, ut as sendMessage, v as createNotifier, vt as updateClientCapabilities, w as findOrCreateDirectChat, x as editMessage, y as deriveAuthState, yt as upsertSessionState, z as listActiveAgentsPinnedToClient } from "./client-CzXmweS9-DhUiuQvL.mjs";
9
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";
10
10
  import { createRequire } from "node:module";
11
11
  import { ZodError, z } from "zod";
@@ -16,7 +16,7 @@ import { EventEmitter } from "node:events";
16
16
  import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, watch, writeFileSync, writeSync } from "node:fs";
17
17
  import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
18
18
  import WebSocket from "ws";
19
- import { chmod, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
19
+ import { chmod, mkdir, readFile, readdir, realpath, rm, stat, writeFile } from "node:fs/promises";
20
20
  import { parse, stringify } from "yaml";
21
21
  import { query } from "@anthropic-ai/claude-agent-sdk";
22
22
  import { execFile, execFileSync, execSync, spawn, spawnSync } from "node:child_process";
@@ -659,6 +659,16 @@ const agentTypeSchema = z.enum([
659
659
  "autonomous_agent"
660
660
  ]);
661
661
  const agentVisibilitySchema = z.enum(["private", "organization"]);
662
+ const avatarColorTokenSchema = z.enum([
663
+ "hue-0",
664
+ "hue-1",
665
+ "hue-2",
666
+ "hue-3",
667
+ "hue-4",
668
+ "hue-5",
669
+ "hue-6",
670
+ "hue-7"
671
+ ]);
662
672
  const agentSourceSchema = z.enum(["admin-api", "portal"]);
663
673
  z.enum(["active", "suspended"]);
664
674
  /**
@@ -705,7 +715,8 @@ z.object({
705
715
  visibility: agentVisibilitySchema.optional(),
706
716
  metadata: z.record(z.string(), z.unknown()).optional(),
707
717
  managerId: z.string().nullable().optional(),
708
- clientId: z.string().min(1).max(100).nullable().optional()
718
+ clientId: z.string().min(1).max(100).nullable().optional(),
719
+ avatarColorToken: avatarColorTokenSchema.nullable().optional()
709
720
  });
710
721
  z.object({
711
722
  clientId: z.string().min(1).max(100),
@@ -727,6 +738,8 @@ z.object({
727
738
  managerId: z.string().nullable(),
728
739
  clientId: z.string().nullable(),
729
740
  runtimeProvider: runtimeProviderSchema,
741
+ avatarColorToken: z.string().nullable(),
742
+ avatarImageUrl: z.string().nullable(),
730
743
  presenceStatus: presenceStatusSchema.optional(),
731
744
  createdAt: z.string(),
732
745
  updatedAt: z.string()
@@ -788,6 +801,14 @@ const chatMetadataSchema = z.discriminatedUnion("source", [githubChatMetadataSch
788
801
  * sneak through `{ source: "github" }` without the required fields.
789
802
  */
790
803
  const optionalChatMetadataSchema = z.union([z.object({}).strict(), chatMetadataSchema]);
804
+ const chatSourceSchema = z.enum([
805
+ "manual",
806
+ "github_issue",
807
+ "github_pull_request",
808
+ "github_discussion",
809
+ "github_commit",
810
+ "feishu"
811
+ ]);
791
812
  const chatTypeSchema = z.enum(["direct", "group"]);
792
813
  const chatEngagementStatusSchema = z.enum([
793
814
  "active",
@@ -999,6 +1020,11 @@ const contextTreeSummarySchema = z.object({
999
1020
  removedCount: z.number().int().nonnegative(),
1000
1021
  changedNodeCount: z.number().int().nonnegative()
1001
1022
  });
1023
+ const contextTreeUsageSummarySchema = z.object({
1024
+ windowDays: z.number().int().positive(),
1025
+ agentCount: z.number().int().nonnegative(),
1026
+ usageCount: z.number().int().nonnegative()
1027
+ });
1002
1028
  z.object({
1003
1029
  repo: z.string().nullable(),
1004
1030
  branch: z.string().nullable(),
@@ -1007,6 +1033,7 @@ z.object({
1007
1033
  snapshotStatus: contextTreeSnapshotStatusSchema,
1008
1034
  contextStatus: contextTreeStatusSchema,
1009
1035
  summary: contextTreeSummarySchema,
1036
+ usage: contextTreeUsageSummarySchema,
1010
1037
  updates: z.array(contextTreeUpdateSchema),
1011
1038
  nodes: z.array(contextTreeNodeSchema),
1012
1039
  edges: z.array(contextTreeEdgeSchema),
@@ -1283,12 +1310,15 @@ z.object({
1283
1310
  cursor: z.string().optional(),
1284
1311
  limit: z.coerce.number().int().min(1).max(200).default(50),
1285
1312
  filter: meChatFilterSchema.default("all"),
1286
- engagement: chatEngagementViewSchema.default("active")
1313
+ engagement: chatEngagementViewSchema.default("active"),
1314
+ source: chatSourceSchema.optional()
1287
1315
  });
1288
1316
  const meChatParticipantSchema = z.object({
1289
1317
  agentId: z.string(),
1290
1318
  displayName: z.string(),
1291
- type: z.string()
1319
+ type: z.string(),
1320
+ avatarColorToken: z.string().nullable(),
1321
+ avatarImageUrl: z.string().nullable()
1292
1322
  });
1293
1323
  /**
1294
1324
  * Live activity hint surfaced in the conversation row's time slot. Derived
@@ -1348,6 +1378,47 @@ z.object({
1348
1378
  type: z.literal("chat:message"),
1349
1379
  chatId: z.string()
1350
1380
  });
1381
+ /**
1382
+ * Per-source aggregate for the conversation-list tag bar.
1383
+ *
1384
+ * - `chatCount` — number of chats the caller is in for this source. Used
1385
+ * to hide tags whose count is 0 ("don't render a PR tag if there are no
1386
+ * PRs").
1387
+ * - `unreadChatCount` — number of chats whose `unread_mention_count > 0`.
1388
+ * This is "chats with unread mentions", NOT "total mention count", so
1389
+ * the badge on each tag matches the semantics of the existing `unread`
1390
+ * filter pill (`totalUnread` in `pages/workspace/conversations`) — a
1391
+ * `2` on the PR tag means "2 PR chats have unread mentions", which is
1392
+ * what a user expects to click into.
1393
+ *
1394
+ * The map ALWAYS contains the `manual` key (the default tab is always
1395
+ * available, even at zero counts); other keys are present only when the
1396
+ * caller has at least one chat for that source.
1397
+ */
1398
+ const chatSourceCountSchema = z.object({
1399
+ chatCount: z.number().int().nonnegative(),
1400
+ unreadChatCount: z.number().int().nonnegative()
1401
+ });
1402
+ z.object({ engagement: chatEngagementViewSchema.default("active") });
1403
+ z.object({ counts: z.partialRecord(chatSourceSchema, chatSourceCountSchema) });
1404
+ const workspaceDocRefSchema = z.object({
1405
+ type: z.literal("workspace"),
1406
+ chatId: z.string().trim().min(1),
1407
+ agentId: z.string().trim().min(1),
1408
+ basePath: z.string().trim().optional(),
1409
+ path: z.string().trim().min(1)
1410
+ });
1411
+ const documentContextSchema = z.object({ basePath: z.string().trim().min(1) });
1412
+ z.object({
1413
+ agentId: z.string().trim().min(1),
1414
+ basePath: z.string().trim().optional(),
1415
+ path: z.string().trim().min(1)
1416
+ });
1417
+ z.object({
1418
+ ref: workspaceDocRefSchema,
1419
+ path: z.string(),
1420
+ content: z.string()
1421
+ });
1351
1422
  z.enum([
1352
1423
  "connect",
1353
1424
  "create_agent",
@@ -1399,7 +1470,8 @@ z.object({
1399
1470
  organizationId: z.string(),
1400
1471
  organizationName: z.string(),
1401
1472
  role: z.enum(["admin", "member"]),
1402
- agentId: z.string()
1473
+ agentId: z.string(),
1474
+ orgHasOtherMembers: z.boolean()
1403
1475
  });
1404
1476
  const memberRoleSchema = z.enum(["admin", "member"]);
1405
1477
  const memberSchema = z.object({
@@ -1774,7 +1846,8 @@ const sessionEventKind = z.enum([
1774
1846
  "error",
1775
1847
  "assistant_text",
1776
1848
  "thinking",
1777
- "turn_end"
1849
+ "turn_end",
1850
+ "context_tree_usage"
1778
1851
  ]);
1779
1852
  const toolCallEventPayload = z.object({
1780
1853
  toolUseId: z.string(),
@@ -1816,6 +1889,10 @@ const thinkingEventPayload = z.object({});
1816
1889
  * completed turns to show only the final result message.
1817
1890
  */
1818
1891
  const turnEndEventPayload = z.object({ status: z.enum(["success", "error"]) });
1892
+ const contextTreeUsageEventPayload = z.object({
1893
+ purpose: z.literal("design_decision"),
1894
+ treeRepoUrl: z.string().nullable()
1895
+ });
1819
1896
  const sessionEventSchema = z.discriminatedUnion("kind", [
1820
1897
  z.object({
1821
1898
  kind: z.literal("tool_call"),
@@ -1836,6 +1913,10 @@ const sessionEventSchema = z.discriminatedUnion("kind", [
1836
1913
  z.object({
1837
1914
  kind: z.literal("turn_end"),
1838
1915
  payload: turnEndEventPayload
1916
+ }),
1917
+ z.object({
1918
+ kind: z.literal("context_tree_usage"),
1919
+ payload: contextTreeUsageEventPayload
1839
1920
  })
1840
1921
  ]);
1841
1922
  z.object({
@@ -1849,7 +1930,8 @@ z.object({
1849
1930
  errorEventPayload,
1850
1931
  assistantTextEventPayload,
1851
1932
  thinkingEventPayload,
1852
- turnEndEventPayload
1933
+ turnEndEventPayload,
1934
+ contextTreeUsageEventPayload
1853
1935
  ]),
1854
1936
  createdAt: z.string()
1855
1937
  });
@@ -2018,6 +2100,7 @@ defineConfig({
2018
2100
  host: field(z.string().default("127.0.0.1"), { env: "FIRST_TREE_HUB_HOST" }),
2019
2101
  publicUrl: field(z.string().optional(), { env: "FIRST_TREE_HUB_PUBLIC_URL" })
2020
2102
  },
2103
+ workspace: { root: field(z.string().default(join(DEFAULT_DATA_DIR, "workspaces")), { env: "FIRST_TREE_HUB_WORKSPACES_ROOT" }) },
2021
2104
  secrets: {
2022
2105
  jwtSecret: field(z.string(), {
2023
2106
  env: "FIRST_TREE_HUB_JWT_SECRET",
@@ -3353,68 +3436,44 @@ function installFirstTreeIntegration(options) {
3353
3436
  function generateToolsDoc() {
3354
3437
  return `# Agent Hub SDK
3355
3438
 
3356
- ## How You Communicate
3357
-
3358
3439
  You are running inside **Agent Hub**, a messaging platform for agent teams.
3359
3440
 
3360
- - Messages from other team members arrive as your prompt input
3361
- - Each message includes a \`[From: <agent-name>]\` header — that name is also
3362
- what you pass back to \`chat send\` to reply to or address that agent
3363
- - **Your final text response is automatically delivered** to the chat just respond normally
3364
- - For **proactive communication** (sending to other agents, other chats, or structured data),
3365
- use the \`first-tree-hub\` CLI below
3366
- - **Use your judgment about when to respond.** Not every message requires
3367
- a reply — if you have nothing new for the recipient, output nothing and
3368
- the runtime will end the turn silently.
3369
- Your role and responsibilities are injected via the Hub-managed system prompt.
3370
-
3371
- ## Environment Variables
3372
-
3373
- These are injected automatically when the agent process starts:
3374
-
3375
- | Variable | Description |
3376
- |----------|-------------|
3377
- | \`FIRST_TREE_HUB_SERVER_URL\` | Server address for API calls |
3378
- | \`FIRST_TREE_HUB_ACCESS_TOKEN\` | User member access JWT (short-lived) |
3379
- | \`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. |
3380
- | \`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. |
3381
-
3382
- The \`first-tree-hub\` CLI reads these automatically — no extra setup needed.
3441
+ - Messages from other team members arrive as your prompt input. Each message has a
3442
+ \`[From: <agent-name>]\` header — that name is what you pass back to \`chat send\`.
3443
+ - **Your final text response is automatically delivered** to the chat just respond normally.
3444
+ - **Stay silent when you have nothing to add.** Not every message needs a reply.
3445
+ If you have nothing new for the recipient, output nothing and the runtime ends the turn.
3446
+ - For **proactive communication** (other agents, other chats, or different format),
3447
+ use the \`first-tree-hub\` CLI below.
3383
3448
 
3384
3449
  ## Sending Messages
3385
3450
 
3386
- Use the \`first-tree-hub chat send\` CLIit reads the env vars above and
3387
- attaches the \`Authorization\` + \`X-Agent-Id\` headers automatically:
3451
+ The CLI auto-reads its config from env no setup needed.
3388
3452
 
3389
3453
  \`\`\`bash
3390
- # Send to another agent — first positional argument is the recipient's NAME
3391
- # (NOT a uuid; uuids in chat history / participant lists are not accepted).
3392
- # Run \`first-tree-hub agent list\` to see available names.
3393
- #
3394
- # Routing: if the recipient is a participant of your current chat (typically
3395
- # the case in a group chat where someone @-mentioned you to talk to them),
3396
- # the message stays in that chat. Otherwise it falls back to a direct chat
3397
- # between you and the recipient. You don't need to think about which.
3454
+ # Send to an agent by NAME (uuids are NOT accepted run \`first-tree-hub agent list\` for names)
3398
3455
  first-tree-hub chat send <agentName> "your message"
3399
3456
 
3400
- # Send into a specific chat by id — use this only when you explicitly want
3401
- # to address a chat your current session is NOT bound to.
3457
+ # Address a specific chat (only when not your current chat)
3402
3458
  first-tree-hub chat send --chat <chatId> "your message"
3403
3459
 
3404
- # Send markdown (default format is text)
3405
- first-tree-hub chat send <agentName> -f markdown "**bold** message"
3460
+ # Markdown format (default is text)
3461
+ first-tree-hub chat send <agentName> -f markdown "**bold**"
3406
3462
 
3407
- # Reply to a specific message
3408
- first-tree-hub chat send <agentName> --reply-to <messageId> "reply content"
3463
+ # Reply to a message
3464
+ first-tree-hub chat send <agentName> --reply-to <messageId> "reply"
3409
3465
 
3410
- # Pipe long content via stdin (recommended for special characters)
3411
- echo "long message body" | first-tree-hub chat send <agentName>
3466
+ # Pipe long / multiline content via stdin
3467
+ echo "long body" | first-tree-hub chat send <agentName>
3412
3468
  \`\`\`
3413
3469
 
3414
- > Agent uuids appear in \`chat list\`, chat history, and participant lists,
3415
- > but they are NOT accepted by \`chat send\` — always use the name.
3470
+ **Content rules (important):**
3416
3471
 
3417
- For content with quotes, \`$\`, backticks, or newlines, prefer stdin to avoid shell escaping issues.
3472
+ - Pass content as a **raw string** never \`JSON.stringify\` it first. Wrapping in
3473
+ outer quotes + \`\\n\` escapes produces a literal \`"@x ...\\n..."\` that the UI
3474
+ cannot render as markdown.
3475
+ - For multi-line / markdown / special chars (quotes, \`$\`, backticks, newlines),
3476
+ use **stdin** with real newlines, plus \`-f markdown\`.
3418
3477
  `;
3419
3478
  }
3420
3479
  function resolveGitRepoTargetPath(workspace, localPath) {
@@ -4691,6 +4750,7 @@ const createClaudeCodeHandler = (config) => {
4691
4750
  /** Worktrees materialised for this session — each entry removed on shutdown. */
4692
4751
  const ownedWorktrees = [];
4693
4752
  async function toSDKUserMessage(message, sessionCtx, sessionId) {
4753
+ emitContextTreeUsage(sessionCtx);
4694
4754
  if (message.format === "file") {
4695
4755
  const senderLabel = message.senderId ? await sessionCtx.resolveSenderLabel(message.senderId) : "";
4696
4756
  const prefix = senderLabel ? `[From: ${senderLabel}]\n\n` : "";
@@ -4753,6 +4813,16 @@ const createClaudeCodeHandler = (config) => {
4753
4813
  session_id: sessionId
4754
4814
  };
4755
4815
  }
4816
+ function emitContextTreeUsage(sessionCtx) {
4817
+ if (!contextTreePath) return;
4818
+ sessionCtx.emitEvent({
4819
+ kind: "context_tree_usage",
4820
+ payload: {
4821
+ purpose: "design_decision",
4822
+ treeRepoUrl: contextTreeRepoUrl
4823
+ }
4824
+ });
4825
+ }
4756
4826
  /**
4757
4827
  * Build env for the child Claude Code process.
4758
4828
  *
@@ -5356,6 +5426,16 @@ const createCodexHandler = (config) => {
5356
5426
  function toCodexInput(message, sessionCtx) {
5357
5427
  return sessionCtx.formatInboundContent(message).then((text) => text);
5358
5428
  }
5429
+ function emitContextTreeUsage(sessionCtx) {
5430
+ if (!contextTreePath) return;
5431
+ sessionCtx.emitEvent({
5432
+ kind: "context_tree_usage",
5433
+ payload: {
5434
+ purpose: "design_decision",
5435
+ treeRepoUrl: contextTreeRepoUrl
5436
+ }
5437
+ });
5438
+ }
5359
5439
  async function prepareGitWorktrees(payload, workspaceCwd, sessionCtx) {
5360
5440
  if (!gitMirrorManager) return;
5361
5441
  const branchAgentKey = agentName ?? sessionCtx.agent.agentId;
@@ -5484,6 +5564,7 @@ const createCodexHandler = (config) => {
5484
5564
  if (!activeThread) return;
5485
5565
  const abort = new AbortController();
5486
5566
  currentAbort = abort;
5567
+ emitContextTreeUsage(sessionCtx);
5487
5568
  sessionCtx.setRuntimeState("working");
5488
5569
  const assistantTexts = [];
5489
5570
  let turnFailed = false;
@@ -5968,13 +6049,17 @@ var Deduplicator = class {
5968
6049
  };
5969
6050
  function createResultSink(deps) {
5970
6051
  async function buildMetadata(trigger) {
5971
- if (!trigger || trigger.senderId === deps.agent.agentId) return void 0;
5972
- const participants = await deps.participants.get();
5973
- if (participants.length <= 2) {
5974
- const peer = participants.find((p) => p.agentId === trigger.senderId);
5975
- if (peer && peer.mode !== "mention_only") return void 0;
5976
- }
5977
- return { mentions: [trigger.senderId] };
6052
+ const metadata = {};
6053
+ const documentBasePath = await deps.getDocumentBasePath?.();
6054
+ if (documentBasePath) metadata.documentContext = documentContextSchema.parse({ basePath: documentBasePath });
6055
+ if (trigger && trigger.senderId !== deps.agent.agentId) {
6056
+ const participants = await deps.participants.get();
6057
+ if (participants.length <= 2) {
6058
+ const peer = participants.find((p) => p.agentId === trigger.senderId);
6059
+ if (!peer || peer.mode === "mention_only") metadata.mentions = [trigger.senderId];
6060
+ } else metadata.mentions = [trigger.senderId];
6061
+ }
6062
+ return Object.keys(metadata).length > 0 ? metadata : void 0;
5978
6063
  }
5979
6064
  return async function forwardResult(text) {
5980
6065
  if (text.trim().length === 0) {
@@ -6065,6 +6150,16 @@ var SessionRegistry = class {
6065
6150
  }
6066
6151
  }
6067
6152
  };
6153
+ function documentBasePathFromRuntimeConfig(payload) {
6154
+ if (payload.gitRepos.length !== 1) return null;
6155
+ const repo = payload.gitRepos[0];
6156
+ if (!repo) return null;
6157
+ const localPath = repoLocalPath(repo).trim();
6158
+ return localPath.length > 0 ? localPath : null;
6159
+ }
6160
+ function repoLocalPath(repo) {
6161
+ return repo.localPath ?? deriveRepoLocalPath(repo.url);
6162
+ }
6068
6163
  /**
6069
6164
  * Manages per-chat session entries with session-oriented handler lifecycle.
6070
6165
  *
@@ -6554,7 +6649,8 @@ var SessionManager = class {
6554
6649
  this.currentTrigger.delete(chatId);
6555
6650
  },
6556
6651
  log,
6557
- participants
6652
+ participants,
6653
+ getDocumentBasePath: () => this.resolveDocumentBasePath(log)
6558
6654
  });
6559
6655
  const envCtx = {
6560
6656
  sdk: this.config.sdk,
@@ -6582,6 +6678,16 @@ var SessionManager = class {
6582
6678
  resolveSenderLabel: async (senderId) => resolveSenderLabel(senderId, await participants.get())
6583
6679
  };
6584
6680
  }
6681
+ async resolveDocumentBasePath(log) {
6682
+ if (!this.config.agentConfigCache) return null;
6683
+ try {
6684
+ const { payload } = await this.config.agentConfigCache.refreshIfNewer(this.config.agentIdentity.agentId, 0);
6685
+ return documentBasePathFromRuntimeConfig(payload);
6686
+ } catch (err) {
6687
+ log(`document preview base path unavailable: ${err instanceof Error ? err.message : String(err)}`);
6688
+ return null;
6689
+ }
6690
+ }
6585
6691
  /** Update per-session runtime state and recompute aggregate. Only active sessions may update. */
6586
6692
  setSessionRuntimeState(chatId, state) {
6587
6693
  const session = this.sessions.get(chatId);
@@ -9553,7 +9659,7 @@ async function onboardCreate(args) {
9553
9659
  }
9554
9660
  const runtimeAgent = args.type === "human" ? args.assistant : args.id;
9555
9661
  if (args.feishuBotAppId && args.feishuBotAppSecret) {
9556
- const { bindFeishuBot } = await import("./feishu-D_vnqC6a.mjs").then((n) => n.r);
9662
+ const { bindFeishuBot } = await import("./feishu-BGx71p5s.mjs").then((n) => n.r);
9557
9663
  const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
9558
9664
  if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
9559
9665
  else {
@@ -10766,7 +10872,7 @@ function createFeedbackHandler(config) {
10766
10872
  return { handle };
10767
10873
  }
10768
10874
  //#endregion
10769
- //#region ../server/dist/app-DQVUb4ZY.mjs
10875
+ //#region ../server/dist/app-BcZq1C1l.mjs
10770
10876
  var import_fastify_opentelemetry = /* @__PURE__ */ __toESM(require_fastify_opentelemetry(), 1);
10771
10877
  init_esm();
10772
10878
  var __defProp = Object.defineProperty;
@@ -11376,6 +11482,21 @@ async function ensureDefaultOrganization(db) {
11376
11482
  */
11377
11483
  const RESERVED_AGENT_NAME_PREFIX = "__";
11378
11484
  /**
11485
+ * Derive the relative URL clients should use to fetch a manager-uploaded
11486
+ * avatar image. Returns `null` when no image is set. Embeds the upload
11487
+ * timestamp as `?v=<epoch>` so a fresh upload busts any browser cache
11488
+ * that may have memoised the previous version.
11489
+ *
11490
+ * Auth: the image route is intentionally public read — the URL leaks no
11491
+ * more than the agent's UUID, which is already required to address it.
11492
+ * Keeping it unauthenticated lets `<img src>` render without bespoke
11493
+ * fetch-and-blob plumbing.
11494
+ */
11495
+ function agentAvatarImageUrl(uuid, updatedAt) {
11496
+ if (!updatedAt) return null;
11497
+ return `/api/v1/agents/${uuid}/avatar?v=${updatedAt.getTime()}`;
11498
+ }
11499
+ /**
11379
11500
  * True iff `clients.metadata.capabilities` is a non-empty object — i.e. the
11380
11501
  * client has reported at least one runtime probe result. Used to distinguish
11381
11502
  * "we don't know what's installed yet" (empty / never reported) from
@@ -11634,6 +11755,8 @@ async function listAgentsForAdmin(db, scope, limit, cursor) {
11634
11755
  managerId: agents.managerId,
11635
11756
  clientId: agents.clientId,
11636
11757
  runtimeProvider: agents.runtimeProvider,
11758
+ avatarColorToken: agents.avatarColorToken,
11759
+ avatarImageUpdatedAt: agents.avatarImageUpdatedAt,
11637
11760
  createdAt: agents.createdAt,
11638
11761
  updatedAt: agents.updatedAt,
11639
11762
  presenceStatus: agentPresence.status,
@@ -11672,6 +11795,8 @@ async function listAgentsForMember(db, scope, limit, cursor, type) {
11672
11795
  managerId: agents.managerId,
11673
11796
  clientId: agents.clientId,
11674
11797
  runtimeProvider: agents.runtimeProvider,
11798
+ avatarColorToken: agents.avatarColorToken,
11799
+ avatarImageUpdatedAt: agents.avatarImageUpdatedAt,
11675
11800
  createdAt: agents.createdAt,
11676
11801
  updatedAt: agents.updatedAt,
11677
11802
  presenceStatus: agentPresence.status,
@@ -11708,6 +11833,7 @@ async function updateAgent(db, uuid, data) {
11708
11833
  }
11709
11834
  if (data.visibility !== void 0) updates.visibility = data.visibility;
11710
11835
  if (data.metadata !== void 0) updates.metadata = data.metadata;
11836
+ if (data.avatarColorToken !== void 0) updates.avatarColorToken = data.avatarColorToken;
11711
11837
  if (data.managerId !== void 0) {
11712
11838
  if (data.managerId === null) throw new BadRequestError("managerId cannot be cleared — every agent must have a manager");
11713
11839
  const [manager] = await db.select({
@@ -11814,6 +11940,63 @@ async function deleteAgent(db, uuid) {
11814
11940
  if (!agent) throw new Error("Unexpected: UPDATE RETURNING produced no row");
11815
11941
  return agent;
11816
11942
  }
11943
+ /**
11944
+ * Supported avatar-image MIME types. The web client always uploads WEBP after
11945
+ * its own resize step; we accept PNG/JPEG too so a caller using the raw HTTP
11946
+ * API (curl, scripts) doesn't have to re-encode. Anything else is rejected at
11947
+ * the boundary — we never store an unknown content type.
11948
+ */
11949
+ const SUPPORTED_AVATAR_IMAGE_MIMES = [
11950
+ "image/webp",
11951
+ "image/png",
11952
+ "image/jpeg"
11953
+ ];
11954
+ /** Hard server-side ceiling for the stored bytea blob. Client pre-resizes to ~50KB. */
11955
+ const MAX_AVATAR_IMAGE_BYTES = 512 * 1024;
11956
+ function isSupportedAvatarMime(mime) {
11957
+ return SUPPORTED_AVATAR_IMAGE_MIMES.find((m) => m === mime) !== void 0;
11958
+ }
11959
+ /**
11960
+ * Fetch the avatar image blob for an agent. Returns `null` when no image
11961
+ * is set (the column is NULL). The data + mime pair is always coherent
11962
+ * (set/cleared together by the service writes below).
11963
+ */
11964
+ async function getAgentAvatarImage(db, uuid) {
11965
+ const [row] = await db.select({
11966
+ data: agents.avatarImageData,
11967
+ mime: agents.avatarImageMime,
11968
+ updatedAt: agents.avatarImageUpdatedAt
11969
+ }).from(agents).where(and(eq(agents.uuid, uuid), ne(agents.status, AGENT_STATUSES.DELETED))).limit(1);
11970
+ if (!row || !row.data || !row.mime || !row.updatedAt) return null;
11971
+ return {
11972
+ data: row.data,
11973
+ mime: row.mime,
11974
+ updatedAt: row.updatedAt
11975
+ };
11976
+ }
11977
+ /** Replace (or set) an agent's avatar image. Validates mime + size. */
11978
+ async function setAgentAvatarImage(db, uuid, data, mime) {
11979
+ if (!isSupportedAvatarMime(mime)) throw new BadRequestError(`Unsupported avatar image type "${mime}". Use PNG, JPEG, or WEBP.`);
11980
+ if (data.length === 0) throw new BadRequestError("Avatar image payload is empty.");
11981
+ if (data.length > 524288) throw new BadRequestError(`Avatar image is too large (${data.length} bytes; max ${MAX_AVATAR_IMAGE_BYTES}).`);
11982
+ const now = /* @__PURE__ */ new Date();
11983
+ if ((await db.update(agents).set({
11984
+ avatarImageData: data,
11985
+ avatarImageMime: mime,
11986
+ avatarImageUpdatedAt: now,
11987
+ updatedAt: now
11988
+ }).where(and(eq(agents.uuid, uuid), ne(agents.status, AGENT_STATUSES.DELETED))).returning({ uuid: agents.uuid })).length === 0) throw new NotFoundError(`Agent "${uuid}" not found`);
11989
+ return now;
11990
+ }
11991
+ /** Clear an agent's avatar image (falls back to color + initial). */
11992
+ async function clearAgentAvatarImage(db, uuid) {
11993
+ if ((await db.update(agents).set({
11994
+ avatarImageData: null,
11995
+ avatarImageMime: null,
11996
+ avatarImageUpdatedAt: null,
11997
+ updatedAt: /* @__PURE__ */ new Date()
11998
+ }).where(and(eq(agents.uuid, uuid), ne(agents.status, AGENT_STATUSES.DELETED))).returning({ uuid: agents.uuid })).length === 0) throw new NotFoundError(`Agent "${uuid}" not found`);
11999
+ }
11817
12000
  const log$5 = createLogger$1("AgentFeishuBot");
11818
12001
  async function agentFeishuBotRoutes(app) {
11819
12002
  /**
@@ -13122,6 +13305,18 @@ async function listEvents(db, agentId, chatId, options) {
13122
13305
  async function clearEvents(db, agentId, chatId) {
13123
13306
  await db.delete(sessionEvents).where(and(eq(sessionEvents.agentId, agentId), eq(sessionEvents.chatId, chatId)));
13124
13307
  }
13308
+ async function summarizeContextTreeUsage(db, organizationId, windowDays) {
13309
+ const since = /* @__PURE__ */ new Date(Date.now() - windowDays * 24 * 60 * 60 * 1e3);
13310
+ const [row] = await db.select({
13311
+ agentCount: sql`count(distinct ${sessionEvents.agentId})::int`,
13312
+ usageCount: sql`count(*)::int`
13313
+ }).from(sessionEvents).innerJoin(agents, eq(agents.uuid, sessionEvents.agentId)).where(and(eq(agents.organizationId, organizationId), eq(sessionEvents.kind, "context_tree_usage"), gte(sessionEvents.createdAt, since)));
13314
+ return {
13315
+ windowDays,
13316
+ agentCount: row?.agentCount ?? 0,
13317
+ usageCount: row?.usageCount ?? 0
13318
+ };
13319
+ }
13125
13320
  /**
13126
13321
  * Default per-agent in-flight cap when `server.inbox.maxInFlightPerAgent` is
13127
13322
  * unset. Mirrors the schema default so a hub running without an explicit
@@ -13772,6 +13967,22 @@ async function agentActivityRoutes(app) {
13772
13967
  });
13773
13968
  }
13774
13969
  /**
13970
+ * Project a DB agent row into its wire shape. Strips the inline image
13971
+ * `avatarImageData` (large bytea, only meant for the image-serve route)
13972
+ * and synthesises the public `avatarImageUrl` from the upload timestamp.
13973
+ * `createdAt`/`updatedAt` are coerced to ISO strings so the response is
13974
+ * pure JSON.
13975
+ */
13976
+ function serializeAgent(agent) {
13977
+ const { avatarImageData: _data, avatarImageMime: _mime, avatarImageUpdatedAt, createdAt, updatedAt, ...rest } = agent;
13978
+ return {
13979
+ ...rest,
13980
+ createdAt: createdAt.toISOString(),
13981
+ updatedAt: updatedAt.toISOString(),
13982
+ avatarImageUrl: agentAvatarImageUrl(agent.uuid, avatarImageUpdatedAt ?? null)
13983
+ };
13984
+ }
13985
+ /**
13775
13986
  * Class C — resource-scoped per-agent routes. Mounted at
13776
13987
  * `/api/v1/agents/:uuid/...`. The agent's UUID locates its org
13777
13988
  * intrinsically; `requireAgentAccess` resolves the caller's membership in
@@ -13800,11 +14011,7 @@ async function agentRoutes(app) {
13800
14011
  }
13801
14012
  app.get("/:uuid", async (request) => {
13802
14013
  const { agent } = await requireAgentAccess(request, app.db, "visible");
13803
- return {
13804
- ...agent,
13805
- createdAt: agent.createdAt.toISOString(),
13806
- updatedAt: agent.updatedAt.toISOString()
13807
- };
14014
+ return serializeAgent(agent);
13808
14015
  });
13809
14016
  app.patch("/:uuid", { config: { otelRecordBody: true } }, async (request) => {
13810
14017
  const { scope } = await requireAgentAccess(request, app.db, "manage");
@@ -13813,22 +14020,14 @@ async function agentRoutes(app) {
13813
14020
  const before = body.clientId !== void 0 ? await getAgent(app.db, request.params.uuid) : null;
13814
14021
  const agent = await updateAgent(app.db, request.params.uuid, body);
13815
14022
  if (before && before.clientId === null && agent.clientId !== null) notifyClientAgentPinned(agent);
13816
- return {
13817
- ...agent,
13818
- createdAt: agent.createdAt.toISOString(),
13819
- updatedAt: agent.updatedAt.toISOString()
13820
- };
14023
+ return serializeAgent(agent);
13821
14024
  });
13822
14025
  app.patch("/:uuid/rebind", { config: { otelRecordBody: true } }, async (request) => {
13823
14026
  await requireAgentAccess(request, app.db, "manage");
13824
14027
  const body = rebindAgentSchema.parse(request.body);
13825
14028
  const agent = await rebindAgent(app.db, request.params.uuid, body);
13826
14029
  notifyClientAgentPinned(agent);
13827
- return {
13828
- ...agent,
13829
- createdAt: agent.createdAt.toISOString(),
13830
- updatedAt: agent.updatedAt.toISOString()
13831
- };
14030
+ return serializeAgent(agent);
13832
14031
  });
13833
14032
  app.post("/:uuid/disconnect", async (request, reply) => {
13834
14033
  await requireAgentAccess(request, app.db, "manage");
@@ -13838,27 +14037,35 @@ async function agentRoutes(app) {
13838
14037
  });
13839
14038
  app.post("/:uuid/suspend", async (request) => {
13840
14039
  await requireAgentAccess(request, app.db, "manage");
13841
- const agent = await suspendAgent(app.db, request.params.uuid);
13842
- return {
13843
- ...agent,
13844
- createdAt: agent.createdAt.toISOString(),
13845
- updatedAt: agent.updatedAt.toISOString()
13846
- };
14040
+ return serializeAgent(await suspendAgent(app.db, request.params.uuid));
13847
14041
  });
13848
14042
  app.post("/:uuid/reactivate", async (request) => {
13849
14043
  await requireAgentAccess(request, app.db, "manage");
13850
- const agent = await reactivateAgent(app.db, request.params.uuid);
13851
- return {
13852
- ...agent,
13853
- createdAt: agent.createdAt.toISOString(),
13854
- updatedAt: agent.updatedAt.toISOString()
13855
- };
14044
+ return serializeAgent(await reactivateAgent(app.db, request.params.uuid));
13856
14045
  });
13857
14046
  app.delete("/:uuid", async (request, reply) => {
13858
14047
  await requireAgentAccess(request, app.db, "manage");
13859
14048
  await deleteAgent(app.db, request.params.uuid);
13860
14049
  return reply.status(204).send();
13861
14050
  });
14051
+ app.addContentTypeParser(/^image\//, { parseAs: "buffer" }, (_req, body, done) => {
14052
+ done(null, body);
14053
+ });
14054
+ app.put("/:uuid/avatar", { bodyLimit: MAX_AVATAR_IMAGE_BYTES + 1024 }, async (request, reply) => {
14055
+ await requireAgentAccess(request, app.db, "manage");
14056
+ const contentType = request.headers["content-type"];
14057
+ if (typeof contentType !== "string" || !contentType.startsWith("image/")) throw new BadRequestError(`Avatar upload requires an image/* Content-Type. Supported: ${SUPPORTED_AVATAR_IMAGE_MIMES.join(", ")}.`);
14058
+ const mime = contentType.split(";")[0]?.trim() ?? "";
14059
+ const body = request.body;
14060
+ if (!Buffer.isBuffer(body)) throw new BadRequestError("Avatar upload body must be raw image bytes.");
14061
+ const updatedAt = await setAgentAvatarImage(app.db, request.params.uuid, body, mime);
14062
+ return reply.status(200).send({ avatarImageUrl: agentAvatarImageUrl(request.params.uuid, updatedAt) });
14063
+ });
14064
+ app.delete("/:uuid/avatar", async (request, reply) => {
14065
+ await requireAgentAccess(request, app.db, "manage");
14066
+ await clearAgentAvatarImage(app.db, request.params.uuid);
14067
+ return reply.status(204).send();
14068
+ });
13862
14069
  app.post("/:uuid/test", async (request, reply) => {
13863
14070
  const { uuid } = request.params;
13864
14071
  const { agent: targetAgent } = await requireAgentAccess(request, app.db, "manage");
@@ -13967,6 +14174,23 @@ async function agentRoutes(app) {
13967
14174
  });
13968
14175
  }
13969
14176
  /**
14177
+ * Public read-only route for agent avatar images. Mounted outside the
14178
+ * member-JWT auth scope so `<img src>` works without bespoke fetch-and-blob
14179
+ * plumbing. Reading an avatar leaks no more than the agent's UUID — which
14180
+ * is already required to address the route — and the UUID itself is only
14181
+ * exposed through authenticated agent-list calls.
14182
+ */
14183
+ async function publicAgentAvatarRoutes(app) {
14184
+ app.get("/:uuid/avatar", async (request, reply) => {
14185
+ const image = await getAgentAvatarImage(app.db, request.params.uuid);
14186
+ if (!image) return reply.status(404).send({ error: "Avatar not set" });
14187
+ reply.header("Content-Type", image.mime);
14188
+ reply.header("Cache-Control", "public, max-age=2592000, immutable");
14189
+ reply.header("ETag", `"${image.updatedAt.getTime()}"`);
14190
+ return reply.send(image.data);
14191
+ });
14192
+ }
14193
+ /**
13970
14194
  * Class C — `/api/v1/agents/:uuid/config`. Runtime config (system prompt,
13971
14195
  * tools, env) is behavior-sensitive — gated on `manage`, not `visible`.
13972
14196
  */
@@ -15012,6 +15236,22 @@ async function listActiveMemberships(db, userId) {
15012
15236
  }).from(members).innerJoin(organizations, eq(members.organizationId, organizations.id)).where(and(eq(members.userId, userId), eq(members.status, "active"))).orderBy(desc(members.createdAt));
15013
15237
  }
15014
15238
  /**
15239
+ * Count ACTIVE members per org, restricted to the given org IDs. Returns a
15240
+ * Map keyed by `organizationId`; orgs absent from the result simply have
15241
+ * zero active members (shouldn't happen in practice — the caller always
15242
+ * passes orgs the user is a member of — but the Map shape lets callers do
15243
+ * `counts.get(orgId) ?? 0` defensively). Used by `/me` to surface
15244
+ * `orgHasOtherMembers` per membership without N+1 queries.
15245
+ */
15246
+ async function countActiveMembersByOrgs(db, organizationIds) {
15247
+ if (organizationIds.length === 0) return /* @__PURE__ */ new Map();
15248
+ const rows = await db.select({
15249
+ organizationId: members.organizationId,
15250
+ count: sql`count(*)::int`
15251
+ }).from(members).where(and(inArray(members.organizationId, organizationIds), eq(members.status, "active"))).groupBy(members.organizationId);
15252
+ return new Map(rows.map((r) => [r.organizationId, r.count]));
15253
+ }
15254
+ /**
15015
15255
  * Pick the most recently joined active membership — used after OAuth login
15016
15256
  * when the user already has at least one team but no `next` was specified.
15017
15257
  */
@@ -15803,16 +16043,16 @@ function decodeCursor(cursor) {
15803
16043
  return null;
15804
16044
  }
15805
16045
  }
15806
- const { ACTIVE, ARCHIVED, DELETED } = CHAT_ENGAGEMENT_STATUSES;
16046
+ const { ACTIVE: ACTIVE$1, ARCHIVED: ARCHIVED$1, DELETED } = CHAT_ENGAGEMENT_STATUSES;
15807
16047
  /**
15808
16048
  * SQL predicate for each engagement view tab. `deleted` is never a valid view
15809
16049
  * value — deleted rows are reachable only through `GET /chats/:chatId` + the
15810
16050
  * Restore banner on the chat detail page.
15811
16051
  */
15812
16052
  const ENGAGEMENT_VIEW_PREDICATE = {
15813
- active: sql`COALESCE(cus.engagement_status, ${ACTIVE}) = ${ACTIVE}`,
15814
- archived: sql`COALESCE(cus.engagement_status, ${ACTIVE}) = ${ARCHIVED}`,
15815
- all: sql`COALESCE(cus.engagement_status, ${ACTIVE}) IN (${ACTIVE}, ${ARCHIVED})`
16053
+ active: sql`COALESCE(cus.engagement_status, ${ACTIVE$1}) = ${ACTIVE$1}`,
16054
+ archived: sql`COALESCE(cus.engagement_status, ${ACTIVE$1}) = ${ARCHIVED$1}`,
16055
+ all: sql`COALESCE(cus.engagement_status, ${ACTIVE$1}) IN (${ACTIVE$1}, ${ARCHIVED$1})`
15816
16056
  };
15817
16057
  /**
15818
16058
  * Write the caller's engagement state for this chat. UPSERT into
@@ -15840,7 +16080,26 @@ async function setChatEngagement(db, chatId, agentId, status) {
15840
16080
  */
15841
16081
  async function getCallerEngagement(db, chatId, agentId) {
15842
16082
  const [row] = await db.select({ engagementStatus: chatUserState.engagementStatus }).from(chatUserState).where(and(eq(chatUserState.chatId, chatId), eq(chatUserState.agentId, agentId))).limit(1);
15843
- return row?.engagementStatus ?? ACTIVE;
16083
+ return row?.engagementStatus ?? ACTIVE$1;
16084
+ }
16085
+ const KNOWN_NON_MANUAL_PREDICATE = sql`(
16086
+ (c.metadata->>'source' = 'github' AND c.metadata->>'entityType' IN (${sql.join(GITHUB_ENTITY_TYPES.map((t) => sql`${t}`), sql.raw(", "))}))
16087
+ OR c.metadata->>'source' = 'feishu'
16088
+ )`;
16089
+ const chatSourceSqlExpression = sql`CASE
16090
+ ${sql.join(GITHUB_ENTITY_TYPES.map((t) => sql`WHEN c.metadata->>'source' = 'github' AND c.metadata->>'entityType' = ${t} THEN ${`github_${t}`}`), sql.raw("\n "))}
16091
+ WHEN c.metadata->>'source' = 'feishu' THEN 'feishu'
16092
+ ELSE 'manual'
16093
+ END`;
16094
+ function sourceFilterSql(source) {
16095
+ switch (source) {
16096
+ case "manual": return sql`(${KNOWN_NON_MANUAL_PREDICATE}) IS NOT TRUE`;
16097
+ case "github_issue": return sql`(c.metadata->>'source' = 'github' AND c.metadata->>'entityType' = 'issue')`;
16098
+ case "github_pull_request": return sql`(c.metadata->>'source' = 'github' AND c.metadata->>'entityType' = 'pull_request')`;
16099
+ case "github_discussion": return sql`(c.metadata->>'source' = 'github' AND c.metadata->>'entityType' = 'discussion')`;
16100
+ case "github_commit": return sql`(c.metadata->>'source' = 'github' AND c.metadata->>'entityType' = 'commit')`;
16101
+ case "feishu": return sql`(c.metadata->>'source' = 'feishu')`;
16102
+ }
15844
16103
  }
15845
16104
  /**
15846
16105
  * GET /me/chats — cursor-paginated conversation list.
@@ -15866,6 +16125,7 @@ async function listMeChats(db, humanAgentId, organizationId, query) {
15866
16125
  const filterUnreadOnly = query.filter === "unread";
15867
16126
  const filterWatchingOnly = query.filter === "watching";
15868
16127
  const engagementPredicate = ENGAGEMENT_VIEW_PREDICATE[query.engagement];
16128
+ const sourcePredicate = query.source ? sourceFilterSql(query.source) : sql`TRUE`;
15869
16129
  const cursorTsIso = cursor?.lastMessageAt ? cursor.lastMessageAt.toISOString() : null;
15870
16130
  const cursorPredicate = !cursor ? sql`TRUE` : cursor.lastMessageAt === null ? sql`(c.last_message_at IS NULL AND c.id < ${cursor.chatId})` : sql`(c.last_message_at IS NULL
15871
16131
  OR c.last_message_at < ${cursorTsIso}::timestamptz
@@ -15882,7 +16142,7 @@ async function listMeChats(db, humanAgentId, organizationId, query) {
15882
16142
  WHERE chat_id = c.id AND access_mode = 'speaker') AS participant_count,
15883
16143
  cm.access_mode AS access_mode,
15884
16144
  COALESCE(cus.unread_mention_count, 0) AS unread_mention_count,
15885
- COALESCE(cus.engagement_status, ${ACTIVE}) AS engagement_status
16145
+ COALESCE(cus.engagement_status, ${ACTIVE$1}) AS engagement_status
15886
16146
  FROM chats c
15887
16147
  JOIN chat_membership cm
15888
16148
  ON cm.chat_id = c.id AND cm.agent_id = ${humanAgentId}
@@ -15898,6 +16158,7 @@ async function listMeChats(db, humanAgentId, organizationId, query) {
15898
16158
  AND (${!filterUnreadOnly}::bool OR COALESCE(cus.unread_mention_count, 0) > 0)
15899
16159
  AND (${!filterWatchingOnly}::bool OR cm.access_mode = 'watcher')
15900
16160
  AND ${engagementPredicate}
16161
+ AND ${sourcePredicate}
15901
16162
  AND ${cursorPredicate}
15902
16163
  ORDER BY c.last_message_at DESC NULLS LAST, c.id DESC
15903
16164
  LIMIT ${limit + 1}
@@ -15920,6 +16181,8 @@ async function listMeChats(db, humanAgentId, organizationId, query) {
15920
16181
  agentId: chatMembership.agentId,
15921
16182
  displayName: agents.displayName,
15922
16183
  type: agents.type,
16184
+ avatarColorToken: agents.avatarColorToken,
16185
+ avatarImageUpdatedAt: agents.avatarImageUpdatedAt,
15923
16186
  sessionState: agentChatSessions.state
15924
16187
  }).from(chatMembership).innerJoin(agents, eq(chatMembership.agentId, agents.uuid)).leftJoin(agentChatSessions, and(eq(agentChatSessions.agentId, chatMembership.agentId), eq(agentChatSessions.chatId, chatMembership.chatId))).where(and(inArray(chatMembership.chatId, chatIds), eq(chatMembership.accessMode, "speaker")));
15925
16188
  const participantsByChat = /* @__PURE__ */ new Map();
@@ -15929,7 +16192,9 @@ async function listMeChats(db, humanAgentId, organizationId, query) {
15929
16192
  list.push({
15930
16193
  agentId: p.agentId,
15931
16194
  displayName: p.displayName,
15932
- type: p.type
16195
+ type: p.type,
16196
+ avatarColorToken: p.avatarColorToken,
16197
+ avatarImageUrl: agentAvatarImageUrl(p.agentId, p.avatarImageUpdatedAt)
15933
16198
  });
15934
16199
  participantsByChat.set(p.chatId, list);
15935
16200
  if (p.sessionState === "active") {
@@ -16177,6 +16442,66 @@ async function leaveMeChat(db, chatId, humanAgentId) {
16177
16442
  return result;
16178
16443
  }
16179
16444
  /**
16445
+ * Used by future bell-badge / list-pill counts. The partial index
16446
+ * `idx_user_state_unread WHERE unread_mention_count > 0` bounds the
16447
+ * driving scan; we then join `chat_membership` + `chats` so the badge
16448
+ * stays consistent with `listMeChats`.
16449
+ *
16450
+ * Why the joins (not just a single-table count): per §11.4 a user's
16451
+ * `chat_user_state` row is **preserved on detach** so read state
16452
+ * survives a leave/rejoin cycle. Without the membership join, any
16453
+ * preserved row with `unread_mention_count > 0` would keep
16454
+ * contributing to the badge even though the chat no longer appears in
16455
+ * the list. The `chats` join applies the same org-scoping +
16456
+ * `parent_chat_id IS NULL` filter as `listMeChats` so the two counts
16457
+ * cannot drift in the cross-org pollution or nested-chat cases either.
16458
+ *
16459
+ * Engagement parity: deleted chats are excluded from `listMeChats`
16460
+ * (any `engagement` view), so the badge must exclude them too — otherwise
16461
+ * the user sees an unread red dot for a chat they've removed from view.
16462
+ */
16463
+ /**
16464
+ * Per-source aggregate for the conversation-list tag bar.
16465
+ *
16466
+ * Returns one row per source the caller has at least one chat for, plus an
16467
+ * always-present `manual` entry (zero counts when there are no manual chats —
16468
+ * the workspace UI uses `manual` as its default tab and must render it even
16469
+ * when empty).
16470
+ *
16471
+ * Filtering matches `listMeChats` for the corresponding tab so the badges
16472
+ * cannot drift from the list: same membership join, same `parent_chat_id IS
16473
+ * NULL` and `organization_id` scopes, same engagement view, same
16474
+ * `chat_user_state.unread_mention_count` source.
16475
+ */
16476
+ async function listMeChatSourceCounts(db, humanAgentId, organizationId, query) {
16477
+ const engagementPredicate = ENGAGEMENT_VIEW_PREDICATE[query.engagement];
16478
+ const rows = await db.execute(sql`
16479
+ SELECT
16480
+ ${chatSourceSqlExpression} AS source,
16481
+ count(*)::int AS chat_count,
16482
+ count(*) FILTER (WHERE COALESCE(cus.unread_mention_count, 0) > 0)::int AS unread_chat_count
16483
+ FROM chats c
16484
+ JOIN chat_membership cm
16485
+ ON cm.chat_id = c.id AND cm.agent_id = ${humanAgentId}
16486
+ LEFT JOIN chat_user_state cus
16487
+ ON cus.chat_id = c.id AND cus.agent_id = ${humanAgentId}
16488
+ WHERE c.parent_chat_id IS NULL
16489
+ AND c.organization_id = ${organizationId}
16490
+ AND ${engagementPredicate}
16491
+ GROUP BY 1
16492
+ `);
16493
+ const counts = {};
16494
+ for (const row of rows) counts[row.source] = {
16495
+ chatCount: Number(row.chat_count),
16496
+ unreadChatCount: Number(row.unread_chat_count)
16497
+ };
16498
+ if (!counts.manual) counts.manual = {
16499
+ chatCount: 0,
16500
+ unreadChatCount: 0
16501
+ };
16502
+ return { counts };
16503
+ }
16504
+ /**
16180
16505
  * Class C — resource-scoped chat routes. Mounted at
16181
16506
  * `/api/v1/chats/:chatId/...`. The chat's `organizationId` locates its
16182
16507
  * org; `requireChatAccess` resolves the caller's membership in that org
@@ -16197,7 +16522,9 @@ async function chatRoutes(app) {
16197
16522
  const agentRows = participantAgentIds.length > 0 ? await app.db.select({
16198
16523
  agentId: agents.uuid,
16199
16524
  displayName: agents.displayName,
16200
- type: agents.type
16525
+ type: agents.type,
16526
+ avatarColorToken: agents.avatarColorToken,
16527
+ avatarImageUpdatedAt: agents.avatarImageUpdatedAt
16201
16528
  }).from(agents).where(inArray(agents.uuid, participantAgentIds)) : [];
16202
16529
  const agentMeta = new Map(agentRows.map((a) => [a.agentId, a]));
16203
16530
  const participantsForTitle = participants.map((p) => {
@@ -16205,7 +16532,9 @@ async function chatRoutes(app) {
16205
16532
  return {
16206
16533
  agentId: p.agentId,
16207
16534
  displayName: meta?.displayName ?? p.agentId,
16208
- type: meta?.type ?? "unknown"
16535
+ type: meta?.type ?? "unknown",
16536
+ avatarColorToken: meta?.avatarColorToken ?? null,
16537
+ avatarImageUrl: agentAvatarImageUrl(p.agentId, meta?.avatarImageUpdatedAt ?? null)
16209
16538
  };
16210
16539
  });
16211
16540
  const title = resolveChatTitle(chat.topic, firstMessagePreview, participantsForTitle, scope.humanAgentId);
@@ -16684,6 +17013,9 @@ const WINDOW_DAYS = {
16684
17013
  "7d": 7,
16685
17014
  "30d": 30
16686
17015
  };
17016
+ function contextTreeSnapshotWindowDays(window) {
17017
+ return WINDOW_DAYS[window];
17018
+ }
16687
17019
  const snapshotCache = /* @__PURE__ */ new Map();
16688
17020
  const remoteSyncPromises = /* @__PURE__ */ new Map();
16689
17021
  const remoteLastSyncedAt = /* @__PURE__ */ new Map();
@@ -16727,6 +17059,7 @@ async function getContextTreeSnapshot(binding, window = CONTEXT_TREE_SNAPSHOT_WI
16727
17059
  snapshotStatus: statusWarning?.stale ? "stale" : "active",
16728
17060
  contextStatus: contextStatus(statusWarning),
16729
17061
  summary,
17062
+ usage: emptyUsageSummary(window),
16730
17063
  updates,
16731
17064
  nodes: nodesWithGhosts,
16732
17065
  edges: tree.edges,
@@ -16981,6 +17314,13 @@ function withSnapshotStatus(snapshot, syncedAt, warning) {
16981
17314
  contextStatus: warning ? contextStatus(warning) : snapshot.contextStatus
16982
17315
  };
16983
17316
  }
17317
+ function emptyUsageSummary(window) {
17318
+ return {
17319
+ windowDays: WINDOW_DAYS[window],
17320
+ agentCount: 0,
17321
+ usageCount: 0
17322
+ };
17323
+ }
16984
17324
  function isSafeBranchName(branch) {
16985
17325
  if (branch.startsWith("-")) return false;
16986
17326
  if (branch.includes("..") || branch.includes("@{") || branch.includes("\\")) return false;
@@ -17011,6 +17351,7 @@ function unavailableSnapshot(repo, branch, detail) {
17011
17351
  removedCount: 0,
17012
17352
  changedNodeCount: 0
17013
17353
  },
17354
+ usage: emptyUsageSummary(CONTEXT_TREE_SNAPSHOT_WINDOWS.SEVEN_DAYS),
17014
17355
  updates: [],
17015
17356
  nodes: [],
17016
17357
  edges: [],
@@ -17586,11 +17927,16 @@ async function contextTreeSnapshotRoutes(app) {
17586
17927
  const orgId = await resolveUserPrimaryOrgId(app.db, userId);
17587
17928
  const binding = orgId ? await getOrgContextTree(app.db, orgId) : {};
17588
17929
  const githubToken = contextTreeGithubTokenForRepo(binding.repo, app.config.contextTreeSync);
17930
+ const window = query.window ?? "7d";
17589
17931
  const snapshot = await getContextTreeSnapshot({
17590
17932
  ...binding,
17591
17933
  githubToken
17592
- }, query.window ?? "7d");
17593
- return contextTreeSnapshotSchema.parse(snapshot);
17934
+ }, window);
17935
+ const usage = orgId ? await summarizeContextTreeUsage(app.db, orgId, contextTreeSnapshotWindowDays(window)) : snapshot.usage;
17936
+ return contextTreeSnapshotSchema.parse({
17937
+ ...snapshot,
17938
+ usage
17939
+ });
17594
17940
  });
17595
17941
  }
17596
17942
  function contextTreeGithubTokenForRepo(repo, syncConfig) {
@@ -17719,7 +18065,7 @@ async function healthzRoutes(app) {
17719
18065
  * `api/orgs/invitations.ts` (Class B, admin-gated).
17720
18066
  */
17721
18067
  async function publicInvitationRoutes(app) {
17722
- const { previewInvitation } = await import("./invitation-CNv7gfFF-D93KQte0.mjs");
18068
+ const { previewInvitation } = await import("./invitation-CNv7gfFF-DOFZ75wb.mjs");
17723
18069
  app.get("/:token/preview", async (request, reply) => {
17724
18070
  if (!request.params.token) throw new UnauthorizedError("Token required");
17725
18071
  const preview = await previewInvitation(app.db, request.params.token);
@@ -17798,6 +18144,7 @@ async function meRoutes(app) {
17798
18144
  createdAt: m.createdAt
17799
18145
  })));
17800
18146
  const defaultOrgId = defaultMembership ? memberships.find((m) => m.memberId === defaultMembership.id)?.organizationId ?? null : null;
18147
+ const memberCounts = await countActiveMembersByOrgs(app.db, memberships.map((mb) => mb.organizationId));
17801
18148
  let inviteUrl = null;
17802
18149
  if (defaultOrgId) {
17803
18150
  if (memberships.find((m) => m.organizationId === defaultOrgId)?.role === "admin") {
@@ -17814,7 +18161,8 @@ async function meRoutes(app) {
17814
18161
  organizationId: mb.organizationId,
17815
18162
  organizationName: mb.orgDisplayName,
17816
18163
  role: mb.role,
17817
- agentId: mb.agentId
18164
+ agentId: mb.agentId,
18165
+ orgHasOtherMembers: (memberCounts.get(mb.organizationId) ?? 1) > 1
17818
18166
  })),
17819
18167
  onboarding: {
17820
18168
  step: onboardingStep,
@@ -18006,7 +18354,7 @@ async function meRoutes(app) {
18006
18354
  */
18007
18355
  app.get("/me/pinned-agents", async (request) => {
18008
18356
  const { userId } = requireUser(request);
18009
- const { listMyPinnedAgents } = await import("./client-h4KZ3b9o-CQyibXig.mjs");
18357
+ const { listMyPinnedAgents } = await import("./client-CREn8bJ0-C5fHJir6.mjs");
18010
18358
  return listMyPinnedAgents(app.db, { userId });
18011
18359
  });
18012
18360
  /**
@@ -18136,6 +18484,80 @@ async function inferOnboardingStep(app, userId) {
18136
18484
  if (!hasAgent) return "create_agent";
18137
18485
  return "completed";
18138
18486
  }
18487
+ const MAX_DOC_BYTES = 5 * 1024 * 1024;
18488
+ async function getMeDocPreview(input) {
18489
+ const workspaceRootReal = await realpathOrNotFound(join(input.workspacesRoot ?? join(DEFAULT_DATA_DIR$1, "workspaces"), input.agentName, input.chatId));
18490
+ const candidate = resolve(workspaceRootReal, join(input.basePath ?? "", input.path));
18491
+ if (extname(assertInsideWorkspace(workspaceRootReal, candidate)).toLowerCase() !== ".md") throw new ForbiddenError("Document preview only supports markdown files in the agent workspace");
18492
+ let fileStat;
18493
+ try {
18494
+ fileStat = await stat(candidate);
18495
+ } catch {
18496
+ throw new NotFoundError("Document not found");
18497
+ }
18498
+ if (!fileStat.isFile()) throw new NotFoundError("Document not found");
18499
+ if (fileStat.size > MAX_DOC_BYTES) throw new AppError(413, "Document is larger than the 5MB preview limit");
18500
+ const fileReal = await realpath(candidate);
18501
+ const normalizedPath = assertInsideWorkspace(workspaceRootReal, fileReal);
18502
+ const refPath = normalizeRefPath(input.path);
18503
+ const normalizedBasePath = input.basePath ? normalizeRefPath(input.basePath) : void 0;
18504
+ return {
18505
+ ref: {
18506
+ type: "workspace",
18507
+ chatId: input.chatId,
18508
+ agentId: input.agentId,
18509
+ ...normalizedBasePath ? { basePath: normalizedBasePath } : {},
18510
+ path: refPath
18511
+ },
18512
+ path: normalizedPath,
18513
+ content: await readFile(fileReal, "utf8")
18514
+ };
18515
+ }
18516
+ async function realpathOrNotFound(path) {
18517
+ try {
18518
+ return await realpath(path);
18519
+ } catch {
18520
+ throw new NotFoundError("Document not found");
18521
+ }
18522
+ }
18523
+ function assertInsideWorkspace(workspaceRoot, target) {
18524
+ const rel = relative(workspaceRoot, target);
18525
+ if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) throw new ForbiddenError("Document path must stay inside the agent workspace");
18526
+ return rel.split(sep).join("/");
18527
+ }
18528
+ function normalizeRefPath(path) {
18529
+ const parts = [];
18530
+ for (const part of path.split(/[\\/]/)) {
18531
+ if (!part || part === ".") continue;
18532
+ if (part === "..") {
18533
+ if (parts.length === 0) throw new ForbiddenError("Document path must stay inside the agent workspace");
18534
+ parts.pop();
18535
+ continue;
18536
+ }
18537
+ parts.push(part);
18538
+ }
18539
+ if (parts.length === 0) throw new ForbiddenError("Document path must name a markdown file");
18540
+ return parts.join("/");
18541
+ }
18542
+ async function meDocsRoutes(app, options = {}) {
18543
+ app.get("/chats/:chatId/docs/preview", async (request) => {
18544
+ await requireChatAccess(request, app.db);
18545
+ const query = getMeDocSchema.parse(request.query);
18546
+ const [participant] = await app.db.select({ agentId: chatMembership.agentId }).from(chatMembership).where(and(eq(chatMembership.chatId, request.params.chatId), eq(chatMembership.agentId, query.agentId), eq(chatMembership.accessMode, "speaker"))).limit(1);
18547
+ if (!participant) throw new NotFoundError("Document not found");
18548
+ const [agent] = await app.db.select({ name: agents.name }).from(agents).where(eq(agents.uuid, query.agentId)).limit(1);
18549
+ if (!agent?.name) throw new NotFoundError("Document not found");
18550
+ const preview = await getMeDocPreview({
18551
+ chatId: request.params.chatId,
18552
+ agentId: query.agentId,
18553
+ agentName: agent.name,
18554
+ basePath: query.basePath,
18555
+ path: query.path,
18556
+ workspacesRoot: options.workspacesRoot
18557
+ });
18558
+ return getMeDocResponseSchema.parse(preview);
18559
+ });
18560
+ }
18139
18561
  /**
18140
18562
  * Resolve the caller's active membership in `:orgId` (from the URL) and
18141
18563
  * return the full `OrgScope`. The type signature requires
@@ -18316,7 +18738,7 @@ async function orgAgentRoutes(app) {
18316
18738
  const { type } = listAgentsFilterSchema.parse(request.query);
18317
18739
  const result = await listAgentsForMember(app.db, scope, query.limit, query.cursor, type);
18318
18740
  return {
18319
- items: result.items.map((a) => ({
18741
+ items: result.items.map(({ avatarImageUpdatedAt, ...a }) => ({
18320
18742
  ...a,
18321
18743
  managerId: a.managerId ?? null,
18322
18744
  presenceStatus: a.presenceStatus ?? "offline",
@@ -18325,7 +18747,8 @@ async function orgAgentRoutes(app) {
18325
18747
  clientId: a.clientId ?? null,
18326
18748
  runtimeType: a.runtimeType ?? null,
18327
18749
  runtimeState: a.runtimeState ?? null,
18328
- activeSessions: a.activeSessions ?? null
18750
+ activeSessions: a.activeSessions ?? null,
18751
+ avatarImageUrl: agentAvatarImageUrl(a.uuid, avatarImageUpdatedAt)
18329
18752
  })),
18330
18753
  nextCursor: result.nextCursor
18331
18754
  };
@@ -18341,7 +18764,7 @@ async function orgAgentRoutes(app) {
18341
18764
  const query = paginationQuerySchema.parse(request.query);
18342
18765
  const result = await listAgentsForAdmin(app.db, scope, query.limit, query.cursor);
18343
18766
  return {
18344
- items: result.items.map((a) => ({
18767
+ items: result.items.map(({ avatarImageUpdatedAt, ...a }) => ({
18345
18768
  ...a,
18346
18769
  managerId: a.managerId ?? null,
18347
18770
  presenceStatus: a.presenceStatus ?? "offline",
@@ -18350,7 +18773,8 @@ async function orgAgentRoutes(app) {
18350
18773
  clientId: a.clientId ?? null,
18351
18774
  runtimeType: a.runtimeType ?? null,
18352
18775
  runtimeState: a.runtimeState ?? null,
18353
- activeSessions: a.activeSessions ?? null
18776
+ activeSessions: a.activeSessions ?? null,
18777
+ avatarImageUrl: agentAvatarImageUrl(a.uuid, avatarImageUpdatedAt)
18354
18778
  })),
18355
18779
  nextCursor: result.nextCursor
18356
18780
  };
@@ -18443,6 +18867,17 @@ async function orgChatRoutes(app) {
18443
18867
  return listMeChats(app.db, scope.humanAgentId, scope.organizationId, query);
18444
18868
  });
18445
18869
  /**
18870
+ * GET /orgs/:orgId/chats/source-counts — per-source aggregate powering the
18871
+ * conversation-list tag bar (Manual / GitHub PR / GitHub Issue / Feishu).
18872
+ * Returns counts only for sources the caller has chats in, plus an
18873
+ * always-present `manual` entry. Same engagement view filter as the list.
18874
+ */
18875
+ app.get("/source-counts", async (request) => {
18876
+ const scope = await requireOrgMembership(request, app.db);
18877
+ const query = listMeChatSourceCountsQuerySchema.parse(request.query);
18878
+ return listMeChatSourceCounts(app.db, scope.humanAgentId, scope.organizationId, query);
18879
+ });
18880
+ /**
18446
18881
  * POST /orgs/:orgId/chats — create a new chat. The :orgId path param
18447
18882
  * makes the org explicit; visibility of every requested participant is
18448
18883
  * verified before the service layer touches the DB.
@@ -18496,11 +18931,16 @@ async function orgContextTreeSnapshotRoutes(app) {
18496
18931
  const scope = await requireOrgMembership(request, app.db);
18497
18932
  const binding = await getOrgContextTree(app.db, scope.organizationId);
18498
18933
  const githubToken = contextTreeGithubTokenForRepo(binding.repo, app.config.contextTreeSync);
18934
+ const window = query.window ?? "7d";
18499
18935
  const snapshot = await getContextTreeSnapshot({
18500
18936
  ...binding,
18501
18937
  githubToken
18502
- }, query.window ?? "7d");
18503
- return contextTreeSnapshotSchema.parse(snapshot);
18938
+ }, window);
18939
+ const usage = await summarizeContextTreeUsage(app.db, scope.organizationId, contextTreeSnapshotWindowDays(window));
18940
+ return contextTreeSnapshotSchema.parse({
18941
+ ...snapshot,
18942
+ usage
18943
+ });
18504
18944
  });
18505
18945
  }
18506
18946
  function orgIdParam(params) {
@@ -19141,6 +19581,64 @@ const githubEntityChatMappings = pgTable("github_entity_chat_mappings", {
19141
19581
  table.entityType,
19142
19582
  table.entityKey
19143
19583
  ] }), index("idx_github_entity_chat_mappings_chat").on(table.chatId)]);
19584
+ /**
19585
+ * Auto-archive chats when one of their bound pull requests is merged.
19586
+ *
19587
+ * Trigger: GitHub `pull_request.closed` webhook with `merged === true`. The
19588
+ * webhook handler calls this service on a bypass branch — the normalize /
19589
+ * audience / deliver pipeline is unaffected (PR closed events still drop in
19590
+ * Stage 1).
19591
+ *
19592
+ * Algorithm: a merged PR flips every chat bound to it into the user's
19593
+ * archived view, with no inspection of sibling PR state. Multi-PR chats can
19594
+ * be temporarily archived while siblings are still open; any later activity
19595
+ * on those siblings produces a normal delivery message, which the existing
19596
+ * chat-projection auto-revives back to `active`. This trades perfect timing
19597
+ * for zero local state, no GitHub API calls, and no schema changes.
19598
+ *
19599
+ * Per-user safety: writes use an UPSERT guarded with
19600
+ * `setWhere = engagement_status = 'active'`, so only the implicit-active or
19601
+ * explicitly-active rows flip. User-manually `deleted` and already-`archived`
19602
+ * rows are left alone. Idempotent under GitHub webhook retries.
19603
+ */
19604
+ const { ACTIVE, ARCHIVED } = CHAT_ENGAGEMENT_STATUSES;
19605
+ async function archiveChatsForMergedPr(db, input) {
19606
+ if (!input.repoFullName || !Number.isFinite(input.prNumber) || input.prNumber <= 0) return {
19607
+ chats: 0,
19608
+ rowsConsidered: 0
19609
+ };
19610
+ const entityKey = `${input.repoFullName}#${input.prNumber}`;
19611
+ const rows = await db.select({
19612
+ chatId: githubEntityChatMappings.chatId,
19613
+ humanAgentId: githubEntityChatMappings.humanAgentId
19614
+ }).from(githubEntityChatMappings).where(and(eq(githubEntityChatMappings.organizationId, input.organizationId), eq(githubEntityChatMappings.entityType, "pull_request"), eq(githubEntityChatMappings.entityKey, entityKey)));
19615
+ if (rows.length === 0) return {
19616
+ chats: 0,
19617
+ rowsConsidered: 0
19618
+ };
19619
+ const seen = /* @__PURE__ */ new Set();
19620
+ const targets = [];
19621
+ for (const row of rows) {
19622
+ const key = `${row.chatId}|${row.humanAgentId}`;
19623
+ if (seen.has(key)) continue;
19624
+ seen.add(key);
19625
+ targets.push(row);
19626
+ }
19627
+ for (const { chatId, humanAgentId } of targets) await db.insert(chatUserState).values({
19628
+ chatId,
19629
+ agentId: humanAgentId,
19630
+ unreadMentionCount: 0,
19631
+ engagementStatus: ARCHIVED
19632
+ }).onConflictDoUpdate({
19633
+ target: [chatUserState.chatId, chatUserState.agentId],
19634
+ set: { engagementStatus: ARCHIVED },
19635
+ setWhere: eq(chatUserState.engagementStatus, ACTIVE)
19636
+ });
19637
+ return {
19638
+ chats: new Set(targets.map((t) => t.chatId)).size,
19639
+ rowsConsidered: targets.length
19640
+ };
19641
+ }
19144
19642
  function evaluateDelegateTarget(target, sourceOrgId) {
19145
19643
  if (!target) return "not_found";
19146
19644
  if (target.organizationId !== sourceOrgId) return "cross_org";
@@ -20286,6 +20784,28 @@ async function githubAppWebhookRoutes(app) {
20286
20784
  };
20287
20785
  const deliveryHeader = request.headers["x-github-delivery"];
20288
20786
  const deliveryId = typeof deliveryHeader === "string" && deliveryHeader.length > 0 ? deliveryHeader : null;
20787
+ if (eventType === "pull_request" && isRecord(payload) && payload.action === "closed") {
20788
+ const pr = isRecord(payload.pull_request) ? payload.pull_request : null;
20789
+ const repoFullName = readString$1((isRecord(payload.repository) ? payload.repository : null)?.full_name);
20790
+ const prNumber = readNumber$1(pr?.number);
20791
+ if (pr?.merged === true && repoFullName && prNumber !== null) try {
20792
+ const stats = await archiveChatsForMergedPr(app.db, {
20793
+ organizationId: installation.hubOrganizationId,
20794
+ repoFullName,
20795
+ prNumber
20796
+ });
20797
+ log$1.info({
20798
+ entityKey: `${repoFullName}#${prNumber}`,
20799
+ ...stats
20800
+ }, "auto-archived chats on PR merge");
20801
+ } catch (err) {
20802
+ log$1.error({
20803
+ err,
20804
+ repoFullName,
20805
+ prNumber
20806
+ }, "failed to auto-archive chats on PR merge");
20807
+ }
20808
+ }
20289
20809
  const event = normalizeGithubEvent(eventType, payload, source, deliveryId);
20290
20810
  if (!event) {
20291
20811
  log$1.debug({
@@ -21886,12 +22406,14 @@ async function buildApp(config) {
21886
22406
  await api.register(githubOauthRoutes, { prefix: "/auth/github" });
21887
22407
  await api.register(publicInvitationRoutes, { prefix: "/invitations" });
21888
22408
  await api.register(bootstrapConfigRoutes, { prefix: "/bootstrap" });
22409
+ await api.register(publicAgentAvatarRoutes, { prefix: "/agents" });
21889
22410
  await api.register(userScope("contextTreeScope", async (scope) => {
21890
22411
  await scope.register(contextTreeInfoRoutes);
21891
22412
  await scope.register(contextTreeSnapshotRoutes);
21892
22413
  }), { prefix: "/context-tree" });
21893
22414
  await api.register(userScope("meRoutesScope", async (scope) => {
21894
22415
  await scope.register(meRoutes);
22416
+ await scope.register(meDocsRoutes, { workspacesRoot: config.workspace.root });
21895
22417
  }), { prefix: "" });
21896
22418
  await api.register(userScope("orgsScope", async (scope) => {
21897
22419
  await scope.register(orgIdentityRoutes);