@agent-team-foundation/first-tree-hub 0.12.10 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,12 @@
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-CW73oEYn.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 onboardingEventSchema, A as createOrgFromMeSchema, B as githubStartQuerySchema, C as contextTreeSnapshotSchema, Ct as updateClientCapabilitiesSchema, D as createChatSchema, E as createAgentSchema, Et as wsAuthFrameSchema, G as isOrgSettingNamespace, H as inboxAckFrameSchema, I as githubAppInstallationClaimBodySchema, J as joinByInvitationSchema, K as isRedactedEnvValue, L as githubAppInstallationPermissionsSchema$1, M as defaultRuntimeConfigPayload, N as delegateFeishuUserSchema, O as createMeChatSchema, P as dryRunAgentRuntimeConfigSchema, Q as notificationQuerySchema, R as githubCallbackQuerySchema, S as connectTokenExchangeSchema, St as updateChatSchema, T as createAdapterMappingSchema, Tt as updateOrganizationSchema, U as inboxDeliverFrameSchema$1, V as imageInlineContentSchema, W as inboxPollQuerySchema, X as loginSchema, Y as listMeChatsQuerySchema, Z as messageSourceSchema$1, _ as agentRuntimeConfigPayloadSchema$1, _t as stripCode, a as AGENT_TYPES, at as rebindAgentSchema, bt as updateAgentRuntimeConfigSchema, ct as safeRedirectPath, d as ORG_SETTINGS_NAMESPACES$1, dt as sendMessageSchema, et as paginationQuerySchema, f as WS_AUTH_FRAME_TIMEOUT_MS, ft as sendToAgentSchema, g as agentPinnedMessageSchema$1, gt as sessionStateMessageSchema, h as agentBindRequestSchema, ht as sessionReconcileRequestSchema, i as AGENT_STATUSES, k as createMemberSchema, l as MENTION_REGEX, m as addParticipantSchema, mt as sessionEventSchema$1, n as AGENT_NAME_REGEX$1, nt as patchOnboardingSchema, o as AGENT_VISIBILITY, ot as refreshTokenSchema, p as addMeChatParticipantsSchema, pt as sessionEventMessageSchema, q as isReservedAgentName$1, r as AGENT_SELECTOR_HEADER$1, s as CHAT_ENGAGEMENT_STATUSES, st as runtimeStateMessageSchema, t as AGENT_BIND_REJECT_REASONS, tt as patchChatEngagementSchema, u as NOTIFICATION_TYPES, ut as selfServiceFeishuBotSchema, v as agentTypeSchema$1, vt as submitQuestionAnswerSchema, w as createAdapterConfigSchema, wt as updateMemberSchema, x as clientRegisterSchema, xt as updateAgentSchema, y as chatMetadataSchema$1, yt as updateAdapterConfigSchema, z as githubDevCallbackQuerySchema } from "./dist-B1GHzMLc.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-CF5evtJt-B0NTIVPt.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";
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-BViGcaUC-CZb2Svgh.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-Bg0TRiyx-BsZH4GCS.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";
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";
@@ -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";
@@ -386,6 +386,7 @@ z.object({
386
386
  const PROMPT_APPEND_MAX_LENGTH = 32e3;
387
387
  const MCP_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
388
388
  const ENV_KEY_PATTERN = /^[A-Z][A-Z0-9_]*$/;
389
+ const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:/;
389
390
  const promptConfigSchema = z.object({ append: z.string().max(PROMPT_APPEND_MAX_LENGTH).default("") });
390
391
  const mcpStdioServerSchema = z.object({
391
392
  name: z.string().regex(MCP_NAME_PATTERN, "MCP name must match /^[a-z0-9][a-z0-9_-]{0,63}$/i"),
@@ -415,10 +416,38 @@ const envEntrySchema = z.object({
415
416
  value: z.string(),
416
417
  sensitive: z.boolean().default(false)
417
418
  });
419
+ function hasControlCharacters(value) {
420
+ for (let idx = 0; idx < value.length; idx++) {
421
+ const code = value.charCodeAt(idx);
422
+ if (code <= 31 || code === 127) return true;
423
+ }
424
+ return false;
425
+ }
426
+ function getRepoLocalPathSafetyError(localPath) {
427
+ if (localPath.length === 0) return "Git repo local path must not be empty";
428
+ if (localPath.trim() !== localPath) return "Git repo local path must not have leading or trailing whitespace";
429
+ if (hasControlCharacters(localPath)) return "Git repo local path must not contain control characters";
430
+ if (localPath.includes("\\")) return "Git repo local path must use forward slashes";
431
+ if (localPath.startsWith("/") || WINDOWS_DRIVE_PATH_PATTERN.test(localPath)) return "Git repo local path must be relative";
432
+ const segments = localPath.split("/");
433
+ for (const segment of segments) {
434
+ if (!segment) return "Git repo local path must not contain empty path segments";
435
+ if (segment === "." || segment === "..") return "Git repo local path must not contain dot segments";
436
+ if (segment.trim() !== segment) return "Git repo local path segments must not have leading or trailing whitespace";
437
+ }
438
+ return null;
439
+ }
418
440
  const gitRepoSchema = z.object({
419
441
  url: z.string().min(1),
420
442
  ref: z.string().min(1).optional(),
421
- localPath: z.string().min(1).optional()
443
+ localPath: z.string().min(1).superRefine((localPath, ctx) => {
444
+ const safetyError = getRepoLocalPathSafetyError(localPath);
445
+ if (!safetyError) return;
446
+ ctx.addIssue({
447
+ code: z.ZodIssueCode.custom,
448
+ message: safetyError
449
+ });
450
+ }).optional()
422
451
  });
423
452
  /**
424
453
  * Untagged base shape — 5 user-tunable fields, no `kind` discriminator.
@@ -630,6 +659,16 @@ const agentTypeSchema = z.enum([
630
659
  "autonomous_agent"
631
660
  ]);
632
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
+ ]);
633
672
  const agentSourceSchema = z.enum(["admin-api", "portal"]);
634
673
  z.enum(["active", "suspended"]);
635
674
  /**
@@ -676,7 +715,8 @@ z.object({
676
715
  visibility: agentVisibilitySchema.optional(),
677
716
  metadata: z.record(z.string(), z.unknown()).optional(),
678
717
  managerId: z.string().nullable().optional(),
679
- clientId: z.string().min(1).max(100).nullable().optional()
718
+ clientId: z.string().min(1).max(100).nullable().optional(),
719
+ avatarColorToken: avatarColorTokenSchema.nullable().optional()
680
720
  });
681
721
  z.object({
682
722
  clientId: z.string().min(1).max(100),
@@ -698,6 +738,8 @@ z.object({
698
738
  managerId: z.string().nullable(),
699
739
  clientId: z.string().nullable(),
700
740
  runtimeProvider: runtimeProviderSchema,
741
+ avatarColorToken: z.string().nullable(),
742
+ avatarImageUrl: z.string().nullable(),
701
743
  presenceStatus: presenceStatusSchema.optional(),
702
744
  createdAt: z.string(),
703
745
  updatedAt: z.string()
@@ -759,6 +801,14 @@ const chatMetadataSchema = z.discriminatedUnion("source", [githubChatMetadataSch
759
801
  * sneak through `{ source: "github" }` without the required fields.
760
802
  */
761
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
+ ]);
762
812
  const chatTypeSchema = z.enum(["direct", "group"]);
763
813
  const chatEngagementStatusSchema = z.enum([
764
814
  "active",
@@ -970,6 +1020,11 @@ const contextTreeSummarySchema = z.object({
970
1020
  removedCount: z.number().int().nonnegative(),
971
1021
  changedNodeCount: z.number().int().nonnegative()
972
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
+ });
973
1028
  z.object({
974
1029
  repo: z.string().nullable(),
975
1030
  branch: z.string().nullable(),
@@ -978,6 +1033,7 @@ z.object({
978
1033
  snapshotStatus: contextTreeSnapshotStatusSchema,
979
1034
  contextStatus: contextTreeStatusSchema,
980
1035
  summary: contextTreeSummarySchema,
1036
+ usage: contextTreeUsageSummarySchema,
981
1037
  updates: z.array(contextTreeUpdateSchema),
982
1038
  nodes: z.array(contextTreeNodeSchema),
983
1039
  edges: z.array(contextTreeEdgeSchema),
@@ -1254,12 +1310,35 @@ z.object({
1254
1310
  cursor: z.string().optional(),
1255
1311
  limit: z.coerce.number().int().min(1).max(200).default(50),
1256
1312
  filter: meChatFilterSchema.default("all"),
1257
- engagement: chatEngagementViewSchema.default("active")
1313
+ engagement: chatEngagementViewSchema.default("active"),
1314
+ source: chatSourceSchema.optional()
1258
1315
  });
1259
1316
  const meChatParticipantSchema = z.object({
1260
1317
  agentId: z.string(),
1261
1318
  displayName: z.string(),
1262
- type: z.string()
1319
+ type: z.string(),
1320
+ avatarColorToken: z.string().nullable(),
1321
+ avatarImageUrl: z.string().nullable()
1322
+ });
1323
+ /**
1324
+ * Live activity hint surfaced in the conversation row's time slot. Derived
1325
+ * server-side from the latest `session_events` row for the chat. See
1326
+ * `MeChatRow.liveActivity` for the lifecycle rules.
1327
+ *
1328
+ * `kind` is intentionally narrower than the full `sessionEventKind` enum:
1329
+ * `turn_end` / `error` produce `liveActivity: null` rather than a live
1330
+ * indicator.
1331
+ */
1332
+ const liveActivityKindSchema = z.enum([
1333
+ "tool_call",
1334
+ "thinking",
1335
+ "assistant_text"
1336
+ ]);
1337
+ const liveActivitySchema = z.object({
1338
+ agentId: z.string(),
1339
+ kind: liveActivityKindSchema,
1340
+ label: z.string(),
1341
+ startedAt: z.string()
1263
1342
  });
1264
1343
  const meChatRowSchema = z.object({
1265
1344
  chatId: z.string(),
@@ -1274,7 +1353,8 @@ const meChatRowSchema = z.object({
1274
1353
  unreadMentionCount: z.number().int(),
1275
1354
  canReply: z.boolean(),
1276
1355
  engagementStatus: chatEngagementStatusSchema,
1277
- workingAgentIds: z.array(z.string())
1356
+ engagedAgentIds: z.array(z.string()),
1357
+ liveActivity: liveActivitySchema.nullable()
1278
1358
  });
1279
1359
  z.object({
1280
1360
  rows: z.array(meChatRowSchema),
@@ -1298,6 +1378,47 @@ z.object({
1298
1378
  type: z.literal("chat:message"),
1299
1379
  chatId: z.string()
1300
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
+ });
1301
1422
  z.enum([
1302
1423
  "connect",
1303
1424
  "create_agent",
@@ -1349,7 +1470,8 @@ z.object({
1349
1470
  organizationId: z.string(),
1350
1471
  organizationName: z.string(),
1351
1472
  role: z.enum(["admin", "member"]),
1352
- agentId: z.string()
1473
+ agentId: z.string(),
1474
+ orgHasOtherMembers: z.boolean()
1353
1475
  });
1354
1476
  const memberRoleSchema = z.enum(["admin", "member"]);
1355
1477
  const memberSchema = z.object({
@@ -1724,7 +1846,8 @@ const sessionEventKind = z.enum([
1724
1846
  "error",
1725
1847
  "assistant_text",
1726
1848
  "thinking",
1727
- "turn_end"
1849
+ "turn_end",
1850
+ "context_tree_usage"
1728
1851
  ]);
1729
1852
  const toolCallEventPayload = z.object({
1730
1853
  toolUseId: z.string(),
@@ -1766,6 +1889,10 @@ const thinkingEventPayload = z.object({});
1766
1889
  * completed turns to show only the final result message.
1767
1890
  */
1768
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
+ });
1769
1896
  const sessionEventSchema = z.discriminatedUnion("kind", [
1770
1897
  z.object({
1771
1898
  kind: z.literal("tool_call"),
@@ -1786,6 +1913,10 @@ const sessionEventSchema = z.discriminatedUnion("kind", [
1786
1913
  z.object({
1787
1914
  kind: z.literal("turn_end"),
1788
1915
  payload: turnEndEventPayload
1916
+ }),
1917
+ z.object({
1918
+ kind: z.literal("context_tree_usage"),
1919
+ payload: contextTreeUsageEventPayload
1789
1920
  })
1790
1921
  ]);
1791
1922
  z.object({
@@ -1799,7 +1930,8 @@ z.object({
1799
1930
  errorEventPayload,
1800
1931
  assistantTextEventPayload,
1801
1932
  thinkingEventPayload,
1802
- turnEndEventPayload
1933
+ turnEndEventPayload,
1934
+ contextTreeUsageEventPayload
1803
1935
  ]),
1804
1936
  createdAt: z.string()
1805
1937
  });
@@ -1968,6 +2100,7 @@ defineConfig({
1968
2100
  host: field(z.string().default("127.0.0.1"), { env: "FIRST_TREE_HUB_HOST" }),
1969
2101
  publicUrl: field(z.string().optional(), { env: "FIRST_TREE_HUB_PUBLIC_URL" })
1970
2102
  },
2103
+ workspace: { root: field(z.string().default(join(DEFAULT_DATA_DIR, "workspaces")), { env: "FIRST_TREE_HUB_WORKSPACES_ROOT" }) },
1971
2104
  secrets: {
1972
2105
  jwtSecret: field(z.string(), {
1973
2106
  env: "FIRST_TREE_HUB_JWT_SECRET",
@@ -3309,7 +3442,7 @@ You are running inside **Agent Hub**, a messaging platform for agent teams.
3309
3442
 
3310
3443
  - Messages from other team members arrive as your prompt input
3311
3444
  - Each message includes a \`[From: <agent-name>]\` header — that name is also
3312
- what you pass back to \`agent send\` to reply to or address that agent
3445
+ what you pass back to \`chat send\` to reply to or address that agent
3313
3446
  - **Your final text response is automatically delivered** to the chat — just respond normally
3314
3447
  - For **proactive communication** (sending to other agents, other chats, or structured data),
3315
3448
  use the \`first-tree-hub\` CLI below
@@ -3333,7 +3466,7 @@ The \`first-tree-hub\` CLI reads these automatically — no extra setup needed.
3333
3466
 
3334
3467
  ## Sending Messages
3335
3468
 
3336
- Use the \`first-tree-hub agent send\` CLI — it reads the env vars above and
3469
+ Use the \`first-tree-hub chat send\` CLI — it reads the env vars above and
3337
3470
  attaches the \`Authorization\` + \`X-Agent-Id\` headers automatically:
3338
3471
 
3339
3472
  \`\`\`bash
@@ -3345,28 +3478,38 @@ attaches the \`Authorization\` + \`X-Agent-Id\` headers automatically:
3345
3478
  # the case in a group chat where someone @-mentioned you to talk to them),
3346
3479
  # the message stays in that chat. Otherwise it falls back to a direct chat
3347
3480
  # between you and the recipient. You don't need to think about which.
3348
- first-tree-hub agent send <agentName> "your message"
3481
+ first-tree-hub chat send <agentName> "your message"
3349
3482
 
3350
3483
  # Send into a specific chat by id — use this only when you explicitly want
3351
3484
  # to address a chat your current session is NOT bound to.
3352
- first-tree-hub agent send --chat <chatId> "your message"
3485
+ first-tree-hub chat send --chat <chatId> "your message"
3353
3486
 
3354
3487
  # Send markdown (default format is text)
3355
- first-tree-hub agent send <agentName> -f markdown "**bold** message"
3488
+ first-tree-hub chat send <agentName> -f markdown "**bold** message"
3356
3489
 
3357
3490
  # Reply to a specific message
3358
- first-tree-hub agent send <agentName> --reply-to <messageId> "reply content"
3491
+ first-tree-hub chat send <agentName> --reply-to <messageId> "reply content"
3359
3492
 
3360
3493
  # Pipe long content via stdin (recommended for special characters)
3361
- echo "long message body" | first-tree-hub agent send <agentName>
3494
+ echo "long message body" | first-tree-hub chat send <agentName>
3362
3495
  \`\`\`
3363
3496
 
3364
- > Agent uuids appear in \`agent chats\`, chat history, and participant lists,
3365
- > but they are NOT accepted by \`agent send\` — always use the name.
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.
3366
3499
 
3367
3500
  For content with quotes, \`$\`, backticks, or newlines, prefer stdin to avoid shell escaping issues.
3368
3501
  `;
3369
3502
  }
3503
+ function resolveGitRepoTargetPath(workspace, localPath) {
3504
+ const safetyError = getRepoLocalPathSafetyError(localPath);
3505
+ if (safetyError) throw new Error(`Unsafe git repo localPath "${localPath}": ${safetyError}`);
3506
+ const workspaceRoot = resolve(workspace);
3507
+ const targetPath = resolve(workspaceRoot, localPath);
3508
+ const relativeTarget = relative(workspaceRoot, targetPath);
3509
+ const escapesWorkspace = relativeTarget === ".." || relativeTarget.startsWith(`..${sep}`);
3510
+ if (!relativeTarget || escapesWorkspace || isAbsolute(relativeTarget)) throw new Error(`Unsafe git repo localPath "${localPath}": resolved path escapes the session workspace`);
3511
+ return targetPath;
3512
+ }
3370
3513
  const DEFAULT_CLONE_TIMEOUT_MS = 300 * 1e3;
3371
3514
  const FETCH_REFSPEC = "+refs/heads/*:refs/remotes/origin/*";
3372
3515
  const SESSION_BRANCH_PREFIX = "hub-session";
@@ -4631,6 +4774,7 @@ const createClaudeCodeHandler = (config) => {
4631
4774
  /** Worktrees materialised for this session — each entry removed on shutdown. */
4632
4775
  const ownedWorktrees = [];
4633
4776
  async function toSDKUserMessage(message, sessionCtx, sessionId) {
4777
+ emitContextTreeUsage(sessionCtx);
4634
4778
  if (message.format === "file") {
4635
4779
  const senderLabel = message.senderId ? await sessionCtx.resolveSenderLabel(message.senderId) : "";
4636
4780
  const prefix = senderLabel ? `[From: ${senderLabel}]\n\n` : "";
@@ -4693,6 +4837,16 @@ const createClaudeCodeHandler = (config) => {
4693
4837
  session_id: sessionId
4694
4838
  };
4695
4839
  }
4840
+ function emitContextTreeUsage(sessionCtx) {
4841
+ if (!contextTreePath) return;
4842
+ sessionCtx.emitEvent({
4843
+ kind: "context_tree_usage",
4844
+ payload: {
4845
+ purpose: "design_decision",
4846
+ treeRepoUrl: contextTreeRepoUrl
4847
+ }
4848
+ });
4849
+ }
4696
4850
  /**
4697
4851
  * Build env for the child Claude Code process.
4698
4852
  *
@@ -4994,7 +5148,7 @@ const createClaudeCodeHandler = (config) => {
4994
5148
  if (!gitMirrorManager || !payload?.gitRepos?.length) return;
4995
5149
  for (const repo of payload.gitRepos) {
4996
5150
  const localPath = repo.localPath ?? deriveRepoLocalPath(repo.url);
4997
- const targetPath = join(workspace, localPath);
5151
+ const targetPath = resolveGitRepoTargetPath(workspace, localPath);
4998
5152
  sessionCtx.log(`Git: preparing ${repo.url} → ${localPath}${repo.ref ? ` @ ${repo.ref}` : ""}`);
4999
5153
  const mirror = await gitMirrorManager.ensureMirror(repo.url);
5000
5154
  if (mirror.cloned) sessionCtx.log(`Git: cloned ${repo.url} in ${mirror.elapsedMs}ms`);
@@ -5205,7 +5359,7 @@ function buildCodexThreadOptions(payload, workspaceCwd) {
5205
5359
  for (const repo of payload.gitRepos) {
5206
5360
  const localPath = repo.localPath ?? deriveRepoLocalPath(repo.url);
5207
5361
  if (!localPath) continue;
5208
- additionalDirectories.push(join(workspaceCwd, localPath));
5362
+ additionalDirectories.push(resolveGitRepoTargetPath(workspaceCwd, localPath));
5209
5363
  }
5210
5364
  const opts = {
5211
5365
  workingDirectory: workspaceCwd,
@@ -5296,13 +5450,23 @@ const createCodexHandler = (config) => {
5296
5450
  function toCodexInput(message, sessionCtx) {
5297
5451
  return sessionCtx.formatInboundContent(message).then((text) => text);
5298
5452
  }
5453
+ function emitContextTreeUsage(sessionCtx) {
5454
+ if (!contextTreePath) return;
5455
+ sessionCtx.emitEvent({
5456
+ kind: "context_tree_usage",
5457
+ payload: {
5458
+ purpose: "design_decision",
5459
+ treeRepoUrl: contextTreeRepoUrl
5460
+ }
5461
+ });
5462
+ }
5299
5463
  async function prepareGitWorktrees(payload, workspaceCwd, sessionCtx) {
5300
5464
  if (!gitMirrorManager) return;
5301
5465
  const branchAgentKey = agentName ?? sessionCtx.agent.agentId;
5302
5466
  for (const repo of payload.gitRepos) {
5303
5467
  const localPath = repo.localPath ?? deriveRepoLocalPath(repo.url);
5304
5468
  if (!localPath) continue;
5305
- const targetPath = join(workspaceCwd, localPath);
5469
+ const targetPath = resolveGitRepoTargetPath(workspaceCwd, localPath);
5306
5470
  if (existsSync(targetPath)) continue;
5307
5471
  try {
5308
5472
  await gitMirrorManager.ensureMirror(repo.url);
@@ -5424,6 +5588,7 @@ const createCodexHandler = (config) => {
5424
5588
  if (!activeThread) return;
5425
5589
  const abort = new AbortController();
5426
5590
  currentAbort = abort;
5591
+ emitContextTreeUsage(sessionCtx);
5427
5592
  sessionCtx.setRuntimeState("working");
5428
5593
  const assistantTexts = [];
5429
5594
  let turnFailed = false;
@@ -5908,13 +6073,17 @@ var Deduplicator = class {
5908
6073
  };
5909
6074
  function createResultSink(deps) {
5910
6075
  async function buildMetadata(trigger) {
5911
- if (!trigger || trigger.senderId === deps.agent.agentId) return void 0;
5912
- const participants = await deps.participants.get();
5913
- if (participants.length <= 2) {
5914
- const peer = participants.find((p) => p.agentId === trigger.senderId);
5915
- if (peer && peer.mode !== "mention_only") return void 0;
5916
- }
5917
- return { mentions: [trigger.senderId] };
6076
+ const metadata = {};
6077
+ const documentBasePath = await deps.getDocumentBasePath?.();
6078
+ 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
+ return Object.keys(metadata).length > 0 ? metadata : void 0;
5918
6087
  }
5919
6088
  return async function forwardResult(text) {
5920
6089
  if (text.trim().length === 0) {
@@ -6005,6 +6174,16 @@ var SessionRegistry = class {
6005
6174
  }
6006
6175
  }
6007
6176
  };
6177
+ function documentBasePathFromRuntimeConfig(payload) {
6178
+ if (payload.gitRepos.length !== 1) return null;
6179
+ const repo = payload.gitRepos[0];
6180
+ if (!repo) return null;
6181
+ const localPath = repoLocalPath(repo).trim();
6182
+ return localPath.length > 0 ? localPath : null;
6183
+ }
6184
+ function repoLocalPath(repo) {
6185
+ return repo.localPath ?? deriveRepoLocalPath(repo.url);
6186
+ }
6008
6187
  /**
6009
6188
  * Manages per-chat session entries with session-oriented handler lifecycle.
6010
6189
  *
@@ -6494,7 +6673,8 @@ var SessionManager = class {
6494
6673
  this.currentTrigger.delete(chatId);
6495
6674
  },
6496
6675
  log,
6497
- participants
6676
+ participants,
6677
+ getDocumentBasePath: () => this.resolveDocumentBasePath(log)
6498
6678
  });
6499
6679
  const envCtx = {
6500
6680
  sdk: this.config.sdk,
@@ -6522,6 +6702,16 @@ var SessionManager = class {
6522
6702
  resolveSenderLabel: async (senderId) => resolveSenderLabel(senderId, await participants.get())
6523
6703
  };
6524
6704
  }
6705
+ async resolveDocumentBasePath(log) {
6706
+ if (!this.config.agentConfigCache) return null;
6707
+ try {
6708
+ const { payload } = await this.config.agentConfigCache.refreshIfNewer(this.config.agentIdentity.agentId, 0);
6709
+ return documentBasePathFromRuntimeConfig(payload);
6710
+ } catch (err) {
6711
+ log(`document preview base path unavailable: ${err instanceof Error ? err.message : String(err)}`);
6712
+ return null;
6713
+ }
6714
+ }
6525
6715
  /** Update per-session runtime state and recompute aggregate. Only active sessions may update. */
6526
6716
  setSessionRuntimeState(chatId, state) {
6527
6717
  const session = this.sessions.get(chatId);
@@ -7276,7 +7466,7 @@ function fail(code, message, exitCode = 1) {
7276
7466
  //#endregion
7277
7467
  //#region src/core/agent-messaging.ts
7278
7468
  /**
7279
- * Resolve `replyTo` envelope fields for `agent send`. When the CLI is invoked
7469
+ * Resolve `replyTo` envelope fields for `chat send`. When the CLI is invoked
7280
7470
  * from inside a claude-code session (the handler exports
7281
7471
  * `FIRST_TREE_HUB_CHAT_ID` + `FIRST_TREE_HUB_INBOX_ID`), default the reply
7282
7472
  * target to the calling session's own chat so the peer's reply routes back
@@ -7568,7 +7758,7 @@ function rotateClientIdWithBackup(configDir) {
7568
7758
  }
7569
7759
  /**
7570
7760
  * Shared handler for `CLIENT_ORG_MISMATCH` across CLI entry points
7571
- * (`client start` and `client connect --no-service`). Prompts interactively,
7761
+ * (`client start` and `connect <token> --no-service`). Prompts interactively,
7572
7762
  * rotates the local clientId, and always exits the current process — the
7573
7763
  * runtime is already poisoned (wrong clientId in memory), so continuing
7574
7764
  * in-band is not safe. Service-supervised (managed) runs skip the prompt and
@@ -8331,7 +8521,7 @@ function installLaunchd() {
8331
8521
  lastBootstrapErr = res;
8332
8522
  if (attempt < 2) sleepSync(1e3);
8333
8523
  }
8334
- if (lastBootstrapErr) throw new Error(`launchctl bootstrap failed: ${lastBootstrapErr.stderr || `exit ${lastBootstrapErr.code ?? "unknown"}`}\n Command: launchctl bootstrap ${target} ${plistPath}\n Recovery: \`launchctl bootout ${target}/${LAUNCHD_LABEL}\` then \`first-tree-hub client connect <server-url>\`.`);
8524
+ if (lastBootstrapErr) throw new Error(`launchctl bootstrap failed: ${lastBootstrapErr.stderr || `exit ${lastBootstrapErr.code ?? "unknown"}`}\n Command: launchctl bootstrap ${target} ${plistPath}\n Recovery: \`launchctl bootout ${target}/${LAUNCHD_LABEL}\` then \`first-tree-hub connect <token>\`.`);
8335
8525
  const enableRes = runCapture("launchctl", ["enable", `${target}/${LAUNCHD_LABEL}`], 5e3);
8336
8526
  if (!enableRes.ok) print.line(` warning: launchctl enable: ${enableRes.stderr || `exit ${enableRes.code ?? "unknown"}`}\n`);
8337
8527
  const { state, pid, detail } = launchdState();
@@ -8491,7 +8681,7 @@ function installSystemd() {
8491
8681
  "--now",
8492
8682
  SYSTEMD_UNIT
8493
8683
  ], 1e4);
8494
- if (!enableRes.ok) throw new Error(`systemctl --user enable --now ${SYSTEMD_UNIT} failed: ${enableRes.stderr || `exit ${enableRes.code ?? "unknown"}`}\n Recovery: \`systemctl --user stop ${SYSTEMD_UNIT}\` then \`first-tree-hub client connect <server-url>\`.`);
8684
+ if (!enableRes.ok) throw new Error(`systemctl --user enable --now ${SYSTEMD_UNIT} failed: ${enableRes.stderr || `exit ${enableRes.code ?? "unknown"}`}\n Recovery: \`systemctl --user stop ${SYSTEMD_UNIT}\` then \`first-tree-hub connect <token>\`.`);
8495
8685
  const { state, pid, detail } = systemdState();
8496
8686
  return {
8497
8687
  platform: "systemd",
@@ -9003,7 +9193,7 @@ function checkBackgroundService() {
9003
9193
  return {
9004
9194
  label: "Background service",
9005
9195
  ok: false,
9006
- detail: "not installed — re-run `first-tree-hub client connect <url>` to install"
9196
+ detail: "not installed — re-run `first-tree-hub connect <token>` to install"
9007
9197
  };
9008
9198
  }
9009
9199
  async function checkWebSocket() {
@@ -9297,7 +9487,7 @@ function runHomeMigration() {
9297
9487
  }
9298
9488
  print.line(`[first-tree-hub] Copied client home to new layout: ${result.from} → ${result.to}\n (Legacy directory preserved as a backup — delete it manually once you've verified the new location works.)\n`);
9299
9489
  if (process.argv.includes("--no-interactive")) {
9300
- print.line("[first-tree-hub] Note: running as background service — skipped auto re-register to avoid self-termination.\n Service paths will refresh on the next `first-tree-hub client connect <url>`.\n");
9490
+ print.line("[first-tree-hub] Note: running as background service — skipped auto re-register to avoid self-termination.\n Service paths will refresh on the next `first-tree-hub connect <token>`.\n");
9301
9491
  return;
9302
9492
  }
9303
9493
  const status = getClientServiceStatus();
@@ -9307,7 +9497,7 @@ function runHomeMigration() {
9307
9497
  print.line(`[first-tree-hub] Re-registered background service with new home paths.\n`);
9308
9498
  } catch (err) {
9309
9499
  const msg = err instanceof Error ? err.message : String(err);
9310
- print.line(`[first-tree-hub] WARNING: home migration succeeded but re-registering the background service failed: ${msg}\n Re-run \`first-tree-hub client connect <url>\` to refresh service paths.\n`);
9500
+ print.line(`[first-tree-hub] WARNING: home migration succeeded but re-registering the background service failed: ${msg}\n Re-run \`first-tree-hub connect <token>\` to refresh service paths.\n`);
9311
9501
  }
9312
9502
  }
9313
9503
  //#endregion
@@ -9339,7 +9529,7 @@ async function onboardCheck(args) {
9339
9529
  key: "connect",
9340
9530
  label: "Signed in",
9341
9531
  status: "missing_required",
9342
- hint: "Run `first-tree-hub client connect <server-url>` first"
9532
+ hint: "Run `first-tree-hub connect <token>` first"
9343
9533
  });
9344
9534
  try {
9345
9535
  const serverUrl = resolveServerUrl(args.server);
@@ -9493,7 +9683,7 @@ async function onboardCreate(args) {
9493
9683
  }
9494
9684
  const runtimeAgent = args.type === "human" ? args.assistant : args.id;
9495
9685
  if (args.feishuBotAppId && args.feishuBotAppSecret) {
9496
- const { bindFeishuBot } = await import("./feishu-30vUx69l.mjs").then((n) => n.r);
9686
+ const { bindFeishuBot } = await import("./feishu-BGx71p5s.mjs").then((n) => n.r);
9497
9687
  const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
9498
9688
  if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
9499
9689
  else {
@@ -9580,13 +9770,13 @@ async function promptMissingFields(options) {
9580
9770
  * add (there's nothing sensible to key the local dir on).
9581
9771
  */
9582
9772
  async function promptAddAgent(opts = {}) {
9583
- if (loadCredentials() === null) throw new Error("Not connected. Run `first-tree-hub client connect <server-url>` first.");
9773
+ if (loadCredentials() === null) throw new Error("Not connected. Run `first-tree-hub connect <token>` first.");
9584
9774
  let serverUrl;
9585
9775
  try {
9586
9776
  serverUrl = resolveServerUrl(process.env.FIRST_TREE_HUB_SERVER_URL);
9587
9777
  } catch (err) {
9588
9778
  const msg = err instanceof Error ? err.message : String(err);
9589
- throw new Error(`${msg} Run \`first-tree-hub client connect\` or set FIRST_TREE_HUB_SERVER_URL.`);
9779
+ throw new Error(`${msg} Run \`first-tree-hub connect <token>\` or set FIRST_TREE_HUB_SERVER_URL.`);
9590
9780
  }
9591
9781
  const agentId = opts.agentId ?? await input({
9592
9782
  message: "Agent UUID on the Hub:",
@@ -10706,7 +10896,7 @@ function createFeedbackHandler(config) {
10706
10896
  return { handle };
10707
10897
  }
10708
10898
  //#endregion
10709
- //#region ../server/dist/app-B1wsm8zG.mjs
10899
+ //#region ../server/dist/app-l2iy80P2.mjs
10710
10900
  var import_fastify_opentelemetry = /* @__PURE__ */ __toESM(require_fastify_opentelemetry(), 1);
10711
10901
  init_esm();
10712
10902
  var __defProp = Object.defineProperty;
@@ -11316,6 +11506,21 @@ async function ensureDefaultOrganization(db) {
11316
11506
  */
11317
11507
  const RESERVED_AGENT_NAME_PREFIX = "__";
11318
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
+ /**
11319
11524
  * True iff `clients.metadata.capabilities` is a non-empty object — i.e. the
11320
11525
  * client has reported at least one runtime probe result. Used to distinguish
11321
11526
  * "we don't know what's installed yet" (empty / never reported) from
@@ -11406,7 +11611,7 @@ async function resolveAgentClient(db, data) {
11406
11611
  userId: clients.userId
11407
11612
  }).from(clients).where(eq(clients.id, data.clientId)).limit(1);
11408
11613
  if (!client) throw new BadRequestError(`Client "${data.clientId}" not found`);
11409
- if (!client.userId) throw new BadRequestError(`Client "${data.clientId}" has not been claimed by a user yet. Have the operator run \`first-tree-hub client connect\` on that machine before pinning an agent to it.`);
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.`);
11410
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.`);
11411
11616
  return client.id;
11412
11617
  }
@@ -11574,6 +11779,8 @@ async function listAgentsForAdmin(db, scope, limit, cursor) {
11574
11779
  managerId: agents.managerId,
11575
11780
  clientId: agents.clientId,
11576
11781
  runtimeProvider: agents.runtimeProvider,
11782
+ avatarColorToken: agents.avatarColorToken,
11783
+ avatarImageUpdatedAt: agents.avatarImageUpdatedAt,
11577
11784
  createdAt: agents.createdAt,
11578
11785
  updatedAt: agents.updatedAt,
11579
11786
  presenceStatus: agentPresence.status,
@@ -11612,6 +11819,8 @@ async function listAgentsForMember(db, scope, limit, cursor, type) {
11612
11819
  managerId: agents.managerId,
11613
11820
  clientId: agents.clientId,
11614
11821
  runtimeProvider: agents.runtimeProvider,
11822
+ avatarColorToken: agents.avatarColorToken,
11823
+ avatarImageUpdatedAt: agents.avatarImageUpdatedAt,
11615
11824
  createdAt: agents.createdAt,
11616
11825
  updatedAt: agents.updatedAt,
11617
11826
  presenceStatus: agentPresence.status,
@@ -11648,6 +11857,7 @@ async function updateAgent(db, uuid, data) {
11648
11857
  }
11649
11858
  if (data.visibility !== void 0) updates.visibility = data.visibility;
11650
11859
  if (data.metadata !== void 0) updates.metadata = data.metadata;
11860
+ if (data.avatarColorToken !== void 0) updates.avatarColorToken = data.avatarColorToken;
11651
11861
  if (data.managerId !== void 0) {
11652
11862
  if (data.managerId === null) throw new BadRequestError("managerId cannot be cleared — every agent must have a manager");
11653
11863
  const [manager] = await db.select({
@@ -11754,6 +11964,63 @@ async function deleteAgent(db, uuid) {
11754
11964
  if (!agent) throw new Error("Unexpected: UPDATE RETURNING produced no row");
11755
11965
  return agent;
11756
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
+ }
11757
12024
  const log$5 = createLogger$1("AgentFeishuBot");
11758
12025
  async function agentFeishuBotRoutes(app) {
11759
12026
  /**
@@ -12964,8 +13231,9 @@ async function pushToWebhook(notification) {
12964
13231
  }
12965
13232
  /**
12966
13233
  * Session events — structured event stream per (agent, chat) session.
12967
- * `kind` is 'tool_call' | 'error'; the payload shape is enforced by the
12968
- * service layer via Zod (no FK / CHECK on this table per project rule).
13234
+ * `kind` is one of `'tool_call' | 'error' | 'assistant_text' | 'thinking'
13235
+ * | 'turn_end'`; payload shape per kind is enforced by the service layer
13236
+ * via Zod (no FK / CHECK on this table per project rule).
12969
13237
  *
12970
13238
  * `seq` is monotonic per (agent_id, chat_id). The single-writer invariant
12971
13239
  * in the client-side session-manager guarantees ordering; the service wraps
@@ -13061,6 +13329,18 @@ async function listEvents(db, agentId, chatId, options) {
13061
13329
  async function clearEvents(db, agentId, chatId) {
13062
13330
  await db.delete(sessionEvents).where(and(eq(sessionEvents.agentId, agentId), eq(sessionEvents.chatId, chatId)));
13063
13331
  }
13332
+ async function summarizeContextTreeUsage(db, organizationId, windowDays) {
13333
+ const since = /* @__PURE__ */ new Date(Date.now() - windowDays * 24 * 60 * 60 * 1e3);
13334
+ const [row] = await db.select({
13335
+ agentCount: sql`count(distinct ${sessionEvents.agentId})::int`,
13336
+ usageCount: sql`count(*)::int`
13337
+ }).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)));
13338
+ return {
13339
+ windowDays,
13340
+ agentCount: row?.agentCount ?? 0,
13341
+ usageCount: row?.usageCount ?? 0
13342
+ };
13343
+ }
13064
13344
  /**
13065
13345
  * Default per-agent in-flight cap when `server.inbox.maxInFlightPerAgent` is
13066
13346
  * unset. Mirrors the schema default so a hub running without an explicit
@@ -13623,9 +13903,11 @@ function clientWsRoutes(notifier, instanceId) {
13623
13903
  return;
13624
13904
  }
13625
13905
  const payload = sessionEventMessageSchema.parse(msg);
13906
+ const boundInfo = boundAgents.get(agentId);
13626
13907
  chainSessionOp(agentId, payload.chatId, async () => {
13627
13908
  try {
13628
13909
  await appendEvent(app.db, agentId, payload.chatId, payload.event);
13910
+ if (boundInfo) notifier.notifySessionEvent(agentId, payload.chatId, payload.event.kind, boundInfo.organizationId).catch(() => {});
13629
13911
  } catch (err) {
13630
13912
  socket.send(JSON.stringify({
13631
13913
  type: "error",
@@ -13709,6 +13991,22 @@ async function agentActivityRoutes(app) {
13709
13991
  });
13710
13992
  }
13711
13993
  /**
13994
+ * Project a DB agent row into its wire shape. Strips the inline image
13995
+ * `avatarImageData` (large bytea, only meant for the image-serve route)
13996
+ * and synthesises the public `avatarImageUrl` from the upload timestamp.
13997
+ * `createdAt`/`updatedAt` are coerced to ISO strings so the response is
13998
+ * pure JSON.
13999
+ */
14000
+ function serializeAgent(agent) {
14001
+ const { avatarImageData: _data, avatarImageMime: _mime, avatarImageUpdatedAt, createdAt, updatedAt, ...rest } = agent;
14002
+ return {
14003
+ ...rest,
14004
+ createdAt: createdAt.toISOString(),
14005
+ updatedAt: updatedAt.toISOString(),
14006
+ avatarImageUrl: agentAvatarImageUrl(agent.uuid, avatarImageUpdatedAt ?? null)
14007
+ };
14008
+ }
14009
+ /**
13712
14010
  * Class C — resource-scoped per-agent routes. Mounted at
13713
14011
  * `/api/v1/agents/:uuid/...`. The agent's UUID locates its org
13714
14012
  * intrinsically; `requireAgentAccess` resolves the caller's membership in
@@ -13737,11 +14035,7 @@ async function agentRoutes(app) {
13737
14035
  }
13738
14036
  app.get("/:uuid", async (request) => {
13739
14037
  const { agent } = await requireAgentAccess(request, app.db, "visible");
13740
- return {
13741
- ...agent,
13742
- createdAt: agent.createdAt.toISOString(),
13743
- updatedAt: agent.updatedAt.toISOString()
13744
- };
14038
+ return serializeAgent(agent);
13745
14039
  });
13746
14040
  app.patch("/:uuid", { config: { otelRecordBody: true } }, async (request) => {
13747
14041
  const { scope } = await requireAgentAccess(request, app.db, "manage");
@@ -13750,22 +14044,14 @@ async function agentRoutes(app) {
13750
14044
  const before = body.clientId !== void 0 ? await getAgent(app.db, request.params.uuid) : null;
13751
14045
  const agent = await updateAgent(app.db, request.params.uuid, body);
13752
14046
  if (before && before.clientId === null && agent.clientId !== null) notifyClientAgentPinned(agent);
13753
- return {
13754
- ...agent,
13755
- createdAt: agent.createdAt.toISOString(),
13756
- updatedAt: agent.updatedAt.toISOString()
13757
- };
14047
+ return serializeAgent(agent);
13758
14048
  });
13759
14049
  app.patch("/:uuid/rebind", { config: { otelRecordBody: true } }, async (request) => {
13760
14050
  await requireAgentAccess(request, app.db, "manage");
13761
14051
  const body = rebindAgentSchema.parse(request.body);
13762
14052
  const agent = await rebindAgent(app.db, request.params.uuid, body);
13763
14053
  notifyClientAgentPinned(agent);
13764
- return {
13765
- ...agent,
13766
- createdAt: agent.createdAt.toISOString(),
13767
- updatedAt: agent.updatedAt.toISOString()
13768
- };
14054
+ return serializeAgent(agent);
13769
14055
  });
13770
14056
  app.post("/:uuid/disconnect", async (request, reply) => {
13771
14057
  await requireAgentAccess(request, app.db, "manage");
@@ -13775,27 +14061,35 @@ async function agentRoutes(app) {
13775
14061
  });
13776
14062
  app.post("/:uuid/suspend", async (request) => {
13777
14063
  await requireAgentAccess(request, app.db, "manage");
13778
- const agent = await suspendAgent(app.db, request.params.uuid);
13779
- return {
13780
- ...agent,
13781
- createdAt: agent.createdAt.toISOString(),
13782
- updatedAt: agent.updatedAt.toISOString()
13783
- };
14064
+ return serializeAgent(await suspendAgent(app.db, request.params.uuid));
13784
14065
  });
13785
14066
  app.post("/:uuid/reactivate", async (request) => {
13786
14067
  await requireAgentAccess(request, app.db, "manage");
13787
- const agent = await reactivateAgent(app.db, request.params.uuid);
13788
- return {
13789
- ...agent,
13790
- createdAt: agent.createdAt.toISOString(),
13791
- updatedAt: agent.updatedAt.toISOString()
13792
- };
14068
+ return serializeAgent(await reactivateAgent(app.db, request.params.uuid));
13793
14069
  });
13794
14070
  app.delete("/:uuid", async (request, reply) => {
13795
14071
  await requireAgentAccess(request, app.db, "manage");
13796
14072
  await deleteAgent(app.db, request.params.uuid);
13797
14073
  return reply.status(204).send();
13798
14074
  });
14075
+ app.addContentTypeParser(/^image\//, { parseAs: "buffer" }, (_req, body, done) => {
14076
+ done(null, body);
14077
+ });
14078
+ app.put("/:uuid/avatar", { bodyLimit: MAX_AVATAR_IMAGE_BYTES + 1024 }, async (request, reply) => {
14079
+ await requireAgentAccess(request, app.db, "manage");
14080
+ const contentType = request.headers["content-type"];
14081
+ if (typeof contentType !== "string" || !contentType.startsWith("image/")) throw new BadRequestError(`Avatar upload requires an image/* Content-Type. Supported: ${SUPPORTED_AVATAR_IMAGE_MIMES.join(", ")}.`);
14082
+ const mime = contentType.split(";")[0]?.trim() ?? "";
14083
+ const body = request.body;
14084
+ if (!Buffer.isBuffer(body)) throw new BadRequestError("Avatar upload body must be raw image bytes.");
14085
+ const updatedAt = await setAgentAvatarImage(app.db, request.params.uuid, body, mime);
14086
+ return reply.status(200).send({ avatarImageUrl: agentAvatarImageUrl(request.params.uuid, updatedAt) });
14087
+ });
14088
+ app.delete("/:uuid/avatar", async (request, reply) => {
14089
+ await requireAgentAccess(request, app.db, "manage");
14090
+ await clearAgentAvatarImage(app.db, request.params.uuid);
14091
+ return reply.status(204).send();
14092
+ });
13799
14093
  app.post("/:uuid/test", async (request, reply) => {
13800
14094
  const { uuid } = request.params;
13801
14095
  const { agent: targetAgent } = await requireAgentAccess(request, app.db, "manage");
@@ -13827,7 +14121,7 @@ async function agentRoutes(app) {
13827
14121
  };
13828
14122
  if (health === "disconnected") return reply.status(200).send({
13829
14123
  status: "offline",
13830
- message: "Agent is not connected. Start the client with: first-tree-hub client connect <server-url>",
14124
+ message: "Agent is not connected. Connect the client with: first-tree-hub connect <token>",
13831
14125
  connection
13832
14126
  });
13833
14127
  if (health === "stale") return reply.status(200).send({
@@ -13904,6 +14198,23 @@ async function agentRoutes(app) {
13904
14198
  });
13905
14199
  }
13906
14200
  /**
14201
+ * Public read-only route for agent avatar images. Mounted outside the
14202
+ * member-JWT auth scope so `<img src>` works without bespoke fetch-and-blob
14203
+ * plumbing. Reading an avatar leaks no more than the agent's UUID — which
14204
+ * is already required to address the route — and the UUID itself is only
14205
+ * exposed through authenticated agent-list calls.
14206
+ */
14207
+ async function publicAgentAvatarRoutes(app) {
14208
+ app.get("/:uuid/avatar", async (request, reply) => {
14209
+ const image = await getAgentAvatarImage(app.db, request.params.uuid);
14210
+ if (!image) return reply.status(404).send({ error: "Avatar not set" });
14211
+ reply.header("Content-Type", image.mime);
14212
+ reply.header("Cache-Control", "public, max-age=2592000, immutable");
14213
+ reply.header("ETag", `"${image.updatedAt.getTime()}"`);
14214
+ return reply.send(image.data);
14215
+ });
14216
+ }
14217
+ /**
13907
14218
  * Class C — `/api/v1/agents/:uuid/config`. Runtime config (system prompt,
13908
14219
  * tools, env) is behavior-sensitive — gated on `manage`, not `visible`.
13909
14220
  */
@@ -14949,6 +15260,22 @@ async function listActiveMemberships(db, userId) {
14949
15260
  }).from(members).innerJoin(organizations, eq(members.organizationId, organizations.id)).where(and(eq(members.userId, userId), eq(members.status, "active"))).orderBy(desc(members.createdAt));
14950
15261
  }
14951
15262
  /**
15263
+ * Count ACTIVE members per org, restricted to the given org IDs. Returns a
15264
+ * Map keyed by `organizationId`; orgs absent from the result simply have
15265
+ * zero active members (shouldn't happen in practice — the caller always
15266
+ * passes orgs the user is a member of — but the Map shape lets callers do
15267
+ * `counts.get(orgId) ?? 0` defensively). Used by `/me` to surface
15268
+ * `orgHasOtherMembers` per membership without N+1 queries.
15269
+ */
15270
+ async function countActiveMembersByOrgs(db, organizationIds) {
15271
+ if (organizationIds.length === 0) return /* @__PURE__ */ new Map();
15272
+ const rows = await db.select({
15273
+ organizationId: members.organizationId,
15274
+ count: sql`count(*)::int`
15275
+ }).from(members).where(and(inArray(members.organizationId, organizationIds), eq(members.status, "active"))).groupBy(members.organizationId);
15276
+ return new Map(rows.map((r) => [r.organizationId, r.count]));
15277
+ }
15278
+ /**
14952
15279
  * Pick the most recently joined active membership — used after OAuth login
14953
15280
  * when the user already has at least one team but no `next` was specified.
14954
15281
  */
@@ -15740,16 +16067,16 @@ function decodeCursor(cursor) {
15740
16067
  return null;
15741
16068
  }
15742
16069
  }
15743
- const { ACTIVE, ARCHIVED, DELETED } = CHAT_ENGAGEMENT_STATUSES;
16070
+ const { ACTIVE: ACTIVE$1, ARCHIVED: ARCHIVED$1, DELETED } = CHAT_ENGAGEMENT_STATUSES;
15744
16071
  /**
15745
16072
  * SQL predicate for each engagement view tab. `deleted` is never a valid view
15746
16073
  * value — deleted rows are reachable only through `GET /chats/:chatId` + the
15747
16074
  * Restore banner on the chat detail page.
15748
16075
  */
15749
16076
  const ENGAGEMENT_VIEW_PREDICATE = {
15750
- active: sql`COALESCE(cus.engagement_status, ${ACTIVE}) = ${ACTIVE}`,
15751
- archived: sql`COALESCE(cus.engagement_status, ${ACTIVE}) = ${ARCHIVED}`,
15752
- all: sql`COALESCE(cus.engagement_status, ${ACTIVE}) IN (${ACTIVE}, ${ARCHIVED})`
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})`
15753
16080
  };
15754
16081
  /**
15755
16082
  * Write the caller's engagement state for this chat. UPSERT into
@@ -15777,7 +16104,26 @@ async function setChatEngagement(db, chatId, agentId, status) {
15777
16104
  */
15778
16105
  async function getCallerEngagement(db, chatId, agentId) {
15779
16106
  const [row] = await db.select({ engagementStatus: chatUserState.engagementStatus }).from(chatUserState).where(and(eq(chatUserState.chatId, chatId), eq(chatUserState.agentId, agentId))).limit(1);
15780
- return row?.engagementStatus ?? ACTIVE;
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
+ }
15781
16127
  }
15782
16128
  /**
15783
16129
  * GET /me/chats — cursor-paginated conversation list.
@@ -15803,6 +16149,7 @@ async function listMeChats(db, humanAgentId, organizationId, query) {
15803
16149
  const filterUnreadOnly = query.filter === "unread";
15804
16150
  const filterWatchingOnly = query.filter === "watching";
15805
16151
  const engagementPredicate = ENGAGEMENT_VIEW_PREDICATE[query.engagement];
16152
+ const sourcePredicate = query.source ? sourceFilterSql(query.source) : sql`TRUE`;
15806
16153
  const cursorTsIso = cursor?.lastMessageAt ? cursor.lastMessageAt.toISOString() : null;
15807
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
15808
16155
  OR c.last_message_at < ${cursorTsIso}::timestamptz
@@ -15819,7 +16166,7 @@ async function listMeChats(db, humanAgentId, organizationId, query) {
15819
16166
  WHERE chat_id = c.id AND access_mode = 'speaker') AS participant_count,
15820
16167
  cm.access_mode AS access_mode,
15821
16168
  COALESCE(cus.unread_mention_count, 0) AS unread_mention_count,
15822
- COALESCE(cus.engagement_status, ${ACTIVE}) AS engagement_status
16169
+ COALESCE(cus.engagement_status, ${ACTIVE$1}) AS engagement_status
15823
16170
  FROM chats c
15824
16171
  JOIN chat_membership cm
15825
16172
  ON cm.chat_id = c.id AND cm.agent_id = ${humanAgentId}
@@ -15835,6 +16182,7 @@ async function listMeChats(db, humanAgentId, organizationId, query) {
15835
16182
  AND (${!filterUnreadOnly}::bool OR COALESCE(cus.unread_mention_count, 0) > 0)
15836
16183
  AND (${!filterWatchingOnly}::bool OR cm.access_mode = 'watcher')
15837
16184
  AND ${engagementPredicate}
16185
+ AND ${sourcePredicate}
15838
16186
  AND ${cursorPredicate}
15839
16187
  ORDER BY c.last_message_at DESC NULLS LAST, c.id DESC
15840
16188
  LIMIT ${limit + 1}
@@ -15857,24 +16205,29 @@ async function listMeChats(db, humanAgentId, organizationId, query) {
15857
16205
  agentId: chatMembership.agentId,
15858
16206
  displayName: agents.displayName,
15859
16207
  type: agents.type,
15860
- runtimeState: agentPresence.runtimeState
15861
- }).from(chatMembership).innerJoin(agents, eq(chatMembership.agentId, agents.uuid)).leftJoin(agentPresence, eq(agentPresence.agentId, chatMembership.agentId)).where(and(inArray(chatMembership.chatId, chatIds), eq(chatMembership.accessMode, "speaker")));
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")));
15862
16212
  const participantsByChat = /* @__PURE__ */ new Map();
15863
- const workingByChat = /* @__PURE__ */ new Map();
16213
+ const engagedByChat = /* @__PURE__ */ new Map();
15864
16214
  for (const p of participantRows) {
15865
16215
  const list = participantsByChat.get(p.chatId) ?? [];
15866
16216
  list.push({
15867
16217
  agentId: p.agentId,
15868
16218
  displayName: p.displayName,
15869
- type: p.type
16219
+ type: p.type,
16220
+ avatarColorToken: p.avatarColorToken,
16221
+ avatarImageUrl: agentAvatarImageUrl(p.agentId, p.avatarImageUpdatedAt)
15870
16222
  });
15871
16223
  participantsByChat.set(p.chatId, list);
15872
- if (p.runtimeState === "working") {
15873
- const working = workingByChat.get(p.chatId) ?? [];
15874
- working.push(p.agentId);
15875
- workingByChat.set(p.chatId, working);
16224
+ if (p.sessionState === "active") {
16225
+ const engaged = engagedByChat.get(p.chatId) ?? [];
16226
+ engaged.push(p.agentId);
16227
+ engagedByChat.set(p.chatId, engaged);
15876
16228
  }
15877
16229
  }
16230
+ const liveActivityByChat = await deriveLiveActivity(db, chatIds);
15878
16231
  const firstMessageRows = chatIds.length > 0 ? await db.selectDistinctOn([messages.chatId], {
15879
16232
  chatId: messages.chatId,
15880
16233
  content: messages.content
@@ -15902,13 +16255,98 @@ async function listMeChats(db, humanAgentId, organizationId, query) {
15902
16255
  unreadMentionCount: r.unread_mention_count,
15903
16256
  canReply: isSpeaker,
15904
16257
  engagementStatus: r.engagement_status,
15905
- workingAgentIds: workingByChat.get(r.chat_id) ?? []
16258
+ engagedAgentIds: engagedByChat.get(r.chat_id) ?? [],
16259
+ liveActivity: liveActivityByChat.get(r.chat_id) ?? null
15906
16260
  };
15907
16261
  }),
15908
16262
  nextCursor
15909
16263
  };
15910
16264
  }
15911
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
+ /**
15912
16350
  * Title resolution priority:
15913
16351
  *
15914
16352
  * 1. `chat.topic` (manual, set via `PATCH /chats/:chatId`)
@@ -16028,6 +16466,66 @@ async function leaveMeChat(db, chatId, humanAgentId) {
16028
16466
  return result;
16029
16467
  }
16030
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
+ /**
16031
16529
  * Class C — resource-scoped chat routes. Mounted at
16032
16530
  * `/api/v1/chats/:chatId/...`. The chat's `organizationId` locates its
16033
16531
  * org; `requireChatAccess` resolves the caller's membership in that org
@@ -16048,7 +16546,9 @@ async function chatRoutes(app) {
16048
16546
  const agentRows = participantAgentIds.length > 0 ? await app.db.select({
16049
16547
  agentId: agents.uuid,
16050
16548
  displayName: agents.displayName,
16051
- type: agents.type
16549
+ type: agents.type,
16550
+ avatarColorToken: agents.avatarColorToken,
16551
+ avatarImageUpdatedAt: agents.avatarImageUpdatedAt
16052
16552
  }).from(agents).where(inArray(agents.uuid, participantAgentIds)) : [];
16053
16553
  const agentMeta = new Map(agentRows.map((a) => [a.agentId, a]));
16054
16554
  const participantsForTitle = participants.map((p) => {
@@ -16056,7 +16556,9 @@ async function chatRoutes(app) {
16056
16556
  return {
16057
16557
  agentId: p.agentId,
16058
16558
  displayName: meta?.displayName ?? p.agentId,
16059
- type: meta?.type ?? "unknown"
16559
+ type: meta?.type ?? "unknown",
16560
+ avatarColorToken: meta?.avatarColorToken ?? null,
16561
+ avatarImageUrl: agentAvatarImageUrl(p.agentId, meta?.avatarImageUpdatedAt ?? null)
16060
16562
  };
16061
16563
  });
16062
16564
  const title = resolveChatTitle(chat.topic, firstMessagePreview, participantsForTitle, scope.humanAgentId);
@@ -16535,6 +17037,9 @@ const WINDOW_DAYS = {
16535
17037
  "7d": 7,
16536
17038
  "30d": 30
16537
17039
  };
17040
+ function contextTreeSnapshotWindowDays(window) {
17041
+ return WINDOW_DAYS[window];
17042
+ }
16538
17043
  const snapshotCache = /* @__PURE__ */ new Map();
16539
17044
  const remoteSyncPromises = /* @__PURE__ */ new Map();
16540
17045
  const remoteLastSyncedAt = /* @__PURE__ */ new Map();
@@ -16578,6 +17083,7 @@ async function getContextTreeSnapshot(binding, window = CONTEXT_TREE_SNAPSHOT_WI
16578
17083
  snapshotStatus: statusWarning?.stale ? "stale" : "active",
16579
17084
  contextStatus: contextStatus(statusWarning),
16580
17085
  summary,
17086
+ usage: emptyUsageSummary(window),
16581
17087
  updates,
16582
17088
  nodes: nodesWithGhosts,
16583
17089
  edges: tree.edges,
@@ -16832,6 +17338,13 @@ function withSnapshotStatus(snapshot, syncedAt, warning) {
16832
17338
  contextStatus: warning ? contextStatus(warning) : snapshot.contextStatus
16833
17339
  };
16834
17340
  }
17341
+ function emptyUsageSummary(window) {
17342
+ return {
17343
+ windowDays: WINDOW_DAYS[window],
17344
+ agentCount: 0,
17345
+ usageCount: 0
17346
+ };
17347
+ }
16835
17348
  function isSafeBranchName(branch) {
16836
17349
  if (branch.startsWith("-")) return false;
16837
17350
  if (branch.includes("..") || branch.includes("@{") || branch.includes("\\")) return false;
@@ -16862,6 +17375,7 @@ function unavailableSnapshot(repo, branch, detail) {
16862
17375
  removedCount: 0,
16863
17376
  changedNodeCount: 0
16864
17377
  },
17378
+ usage: emptyUsageSummary(CONTEXT_TREE_SNAPSHOT_WINDOWS.SEVEN_DAYS),
16865
17379
  updates: [],
16866
17380
  nodes: [],
16867
17381
  edges: [],
@@ -17437,11 +17951,16 @@ async function contextTreeSnapshotRoutes(app) {
17437
17951
  const orgId = await resolveUserPrimaryOrgId(app.db, userId);
17438
17952
  const binding = orgId ? await getOrgContextTree(app.db, orgId) : {};
17439
17953
  const githubToken = contextTreeGithubTokenForRepo(binding.repo, app.config.contextTreeSync);
17954
+ const window = query.window ?? "7d";
17440
17955
  const snapshot = await getContextTreeSnapshot({
17441
17956
  ...binding,
17442
17957
  githubToken
17443
- }, query.window ?? "7d");
17444
- return contextTreeSnapshotSchema.parse(snapshot);
17958
+ }, window);
17959
+ const usage = orgId ? await summarizeContextTreeUsage(app.db, orgId, contextTreeSnapshotWindowDays(window)) : snapshot.usage;
17960
+ return contextTreeSnapshotSchema.parse({
17961
+ ...snapshot,
17962
+ usage
17963
+ });
17445
17964
  });
17446
17965
  }
17447
17966
  function contextTreeGithubTokenForRepo(repo, syncConfig) {
@@ -17570,7 +18089,7 @@ async function healthzRoutes(app) {
17570
18089
  * `api/orgs/invitations.ts` (Class B, admin-gated).
17571
18090
  */
17572
18091
  async function publicInvitationRoutes(app) {
17573
- const { previewInvitation } = await import("./invitation-C299fxkP-CZgGbsN_.mjs");
18092
+ const { previewInvitation } = await import("./invitation-CNv7gfFF-DOFZ75wb.mjs");
17574
18093
  app.get("/:token/preview", async (request, reply) => {
17575
18094
  if (!request.params.token) throw new UnauthorizedError("Token required");
17576
18095
  const preview = await previewInvitation(app.db, request.params.token);
@@ -17640,7 +18159,8 @@ async function meRoutes(app) {
17640
18159
  username: users.username,
17641
18160
  displayName: users.displayName,
17642
18161
  avatarUrl: users.avatarUrl,
17643
- onboardingDismissedAt: users.onboardingDismissedAt
18162
+ onboardingDismissedAt: users.onboardingDismissedAt,
18163
+ onboardingCompletedAt: users.onboardingCompletedAt
17644
18164
  }).from(users).where(eq(users.id, userId)).limit(1);
17645
18165
  const memberships = await listActiveMemberships(app.db, userId);
17646
18166
  const defaultMembership = pickDefaultMembership(memberships.map((m) => ({
@@ -17648,6 +18168,7 @@ async function meRoutes(app) {
17648
18168
  createdAt: m.createdAt
17649
18169
  })));
17650
18170
  const defaultOrgId = defaultMembership ? memberships.find((m) => m.memberId === defaultMembership.id)?.organizationId ?? null : null;
18171
+ const memberCounts = await countActiveMembersByOrgs(app.db, memberships.map((mb) => mb.organizationId));
17651
18172
  let inviteUrl = null;
17652
18173
  if (defaultOrgId) {
17653
18174
  if (memberships.find((m) => m.organizationId === defaultOrgId)?.role === "admin") {
@@ -17664,11 +18185,13 @@ async function meRoutes(app) {
17664
18185
  organizationId: mb.organizationId,
17665
18186
  organizationName: mb.orgDisplayName,
17666
18187
  role: mb.role,
17667
- agentId: mb.agentId
18188
+ agentId: mb.agentId,
18189
+ orgHasOtherMembers: (memberCounts.get(mb.organizationId) ?? 1) > 1
17668
18190
  })),
17669
18191
  onboarding: {
17670
18192
  step: onboardingStep,
17671
- dismissedAt: user?.onboardingDismissedAt ? user.onboardingDismissedAt.toISOString() : null
18193
+ dismissedAt: user?.onboardingDismissedAt ? user.onboardingDismissedAt.toISOString() : null,
18194
+ completedAt: user?.onboardingCompletedAt ? user.onboardingCompletedAt.toISOString() : null
17672
18195
  },
17673
18196
  inviteUrl
17674
18197
  };
@@ -17694,6 +18217,25 @@ async function meRoutes(app) {
17694
18217
  return reply.status(200).send({ dismissedAt: u?.onboardingDismissedAt ? u.onboardingDismissedAt.toISOString() : null });
17695
18218
  });
17696
18219
  /**
18220
+ * POST /me/onboarding-completed — stamp the terminal-state column when
18221
+ * the user walks Step 3 to success (admin Continue, invitee Confirm /
18222
+ * Continue). Distinct from PATCH /me/onboarding { dismissed: true },
18223
+ * which only hides the stepper UI. Once stamped, the web sidebar drops
18224
+ * the Settings → Onboarding entry point and /settings/onboarding
18225
+ * redirects, so the wizard cannot re-enter.
18226
+ *
18227
+ * Idempotent: only writes when the column is still NULL — re-calling on
18228
+ * an already-completed user is a no-op rather than resetting the stamp.
18229
+ */
18230
+ app.post("/me/onboarding-completed", async (request, reply) => {
18231
+ const { userId } = requireUser(request);
18232
+ if ((await app.db.update(users).set({ onboardingCompletedAt: /* @__PURE__ */ new Date() }).where(and(eq(users.id, userId), isNull(users.onboardingCompletedAt))).returning({ id: users.id })).length > 0) app.log.info({
18233
+ event: "onboarding.completed",
18234
+ userId
18235
+ }, "onboarding funnel: setup completed");
18236
+ return reply.status(200).send({ ok: true });
18237
+ });
18238
+ /**
17697
18239
  * POST /me/onboarding/events — web-side onboarding funnel reporter.
17698
18240
  * Server-side milestones (`team_created` at OAuth, `dismissed` on PATCH)
17699
18241
  * are emitted directly; this endpoint surfaces the web-driven ones into
@@ -17836,7 +18378,7 @@ async function meRoutes(app) {
17836
18378
  */
17837
18379
  app.get("/me/pinned-agents", async (request) => {
17838
18380
  const { userId } = requireUser(request);
17839
- const { listMyPinnedAgents } = await import("./client-DNiLcPEq-db3YS57z.mjs");
18381
+ const { listMyPinnedAgents } = await import("./client-gSnsRu5W-v_mC1sRY.mjs");
17840
18382
  return listMyPinnedAgents(app.db, { userId });
17841
18383
  });
17842
18384
  /**
@@ -17966,6 +18508,80 @@ async function inferOnboardingStep(app, userId) {
17966
18508
  if (!hasAgent) return "create_agent";
17967
18509
  return "completed";
17968
18510
  }
18511
+ const MAX_DOC_BYTES = 5 * 1024 * 1024;
18512
+ async function getMeDocPreview(input) {
18513
+ const workspaceRootReal = await realpathOrNotFound(join(input.workspacesRoot ?? join(DEFAULT_DATA_DIR$1, "workspaces"), input.agentName, input.chatId));
18514
+ const candidate = resolve(workspaceRootReal, join(input.basePath ?? "", input.path));
18515
+ if (extname(assertInsideWorkspace(workspaceRootReal, candidate)).toLowerCase() !== ".md") throw new ForbiddenError("Document preview only supports markdown files in the agent workspace");
18516
+ let fileStat;
18517
+ try {
18518
+ fileStat = await stat(candidate);
18519
+ } catch {
18520
+ throw new NotFoundError("Document not found");
18521
+ }
18522
+ if (!fileStat.isFile()) throw new NotFoundError("Document not found");
18523
+ if (fileStat.size > MAX_DOC_BYTES) throw new AppError(413, "Document is larger than the 5MB preview limit");
18524
+ const fileReal = await realpath(candidate);
18525
+ const normalizedPath = assertInsideWorkspace(workspaceRootReal, fileReal);
18526
+ const refPath = normalizeRefPath(input.path);
18527
+ const normalizedBasePath = input.basePath ? normalizeRefPath(input.basePath) : void 0;
18528
+ return {
18529
+ ref: {
18530
+ type: "workspace",
18531
+ chatId: input.chatId,
18532
+ agentId: input.agentId,
18533
+ ...normalizedBasePath ? { basePath: normalizedBasePath } : {},
18534
+ path: refPath
18535
+ },
18536
+ path: normalizedPath,
18537
+ content: await readFile(fileReal, "utf8")
18538
+ };
18539
+ }
18540
+ async function realpathOrNotFound(path) {
18541
+ try {
18542
+ return await realpath(path);
18543
+ } catch {
18544
+ throw new NotFoundError("Document not found");
18545
+ }
18546
+ }
18547
+ function assertInsideWorkspace(workspaceRoot, target) {
18548
+ const rel = relative(workspaceRoot, target);
18549
+ if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) throw new ForbiddenError("Document path must stay inside the agent workspace");
18550
+ return rel.split(sep).join("/");
18551
+ }
18552
+ function normalizeRefPath(path) {
18553
+ const parts = [];
18554
+ for (const part of path.split(/[\\/]/)) {
18555
+ if (!part || part === ".") continue;
18556
+ if (part === "..") {
18557
+ if (parts.length === 0) throw new ForbiddenError("Document path must stay inside the agent workspace");
18558
+ parts.pop();
18559
+ continue;
18560
+ }
18561
+ parts.push(part);
18562
+ }
18563
+ if (parts.length === 0) throw new ForbiddenError("Document path must name a markdown file");
18564
+ return parts.join("/");
18565
+ }
18566
+ async function meDocsRoutes(app, options = {}) {
18567
+ app.get("/chats/:chatId/docs/preview", async (request) => {
18568
+ await requireChatAccess(request, app.db);
18569
+ const query = getMeDocSchema.parse(request.query);
18570
+ 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);
18571
+ if (!participant) throw new NotFoundError("Document not found");
18572
+ const [agent] = await app.db.select({ name: agents.name }).from(agents).where(eq(agents.uuid, query.agentId)).limit(1);
18573
+ if (!agent?.name) throw new NotFoundError("Document not found");
18574
+ const preview = await getMeDocPreview({
18575
+ chatId: request.params.chatId,
18576
+ agentId: query.agentId,
18577
+ agentName: agent.name,
18578
+ basePath: query.basePath,
18579
+ path: query.path,
18580
+ workspacesRoot: options.workspacesRoot
18581
+ });
18582
+ return getMeDocResponseSchema.parse(preview);
18583
+ });
18584
+ }
17969
18585
  /**
17970
18586
  * Resolve the caller's active membership in `:orgId` (from the URL) and
17971
18587
  * return the full `OrgScope`. The type signature requires
@@ -18146,7 +18762,7 @@ async function orgAgentRoutes(app) {
18146
18762
  const { type } = listAgentsFilterSchema.parse(request.query);
18147
18763
  const result = await listAgentsForMember(app.db, scope, query.limit, query.cursor, type);
18148
18764
  return {
18149
- items: result.items.map((a) => ({
18765
+ items: result.items.map(({ avatarImageUpdatedAt, ...a }) => ({
18150
18766
  ...a,
18151
18767
  managerId: a.managerId ?? null,
18152
18768
  presenceStatus: a.presenceStatus ?? "offline",
@@ -18155,7 +18771,8 @@ async function orgAgentRoutes(app) {
18155
18771
  clientId: a.clientId ?? null,
18156
18772
  runtimeType: a.runtimeType ?? null,
18157
18773
  runtimeState: a.runtimeState ?? null,
18158
- activeSessions: a.activeSessions ?? null
18774
+ activeSessions: a.activeSessions ?? null,
18775
+ avatarImageUrl: agentAvatarImageUrl(a.uuid, avatarImageUpdatedAt)
18159
18776
  })),
18160
18777
  nextCursor: result.nextCursor
18161
18778
  };
@@ -18171,7 +18788,7 @@ async function orgAgentRoutes(app) {
18171
18788
  const query = paginationQuerySchema.parse(request.query);
18172
18789
  const result = await listAgentsForAdmin(app.db, scope, query.limit, query.cursor);
18173
18790
  return {
18174
- items: result.items.map((a) => ({
18791
+ items: result.items.map(({ avatarImageUpdatedAt, ...a }) => ({
18175
18792
  ...a,
18176
18793
  managerId: a.managerId ?? null,
18177
18794
  presenceStatus: a.presenceStatus ?? "offline",
@@ -18180,7 +18797,8 @@ async function orgAgentRoutes(app) {
18180
18797
  clientId: a.clientId ?? null,
18181
18798
  runtimeType: a.runtimeType ?? null,
18182
18799
  runtimeState: a.runtimeState ?? null,
18183
- activeSessions: a.activeSessions ?? null
18800
+ activeSessions: a.activeSessions ?? null,
18801
+ avatarImageUrl: agentAvatarImageUrl(a.uuid, avatarImageUpdatedAt)
18184
18802
  })),
18185
18803
  nextCursor: result.nextCursor
18186
18804
  };
@@ -18273,6 +18891,17 @@ async function orgChatRoutes(app) {
18273
18891
  return listMeChats(app.db, scope.humanAgentId, scope.organizationId, query);
18274
18892
  });
18275
18893
  /**
18894
+ * GET /orgs/:orgId/chats/source-counts — per-source aggregate powering the
18895
+ * conversation-list tag bar (Manual / GitHub PR / GitHub Issue / Feishu).
18896
+ * Returns counts only for sources the caller has chats in, plus an
18897
+ * always-present `manual` entry. Same engagement view filter as the list.
18898
+ */
18899
+ app.get("/source-counts", async (request) => {
18900
+ const scope = await requireOrgMembership(request, app.db);
18901
+ const query = listMeChatSourceCountsQuerySchema.parse(request.query);
18902
+ return listMeChatSourceCounts(app.db, scope.humanAgentId, scope.organizationId, query);
18903
+ });
18904
+ /**
18276
18905
  * POST /orgs/:orgId/chats — create a new chat. The :orgId path param
18277
18906
  * makes the org explicit; visibility of every requested participant is
18278
18907
  * verified before the service layer touches the DB.
@@ -18326,11 +18955,16 @@ async function orgContextTreeSnapshotRoutes(app) {
18326
18955
  const scope = await requireOrgMembership(request, app.db);
18327
18956
  const binding = await getOrgContextTree(app.db, scope.organizationId);
18328
18957
  const githubToken = contextTreeGithubTokenForRepo(binding.repo, app.config.contextTreeSync);
18958
+ const window = query.window ?? "7d";
18329
18959
  const snapshot = await getContextTreeSnapshot({
18330
18960
  ...binding,
18331
18961
  githubToken
18332
- }, query.window ?? "7d");
18333
- return contextTreeSnapshotSchema.parse(snapshot);
18962
+ }, window);
18963
+ const usage = await summarizeContextTreeUsage(app.db, scope.organizationId, contextTreeSnapshotWindowDays(window));
18964
+ return contextTreeSnapshotSchema.parse({
18965
+ ...snapshot,
18966
+ usage
18967
+ });
18334
18968
  });
18335
18969
  }
18336
18970
  function orgIdParam(params) {
@@ -18757,6 +19391,12 @@ function orgWsRoutes(notifier, jwtSecret) {
18757
19391
  ...payload
18758
19392
  });
18759
19393
  });
19394
+ notifier.onSessionEvent((payload) => {
19395
+ broadcastOrgScoped({
19396
+ type: "session:event",
19397
+ ...payload
19398
+ });
19399
+ });
18760
19400
  notifier.onChatMessage(({ chatId }) => {
18761
19401
  dispatchChatMessage(chatId);
18762
19402
  });
@@ -18965,6 +19605,64 @@ const githubEntityChatMappings = pgTable("github_entity_chat_mappings", {
18965
19605
  table.entityType,
18966
19606
  table.entityKey
18967
19607
  ] }), index("idx_github_entity_chat_mappings_chat").on(table.chatId)]);
19608
+ /**
19609
+ * Auto-archive chats when one of their bound pull requests is merged.
19610
+ *
19611
+ * Trigger: GitHub `pull_request.closed` webhook with `merged === true`. The
19612
+ * webhook handler calls this service on a bypass branch — the normalize /
19613
+ * audience / deliver pipeline is unaffected (PR closed events still drop in
19614
+ * Stage 1).
19615
+ *
19616
+ * Algorithm: a merged PR flips every chat bound to it into the user's
19617
+ * archived view, with no inspection of sibling PR state. Multi-PR chats can
19618
+ * be temporarily archived while siblings are still open; any later activity
19619
+ * on those siblings produces a normal delivery message, which the existing
19620
+ * chat-projection auto-revives back to `active`. This trades perfect timing
19621
+ * for zero local state, no GitHub API calls, and no schema changes.
19622
+ *
19623
+ * Per-user safety: writes use an UPSERT guarded with
19624
+ * `setWhere = engagement_status = 'active'`, so only the implicit-active or
19625
+ * explicitly-active rows flip. User-manually `deleted` and already-`archived`
19626
+ * rows are left alone. Idempotent under GitHub webhook retries.
19627
+ */
19628
+ const { ACTIVE, ARCHIVED } = CHAT_ENGAGEMENT_STATUSES;
19629
+ async function archiveChatsForMergedPr(db, input) {
19630
+ if (!input.repoFullName || !Number.isFinite(input.prNumber) || input.prNumber <= 0) return {
19631
+ chats: 0,
19632
+ rowsConsidered: 0
19633
+ };
19634
+ const entityKey = `${input.repoFullName}#${input.prNumber}`;
19635
+ const rows = await db.select({
19636
+ chatId: githubEntityChatMappings.chatId,
19637
+ humanAgentId: githubEntityChatMappings.humanAgentId
19638
+ }).from(githubEntityChatMappings).where(and(eq(githubEntityChatMappings.organizationId, input.organizationId), eq(githubEntityChatMappings.entityType, "pull_request"), eq(githubEntityChatMappings.entityKey, entityKey)));
19639
+ if (rows.length === 0) return {
19640
+ chats: 0,
19641
+ rowsConsidered: 0
19642
+ };
19643
+ const seen = /* @__PURE__ */ new Set();
19644
+ const targets = [];
19645
+ for (const row of rows) {
19646
+ const key = `${row.chatId}|${row.humanAgentId}`;
19647
+ if (seen.has(key)) continue;
19648
+ seen.add(key);
19649
+ targets.push(row);
19650
+ }
19651
+ for (const { chatId, humanAgentId } of targets) await db.insert(chatUserState).values({
19652
+ chatId,
19653
+ agentId: humanAgentId,
19654
+ unreadMentionCount: 0,
19655
+ engagementStatus: ARCHIVED
19656
+ }).onConflictDoUpdate({
19657
+ target: [chatUserState.chatId, chatUserState.agentId],
19658
+ set: { engagementStatus: ARCHIVED },
19659
+ setWhere: eq(chatUserState.engagementStatus, ACTIVE)
19660
+ });
19661
+ return {
19662
+ chats: new Set(targets.map((t) => t.chatId)).size,
19663
+ rowsConsidered: targets.length
19664
+ };
19665
+ }
18968
19666
  function evaluateDelegateTarget(target, sourceOrgId) {
18969
19667
  if (!target) return "not_found";
18970
19668
  if (target.organizationId !== sourceOrgId) return "cross_org";
@@ -20110,6 +20808,28 @@ async function githubAppWebhookRoutes(app) {
20110
20808
  };
20111
20809
  const deliveryHeader = request.headers["x-github-delivery"];
20112
20810
  const deliveryId = typeof deliveryHeader === "string" && deliveryHeader.length > 0 ? deliveryHeader : null;
20811
+ if (eventType === "pull_request" && isRecord(payload) && payload.action === "closed") {
20812
+ const pr = isRecord(payload.pull_request) ? payload.pull_request : null;
20813
+ const repoFullName = readString$1((isRecord(payload.repository) ? payload.repository : null)?.full_name);
20814
+ const prNumber = readNumber$1(pr?.number);
20815
+ if (pr?.merged === true && repoFullName && prNumber !== null) try {
20816
+ const stats = await archiveChatsForMergedPr(app.db, {
20817
+ organizationId: installation.hubOrganizationId,
20818
+ repoFullName,
20819
+ prNumber
20820
+ });
20821
+ log$1.info({
20822
+ entityKey: `${repoFullName}#${prNumber}`,
20823
+ ...stats
20824
+ }, "auto-archived chats on PR merge");
20825
+ } catch (err) {
20826
+ log$1.error({
20827
+ err,
20828
+ repoFullName,
20829
+ prNumber
20830
+ }, "failed to auto-archive chats on PR merge");
20831
+ }
20832
+ }
20113
20833
  const event = normalizeGithubEvent(eventType, payload, source, deliveryId);
20114
20834
  if (!event) {
20115
20835
  log$1.debug({
@@ -21710,12 +22430,14 @@ async function buildApp(config) {
21710
22430
  await api.register(githubOauthRoutes, { prefix: "/auth/github" });
21711
22431
  await api.register(publicInvitationRoutes, { prefix: "/invitations" });
21712
22432
  await api.register(bootstrapConfigRoutes, { prefix: "/bootstrap" });
22433
+ await api.register(publicAgentAvatarRoutes, { prefix: "/agents" });
21713
22434
  await api.register(userScope("contextTreeScope", async (scope) => {
21714
22435
  await scope.register(contextTreeInfoRoutes);
21715
22436
  await scope.register(contextTreeSnapshotRoutes);
21716
22437
  }), { prefix: "/context-tree" });
21717
22438
  await api.register(userScope("meRoutesScope", async (scope) => {
21718
22439
  await scope.register(meRoutes);
22440
+ await scope.register(meDocsRoutes, { workspacesRoot: config.workspace.root });
21719
22441
  }), { prefix: "" });
21720
22442
  await api.register(userScope("orgsScope", async (scope) => {
21721
22443
  await scope.register(orgIdentityRoutes);
@@ -22147,7 +22869,7 @@ const declineUpdate = async () => false;
22147
22869
  * relaunch picks up the new binary.
22148
22870
  *
22149
22871
  * `managed=false` means the process is running standalone (e.g. manual
22150
- * `client start`, `client connect --no-service`, CI without a supervisor).
22872
+ * `client start`, `connect <token> --no-service`, CI without a supervisor).
22151
22873
  * Exiting in that mode would leave the client offline until an operator
22152
22874
  * noticed — so the callback instead prints a restart hint, returns
22153
22875
  * `{ installed: true }`, and the UpdateManager stops retrying until the