@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.
- package/dist/{bootstrap-CW73oEYn.mjs → bootstrap-C15ZBOCC.mjs} +7 -6
- package/dist/cli/index.mjs +268 -480
- package/dist/{client-BViGcaUC-CZb2Svgh.mjs → client-RM_03B_l-DiEIa9xe.mjs} +53 -6
- package/dist/{client-DNiLcPEq-db3YS57z.mjs → client-gSnsRu5W-v_mC1sRY.mjs} +3 -3
- package/dist/{dist-B1GHzMLc.mjs → dist-1XGLJMOq.mjs} +163 -11
- package/dist/drizzle/0043_onboarding_completed_at.sql +32 -0
- package/dist/drizzle/0044_agent_avatar_color.sql +11 -0
- package/dist/drizzle/0045_agent_avatar_image.sql +17 -0
- package/dist/drizzle/meta/_journal.json +21 -0
- package/dist/{errors-CF5evtJt-B0NTIVPt.mjs → errors-LPcARA4K-Dbrptiyz.mjs} +2 -1
- package/dist/{feishu-30vUx69l.mjs → feishu-BGx71p5s.mjs} +1 -1
- package/dist/index.mjs +7 -7
- package/dist/invitation-CNv7gfFF-DOFZ75wb.mjs +4 -0
- package/dist/{invitation-Bg0TRiyx-BsZH4GCS.mjs → invitation-DZO4NX3P-BPxTeHf-.mjs} +2 -2
- package/dist/{saas-connect-Fgnnnola.mjs → saas-connect-DX3-nDs9.mjs} +830 -108
- package/dist/web/assets/index-BOK7e_td.css +1 -0
- package/dist/web/assets/{index-B7FIVwrn.js → index-BppGbmdT.js} +1 -1
- package/dist/web/assets/index-Is-7_y6O.js +416 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/invitation-C299fxkP-CZgGbsN_.mjs +0 -4
- package/dist/web/assets/index-DJbUySaH.css +0 -1
- package/dist/web/assets/index-DiDfVdIH.js +0 -421
|
@@ -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-
|
|
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
|
|
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-
|
|
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-
|
|
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-
|
|
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).
|
|
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
|
-
|
|
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 \`
|
|
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
|
|
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
|
|
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
|
|
3485
|
+
first-tree-hub chat send --chat <chatId> "your message"
|
|
3353
3486
|
|
|
3354
3487
|
# Send markdown (default format is text)
|
|
3355
|
-
first-tree-hub
|
|
3488
|
+
first-tree-hub chat send <agentName> -f markdown "**bold** message"
|
|
3356
3489
|
|
|
3357
3490
|
# Reply to a specific message
|
|
3358
|
-
first-tree-hub
|
|
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
|
|
3494
|
+
echo "long message body" | first-tree-hub chat send <agentName>
|
|
3362
3495
|
\`\`\`
|
|
3363
3496
|
|
|
3364
|
-
> Agent uuids appear in \`
|
|
3365
|
-
> but they are NOT accepted by \`
|
|
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 =
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
5912
|
-
const
|
|
5913
|
-
if (
|
|
5914
|
-
|
|
5915
|
-
|
|
5916
|
-
|
|
5917
|
-
|
|
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 `
|
|
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 `
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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-
|
|
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
|
|
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
|
|
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-
|
|
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
|
|
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'
|
|
12968
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
15861
|
-
|
|
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
|
|
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.
|
|
15873
|
-
const
|
|
15874
|
-
|
|
15875
|
-
|
|
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
|
-
|
|
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
|
-
},
|
|
17444
|
-
|
|
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-
|
|
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-
|
|
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
|
-
},
|
|
18333
|
-
|
|
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`, `
|
|
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
|