@agent-team-foundation/first-tree-hub 0.11.5 → 0.12.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.
Files changed (34) hide show
  1. package/dist/cli/index.mjs +6 -6
  2. package/dist/client-BPUdUaZT-CyCrpCTP.mjs +2033 -0
  3. package/dist/client-BhCtO2df-BGOu-rRN.mjs +7 -0
  4. package/dist/{dist-BQtAQNRD.mjs → dist-LgF7LHpE.mjs} +1 -1
  5. package/dist/{dist-CfvCT4E0.mjs → dist-UOZ6vMUW.mjs} +112 -9
  6. package/dist/drizzle/0034_pending_questions.sql +34 -0
  7. package/dist/drizzle/meta/_journal.json +7 -0
  8. package/dist/{feishu-DbSvp9UH.mjs → feishu-C6qlhju2.mjs} +1 -1
  9. package/dist/{getMachineId-bsd-c2VImogj.mjs → getMachineId-bsd-BmasEOJr.mjs} +1 -1
  10. package/dist/{getMachineId-bsd-DyySs8xz.mjs → getMachineId-bsd-Dh3h0DDE.mjs} +1 -1
  11. package/dist/{getMachineId-darwin-Cl7TSzgO.mjs → getMachineId-darwin-CuhM3hfZ.mjs} +1 -1
  12. package/dist/{getMachineId-darwin-DKgI8b1d.mjs → getMachineId-darwin-D9wR0SLj.mjs} +1 -1
  13. package/dist/{getMachineId-linux-1OIMWfdh.mjs → getMachineId-linux-CYfb0oxZ.mjs} +1 -1
  14. package/dist/{getMachineId-linux-cT7EbP10.mjs → getMachineId-linux-D8ZaSjAC.mjs} +1 -1
  15. package/dist/{getMachineId-unsupported-CkX-YOG1.mjs → getMachineId-unsupported-Cu3iisaD.mjs} +1 -1
  16. package/dist/{getMachineId-unsupported-CmVlhzIo.mjs → getMachineId-unsupported-DZqI4ZT5.mjs} +1 -1
  17. package/dist/{getMachineId-win-C2cM60YT.mjs → getMachineId-win-8ZJbtrdf.mjs} +1 -1
  18. package/dist/{getMachineId-win-Chl03TYe.mjs → getMachineId-win-DT-hqwVp.mjs} +1 -1
  19. package/dist/index.mjs +6 -6
  20. package/dist/{invitation-C299fxkP-BR-niZyp.mjs → invitation-C299fxkP-KyCNax4T.mjs} +1 -1
  21. package/dist/{observability-BAScT_5S-gw1ODB_o.mjs → observability-BAScT_5S-BcW9HgkG.mjs} +13 -13
  22. package/dist/{observability-CYsdAcoF.mjs → observability-eLA9iNK_.mjs} +3 -3
  23. package/dist/{saas-connect-CO554S-V.mjs → saas-connect-Drn9g6cR.mjs} +367 -1340
  24. package/dist/web/assets/index-B_Tf2I6v.css +1 -0
  25. package/dist/web/assets/{index-B7noAoV-.js → index-Bnyz7inW.js} +1 -1
  26. package/dist/web/assets/index-Dy3jIUX5.js +391 -0
  27. package/dist/web/index.html +2 -2
  28. package/package.json +1 -1
  29. package/dist/client-D_TRJFZY-LbgJF47t.mjs +0 -4
  30. package/dist/client-DqdGiggm-NQoGZ2vM.mjs +0 -524
  31. package/dist/web/assets/index-DPLa60vJ.css +0 -1
  32. package/dist/web/assets/index-DvGkka4N.js +0 -390
  33. /package/dist/{esm-Ci8E1Gtj.mjs → esm-iadMkGbV.mjs} +0 -0
  34. /package/dist/{src-aJMV60mR.mjs → src-DNBS5Yjj.mjs} +0 -0
@@ -1,11 +1,11 @@
1
1
  import { a as __toCommonJS, o as __toESM, t as __commonJSMin } from "./chunk-BSw8zbkd.mjs";
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, f as messageAttrs, 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-gw1ODB_o.mjs";
2
+ import { A as FIRST_TREE_HUB_ATTR, C as stampOrgScope, D as untrustedAttrs, E as startWsConnectionSpan, M as require_pino, O as withSpan, S as stampChatResource, _ as rootLogger$1, a as buildRateLimitError, c as currentTraceId, g as reportErrorToRoot, i as bodyCaptureOnSendHook, j as redactUrl, k as withWsMessageSpan, l as decodeJwtForTrace, m as observabilityPlugin, n as applyLoggerConfig, o as classifyJoseError, r as attachRequestContext, s as createLogger$1, t as adapterAttrs, u as endWsConnectionSpan, x as stampAgentResource, y as setWsConnectionAttrs } from "./observability-BAScT_5S-BcW9HgkG.mjs";
3
3
  import { C as resetConfigMeta, E as setConfigValue, S as resetConfig, T as serverConfigSchema, b as migrateLegacyHome, c as resolveServerUrl, d as DEFAULT_CONFIG_DIR, f as DEFAULT_DATA_DIR$1, g as collectMissingPrompts, h as clientConfigSchema, i as ensureFreshAccessToken, l as saveAgentConfig, m as agentConfigSchema, o as loadCredentials, p as DEFAULT_HOME_DIR$1, u as saveCredentials, v as initConfig, w as resolveConfigReadonly, y as loadAgents } from "./bootstrap-C_K2CKXC.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 loginSchema, A as createAgentSchema, B as githubCallbackQuerySchema, C as agentTypeSchema$1, Ct as updateChatSchema, D as contextTreeSnapshotSchema, Dt as updateTaskStatusSchema, E as connectTokenExchangeSchema, Et as updateOrganizationSchema, F as createTaskSchema, G as inboxDeliverFrameSchema$1, H as githubStartQuerySchema, I as defaultRuntimeConfigPayload, J as isRedactedEnvValue, K as inboxPollQuerySchema, L as delegateFeishuUserSchema, M as createMeChatSchema, N as createMemberSchema, O as createAdapterConfigSchema, Ot as wsAuthFrameSchema, P as createOrgFromMeSchema, Q as listMeChatsQuerySchema, R as dryRunAgentRuntimeConfigSchema, S as agentRuntimeConfigPayloadSchema$1, St as updateAgentSchema, T as clientRegisterSchema, Tt as updateMemberSchema, U as imageInlineContentSchema, V as githubDevCallbackQuerySchema, W as inboxAckFrameSchema, X as joinByInvitationSchema, Y as isReservedAgentName$1, Z as linkTaskChatSchema, _ as addParticipantSchema, _t as sessionStateMessageSchema, a as AGENT_STATUSES, at as rebindAgentSchema, b as agentBindRequestSchema, bt as updateAdapterConfigSchema, ct as safeRedirectPath, d as TASK_CREATOR_TYPES, dt as sendMessageSchema, et as messageSourceSchema$1, f as TASK_HEALTH_SIGNALS, ft as sendToAgentSchema, g as addMeChatParticipantsSchema, gt as sessionReconcileRequestSchema, h as WS_AUTH_FRAME_TIMEOUT_MS, ht as sessionEventSchema$1, i as AGENT_SOURCES, it as patchOnboardingSchema, j as createChatSchema, k as createAdapterMappingSchema, l as MENTION_REGEX, lt as scanMentionTokens, m as TASK_TERMINAL_STATUSES, mt as sessionEventMessageSchema, n as AGENT_NAME_REGEX$1, nt as onboardingEventSchema, o as AGENT_TYPES, ot as refreshTokenSchema, p as TASK_STATUSES, pt as sessionCompletionMessageSchema, q as isOrgSettingNamespace, r as AGENT_SELECTOR_HEADER$1, rt as paginationQuerySchema, s as AGENT_VISIBILITY, st as runtimeStateMessageSchema, t as AGENT_BIND_REJECT_REASONS, tt as notificationQuerySchema, u as ORG_SETTINGS_NAMESPACES$1, ut as selfServiceFeishuBotSchema, v as adminCreateTaskSchema, vt as stripCode, wt as updateClientCapabilitiesSchema, x as agentPinnedMessageSchema$1, xt as updateAgentRuntimeConfigSchema, y as adminUpdateTaskSchema, yt as taskListQuerySchema, z as extractMentions } from "./dist-CfvCT4E0.mjs";
5
+ import { $ as loginSchema, A as createAgentSchema, At as updateTaskStatusSchema, B as githubCallbackQuerySchema, C as agentTypeSchema$1, Ct as updateAdapterConfigSchema, D as contextTreeSnapshotSchema, Dt as updateClientCapabilitiesSchema, E as connectTokenExchangeSchema, Et as updateChatSchema, F as createTaskSchema, G as inboxDeliverFrameSchema$1, H as githubStartQuerySchema, I as defaultRuntimeConfigPayload, J as isRedactedEnvValue, K as inboxPollQuerySchema, L as delegateFeishuUserSchema, M as createMeChatSchema, N as createMemberSchema, O as createAdapterConfigSchema, Ot as updateMemberSchema, P as createOrgFromMeSchema, Q as listMeChatsQuerySchema, R as dryRunAgentRuntimeConfigSchema, S as agentRuntimeConfigPayloadSchema$1, St as taskListQuerySchema, T as clientRegisterSchema, Tt as updateAgentSchema, U as imageInlineContentSchema, V as githubDevCallbackQuerySchema, W as inboxAckFrameSchema, X as joinByInvitationSchema, Y as isReservedAgentName$1, Z as linkTaskChatSchema, _ as addParticipantSchema, _t as sessionEventSchema$1, a as AGENT_STATUSES, b as agentBindRequestSchema, bt as stripCode, ct as refreshTokenSchema, d as TASK_CREATOR_TYPES, et as messageSourceSchema$1, f as TASK_HEALTH_SIGNALS, ft as selfServiceFeishuBotSchema, g as addMeChatParticipantsSchema, gt as sessionEventMessageSchema, h as WS_AUTH_FRAME_TIMEOUT_MS, ht as sessionCompletionMessageSchema, i as AGENT_SOURCES, it as patchOnboardingSchema, j as createChatSchema, jt as wsAuthFrameSchema, k as createAdapterMappingSchema, kt as updateOrganizationSchema, l as MENTION_REGEX, lt as runtimeStateMessageSchema, m as TASK_TERMINAL_STATUSES, mt as sendToAgentSchema, n as AGENT_NAME_REGEX$1, nt as onboardingEventSchema, o as AGENT_TYPES, p as TASK_STATUSES, pt as sendMessageSchema, q as isOrgSettingNamespace, r as AGENT_SELECTOR_HEADER$1, rt as paginationQuerySchema, s as AGENT_VISIBILITY, st as rebindAgentSchema, t as AGENT_BIND_REJECT_REASONS, tt as notificationQuerySchema, u as ORG_SETTINGS_NAMESPACES$1, ut as safeRedirectPath, v as adminCreateTaskSchema, vt as sessionReconcileRequestSchema, wt as updateAgentRuntimeConfigSchema, x as agentPinnedMessageSchema$1, xt as submitQuestionAnswerSchema, y as adminUpdateTaskSchema, yt as sessionStateMessageSchema } from "./dist-UOZ6vMUW.mjs";
6
6
  import { a as ConflictError, c as UnauthorizedError, i as ClientUserMismatchError$1, l as organizations, n as BadRequestError, o as ForbiddenError, r as ClientOrgMismatchError$1, s as NotFoundError, t as AppError, u as users } from "./errors-CF5evtJt-B0NTIVPt.mjs";
7
- import { C as retireClient, D as touchAgent, E as setRuntimeState, O as unbindAgent, S as registerClient, T as setOffline, _ as listClients, a as claimClient, b as markStaleAgents, c as clients, d as getClient, f as getOnlineCount, g as listActiveAgentsPinnedToClient, h as heartbeatInstance, i as bindAgent, k as updateClientCapabilities, l as deriveAuthState, m as heartbeatClient, n as agents, o as cleanupStaleClients, p as getPresence, r as assertClientOwner, s as cleanupStalePresence, t as agentPresence, u as disconnectClient, v as listClientsForOrgAdmin, w as serverInstances, x as members } from "./client-DqdGiggm-NQoGZ2vM.mjs";
8
- import { n as init_esm, r as trace, t as esm_exports } from "./esm-Ci8E1Gtj.mjs";
7
+ import { n as init_esm, r as trace, t as esm_exports } from "./esm-iadMkGbV.mjs";
8
+ import { $ as pendingQuestions, A as heartbeatClient, B as listAgentsWithRuntime, C as findOrCreateDirectChat, D as getClient, E as getChatDetail, F as joinChat, G as listClientsForOrgAdmin, H as listChats, I as leaveAsParticipant, J as markStaleAgents, K as listMessages, L as leaveChat, M as inboxEntries, N as invalidateChatAudience, O as getOnlineCount, P as joinAsParticipant, Q as notifyRecipients, R as listActiveAgentsPinnedToClient, S as ensureParticipant$1, T as getCachedAudience, U as listChatsForMember, V as listChatParticipantsWithNames, W as listClients, X as members, Y as markSupersededByChat, Z as messages, _ as createNotifier, _t as updateClientCapabilities, a as agents, at as removeParticipant, b as editMessage, c as bindAgent, ct as retireClient, d as chats, dt as serverInstances, et as recomputeChatWatchers, f as claimClient, ft as setOffline, g as createChat, gt as unbindAgent, h as clients, ht as touchAgent, i as agentVisibilityCondition, it as registerClient, j as heartbeatInstance, k as getPresence, l as chatParticipants, lt as sendMessage, m as cleanupStalePresence, mt as submitAnswer, n as agentChatSessions, nt as recomputeWatchersForMember, o as assertClientOwner, ot as resetActivity, p as cleanupStaleClients, pt as setRuntimeState, r as agentPresence, rt as registerChatMessageDispatcher, s as assertParticipant, st as resolveChatMembership, t as addParticipant, tt as recomputeWatchersForAgent, u as chatSubscriptions, ut as sendToAgent$1, v as deriveAuthState, vt as upsertSessionState, w as getActivityOverview, x as ensureCanJoin, y as disconnectClient, z as listAgentsManagedByUser } from "./client-BPUdUaZT-CyCrpCTP.mjs";
9
9
  import { a as invitationRedemptions, c as recordRedemption, i as getActiveInvitation, l as rotateInvitation, n as ensureActiveInvitation, o as invitations, r as findActiveByToken, t as buildInviteUrl, u as uuidv7 } from "./invitation-Bg0TRiyx-BsZH4GCS.mjs";
10
10
  import { createRequire } from "node:module";
11
11
  import { ZodError, z } from "zod";
@@ -29,7 +29,7 @@ import { and, asc, count, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, ne,
29
29
  import { drizzle } from "drizzle-orm/postgres-js";
30
30
  import postgres from "postgres";
31
31
  import { migrate } from "drizzle-orm/postgres-js/migrator";
32
- import { bigserial, boolean, index, integer, jsonb, pgTable, primaryKey, serial, text, timestamp, unique, uniqueIndex } from "drizzle-orm/pg-core";
32
+ import { boolean, index, integer, jsonb, pgTable, primaryKey, serial, text, timestamp, unique, uniqueIndex } from "drizzle-orm/pg-core";
33
33
  import { SignJWT, jwtVerify } from "jose";
34
34
  import cors from "@fastify/cors";
35
35
  import rateLimit from "@fastify/rate-limit";
@@ -1015,7 +1015,9 @@ const messageFormatSchema = z.enum([
1015
1015
  "card",
1016
1016
  "reference",
1017
1017
  "file",
1018
- "task"
1018
+ "task",
1019
+ "question",
1020
+ "question_answer"
1019
1021
  ]);
1020
1022
  z.object({
1021
1023
  format: messageFormatSchema.default("text"),
@@ -1363,26 +1365,26 @@ z.object({
1363
1365
  * 2. Add a key to `ORG_SETTINGS_NAMESPACES`.
1364
1366
  * 3. Done. No DB migration, no new API route.
1365
1367
  */
1366
- const orgContextTreeRepoUrlSchema = z.string().url().refine((value) => {
1368
+ const httpsRepoUrlSchema = z.string().url().refine((value) => {
1367
1369
  try {
1368
1370
  return new URL(value).protocol === "https:";
1369
1371
  } catch {
1370
1372
  return false;
1371
1373
  }
1372
- }, { message: "Context Tree repo URL must use HTTPS." }).refine((value) => {
1374
+ }, { message: "Repo URL must use HTTPS." }).refine((value) => {
1373
1375
  try {
1374
1376
  const url = new URL(value);
1375
1377
  return url.username.length === 0 && url.password.length === 0;
1376
1378
  } catch {
1377
1379
  return false;
1378
1380
  }
1379
- }, { message: "Context Tree repo URL must not include credentials." });
1381
+ }, { message: "Repo URL must not include credentials." });
1380
1382
  const orgContextTreeStorageSchema = z.object({
1381
- repo: orgContextTreeRepoUrlSchema.optional(),
1383
+ repo: httpsRepoUrlSchema.optional(),
1382
1384
  branch: z.string().default("main")
1383
1385
  });
1384
1386
  const orgContextTreeInputSchema = z.object({
1385
- repo: orgContextTreeRepoUrlSchema.nullish(),
1387
+ repo: httpsRepoUrlSchema.nullish(),
1386
1388
  branch: z.string().min(1).nullish()
1387
1389
  });
1388
1390
  const orgContextTreeOutputSchema = z.object({
@@ -1395,16 +1397,36 @@ const orgGithubIntegrationOutputSchema = z.object({
1395
1397
  webhookSecretConfigured: z.boolean(),
1396
1398
  webhookUrl: z.string()
1397
1399
  });
1400
+ const orgSourceReposStorageSchema = z.object({ repos: z.array(z.object({
1401
+ url: httpsRepoUrlSchema,
1402
+ defaultBranch: z.string().optional()
1403
+ })).default([]) });
1404
+ const orgSourceReposInputSchema = z.object({ repos: z.array(z.object({
1405
+ url: httpsRepoUrlSchema,
1406
+ defaultBranch: z.string().min(1).optional()
1407
+ })).optional() });
1408
+ const orgSourceReposOutputSchema = z.object({ repos: z.array(z.object({
1409
+ url: z.string(),
1410
+ defaultBranch: z.string().optional()
1411
+ })) });
1398
1412
  const ORG_SETTINGS_NAMESPACES = {
1399
1413
  context_tree: {
1400
1414
  storage: orgContextTreeStorageSchema,
1401
1415
  input: orgContextTreeInputSchema,
1402
- output: orgContextTreeOutputSchema
1416
+ output: orgContextTreeOutputSchema,
1417
+ readPolicy: "member"
1403
1418
  },
1404
1419
  github_integration: {
1405
1420
  storage: orgGithubIntegrationStorageSchema,
1406
1421
  input: orgGithubIntegrationInputSchema,
1407
- output: orgGithubIntegrationOutputSchema
1422
+ output: orgGithubIntegrationOutputSchema,
1423
+ readPolicy: "admin"
1424
+ },
1425
+ source_repos: {
1426
+ storage: orgSourceReposStorageSchema,
1427
+ input: orgSourceReposInputSchema,
1428
+ output: orgSourceReposOutputSchema,
1429
+ readPolicy: "member"
1408
1430
  }
1409
1431
  };
1410
1432
  const ORG_SETTINGS_NAMESPACE_KEYS = Object.keys(ORG_SETTINGS_NAMESPACES);
@@ -1443,6 +1465,78 @@ z.object({
1443
1465
  organizationId: z.string(),
1444
1466
  agents: z.record(z.string(), z.array(pulseBucketSchema).length(32))
1445
1467
  });
1468
+ /**
1469
+ * Structured ask-user payloads bridged from the Claude Agent SDK
1470
+ * `AskUserQuestion` tool to Hub messages.
1471
+ *
1472
+ * Shape mirrors the SDK 0.2.84 input/output verbatim so the client
1473
+ * runtime adapter can pass `updatedInput` straight through. See
1474
+ * verify scripts under `packages/client/tmp-verify/` for the live
1475
+ * matrix this was validated against.
1476
+ *
1477
+ * Lifecycle:
1478
+ * 1. Agent emits a `format: "question"` message — its `content` is a
1479
+ * `QuestionMessageContent` carrying `correlationId` + `questions[]`.
1480
+ * 2. User picks options in the Web UI and POSTs answers; server writes
1481
+ * a `format: "question_answer"` message — its `content` is a
1482
+ * `QuestionAnswerMessageContent` referencing the same `correlationId`.
1483
+ * 3. Client runtime resolves the in-flight `canUseTool` promise with the
1484
+ * answers, and the SDK feeds them back to the model.
1485
+ *
1486
+ * `pending → answered → superseded` runtime status lives in a separate
1487
+ * server table (`pending_questions`) and is not part of the message —
1488
+ * messages are immutable once written.
1489
+ */
1490
+ /**
1491
+ * Single option inside a question. `preview` is rich content rendered above
1492
+ * the label — the SDK's tool input emits it as `string | undefined` (the
1493
+ * field is omitted when the model didn't generate any preview content), so
1494
+ * we accept undefined / null / string and normalise downstream renderers
1495
+ * to treat all three the same way.
1496
+ */
1497
+ const questionOptionSchema = z.object({
1498
+ label: z.string().min(1),
1499
+ description: z.string(),
1500
+ preview: z.string().nullable().optional()
1501
+ });
1502
+ /**
1503
+ * One question. `header` is a chip-style short tag. The SDK schema docs
1504
+ * describe ≤12 chars but in practice the model occasionally emits
1505
+ * slightly longer headers; we keep the rule loose (≤24) so a stylistic
1506
+ * regression doesn't fail-closed at canUseTool and abandon the entire
1507
+ * tool call. The UI truncates visually if needed.
1508
+ */
1509
+ const questionItemSchema = z.object({
1510
+ question: z.string().min(1),
1511
+ header: z.string().min(1).max(24),
1512
+ options: z.array(questionOptionSchema).min(2).max(4),
1513
+ multiSelect: z.boolean()
1514
+ });
1515
+ /** Session-level preview format hint. Mirrors `toolConfig.askUserQuestion.previewFormat`. */
1516
+ const questionPreviewFormatSchema = z.enum(["html", "markdown"]).nullable();
1517
+ z.object({
1518
+ correlationId: z.string().min(1),
1519
+ questions: z.array(questionItemSchema).min(1).max(4),
1520
+ previewFormat: questionPreviewFormatSchema,
1521
+ allowFreeText: z.boolean()
1522
+ });
1523
+ /**
1524
+ * Content payload for a message whose `format === "question_answer"`.
1525
+ *
1526
+ * `answers` is keyed by `QuestionItem.question` text. For `multiSelect` questions
1527
+ * the value is a `, `-joined string of selected labels (matches SDK convention).
1528
+ * For free-text answers the value is the user's raw input.
1529
+ */
1530
+ const questionAnswerMessageContentSchema = z.object({
1531
+ correlationId: z.string().min(1),
1532
+ answers: z.record(z.string().min(1), z.string())
1533
+ });
1534
+ z.object({ answers: z.record(z.string().min(1), z.string()) });
1535
+ z.enum([
1536
+ "pending",
1537
+ "answered",
1538
+ "superseded"
1539
+ ]);
1446
1540
  const sessionEventKind = z.enum([
1447
1541
  "tool_call",
1448
1542
  "error",
@@ -3471,6 +3565,85 @@ function cleanWorkspaces(workspaceRoot, activeChatIds, ttlMs = DEFAULT_WORKSPACE
3471
3565
  }
3472
3566
  return removed;
3473
3567
  }
3568
+ const pending = /* @__PURE__ */ new Map();
3569
+ /**
3570
+ * Register a Promise that will resolve when the matching `question_answer`
3571
+ * message arrives. Same `correlationId` re-registration is treated as the
3572
+ * SDK retrying — the previous entry is rejected as superseded so its
3573
+ * `canUseTool` callback unblocks.
3574
+ */
3575
+ function registerPendingQuestion(args) {
3576
+ const { correlationId, agentId, chatId } = args;
3577
+ const existing = pending.get(correlationId);
3578
+ if (existing) existing.resolve({
3579
+ status: "denied",
3580
+ reason: "Question re-registered with the same correlation id."
3581
+ });
3582
+ return new Promise((resolve) => {
3583
+ pending.set(correlationId, {
3584
+ agentId,
3585
+ chatId,
3586
+ registeredAt: Date.now(),
3587
+ resolve
3588
+ });
3589
+ });
3590
+ }
3591
+ /**
3592
+ * Best-effort resolve. Called by the inbox dispatcher when a
3593
+ * `format: "question_answer"` message arrives. Returns `true` when an entry
3594
+ * matched (so the caller knows it can ack + skip normal session routing),
3595
+ * `false` when there was no waiter (stale answer, e.g. session resumed after
3596
+ * the bridge was already cleaned up).
3597
+ *
3598
+ * Schema validation lives here too — a malformed answer payload is logged
3599
+ * (well, `false` returned and the dispatcher emits a warn) but never throws.
3600
+ */
3601
+ function tryResolveQuestionAnswer(content) {
3602
+ const parsed = questionAnswerMessageContentSchema.safeParse(content);
3603
+ if (!parsed.success) return false;
3604
+ const entry = pending.get(parsed.data.correlationId);
3605
+ if (!entry) return false;
3606
+ pending.delete(parsed.data.correlationId);
3607
+ entry.resolve({
3608
+ status: "answered",
3609
+ answers: parsed.data.answers
3610
+ });
3611
+ return true;
3612
+ }
3613
+ /** Cleanup hook used by handler.shutdown() to fail-fast every in-flight question. */
3614
+ function rejectPendingForAgent(agentId, reason) {
3615
+ let count = 0;
3616
+ for (const [correlationId, entry] of pending) {
3617
+ if (entry.agentId !== agentId) continue;
3618
+ pending.delete(correlationId);
3619
+ entry.resolve({
3620
+ status: "denied",
3621
+ reason
3622
+ });
3623
+ count++;
3624
+ }
3625
+ return count;
3626
+ }
3627
+ /**
3628
+ * Silent cleanup for `handler.suspend()`. Removes pending entries WITHOUT
3629
+ * resolving the Promise — the SDK process is being torn down anyway, and
3630
+ * resolving would unblock the canUseTool callback whose return value the
3631
+ * SDK then writes to a now-closed transport (the symptom: 'ProcessTransport
3632
+ * is not ready for writing' uncaught). The orphaned Promise stack frame is
3633
+ * GC'd alongside the SDK process exit. When the user eventually answers,
3634
+ * SessionManager.dispatch sees no matching waiter (`tryResolveQuestionAnswer`
3635
+ * returns false) and routes the answer message through the normal dispatch
3636
+ * path so the suspended session resumes with the answer as fresh input.
3637
+ */
3638
+ function clearPendingForAgent(agentId) {
3639
+ let count = 0;
3640
+ for (const [correlationId, entry] of pending) {
3641
+ if (entry.agentId !== agentId) continue;
3642
+ pending.delete(correlationId);
3643
+ count++;
3644
+ }
3645
+ return count;
3646
+ }
3474
3647
  /**
3475
3648
  * Resolve which Claude Code binary the SDK should spawn.
3476
3649
  *
@@ -3826,6 +3999,90 @@ const createClaudeCodeHandler = (config) => {
3826
3999
  recordAppliedPayload(sessionCtx);
3827
4000
  consumerDone = consumeOutput(sessionCtx);
3828
4001
  }
4002
+ /**
4003
+ * Build the SDK `canUseTool` callback for this session. Auto-allows every
4004
+ * tool except `AskUserQuestion`, which we route through the Hub's inbox:
4005
+ *
4006
+ * 1. Validate the SDK's question shape against the shared Zod schema (so
4007
+ * a malformed model output can't smuggle bad data into Hub messages).
4008
+ * 2. Send a `format: "question"` message via the agent SDK — this hits
4009
+ * the server's `sendMessage` path which writes the `pending_questions`
4010
+ * lifecycle row in the same transaction (see commit 2).
4011
+ * 3. Register a Promise keyed on the SDK `toolUseID`. The matching
4012
+ * `question_answer` message arrives over the inbox WS / poll path
4013
+ * and SessionManager.dispatch resolves the Promise (commit 2 wired
4014
+ * the answer route + supersede hooks; SessionManager wiring lives
4015
+ * in this commit).
4016
+ * 4. Map the bridge result to `PermissionResult`: `answered` →
4017
+ * `{ behavior: "allow", updatedInput: { questions, answers } }`,
4018
+ * `denied` → `{ behavior: "deny", message }` so the model abandons
4019
+ * the call instead of looping.
4020
+ *
4021
+ * `bypassPermissions` mode still calls `canUseTool` for `AskUserQuestion`
4022
+ * specifically — verified by `tmp-verify/verify.mjs` cases A through G.
4023
+ */
4024
+ function buildAskUserCanUseTool(sessionCtx) {
4025
+ return async (toolName, input, options) => {
4026
+ if (toolName !== "AskUserQuestion") return {
4027
+ behavior: "allow",
4028
+ updatedInput: input
4029
+ };
4030
+ const parsed = z.object({ questions: z.array(questionItemSchema).min(1).max(4) }).safeParse(input);
4031
+ if (!parsed.success) {
4032
+ sessionCtx.log(`AskUserQuestion: malformed input — ${parsed.error.message.slice(0, 200)}`);
4033
+ return {
4034
+ behavior: "deny",
4035
+ message: "AskUserQuestion input did not validate; abandon the question and pick a different tool or answer."
4036
+ };
4037
+ }
4038
+ const correlationId = options.toolUseID;
4039
+ const questions = parsed.data.questions;
4040
+ const questionContent = {
4041
+ correlationId,
4042
+ questions,
4043
+ previewFormat: "html",
4044
+ allowFreeText: true
4045
+ };
4046
+ try {
4047
+ await sessionCtx.sdk.sendMessage(sessionCtx.chatId, {
4048
+ format: "question",
4049
+ content: questionContent
4050
+ });
4051
+ } catch (err) {
4052
+ const reason = err instanceof Error ? err.message : String(err);
4053
+ sessionCtx.log(`AskUserQuestion: failed to publish question — ${reason}`);
4054
+ return {
4055
+ behavior: "deny",
4056
+ message: `Hub could not publish the question (${reason}); abandon the call.`
4057
+ };
4058
+ }
4059
+ sessionCtx.log(`AskUserQuestion: published correlationId=${correlationId}; awaiting user answer`);
4060
+ const result = await registerPendingQuestion({
4061
+ correlationId,
4062
+ agentId: sessionCtx.agent.agentId,
4063
+ chatId: sessionCtx.chatId
4064
+ });
4065
+ if (options.signal.aborted) return {
4066
+ behavior: "deny",
4067
+ message: "AskUserQuestion aborted before an answer arrived."
4068
+ };
4069
+ if (result.status === "denied") {
4070
+ sessionCtx.log(`AskUserQuestion: denied correlationId=${correlationId} reason=${result.reason}`);
4071
+ return {
4072
+ behavior: "deny",
4073
+ message: result.reason
4074
+ };
4075
+ }
4076
+ sessionCtx.log(`AskUserQuestion: answered correlationId=${correlationId}`);
4077
+ return {
4078
+ behavior: "allow",
4079
+ updatedInput: {
4080
+ questions,
4081
+ answers: result.answers
4082
+ }
4083
+ };
4084
+ };
4085
+ }
3829
4086
  /** Rebuild query and input controller without starting a new consumer loop (used for retry within the existing loop). */
3830
4087
  function respawnQuery(sessionId, sessionCtx) {
3831
4088
  buildQuery(sessionId, sessionCtx, sessionId);
@@ -3858,6 +4115,8 @@ const createClaudeCodeHandler = (config) => {
3858
4115
  allowDangerouslySkipPermissions: true,
3859
4116
  settingSources: ["user", "project"],
3860
4117
  env: buildEnv(sessionCtx),
4118
+ canUseTool: buildAskUserCanUseTool(sessionCtx),
4119
+ toolConfig: { askUserQuestion: { previewFormat: "html" } },
3861
4120
  ...claudeCodeExecutable ? { pathToClaudeCodeExecutable: claudeCodeExecutable } : {},
3862
4121
  ...payload?.model ? { model: payload.model } : {},
3863
4122
  ...payload?.prompt.append ? { systemPrompt: {
@@ -4120,6 +4379,11 @@ const createClaudeCodeHandler = (config) => {
4120
4379
  },
4121
4380
  async suspend() {
4122
4381
  ctx?.log("Suspending session");
4382
+ const sessionCtx = ctx;
4383
+ if (sessionCtx) {
4384
+ const dropped = clearPendingForAgent(sessionCtx.agent.agentId);
4385
+ if (dropped > 0) sessionCtx.log(`Cleared ${dropped} pending AskUserQuestion entries on suspend`);
4386
+ }
4123
4387
  if (inputController) {
4124
4388
  inputController.end();
4125
4389
  inputController = null;
@@ -4137,6 +4401,10 @@ const createClaudeCodeHandler = (config) => {
4137
4401
  async shutdown() {
4138
4402
  const sessionCtx = ctx;
4139
4403
  await handler.suspend();
4404
+ if (sessionCtx) {
4405
+ const dropped = rejectPendingForAgent(sessionCtx.agent.agentId, "Session shutting down.");
4406
+ if (dropped > 0) sessionCtx.log(`Rejected ${dropped} pending AskUserQuestion entries during shutdown`);
4407
+ }
4140
4408
  if (sessionCtx) await cleanupGitWorktrees(sessionCtx);
4141
4409
  if (cwd) {
4142
4410
  try {
@@ -4815,8 +5083,34 @@ function resolveSenderLabel(senderId, participants) {
4815
5083
  * Async because the participant list may need a server round-trip on first
4816
5084
  * use; subsequent messages in the same session hit the cache.
4817
5085
  */
5086
+ /**
5087
+ * Convert a SessionMessage's payload to a plain-text snippet the LLM can
5088
+ * read as user input. Most formats are already strings; the special case
5089
+ * is `question_answer` — when an answer arrives AFTER the SDK process was
5090
+ * suspended, SessionManager.dispatch routes it through the normal path,
5091
+ * and we need to render the structured `{correlationId, answers}` payload
5092
+ * as readable English so the resumed Claude turn can act on it.
5093
+ */
5094
+ function renderForLLM(message) {
5095
+ if (message.format === "question_answer" && message.content && typeof message.content === "object") {
5096
+ const c = message.content;
5097
+ if (c.answers && typeof c.answers === "object") {
5098
+ const lines = [];
5099
+ for (const [question, answer] of Object.entries(c.answers)) {
5100
+ lines.push(`Q: ${question}`);
5101
+ lines.push(`A: ${answer}`);
5102
+ }
5103
+ return [
5104
+ "[The user has answered an earlier AskUserQuestion you raised. Continue the task using their answers below; do NOT call AskUserQuestion again for the same questions.]",
5105
+ "",
5106
+ ...lines
5107
+ ].join("\n");
5108
+ }
5109
+ }
5110
+ return typeof message.content === "string" ? message.content : JSON.stringify(message.content);
5111
+ }
4818
5112
  async function formatInboundContent(message, participants) {
4819
- const rawContent = typeof message.content === "string" ? message.content : JSON.stringify(message.content);
5113
+ const rawContent = renderForLLM(message);
4820
5114
  const preceding = message.precedingMessages ?? [];
4821
5115
  let header = "";
4822
5116
  if (preceding.length > 0) {
@@ -5019,6 +5313,16 @@ var SessionManager = class {
5019
5313
  async dispatch(entry) {
5020
5314
  const chatId = entry.chatId ?? entry.message.chatId;
5021
5315
  const messageId = entry.message.id;
5316
+ if (entry.message.format === "question_answer") {
5317
+ if (tryResolveQuestionAnswer(entry.message.content)) {
5318
+ await this.ackEntry(entry.id, chatId);
5319
+ return;
5320
+ }
5321
+ this.config.log.info({
5322
+ chatId,
5323
+ messageId
5324
+ }, "question_answer with no live bridge waiter — resuming session with answer as input");
5325
+ }
5022
5326
  const dedupKey = `${chatId}:${messageId}`;
5023
5327
  if (this.deduplicator.isDuplicate(dedupKey)) {
5024
5328
  this.config.log.debug({
@@ -8376,7 +8680,7 @@ async function onboardCreate(args) {
8376
8680
  }
8377
8681
  const runtimeAgent = args.type === "human" ? args.assistant : args.id;
8378
8682
  if (args.feishuBotAppId && args.feishuBotAppSecret) {
8379
- const { bindFeishuBot } = await import("./feishu-DbSvp9UH.mjs").then((n) => n.r);
8683
+ const { bindFeishuBot } = await import("./feishu-C6qlhju2.mjs").then((n) => n.r);
8380
8684
  const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
8381
8685
  if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
8382
8686
  else {
@@ -9589,7 +9893,7 @@ function createFeedbackHandler(config) {
9589
9893
  return { handle };
9590
9894
  }
9591
9895
  //#endregion
9592
- //#region ../server/dist/app--DB1keQE.mjs
9896
+ //#region ../server/dist/app-CVe_gn9M.mjs
9593
9897
  var import_fastify_opentelemetry = /* @__PURE__ */ __toESM(require_fastify_opentelemetry(), 1);
9594
9898
  init_esm();
9595
9899
  var __defProp = Object.defineProperty;
@@ -9613,53 +9917,6 @@ const adapterAgentMappings = pgTable("adapter_agent_mappings", {
9613
9917
  metadata: jsonb("metadata").$type().notNull().default({}),
9614
9918
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
9615
9919
  }, (table) => [unique("uq_adapter_agent_mapping").on(table.platform, table.externalUserId)]);
9616
- /** Communication container. All messages between agents flow within a Chat. */
9617
- const chats = pgTable("chats", {
9618
- id: text("id").primaryKey(),
9619
- organizationId: text("organization_id").notNull().references(() => organizations.id),
9620
- type: text("type").notNull().default("direct"),
9621
- topic: text("topic"),
9622
- lifecyclePolicy: text("lifecycle_policy").default("persistent"),
9623
- parentChatId: text("parent_chat_id"),
9624
- metadata: jsonb("metadata").$type().notNull().default({}),
9625
- lastMessageAt: timestamp("last_message_at", { withTimezone: true }),
9626
- lastMessagePreview: text("last_message_preview"),
9627
- createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
9628
- updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
9629
- }, (table) => [index("idx_chats_org_last_message").on(table.organizationId, desc(table.lastMessageAt))]);
9630
- /** Speaking participants of a chat (M:N). Watchers live in chat_subscriptions. */
9631
- const chatParticipants = pgTable("chat_participants", {
9632
- chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
9633
- agentId: text("agent_id").notNull().references(() => agents.uuid),
9634
- role: text("role").notNull().default("member"),
9635
- mode: text("mode").notNull().default("full"),
9636
- lastReadAt: timestamp("last_read_at", { withTimezone: true }),
9637
- unreadMentionCount: integer("unread_mention_count").notNull().default(0),
9638
- joinedAt: timestamp("joined_at", { withTimezone: true }).notNull().defaultNow()
9639
- }, (table) => [primaryKey({ columns: [table.chatId, table.agentId] }), index("idx_participants_agent").on(table.agentId)]);
9640
- /**
9641
- * Non-speaking observers ("watchers"). Used by the chat-first workspace so a
9642
- * user can supervise chats their managed agents participate in without
9643
- * accidentally being part of fan-out.
9644
- *
9645
- * Invariants:
9646
- * 1. (chat_id, agent_id) is mutually exclusive with chat_participants.
9647
- * 2. Rows here NEVER produce inbox_entries (fan-out exclusivity).
9648
- * 3. Mention candidate resolution NEVER includes these rows.
9649
- * 4. State transitions (join/leave) carry last_read_at + counter; lifecycle
9650
- * recomputes default to NULL/0 and MUST NOT run on the join/leave path.
9651
- *
9652
- * See docs/chat-first-workspace-product-design.md "Data Model" + "State
9653
- * Transitions" for the full contract.
9654
- */
9655
- const chatSubscriptions = pgTable("chat_subscriptions", {
9656
- chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
9657
- agentId: text("agent_id").notNull().references(() => agents.uuid),
9658
- kind: text("kind").notNull().default("watching"),
9659
- lastReadAt: timestamp("last_read_at", { withTimezone: true }),
9660
- unreadMentionCount: integer("unread_mention_count").notNull().default(0),
9661
- createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
9662
- }, (table) => [primaryKey({ columns: [table.chatId, table.agentId] }), index("idx_chat_subscriptions_agent").on(table.agentId)]);
9663
9920
  /**
9664
9921
  * Tasks — lightweight work units. Process descriptors, not tickets.
9665
9922
  * Immutable status state machine: pending → assigned → working → (completed | failed | cancelled).
@@ -10090,7 +10347,7 @@ async function deleteAdapterConfig(db, id) {
10090
10347
  const [row] = await db.delete(adapterConfigs).where(eq(adapterConfigs.id, id)).returning();
10091
10348
  if (!row) throw new NotFoundError(`Adapter config "${id}" not found`);
10092
10349
  }
10093
- const log$7 = createLogger$1("Adapters");
10350
+ const log$5 = createLogger$1("Adapters");
10094
10351
  function parseId(raw) {
10095
10352
  const id = Number(raw);
10096
10353
  if (!Number.isInteger(id) || id <= 0) throw new BadRequestError(`Invalid adapter ID: "${raw}"`);
@@ -10116,7 +10373,7 @@ async function adapterRoutes(app) {
10116
10373
  const existing = await getAdapterConfig(app.db, id);
10117
10374
  await assertAgentManageableByUser(app.db, userId, existing.agentId);
10118
10375
  const config = await updateAdapterConfig(app.db, id, body, app.config.secrets.encryptionKey);
10119
- app.adapterManager.reload().catch((err) => log$7.error({ err }, "adapter reload failed after update"));
10376
+ app.adapterManager.reload().catch((err) => log$5.error({ err }, "adapter reload failed after update"));
10120
10377
  app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
10121
10378
  return {
10122
10379
  ...config,
@@ -10130,7 +10387,7 @@ async function adapterRoutes(app) {
10130
10387
  const existing = await getAdapterConfig(app.db, id);
10131
10388
  await assertAgentManageableByUser(app.db, userId, existing.agentId);
10132
10389
  await deleteAdapterConfig(app.db, id);
10133
- app.adapterManager.reload().catch((err) => log$7.error({ err }, "adapter reload failed after delete"));
10390
+ app.adapterManager.reload().catch((err) => log$5.error({ err }, "adapter reload failed after delete"));
10134
10391
  app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
10135
10392
  return reply.status(204).send();
10136
10393
  });
@@ -10146,619 +10403,6 @@ function requireAgent(request) {
10146
10403
  if (!agent) throw new UnauthorizedError("Agent authentication required");
10147
10404
  return agent;
10148
10405
  }
10149
- /**
10150
- * Process-local cache for the per-chat realtime push audience
10151
- * (`chat_participants ∪ chat_subscriptions`, keyed by human agent
10152
- * uuid). Sits in front of the admin WS dispatch so a chat with N
10153
- * messages/sec doesn't issue N audience-resolution queries; one query
10154
- * + cache hit per chat per TTL window.
10155
- *
10156
- * The cache exposes both a populator (`getCachedAudience`) and an
10157
- * invalidator (`invalidateChatAudience`). Participant-mutation paths
10158
- * (`addMeChatParticipants`, `joinMeChat`, `leaveMeChat`,
10159
- * `recomputeChatWatchers`, `joinAsParticipant`, `leaveAsParticipant`)
10160
- * MUST call `invalidateChatAudience` after their tx commits so the
10161
- * very next dispatch reflects the new audience without waiting for
10162
- * the TTL to age out — without invalidation, a freshly-added speaker
10163
- * would miss `chat:message` pushes for up to TTL_MS.
10164
- *
10165
- * Cross-instance correctness: not handled here. The PG NOTIFY layer
10166
- * already broadcasts message events to every replica; each replica's
10167
- * audience cache is independently invalidated by its own
10168
- * service-layer mutations on chats it routes traffic for. For
10169
- * cross-replica participant changes to invalidate this cache, route
10170
- * the mutation through the same replica that hosts the WS connection
10171
- * (sticky routing) or add a dedicated `chat:audience` PG NOTIFY in
10172
- * a follow-up.
10173
- */
10174
- const log$6 = createLogger$1("ChatAudienceCache");
10175
- const TTL_MS = 5e3;
10176
- const MAX_ENTRIES = 1024;
10177
- const cache = /* @__PURE__ */ new Map();
10178
- /** Resolve a chat's push audience, hitting the cache when fresh.
10179
- * Returns null on DB error (caller should skip dispatch). */
10180
- async function getCachedAudience(db, chatId) {
10181
- const now = Date.now();
10182
- const cached = cache.get(chatId);
10183
- if (cached && cached.expiresAt > now) return cached.audience;
10184
- try {
10185
- const rows = await db.execute(sql`
10186
- SELECT agent_id FROM chat_participants WHERE chat_id = ${chatId}
10187
- UNION
10188
- SELECT agent_id FROM chat_subscriptions WHERE chat_id = ${chatId}
10189
- `);
10190
- const audience = new Set(rows.map((r) => r.agent_id));
10191
- cache.set(chatId, {
10192
- audience,
10193
- expiresAt: now + TTL_MS
10194
- });
10195
- if (cache.size > MAX_ENTRIES) {
10196
- for (const [k, v] of cache) if (v.expiresAt <= now) cache.delete(k);
10197
- }
10198
- return audience;
10199
- } catch (err) {
10200
- log$6.warn({
10201
- err,
10202
- chatId
10203
- }, "failed to resolve chat audience");
10204
- return null;
10205
- }
10206
- }
10207
- /** Drop the cached audience for a chat. Called from participant-
10208
- * mutation paths after their transaction commits, so the next
10209
- * `chat:message` dispatch hits the DB and reflects the new
10210
- * membership instead of serving a stale TTL window. */
10211
- function invalidateChatAudience(chatId) {
10212
- cache.delete(chatId);
10213
- }
10214
- /**
10215
- * Chat-first workspace — watcher subscription helpers.
10216
- *
10217
- * Watchers (rows in `chat_subscriptions`) are non-speaking observers. A
10218
- * member who manages an agent that participates in a chat — but whose own
10219
- * human agent is not a speaker there — sees the chat in their workspace
10220
- * via a watcher row.
10221
- *
10222
- * Two distinct kinds of operation live here:
10223
- *
10224
- * 1. Set rebuilds (`recompute*`). Idempotent set-based recomputations
10225
- * driven by lifecycle events (chat created, participant added/removed,
10226
- * member status flipped, etc.). These DEFAULT new rows to NULL/0 read
10227
- * state.
10228
- *
10229
- * 2. State-carry transitions (`joinAsParticipant`, `leaveAsParticipant`).
10230
- * Move a single (chat, agent) pair between `chat_participants` and
10231
- * `chat_subscriptions` while preserving `last_read_at` and
10232
- * `unread_mention_count`. NEVER call recompute on this path or you'll
10233
- * lose read state.
10234
- *
10235
- * See docs/chat-first-workspace-product-design.md "State Transitions" and
10236
- * "Risk Constraints".
10237
- */
10238
- /**
10239
- * Recompute watcher rows for ONE chat. For every active member who:
10240
- * - manages a non-human agent that speaks in the chat, AND
10241
- * - whose own human agent is NOT a speaker in the chat
10242
- * an `(chat_id, member.agent_id)` watcher row is upserted (NULL read state).
10243
- *
10244
- * Watchers whose anchoring condition no longer holds (manager left, the
10245
- * managed agent was removed from the chat, the manager joined as a speaker
10246
- * themselves) are deleted.
10247
- *
10248
- * Idempotent: safe to call multiple times for the same chat.
10249
- */
10250
- async function recomputeChatWatchers(db, chatId) {
10251
- await db.execute(sql`
10252
- INSERT INTO chat_subscriptions
10253
- (chat_id, agent_id, kind, last_read_at, unread_mention_count, created_at)
10254
- SELECT DISTINCT cp.chat_id, m.agent_id, 'watching', NULL::timestamp with time zone, 0, now()
10255
- FROM chat_participants cp
10256
- JOIN agents a ON a.uuid = cp.agent_id
10257
- JOIN members m ON m.id = a.manager_id
10258
- WHERE cp.chat_id = ${chatId}
10259
- AND m.status = 'active'
10260
- AND a.type <> 'human'
10261
- AND NOT EXISTS (
10262
- SELECT 1 FROM chat_participants cp2
10263
- WHERE cp2.chat_id = cp.chat_id
10264
- AND cp2.agent_id = m.agent_id
10265
- )
10266
- ON CONFLICT (chat_id, agent_id) DO NOTHING
10267
- `);
10268
- await db.execute(sql`
10269
- DELETE FROM chat_subscriptions cs
10270
- WHERE cs.chat_id = ${chatId}
10271
- AND NOT EXISTS (
10272
- SELECT 1
10273
- FROM chat_participants cp
10274
- JOIN agents a ON a.uuid = cp.agent_id
10275
- JOIN members m ON m.id = a.manager_id
10276
- WHERE cp.chat_id = cs.chat_id
10277
- AND m.agent_id = cs.agent_id
10278
- AND m.status = 'active'
10279
- AND a.type <> 'human'
10280
- AND NOT EXISTS (
10281
- SELECT 1 FROM chat_participants cp2
10282
- WHERE cp2.chat_id = cp.chat_id
10283
- AND cp2.agent_id = m.agent_id
10284
- )
10285
- )
10286
- `);
10287
- }
10288
- /**
10289
- * Recompute watcher rows touching ONE agent across all chats it speaks in.
10290
- * Used after `rebindAgent` (manager change) so the new manager picks up
10291
- * watcher rows and the old manager's are dropped.
10292
- */
10293
- async function recomputeWatchersForAgent(db, agentId) {
10294
- const chatRows = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(eq(chatParticipants.agentId, agentId));
10295
- for (const { chatId } of chatRows) await recomputeChatWatchers(db, chatId);
10296
- }
10297
- /**
10298
- * Recompute watcher rows touching ONE member across all chats. Triggered
10299
- * when the member's status flips active ↔ left.
10300
- */
10301
- async function recomputeWatchersForMember(db, memberId) {
10302
- const rows = await db.selectDistinct({ chatId: chatParticipants.chatId }).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(and(eq(agents.managerId, memberId), ne(agents.type, "human")));
10303
- for (const { chatId } of rows) await recomputeChatWatchers(db, chatId);
10304
- }
10305
- /**
10306
- * Mirror of `services/chat.ts` `maybeUpgradeDirectToGroup`. Inlined here so
10307
- * `joinAsParticipant` keeps the upgrade rule + the state carry in one
10308
- * transaction without depending on chat.ts (avoids a circular import).
10309
- */
10310
- async function maybeUpgradeDirectToGroup$1(tx, chatId, existingParticipantIds) {
10311
- if (existingParticipantIds.length + 1 < 3) return;
10312
- const [chat] = await tx.select({ type: chats.type }).from(chats).where(eq(chats.id, chatId)).limit(1);
10313
- if (!chat || chat.type !== "direct") return;
10314
- await tx.update(chats).set({
10315
- type: "group",
10316
- updatedAt: /* @__PURE__ */ new Date()
10317
- }).where(eq(chats.id, chatId));
10318
- if (existingParticipantIds.length === 0) return;
10319
- const ids = (await tx.select({ uuid: agents.uuid }).from(agents).where(and(inArray(agents.uuid, existingParticipantIds), ne(agents.type, "human")))).map((r) => r.uuid);
10320
- if (ids.length === 0) return;
10321
- await tx.update(chatParticipants).set({ mode: "mention_only" }).where(and(eq(chatParticipants.chatId, chatId), inArray(chatParticipants.agentId, ids)));
10322
- }
10323
- /**
10324
- * Watcher → speaking participant. State-carry transaction.
10325
- *
10326
- * 1. DELETE the watcher row (returning read state).
10327
- * 2. If a participant row already exists, no-op (idempotent).
10328
- * 3. Otherwise, run the direct → group upgrade rule against the *current*
10329
- * participant set, then INSERT the participant row carrying read state.
10330
- *
10331
- * If `requireWatcherOrVisible` is true, refuse when the user has neither a
10332
- * watcher row nor admin-derived visibility — used to keep the public
10333
- * `/me/chats/:chatId/join` endpoint honest. Pre-check happens in the
10334
- * route layer where we have the full member scope.
10335
- */
10336
- async function joinAsParticipant(db, chatId, humanAgentId) {
10337
- return db.transaction(async (tx) => {
10338
- const [carriedRow] = await tx.delete(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, humanAgentId))).returning({
10339
- lastReadAt: chatSubscriptions.lastReadAt,
10340
- unreadMentionCount: chatSubscriptions.unreadMentionCount
10341
- });
10342
- const [existing] = await tx.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, humanAgentId))).limit(1);
10343
- if (existing) return {
10344
- chatId,
10345
- inserted: false,
10346
- carried: carriedRow ?? null
10347
- };
10348
- await maybeUpgradeDirectToGroup$1(tx, chatId, (await tx.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId))).map((p) => p.agentId));
10349
- await tx.insert(chatParticipants).values({
10350
- chatId,
10351
- agentId: humanAgentId,
10352
- role: "member",
10353
- mode: "full",
10354
- lastReadAt: carriedRow?.lastReadAt ?? null,
10355
- unreadMentionCount: carriedRow?.unreadMentionCount ?? 0
10356
- });
10357
- return {
10358
- chatId,
10359
- inserted: true,
10360
- carried: carriedRow ?? null
10361
- };
10362
- });
10363
- }
10364
- /**
10365
- * Speaking participant → watcher (or fully detach).
10366
- *
10367
- * 1. DELETE the participant row (returning read state).
10368
- * 2. Test "still visible": is the user still the manager of an agent that
10369
- * remains a participant in this chat? If yes, INSERT a watcher row
10370
- * carrying read state. If no, drop entirely.
10371
- *
10372
- * Caller must validate that the user actually has a participant row to
10373
- * leave (returns `NotFoundError` if not).
10374
- */
10375
- async function leaveAsParticipant(db, chatId, humanAgentId) {
10376
- return db.transaction(async (tx) => {
10377
- const [carried] = await tx.delete(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, humanAgentId))).returning({
10378
- lastReadAt: chatParticipants.lastReadAt,
10379
- unreadMentionCount: chatParticipants.unreadMentionCount
10380
- });
10381
- if (!carried) throw new NotFoundError("Not a participant of this chat");
10382
- const [stillVisibleRow] = await tx.execute(sql`
10383
- SELECT EXISTS (
10384
- SELECT 1
10385
- FROM chat_participants cp
10386
- JOIN agents a ON a.uuid = cp.agent_id
10387
- JOIN members m ON m.id = a.manager_id
10388
- WHERE cp.chat_id = ${chatId}
10389
- AND m.agent_id = ${humanAgentId}
10390
- AND m.status = 'active'
10391
- AND a.type <> 'human'
10392
- ) AS visible
10393
- `);
10394
- if (!Boolean(stillVisibleRow?.visible)) return {
10395
- chatId,
10396
- membershipKind: null
10397
- };
10398
- await tx.insert(chatSubscriptions).values({
10399
- chatId,
10400
- agentId: humanAgentId,
10401
- kind: "watching",
10402
- lastReadAt: carried.lastReadAt,
10403
- unreadMentionCount: carried.unreadMentionCount
10404
- }).onConflictDoNothing();
10405
- return {
10406
- chatId,
10407
- membershipKind: "watching"
10408
- };
10409
- });
10410
- }
10411
- /**
10412
- * Resolve the membership row of the human agent for the given chat. Returns
10413
- * one of: 'participant', 'watching', or null.
10414
- *
10415
- * Used by `/me/chats/:chatId/join` to refuse a join when the user has
10416
- * neither a watcher row nor a participant row, and isn't otherwise
10417
- * authorised (admin in the chat's org).
10418
- */
10419
- async function resolveChatMembership(db, chatId, humanAgentId) {
10420
- const [participant] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, humanAgentId))).limit(1);
10421
- if (participant) return "participant";
10422
- const [sub] = await db.select({ chatId: chatSubscriptions.chatId }).from(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, humanAgentId))).limit(1);
10423
- if (sub) return "watching";
10424
- return null;
10425
- }
10426
- /**
10427
- * Used by `/me/chats/:chatId/join`. Throw 409 if already a speaker (no work
10428
- * to do) and 403 if no watcher row and no admin override. Admin override is
10429
- * resolved at the route layer; this helper only reports the watcher state.
10430
- */
10431
- function ensureCanJoin(membership) {
10432
- if (membership === "participant") throw new ConflictError("Already a participant in this chat");
10433
- if (membership === null) throw new ForbiddenError("Not a watcher of this chat — open the chat from your workspace before joining");
10434
- }
10435
- /**
10436
- * When a direct chat grows past 2 participants, upgrade it to `group` and
10437
- * flip every existing non-human agent participant to `mention_only` — see
10438
- * proposals/hub-agent-messaging-reply-and-mentions §3.3. The caller is
10439
- * expected to insert the new participant AFTER this runs, so the "existing"
10440
- * set excludes them.
10441
- *
10442
- * Idempotent: if the chat is already a group, no-op.
10443
- */
10444
- async function maybeUpgradeDirectToGroup(db, chatId, existingParticipantIds, newParticipantCount) {
10445
- if (existingParticipantIds.length + newParticipantCount < 3) return;
10446
- const [chat] = await db.select({ type: chats.type }).from(chats).where(eq(chats.id, chatId)).limit(1);
10447
- if (!chat || chat.type !== "direct") return;
10448
- await db.update(chats).set({
10449
- type: "group",
10450
- updatedAt: /* @__PURE__ */ new Date()
10451
- }).where(eq(chats.id, chatId));
10452
- if (existingParticipantIds.length === 0) return;
10453
- const ids = (await db.select({ uuid: agents.uuid }).from(agents).where(and(inArray(agents.uuid, existingParticipantIds), ne(agents.type, "human")))).map((a) => a.uuid);
10454
- if (ids.length === 0) return;
10455
- await db.update(chatParticipants).set({ mode: "mention_only" }).where(and(eq(chatParticipants.chatId, chatId), inArray(chatParticipants.agentId, ids)));
10456
- }
10457
- async function createChat(db, creatorId, data) {
10458
- const chatId = randomUUID();
10459
- const allParticipantIds = new Set([creatorId, ...data.participantIds]);
10460
- const existingAgents = await db.select({
10461
- id: agents.uuid,
10462
- organizationId: agents.organizationId,
10463
- type: agents.type
10464
- }).from(agents).where(inArray(agents.uuid, [...allParticipantIds]));
10465
- if (existingAgents.length !== allParticipantIds.size) {
10466
- const found = new Set(existingAgents.map((a) => a.id));
10467
- throw new BadRequestError(`Agents not found: ${[...allParticipantIds].filter((id) => !found.has(id)).join(", ")}`);
10468
- }
10469
- const creator = existingAgents.find((a) => a.id === creatorId);
10470
- if (!creator) throw new Error("Unexpected: creator not in existingAgents");
10471
- const orgId = creator.organizationId;
10472
- const crossOrg = existingAgents.filter((a) => a.organizationId !== orgId);
10473
- if (crossOrg.length > 0) throw new BadRequestError(`Cross-organization chat not allowed: ${crossOrg.map((a) => a.id).join(", ")}`);
10474
- const isDirectAgentOnly = data.type === "direct" && existingAgents.every((a) => a.type !== "human");
10475
- return db.transaction(async (tx) => {
10476
- const [chat] = await tx.insert(chats).values({
10477
- id: chatId,
10478
- organizationId: orgId,
10479
- type: data.type,
10480
- topic: data.topic ?? null,
10481
- metadata: data.metadata ?? {}
10482
- }).returning();
10483
- const participantRows = [...allParticipantIds].map((agentId) => ({
10484
- chatId,
10485
- agentId,
10486
- role: agentId === creatorId ? "owner" : "member",
10487
- ...isDirectAgentOnly ? { mode: "mention_only" } : {}
10488
- }));
10489
- await tx.insert(chatParticipants).values(participantRows);
10490
- await recomputeChatWatchers(tx, chatId);
10491
- const participants = await tx.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
10492
- if (!chat) throw new Error("Unexpected: INSERT RETURNING produced no row");
10493
- return {
10494
- ...chat,
10495
- participants
10496
- };
10497
- });
10498
- }
10499
- async function getChat(db, chatId) {
10500
- const [chat] = await db.select().from(chats).where(eq(chats.id, chatId)).limit(1);
10501
- if (!chat) throw new NotFoundError(`Chat "${chatId}" not found`);
10502
- return chat;
10503
- }
10504
- async function getChatDetail(db, chatId) {
10505
- const chat = await getChat(db, chatId);
10506
- const participants = await db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
10507
- return {
10508
- ...chat,
10509
- participants
10510
- };
10511
- }
10512
- async function listChats(db, agentId, limit, cursor) {
10513
- const chatIds = (await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(eq(chatParticipants.agentId, agentId))).map((r) => r.chatId);
10514
- if (chatIds.length === 0) return {
10515
- items: [],
10516
- nextCursor: null
10517
- };
10518
- const where = cursor ? and(inArray(chats.id, chatIds), lt(chats.updatedAt, new Date(cursor))) : inArray(chats.id, chatIds);
10519
- const rows = await db.select().from(chats).where(where).orderBy(desc(chats.updatedAt)).limit(limit + 1);
10520
- const hasMore = rows.length > limit;
10521
- const items = hasMore ? rows.slice(0, limit) : rows;
10522
- const last = items[items.length - 1];
10523
- return {
10524
- items,
10525
- nextCursor: hasMore && last ? last.updatedAt.toISOString() : null
10526
- };
10527
- }
10528
- /**
10529
- * List participants of a chat with their agent names — used by the client
10530
- * runtime to resolve `@<name>` mentions against the authoritative participant
10531
- * set (see proposals/hub-agent-messaging-reply-and-mentions §4).
10532
- */
10533
- async function listChatParticipantsWithNames(db, chatId) {
10534
- return await db.select({
10535
- agentId: chatParticipants.agentId,
10536
- role: chatParticipants.role,
10537
- mode: chatParticipants.mode,
10538
- joinedAt: chatParticipants.joinedAt,
10539
- name: agents.name,
10540
- displayName: agents.displayName,
10541
- type: agents.type
10542
- }).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(eq(chatParticipants.chatId, chatId));
10543
- }
10544
- async function assertParticipant(db, chatId, agentId) {
10545
- const [row] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, agentId))).limit(1);
10546
- if (!row) throw new ForbiddenError("Not a participant of this chat");
10547
- }
10548
- /** Ensure an agent is a participant of a chat. Silently adds them if not already. */
10549
- async function ensureParticipant$1(db, chatId, agentId) {
10550
- const [existing] = await db.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, agentId))).limit(1);
10551
- if (existing) return;
10552
- await db.transaction(async (tx) => {
10553
- await maybeUpgradeDirectToGroup(tx, chatId, (await tx.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId))).map((p) => p.agentId), 1);
10554
- await tx.delete(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, agentId)));
10555
- await tx.insert(chatParticipants).values({
10556
- chatId,
10557
- agentId,
10558
- mode: "full"
10559
- }).onConflictDoNothing({ target: [chatParticipants.chatId, chatParticipants.agentId] });
10560
- await recomputeChatWatchers(tx, chatId);
10561
- });
10562
- invalidateChatAudience(chatId);
10563
- }
10564
- async function addParticipant(db, chatId, requesterId, data) {
10565
- const chat = await getChat(db, chatId);
10566
- await assertParticipant(db, chatId, requesterId);
10567
- const [targetAgent] = await db.select({
10568
- id: agents.uuid,
10569
- organizationId: agents.organizationId
10570
- }).from(agents).where(eq(agents.uuid, data.agentId)).limit(1);
10571
- if (!targetAgent) throw new NotFoundError(`Agent "${data.agentId}" not found`);
10572
- if (targetAgent.organizationId !== chat.organizationId) throw new BadRequestError("Cannot add agent from different organization");
10573
- const [existing] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, data.agentId))).limit(1);
10574
- if (existing) throw new ConflictError(`Agent "${data.agentId}" is already a participant`);
10575
- await db.transaction(async (tx) => {
10576
- await maybeUpgradeDirectToGroup(tx, chatId, (await tx.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId))).map((p) => p.agentId), 1);
10577
- await tx.delete(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, data.agentId)));
10578
- await tx.insert(chatParticipants).values({
10579
- chatId,
10580
- agentId: data.agentId,
10581
- mode: data.mode ?? "full"
10582
- });
10583
- await recomputeChatWatchers(tx, chatId);
10584
- });
10585
- invalidateChatAudience(chatId);
10586
- return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
10587
- }
10588
- async function removeParticipant(db, chatId, requesterId, targetAgentId) {
10589
- await assertParticipant(db, chatId, requesterId);
10590
- if (requesterId === targetAgentId) throw new BadRequestError("Cannot remove yourself from a chat");
10591
- const [removed] = await db.delete(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, targetAgentId))).returning();
10592
- if (!removed) throw new NotFoundError(`Agent "${targetAgentId}" is not a participant of this chat`);
10593
- await recomputeChatWatchers(db, chatId);
10594
- invalidateChatAudience(chatId);
10595
- return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
10596
- }
10597
- /**
10598
- * List chats visible to a member, grouped by agent.
10599
- * A member sees chats where:
10600
- * 1. Their human agent is a participant, OR
10601
- * 2. Any agent they manage (managerId = memberId) is a participant (supervision)
10602
- */
10603
- async function listChatsForMember(db, memberId, humanAgentId) {
10604
- const managedAgents = await db.select({
10605
- uuid: agents.uuid,
10606
- name: agents.name,
10607
- type: agents.type,
10608
- displayName: agents.displayName
10609
- }).from(agents).where(eq(agents.managerId, memberId));
10610
- const agentMap = /* @__PURE__ */ new Map();
10611
- for (const a of managedAgents) agentMap.set(a.uuid, a);
10612
- if (!agentMap.has(humanAgentId)) {
10613
- const [ha] = await db.select({
10614
- uuid: agents.uuid,
10615
- name: agents.name,
10616
- type: agents.type,
10617
- displayName: agents.displayName
10618
- }).from(agents).where(eq(agents.uuid, humanAgentId)).limit(1);
10619
- if (ha) agentMap.set(ha.uuid, ha);
10620
- }
10621
- const agentIds = [...agentMap.keys()];
10622
- if (agentIds.length === 0) return [];
10623
- const participations = await db.select({
10624
- chatId: chatParticipants.chatId,
10625
- agentId: chatParticipants.agentId,
10626
- role: chatParticipants.role,
10627
- mode: chatParticipants.mode
10628
- }).from(chatParticipants).where(inArray(chatParticipants.agentId, agentIds));
10629
- if (participations.length === 0) return [];
10630
- const chatIds = [...new Set(participations.map((p) => p.chatId))];
10631
- const agentChatMap = /* @__PURE__ */ new Map();
10632
- for (const p of participations) {
10633
- const list = agentChatMap.get(p.agentId) ?? [];
10634
- list.push(p.chatId);
10635
- agentChatMap.set(p.agentId, list);
10636
- }
10637
- const chatRows = await db.select({
10638
- id: chats.id,
10639
- type: chats.type,
10640
- topic: chats.topic,
10641
- metadata: chats.metadata,
10642
- createdAt: chats.createdAt,
10643
- updatedAt: chats.updatedAt,
10644
- participantCount: sql`(SELECT count(*)::int FROM chat_participants WHERE chat_id = ${chats.id})`
10645
- }).from(chats).where(inArray(chats.id, chatIds)).orderBy(desc(chats.updatedAt));
10646
- const chatMap = new Map(chatRows.map((c) => [c.id, c]));
10647
- const humanParticipantChatIds = new Set(participations.filter((p) => p.agentId === humanAgentId).map((p) => p.chatId));
10648
- const result = [];
10649
- for (const [agentId, agentChatIds] of agentChatMap) {
10650
- const agentInfo = agentMap.get(agentId);
10651
- if (!agentInfo) continue;
10652
- const agentChats = agentChatIds.map((chatId) => {
10653
- const chat = chatMap.get(chatId);
10654
- if (!chat) return null;
10655
- const isSupervisionOnly = agentId !== humanAgentId && !humanParticipantChatIds.has(chatId);
10656
- return {
10657
- id: chat.id,
10658
- type: chat.type,
10659
- topic: chat.topic,
10660
- participantCount: chat.participantCount,
10661
- isSupervisionOnly,
10662
- createdAt: chat.createdAt.toISOString(),
10663
- updatedAt: chat.updatedAt.toISOString()
10664
- };
10665
- }).filter((c) => c !== null);
10666
- if (agentChats.length > 0) result.push({
10667
- agent: agentInfo,
10668
- chats: agentChats
10669
- });
10670
- }
10671
- return result;
10672
- }
10673
- /**
10674
- * Manager joins a chat. Adds their human agent as a participant.
10675
- * Requires the member to have supervision rights (manages at least one existing participant).
10676
- */
10677
- async function joinChat(db, chatId, memberId, humanAgentId) {
10678
- const chat = await getChat(db, chatId);
10679
- const participantAgentIds = (await db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId))).map((p) => p.agentId);
10680
- if (participantAgentIds.length === 0) throw new NotFoundError("Chat has no participants");
10681
- if (participantAgentIds.includes(humanAgentId)) throw new ConflictError("Already a participant in this chat");
10682
- if ((await db.select({ uuid: agents.uuid }).from(agents).where(and(inArray(agents.uuid, participantAgentIds), eq(agents.managerId, memberId)))).length === 0) throw new ForbiddenError("You can only join chats where you manage at least one participant");
10683
- const [humanAgent] = await db.select({ organizationId: agents.organizationId }).from(agents).where(eq(agents.uuid, humanAgentId)).limit(1);
10684
- if (!humanAgent || humanAgent.organizationId !== chat.organizationId) throw new BadRequestError("Agent does not belong to the same organization as the chat");
10685
- await db.transaction(async (tx) => {
10686
- const [carriedRow] = await tx.delete(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, humanAgentId))).returning({
10687
- lastReadAt: chatSubscriptions.lastReadAt,
10688
- unreadMentionCount: chatSubscriptions.unreadMentionCount
10689
- });
10690
- await maybeUpgradeDirectToGroup(tx, chatId, participantAgentIds, 1);
10691
- await tx.insert(chatParticipants).values({
10692
- chatId,
10693
- agentId: humanAgentId,
10694
- role: "member",
10695
- mode: "full",
10696
- lastReadAt: carriedRow?.lastReadAt ?? null,
10697
- unreadMentionCount: carriedRow?.unreadMentionCount ?? 0
10698
- });
10699
- await recomputeChatWatchers(tx, chatId);
10700
- });
10701
- invalidateChatAudience(chatId);
10702
- return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
10703
- }
10704
- /**
10705
- * Manager leaves a chat. Removes their human agent from participants.
10706
- * Only allowed if the human agent is a participant.
10707
- *
10708
- * Delegates the participant→watcher transition to `leaveAsParticipant`
10709
- * so admin-side and `/me/chats/:id/leave` share one canonical path. The
10710
- * earlier "recompute then UPDATE-back state" variant violated the design
10711
- * rule that recompute is only for set rebuild — never on a transition
10712
- * path (review #228 issue #2). The returned participant list is fetched
10713
- * after the tx commits, matching the admin route's existing contract.
10714
- */
10715
- async function leaveChat(db, chatId, humanAgentId) {
10716
- await leaveAsParticipant(db, chatId, humanAgentId);
10717
- invalidateChatAudience(chatId);
10718
- return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
10719
- }
10720
- async function findOrCreateDirectChat(db, agentAId, agentBId) {
10721
- const aChats = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(eq(chatParticipants.agentId, agentAId));
10722
- const bChats = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(eq(chatParticipants.agentId, agentBId));
10723
- const bChatIds = new Set(bChats.map((r) => r.chatId));
10724
- const commonChatIds = aChats.map((r) => r.chatId).filter((id) => bChatIds.has(id));
10725
- if (commonChatIds.length > 0) {
10726
- const directChats = await db.select().from(chats).where(and(inArray(chats.id, commonChatIds), eq(chats.type, "direct")));
10727
- if (directChats.length > 0 && directChats[0]) return directChats[0];
10728
- }
10729
- const ends = await db.select({
10730
- uuid: agents.uuid,
10731
- organizationId: agents.organizationId,
10732
- type: agents.type
10733
- }).from(agents).where(inArray(agents.uuid, [agentAId, agentBId]));
10734
- const agentA = ends.find((a) => a.uuid === agentAId);
10735
- if (!agentA) throw new NotFoundError(`Agent "${agentAId}" not found`);
10736
- const agentB = ends.find((a) => a.uuid === agentBId);
10737
- if (!agentB) throw new NotFoundError(`Agent "${agentBId}" not found`);
10738
- const mode = agentA.type !== "human" && agentB.type !== "human" ? "mention_only" : "full";
10739
- const chatId = randomUUID();
10740
- return db.transaction(async (tx) => {
10741
- const [chat] = await tx.insert(chats).values({
10742
- id: chatId,
10743
- organizationId: agentA.organizationId,
10744
- type: "direct"
10745
- }).returning();
10746
- await tx.insert(chatParticipants).values([{
10747
- chatId,
10748
- agentId: agentAId,
10749
- role: "member",
10750
- mode
10751
- }, {
10752
- chatId,
10753
- agentId: agentBId,
10754
- role: "member",
10755
- mode
10756
- }]);
10757
- await recomputeChatWatchers(tx, chatId);
10758
- if (!chat) throw new Error("Unexpected: INSERT RETURNING produced no row");
10759
- return chat;
10760
- });
10761
- }
10762
10406
  function serializeChat(chat) {
10763
10407
  return {
10764
10408
  ...chat,
@@ -10866,46 +10510,6 @@ const agentConfigs = pgTable("agent_configs", {
10866
10510
  updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
10867
10511
  });
10868
10512
  /**
10869
- * Shared access-control primitives. Most route-level gating now lives in
10870
- * `scope/require-*.ts` — this module is reduced to two helpers that need
10871
- * SQL building blocks reused across routes and tests:
10872
- *
10873
- * - `agentVisibilityCondition` — WHERE clause for "agents visible to a
10874
- * member" (org-visible OR managerId = the caller's member). Composed
10875
- * into list queries that already select from `agents`.
10876
- * - `listAgentsManagedByUser` — cross-org list of agents personally
10877
- * managed by a user; powers the CLI `agent list --remote` view.
10878
- *
10879
- * Visibility is the same for all roles — admin sees the same set as a
10880
- * regular member. Admin privilege is expressed through manageability
10881
- * (`requireAgentAccess(..., "manage")`), not visibility.
10882
- */
10883
- /**
10884
- * SQL WHERE conditions for agents visible to a member.
10885
- * target org + not deleted + (organization-visible OR managerId = caller's member)
10886
- */
10887
- function agentVisibilityCondition(orgId, memberId) {
10888
- return and(eq(agents.organizationId, orgId), ne(agents.status, AGENT_STATUSES.DELETED), or(eq(agents.visibility, AGENT_VISIBILITY.ORGANIZATION), eq(agents.managerId, memberId)));
10889
- }
10890
- /**
10891
- * Cross-org listing helper for "agents I personally manage". Used by the
10892
- * CLI `agent list --remote` view — JOINs `agents → members.id` and filters
10893
- * by `members.user_id`.
10894
- */
10895
- async function listAgentsManagedByUser(db, userId) {
10896
- return db.select({
10897
- uuid: agents.uuid,
10898
- name: agents.name,
10899
- displayName: agents.displayName,
10900
- type: agents.type,
10901
- organizationId: agents.organizationId,
10902
- inboxId: agents.inboxId,
10903
- visibility: agents.visibility,
10904
- runtimeProvider: agents.runtimeProvider,
10905
- clientId: agents.clientId
10906
- }).from(agents).innerJoin(members, eq(agents.managerId, members.id)).where(and(eq(members.userId, userId), eq(members.status, "active"), ne(agents.status, AGENT_STATUSES.DELETED)));
10907
- }
10908
- /**
10909
10513
  * Resolve the UUID of the "default" organization. Internal use only —
10910
10514
  * webhooks, fallbacks, etc. The HTTP API layer no longer falls back to
10911
10515
  * the JWT default org.
@@ -11348,7 +10952,7 @@ async function deleteAgent(db, uuid) {
11348
10952
  if (!agent) throw new Error("Unexpected: UPDATE RETURNING produced no row");
11349
10953
  return agent;
11350
10954
  }
11351
- const log$5 = createLogger$1("AgentFeishuBot");
10955
+ const log$4 = createLogger$1("AgentFeishuBot");
11352
10956
  async function agentFeishuBotRoutes(app) {
11353
10957
  /**
11354
10958
  * PUT /agent/me/feishu-bot
@@ -11376,7 +10980,7 @@ async function agentFeishuBotRoutes(app) {
11376
10980
  },
11377
10981
  status: "active"
11378
10982
  }, app.config.secrets.encryptionKey);
11379
- app.adapterManager.reload().catch((err) => log$5.error({ err }, "adapter reload failed after self-service bind"));
10983
+ app.adapterManager.reload().catch((err) => log$4.error({ err }, "adapter reload failed after self-service bind"));
11380
10984
  app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
11381
10985
  return reply.status(current ? 200 : 201).send({
11382
10986
  ...config,
@@ -11393,7 +10997,7 @@ async function agentFeishuBotRoutes(app) {
11393
10997
  const current = (await listAdapterConfigs(app.db)).find((c) => c.agentId === identity.uuid && c.platform === "feishu");
11394
10998
  if (!current) return reply.status(204).send();
11395
10999
  await deleteAdapterConfig(app.db, current.id);
11396
- app.adapterManager.reload().catch((err) => log$5.error({ err }, "adapter reload failed after self-service unbind"));
11000
+ app.adapterManager.reload().catch((err) => log$4.error({ err }, "adapter reload failed after self-service unbind"));
11397
11001
  app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
11398
11002
  return reply.status(204).send();
11399
11003
  });
@@ -11414,20 +11018,6 @@ const adapterChatMappings = pgTable("adapter_chat_mappings", {
11414
11018
  metadata: jsonb("metadata").$type().notNull().default({}),
11415
11019
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
11416
11020
  });
11417
- /** Messages. Immutable after creation. Each message belongs to exactly one Chat. */
11418
- const messages = pgTable("messages", {
11419
- id: text("id").primaryKey(),
11420
- chatId: text("chat_id").notNull().references(() => chats.id),
11421
- senderId: text("sender_id").notNull(),
11422
- format: text("format").notNull(),
11423
- content: jsonb("content").$type().notNull(),
11424
- metadata: jsonb("metadata").$type().notNull().default({}),
11425
- replyToInbox: text("reply_to_inbox"),
11426
- replyToChat: text("reply_to_chat"),
11427
- inReplyTo: text("in_reply_to"),
11428
- source: text("source"),
11429
- createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
11430
- }, (table) => [index("idx_messages_chat_time").on(table.chatId, table.createdAt), index("idx_messages_in_reply_to").on(table.inReplyTo)]);
11431
11021
  /** Cross-reference between internal messages and external platform message IDs. */
11432
11022
  const adapterMessageReferences = pgTable("adapter_message_references", {
11433
11023
  id: serial("id").primaryKey(),
@@ -11618,24 +11208,6 @@ async function agentFeishuUserRoutes(app) {
11618
11208
  return reply.status(204).send();
11619
11209
  });
11620
11210
  }
11621
- /** Delivery queue (envelope). One entry per recipient created during message fan-out. Uses SKIP LOCKED for concurrent-safe consumption. */
11622
- const inboxEntries = pgTable("inbox_entries", {
11623
- id: bigserial("id", { mode: "number" }).primaryKey(),
11624
- inboxId: text("inbox_id").notNull(),
11625
- messageId: text("message_id").notNull().references(() => messages.id),
11626
- chatId: text("chat_id"),
11627
- status: text("status").notNull().default("pending"),
11628
- notify: boolean("notify").notNull().default(true),
11629
- retryCount: integer("retry_count").notNull().default(0),
11630
- createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
11631
- deliveredAt: timestamp("delivered_at", { withTimezone: true }),
11632
- ackedAt: timestamp("acked_at", { withTimezone: true })
11633
- }, (table) => [
11634
- unique("uq_inbox_delivery").on(table.inboxId, table.messageId, table.chatId),
11635
- index("idx_inbox_pending").on(table.inboxId, table.createdAt),
11636
- index("idx_inbox_pending_notify").on(table.inboxId, table.createdAt).where(sql`status = 'pending' AND notify = true`),
11637
- index("idx_inbox_chat_silent").on(table.inboxId, table.chatId, table.notify, table.status)
11638
- ]);
11639
11211
  function normaliseSource(source) {
11640
11212
  if (source === null) return null;
11641
11213
  const parsed = messageSourceSchema$1.safeParse(source);
@@ -12071,585 +11643,6 @@ async function collectTargetInboxes(db, chatId, inReplyTo) {
12071
11643
  }
12072
11644
  return [...set];
12073
11645
  }
12074
- /** Per-session state snapshot. One row per (agent, chat) pair, upserted on each session:state message. */
12075
- const agentChatSessions = pgTable("agent_chat_sessions", {
12076
- agentId: text("agent_id").notNull().references(() => agents.uuid, { onDelete: "cascade" }),
12077
- chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
12078
- state: text("state").notNull(),
12079
- updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
12080
- }, (table) => [primaryKey({ columns: [table.agentId, table.chatId] })]);
12081
- /**
12082
- * Upsert session state + refresh presence aggregates + NOTIFY.
12083
- *
12084
- * `agent_chat_sessions.(agent_id, chat_id)` is a single-row "current session
12085
- * state" cache, not a session history log. A new runtime session starting on
12086
- * the same (agent, chat) pair MUST overwrite whatever ended before — including
12087
- * an `evicted` row left by a previous terminate. The previous "revival
12088
- * defense" conflated two concerns: "this runtime session ended" (which is
12089
- * what `evicted` actually means) and "this chat is permanently archived for
12090
- * this agent" (a chat-level decision that should live on `chats`, not here).
12091
- * See proposals/hub-agent-messaging-reply-and-mentions §M2-session-lifecycle.
12092
- *
12093
- * Presence row contract: this function tolerates a missing `agent_presence`
12094
- * row by using `INSERT ... ON CONFLICT DO UPDATE`. The predictive-write path
12095
- * (sendMessage on first message) may target an agent whose client has never
12096
- * bound, so a prior `update agent_presence ... where agentId` would silently
12097
- * drop the activeSessions/totalSessions refresh. See PR #198 review §2.
12098
- */
12099
- async function upsertSessionState(db, agentId, chatId, state, organizationId, notifier, options) {
12100
- const now = /* @__PURE__ */ new Date();
12101
- let wrote = false;
12102
- await db.transaction(async (tx) => {
12103
- await tx.insert(agentChatSessions).values({
12104
- agentId,
12105
- chatId,
12106
- state,
12107
- updatedAt: now
12108
- }).onConflictDoUpdate({
12109
- target: [agentChatSessions.agentId, agentChatSessions.chatId],
12110
- set: {
12111
- state,
12112
- updatedAt: now
12113
- },
12114
- setWhere: ne(agentChatSessions.state, state)
12115
- });
12116
- const [counts] = await tx.select({
12117
- active: sql`count(*) FILTER (WHERE ${agentChatSessions.state} = 'active')::int`,
12118
- total: sql`count(*) FILTER (WHERE ${agentChatSessions.state} != 'evicted')::int`
12119
- }).from(agentChatSessions).where(eq(agentChatSessions.agentId, agentId));
12120
- const activeSessions = counts?.active ?? 0;
12121
- const totalSessions = counts?.total ?? 0;
12122
- const presenceSet = options?.touchPresenceLastSeen ?? true ? {
12123
- activeSessions,
12124
- totalSessions,
12125
- lastSeenAt: now
12126
- } : {
12127
- activeSessions,
12128
- totalSessions
12129
- };
12130
- await tx.insert(agentPresence).values({
12131
- agentId,
12132
- activeSessions,
12133
- totalSessions
12134
- }).onConflictDoUpdate({
12135
- target: [agentPresence.agentId],
12136
- set: presenceSet
12137
- });
12138
- wrote = true;
12139
- });
12140
- if (wrote && notifier) notifier.notifySessionStateChange(agentId, chatId, state, organizationId).catch(() => {});
12141
- }
12142
- async function resetActivity(db, agentId) {
12143
- const now = /* @__PURE__ */ new Date();
12144
- await db.update(agentPresence).set({
12145
- runtimeState: "idle",
12146
- runtimeUpdatedAt: now
12147
- }).where(eq(agentPresence.agentId, agentId));
12148
- }
12149
- async function getActivityOverview(db) {
12150
- const [agentCounts] = await db.select({
12151
- total: sql`count(*)::int`,
12152
- running: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} IS NOT NULL)::int`,
12153
- idle: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'idle')::int`,
12154
- working: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'working')::int`,
12155
- blocked: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'blocked')::int`,
12156
- error: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'error')::int`
12157
- }).from(agentPresence);
12158
- const [clientCounts] = await db.select({ count: sql`count(*)::int` }).from(clients).where(eq(clients.status, "connected"));
12159
- return {
12160
- total: agentCounts?.total ?? 0,
12161
- running: agentCounts?.running ?? 0,
12162
- byState: {
12163
- idle: agentCounts?.idle ?? 0,
12164
- working: agentCounts?.working ?? 0,
12165
- blocked: agentCounts?.blocked ?? 0,
12166
- error: agentCounts?.error ?? 0
12167
- },
12168
- clients: clientCounts?.count ?? 0
12169
- };
12170
- }
12171
- /**
12172
- * List agents with active runtime state.
12173
- * When scope is provided, filters to agents visible to the member.
12174
- */
12175
- async function listAgentsWithRuntime(db, scope) {
12176
- if (!scope) return db.select().from(agentPresence).where(isNotNull(agentPresence.runtimeState));
12177
- return db.select({
12178
- agentId: agentPresence.agentId,
12179
- status: agentPresence.status,
12180
- instanceId: agentPresence.instanceId,
12181
- connectedAt: agentPresence.connectedAt,
12182
- lastSeenAt: agentPresence.lastSeenAt,
12183
- clientId: agentPresence.clientId,
12184
- runtimeType: agentPresence.runtimeType,
12185
- runtimeVersion: agentPresence.runtimeVersion,
12186
- runtimeState: agentPresence.runtimeState,
12187
- activeSessions: agentPresence.activeSessions,
12188
- totalSessions: agentPresence.totalSessions,
12189
- runtimeUpdatedAt: agentPresence.runtimeUpdatedAt,
12190
- type: agents.type
12191
- }).from(agentPresence).innerJoin(agents, eq(agentPresence.agentId, agents.uuid)).where(and(isNotNull(agentPresence.runtimeState), agentVisibilityCondition(scope.organizationId, scope.memberId)));
12192
- }
12193
- /**
12194
- * Chat-first workspace — append-only post-fan-out projection.
12195
- *
12196
- * The single sanctioned extension point on the message hot path. Called
12197
- * from `services/message.ts` AFTER existing fan-out completes, inside the
12198
- * same transaction. Three responsibilities:
12199
- *
12200
- * 1. Mention propagation: increment `unread_mention_count` for mentioned
12201
- * speaking participants AND for watcher rows whose managed agent was
12202
- * mentioned. Sender row is excluded.
12203
- *
12204
- * 2. Chats projection: roll forward `chats.last_message_at`,
12205
- * `chats.last_message_preview`. Powers the conversation list cursor +
12206
- * sort + preview.
12207
- *
12208
- * 3. Realtime kick: fire-and-forget `pg_notify('chat_message_events', …)`
12209
- * so admin WS sockets can translate it into a `chat:message` frame.
12210
- * Failure is swallowed — durable persistence is the correctness path.
12211
- *
12212
- * Strict invariants (see docs/chat-first-workspace-product-design.md
12213
- * "Risk Constraints"):
12214
- * - This module appends ONLY. Never edits existing fan-out / inbox /
12215
- * mention-extraction code.
12216
- * - Watchers (chat_subscriptions) are NEVER added to inbox_entries here.
12217
- * Their counters are bumped purely as a per-user red-dot signal.
12218
- * - Mention candidate set is `chat_participants` only; watchers are not
12219
- * direct `@`-mention targets.
12220
- */
12221
- let dispatcher = null;
12222
- function registerChatMessageDispatcher(fn) {
12223
- dispatcher = fn;
12224
- }
12225
- /**
12226
- * Best-effort cross-process kick for the chat-first workspace. Call AFTER
12227
- * the message transaction commits — never inside the tx. Failure logs +
12228
- * drops; web reconnect refetches.
12229
- *
12230
- * Speakers also get an inbox NOTIFY through the existing path. They will
12231
- * receive both, and the web client de-dupes naturally because both end up
12232
- * invalidating the same query keys.
12233
- */
12234
- function fireChatMessageKick(chatId, messageId) {
12235
- if (!dispatcher) return;
12236
- try {
12237
- dispatcher(chatId, messageId);
12238
- } catch {}
12239
- }
12240
- /**
12241
- * Apply the post-fan-out projection. MUST be called inside the same
12242
- * transaction as the message INSERT. Safe to call when `mentionedAgentIds`
12243
- * is empty (degenerate case skips the mention UPDATEs).
12244
- */
12245
- async function applyAfterFanOut(tx, input) {
12246
- const { chatId, senderId, mentionedAgentIds, contentPreview, messageCreatedAt } = input;
12247
- const previewClipped = contentPreview.length > 0 ? contentPreview.slice(0, 200) : null;
12248
- const ts = messageCreatedAt ?? /* @__PURE__ */ new Date();
12249
- await tx.update(chats).set({
12250
- lastMessageAt: ts,
12251
- lastMessagePreview: previewClipped
12252
- }).where(eq(chats.id, chatId));
12253
- if (mentionedAgentIds.length === 0) return;
12254
- await tx.update(chatParticipants).set({ unreadMentionCount: sql`${chatParticipants.unreadMentionCount} + 1` }).where(and(eq(chatParticipants.chatId, chatId), inArray(chatParticipants.agentId, mentionedAgentIds), ne(chatParticipants.agentId, senderId)));
12255
- const managerHumanAgentIds = (await tx.execute(sql`
12256
- SELECT DISTINCT m.agent_id AS human_agent_id
12257
- FROM agents a
12258
- JOIN members m ON m.id = a.manager_id
12259
- WHERE a.uuid IN ${makeUuidList(mentionedAgentIds)}
12260
- AND a.type <> 'human'
12261
- AND m.status = 'active'
12262
- `)).map((r) => r.human_agent_id);
12263
- if (managerHumanAgentIds.length === 0) return;
12264
- await tx.update(chatSubscriptions).set({ unreadMentionCount: sql`${chatSubscriptions.unreadMentionCount} + 1` }).where(and(eq(chatSubscriptions.chatId, chatId), inArray(chatSubscriptions.agentId, managerHumanAgentIds)));
12265
- }
12266
- /**
12267
- * Build a parenthesised, comma-separated list of bound parameters: `(?, ?, ?)`.
12268
- * Used in raw SQL where drizzle's `inArray` can't be directly applied (e.g.
12269
- * inside a hand-rolled SELECT). Always called with a non-empty list — the
12270
- * caller short-circuits the empty case.
12271
- */
12272
- function makeUuidList(ids) {
12273
- return sql`(${sql.join(ids.map((id) => sql`${id}`), sql`, `)})`;
12274
- }
12275
- const log$4 = createLogger$1("message");
12276
- async function sendMessage(db, chatId, senderId, data, options = {}) {
12277
- return withSpan("inbox.enqueue", messageAttrs({
12278
- chatId,
12279
- senderAgentId: senderId,
12280
- source: data.source ?? void 0
12281
- }), () => sendMessageInner(db, chatId, senderId, data, options));
12282
- }
12283
- async function sendMessageInner(db, chatId, senderId, data, options) {
12284
- const txResult = await db.transaction(async (tx) => {
12285
- const [participants, [chatRow], [senderRow]] = await Promise.all([
12286
- tx.select({
12287
- agentId: chatParticipants.agentId,
12288
- inboxId: agents.inboxId,
12289
- mode: chatParticipants.mode,
12290
- name: agents.name
12291
- }).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(eq(chatParticipants.chatId, chatId)),
12292
- tx.select({ type: chats.type }).from(chats).where(eq(chats.id, chatId)).limit(1),
12293
- tx.select({
12294
- inboxId: agents.inboxId,
12295
- organizationId: agents.organizationId
12296
- }).from(agents).where(eq(agents.uuid, senderId)).limit(1)
12297
- ]);
12298
- const chatType = chatRow?.type ?? null;
12299
- if (!senderRow) throw new NotFoundError(`Sender agent "${senderId}" not found`);
12300
- if (data.replyToInbox !== void 0 && data.replyToInbox !== null) {
12301
- if (senderRow.inboxId !== data.replyToInbox) throw new BadRequestError("replyToInbox must reference the sender's own inbox");
12302
- }
12303
- const incomingMeta = data.metadata ?? {};
12304
- const explicitMentionsRaw = incomingMeta.mentions;
12305
- const explicitMentions = Array.isArray(explicitMentionsRaw) ? explicitMentionsRaw.filter((m) => typeof m === "string") : [];
12306
- const contentText = typeof data.content === "string" ? data.content : "";
12307
- const resolved = contentText ? extractMentions(contentText, participants) : [];
12308
- const mergedMentions = [...new Set([...explicitMentions, ...resolved])];
12309
- const metadataToStore = mergedMentions.length > 0 ? {
12310
- ...incomingMeta,
12311
- mentions: mergedMentions
12312
- } : incomingMeta;
12313
- if (options.enforceGroupMention && chatType === "group") {
12314
- if (mergedMentions.filter((id) => id !== senderId).length === 0) throw new BadRequestError("Sending to a group chat requires an explicit @mention. Use `agent send <name>` to message a single agent, or @<name> in the content to address one or more group members.");
12315
- }
12316
- let outboundContent = data.content;
12317
- if (options.normalizeMentionsInContent && typeof outboundContent === "string") {
12318
- const present = new Set(scanMentionTokens(outboundContent));
12319
- const missingNames = [];
12320
- for (const id of mergedMentions) {
12321
- if (id === senderId) continue;
12322
- const p = participants.find((q) => q.agentId === id);
12323
- if (!p?.name) continue;
12324
- if (present.has(p.name.toLowerCase())) continue;
12325
- missingNames.push(p.name);
12326
- }
12327
- if (missingNames.length > 0) {
12328
- const prefix = missingNames.map((n) => `@${n}`).join(" ");
12329
- outboundContent = outboundContent.length > 0 ? `${prefix} ${outboundContent}` : prefix;
12330
- }
12331
- }
12332
- const messageId = randomUUID();
12333
- const [msg] = await tx.insert(messages).values({
12334
- id: messageId,
12335
- chatId,
12336
- senderId,
12337
- format: data.format,
12338
- content: outboundContent,
12339
- metadata: metadataToStore,
12340
- replyToInbox: data.replyToInbox ?? null,
12341
- replyToChat: data.replyToChat ?? null,
12342
- inReplyTo: data.inReplyTo ?? null,
12343
- source: data.source ?? null
12344
- }).returning();
12345
- const mentionSet = new Set(mergedMentions);
12346
- const fanout = participants.filter((p) => p.agentId !== senderId).map((p) => ({
12347
- agentId: p.agentId,
12348
- inboxId: p.inboxId,
12349
- notify: p.mode !== "mention_only" || mentionSet.has(p.agentId)
12350
- }));
12351
- if (fanout.length > 0) await tx.insert(inboxEntries).values(fanout.map((f) => ({
12352
- inboxId: f.inboxId,
12353
- messageId,
12354
- chatId,
12355
- notify: f.notify
12356
- })));
12357
- const notified = fanout.filter((f) => f.notify);
12358
- const recipients = notified.map((f) => f.inboxId);
12359
- const recipientAgentIds = notified.map((f) => f.agentId);
12360
- if (data.inReplyTo) {
12361
- const [original] = await tx.select({
12362
- replyToInbox: messages.replyToInbox,
12363
- replyToChat: messages.replyToChat
12364
- }).from(messages).where(eq(messages.id, data.inReplyTo)).limit(1);
12365
- if (original?.replyToInbox && original?.replyToChat) {
12366
- await tx.insert(inboxEntries).values({
12367
- inboxId: original.replyToInbox,
12368
- messageId,
12369
- chatId: original.replyToChat
12370
- }).onConflictDoNothing();
12371
- if (!recipients.includes(original.replyToInbox)) recipients.push(original.replyToInbox);
12372
- }
12373
- }
12374
- await tx.update(chats).set({ updatedAt: /* @__PURE__ */ new Date() }).where(eq(chats.id, chatId));
12375
- if (!msg) throw new Error("Unexpected: INSERT RETURNING produced no row");
12376
- const previewText = typeof outboundContent === "string" ? outboundContent.trim() : "";
12377
- await applyAfterFanOut(tx, {
12378
- chatId,
12379
- messageId: msg.id,
12380
- senderId,
12381
- mentionedAgentIds: mergedMentions,
12382
- contentPreview: previewText,
12383
- messageCreatedAt: msg.createdAt
12384
- });
12385
- return {
12386
- message: msg,
12387
- recipients,
12388
- recipientAgentIds,
12389
- organizationId: senderRow.organizationId
12390
- };
12391
- });
12392
- const settled = await Promise.allSettled(txResult.recipientAgentIds.map((agentId) => upsertSessionState(db, agentId, chatId, "active", txResult.organizationId, void 0, { touchPresenceLastSeen: false })));
12393
- for (let i = 0; i < settled.length; i++) {
12394
- const r = settled[i];
12395
- if (r?.status === "rejected") log$4.error({
12396
- err: r.reason,
12397
- chatId,
12398
- agentId: txResult.recipientAgentIds[i]
12399
- }, "predictive session activation failed");
12400
- }
12401
- fireChatMessageKick(chatId, txResult.message.id);
12402
- return {
12403
- message: txResult.message,
12404
- recipients: txResult.recipients
12405
- };
12406
- }
12407
- async function sendToAgent$1(db, senderUuid, targetName, data) {
12408
- const [sender] = await db.select({
12409
- uuid: agents.uuid,
12410
- organizationId: agents.organizationId
12411
- }).from(agents).where(eq(agents.uuid, senderUuid)).limit(1);
12412
- if (!sender) throw new NotFoundError(`Agent "${senderUuid}" not found`);
12413
- const [target] = await db.select({ uuid: agents.uuid }).from(agents).where(and(eq(agents.organizationId, sender.organizationId), eq(agents.name, targetName), ne(agents.status, "deleted"))).limit(1);
12414
- if (!target) throw new NotFoundError(`Agent "${targetName}" not found${/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(targetName) ? " — `agent send` expects an agent NAME, not a uuid. Run `first-tree-hub agent list` to see available names." : ""}`);
12415
- const chat = await findOrCreateDirectChat(db, senderUuid, target.uuid);
12416
- const incomingMeta = data.metadata ?? {};
12417
- const existingMentionsRaw = incomingMeta.mentions;
12418
- const existingMentions = Array.isArray(existingMentionsRaw) ? existingMentionsRaw.filter((m) => typeof m === "string") : [];
12419
- const mergedMentions = existingMentions.includes(target.uuid) ? existingMentions : [...existingMentions, target.uuid];
12420
- const metadata = {
12421
- ...incomingMeta,
12422
- mentions: mergedMentions
12423
- };
12424
- return sendMessage(db, chat.id, senderUuid, {
12425
- format: data.format,
12426
- content: data.content,
12427
- metadata,
12428
- replyToInbox: data.replyToInbox,
12429
- replyToChat: data.replyToChat,
12430
- source: data.source
12431
- }, { normalizeMentionsInContent: true });
12432
- }
12433
- async function editMessage(db, chatId, messageId, senderId, data) {
12434
- const [msg] = await db.select().from(messages).where(eq(messages.id, messageId)).limit(1);
12435
- if (!msg) throw new NotFoundError(`Message "${messageId}" not found`);
12436
- if (msg.chatId !== chatId) throw new NotFoundError(`Message "${messageId}" not found in this chat`);
12437
- if (msg.senderId !== senderId) throw new ForbiddenError("Only the sender can edit a message");
12438
- const setClause = {};
12439
- if (data.format !== void 0) setClause.format = data.format;
12440
- if (data.content !== void 0) setClause.content = data.content;
12441
- const meta = msg.metadata ?? {};
12442
- meta.editedAt = (/* @__PURE__ */ new Date()).toISOString();
12443
- setClause.metadata = meta;
12444
- const [updated] = await db.update(messages).set(setClause).where(eq(messages.id, messageId)).returning();
12445
- if (!updated) throw new Error("Unexpected: UPDATE RETURNING produced no row");
12446
- return updated;
12447
- }
12448
- async function listMessages(db, chatId, limit, cursor) {
12449
- const where = cursor ? and(eq(messages.chatId, chatId), lt(messages.createdAt, new Date(cursor))) : eq(messages.chatId, chatId);
12450
- const rows = await db.select().from(messages).where(where).orderBy(desc(messages.createdAt)).limit(limit + 1);
12451
- const hasMore = rows.length > limit;
12452
- const items = hasMore ? rows.slice(0, limit) : rows;
12453
- const last = items[items.length - 1];
12454
- return {
12455
- items,
12456
- nextCursor: hasMore && last ? last.createdAt.toISOString() : null
12457
- };
12458
- }
12459
- const INBOX_CHANNEL = "inbox_notifications";
12460
- const CONFIG_CHANNEL = "config_changes";
12461
- const SESSION_STATE_CHANNEL = "session_state_changes";
12462
- const RUNTIME_STATE_CHANNEL = "runtime_state_changes";
12463
- /**
12464
- * Chat-first workspace cross-process kick. Carries `<chatId>:<messageId>`.
12465
- * Lets admin WS sockets translate every chat message (speaker AND watcher
12466
- * audience) into a `chat:message` frame, without being coupled to the
12467
- * inbox NOTIFY path that only reaches speakers.
12468
- */
12469
- const CHAT_MESSAGE_CHANNEL = "chat_message_events";
12470
- function createNotifier(listenClient) {
12471
- const subscriptions = /* @__PURE__ */ new Map();
12472
- const configChangeHandlers = [];
12473
- const sessionStateChangeHandlers = [];
12474
- const runtimeStateChangeHandlers = [];
12475
- const chatMessageHandlers = [];
12476
- let unlistenInboxFn = null;
12477
- let unlistenConfigFn = null;
12478
- let unlistenSessionStateFn = null;
12479
- let unlistenRuntimeStateFn = null;
12480
- let unlistenChatMessageFn = null;
12481
- function handleNotification(payload) {
12482
- const sepIdx = payload.indexOf(":");
12483
- if (sepIdx === -1) return;
12484
- const inboxId = payload.slice(0, sepIdx);
12485
- const messageId = payload.slice(sepIdx + 1);
12486
- const sockets = subscriptions.get(inboxId);
12487
- if (!sockets) return;
12488
- const doorbellFrame = JSON.stringify({
12489
- type: "new_message",
12490
- inboxId,
12491
- messageId
12492
- });
12493
- for (const [ws, pushHandler] of sockets) {
12494
- if (ws.readyState !== ws.OPEN) continue;
12495
- if (pushHandler) Promise.resolve(pushHandler(messageId)).catch(() => {});
12496
- else ws.send(doorbellFrame);
12497
- }
12498
- }
12499
- return {
12500
- subscribe(inboxId, ws, pushHandler) {
12501
- let map = subscriptions.get(inboxId);
12502
- if (!map) {
12503
- map = /* @__PURE__ */ new Map();
12504
- subscriptions.set(inboxId, map);
12505
- }
12506
- map.set(ws, pushHandler ?? null);
12507
- },
12508
- unsubscribe(inboxId, ws) {
12509
- const map = subscriptions.get(inboxId);
12510
- if (map) {
12511
- map.delete(ws);
12512
- if (map.size === 0) subscriptions.delete(inboxId);
12513
- }
12514
- },
12515
- async notify(inboxId, messageId) {
12516
- try {
12517
- await listenClient`SELECT pg_notify(${INBOX_CHANNEL}, ${`${inboxId}:${messageId}`})`;
12518
- } catch {}
12519
- },
12520
- async notifyConfigChange(configType) {
12521
- try {
12522
- await listenClient`SELECT pg_notify(${CONFIG_CHANNEL}, ${configType})`;
12523
- } catch {}
12524
- },
12525
- async notifySessionStateChange(agentId, chatId, state, organizationId) {
12526
- try {
12527
- await listenClient`SELECT pg_notify(${SESSION_STATE_CHANNEL}, ${`${agentId}:${chatId}:${state}:${organizationId}`})`;
12528
- } catch {}
12529
- },
12530
- async notifyRuntimeStateChange(agentId, state, organizationId) {
12531
- try {
12532
- await listenClient`SELECT pg_notify(${RUNTIME_STATE_CHANNEL}, ${`${agentId}:${state}:${organizationId}`})`;
12533
- } catch {}
12534
- },
12535
- async notifyChatMessage(chatId, messageId) {
12536
- try {
12537
- await listenClient`SELECT pg_notify(${CHAT_MESSAGE_CHANNEL}, ${`${chatId}:${messageId}`})`;
12538
- } catch {}
12539
- },
12540
- async pushFrameToInbox(inboxId, frame) {
12541
- const map = subscriptions.get(inboxId);
12542
- if (!map) return 0;
12543
- let queued = 0;
12544
- const pending = [];
12545
- for (const ws of map.keys()) {
12546
- if (ws.readyState !== ws.OPEN) continue;
12547
- pending.push(new Promise((resolve) => {
12548
- ws.send(frame, (err) => {
12549
- if (!err) queued += 1;
12550
- resolve();
12551
- });
12552
- }));
12553
- }
12554
- await Promise.all(pending);
12555
- return queued;
12556
- },
12557
- onConfigChange(handler) {
12558
- configChangeHandlers.push(handler);
12559
- },
12560
- onSessionStateChange(handler) {
12561
- sessionStateChangeHandlers.push(handler);
12562
- },
12563
- onRuntimeStateChange(handler) {
12564
- runtimeStateChangeHandlers.push(handler);
12565
- },
12566
- onChatMessage(handler) {
12567
- chatMessageHandlers.push(handler);
12568
- },
12569
- async start() {
12570
- unlistenInboxFn = (await listenClient.listen(INBOX_CHANNEL, (payload) => {
12571
- if (payload) handleNotification(payload);
12572
- })).unlisten;
12573
- unlistenConfigFn = (await listenClient.listen(CONFIG_CHANNEL, (payload) => {
12574
- if (payload) for (const handler of configChangeHandlers) handler(payload);
12575
- })).unlisten;
12576
- unlistenSessionStateFn = (await listenClient.listen(SESSION_STATE_CHANNEL, (payload) => {
12577
- if (payload) {
12578
- const firstSep = payload.indexOf(":");
12579
- const secondSep = payload.indexOf(":", firstSep + 1);
12580
- const thirdSep = payload.indexOf(":", secondSep + 1);
12581
- if (firstSep > 0 && secondSep > firstSep && thirdSep > secondSep) {
12582
- const agentId = payload.slice(0, firstSep);
12583
- const chatId = payload.slice(firstSep + 1, secondSep);
12584
- const state = payload.slice(secondSep + 1, thirdSep);
12585
- const organizationId = payload.slice(thirdSep + 1);
12586
- for (const handler of sessionStateChangeHandlers) handler({
12587
- agentId,
12588
- chatId,
12589
- state,
12590
- organizationId
12591
- });
12592
- }
12593
- }
12594
- })).unlisten;
12595
- unlistenRuntimeStateFn = (await listenClient.listen(RUNTIME_STATE_CHANNEL, (payload) => {
12596
- if (payload) {
12597
- const firstSep = payload.indexOf(":");
12598
- const secondSep = payload.indexOf(":", firstSep + 1);
12599
- if (firstSep > 0 && secondSep > firstSep) {
12600
- const agentId = payload.slice(0, firstSep);
12601
- const state = payload.slice(firstSep + 1, secondSep);
12602
- const organizationId = payload.slice(secondSep + 1);
12603
- for (const handler of runtimeStateChangeHandlers) handler({
12604
- agentId,
12605
- state,
12606
- organizationId
12607
- });
12608
- }
12609
- }
12610
- })).unlisten;
12611
- unlistenChatMessageFn = (await listenClient.listen(CHAT_MESSAGE_CHANNEL, (payload) => {
12612
- if (!payload) return;
12613
- const sep = payload.indexOf(":");
12614
- if (sep <= 0) return;
12615
- const chatId = payload.slice(0, sep);
12616
- const messageId = payload.slice(sep + 1);
12617
- for (const handler of chatMessageHandlers) try {
12618
- handler({
12619
- chatId,
12620
- messageId
12621
- });
12622
- } catch {}
12623
- })).unlisten;
12624
- },
12625
- async stop() {
12626
- if (unlistenInboxFn) {
12627
- await unlistenInboxFn();
12628
- unlistenInboxFn = null;
12629
- }
12630
- if (unlistenConfigFn) {
12631
- await unlistenConfigFn();
12632
- unlistenConfigFn = null;
12633
- }
12634
- if (unlistenSessionStateFn) {
12635
- await unlistenSessionStateFn();
12636
- unlistenSessionStateFn = null;
12637
- }
12638
- if (unlistenRuntimeStateFn) {
12639
- await unlistenRuntimeStateFn();
12640
- unlistenRuntimeStateFn = null;
12641
- }
12642
- if (unlistenChatMessageFn) {
12643
- await unlistenChatMessageFn();
12644
- unlistenChatMessageFn = null;
12645
- }
12646
- }
12647
- };
12648
- }
12649
- /** Fire-and-forget: notify all recipients that a new message is available. */
12650
- function notifyRecipients(notifier, recipients, messageId) {
12651
- for (const inboxId of recipients) notifier.notify(inboxId, messageId).catch(() => {});
12652
- }
12653
11646
  const log$3 = createLogger$1("AgentMessages");
12654
11647
  const editMessageSchema = z.object({
12655
11648
  format: z.string().optional(),
@@ -15602,6 +14595,7 @@ async function transitionSessionState(db, agentId, chatId, target, from, organiz
15602
14595
  totalSessions: counts?.total ?? 0,
15603
14596
  lastSeenAt: now
15604
14597
  }).where(eq(agentPresence.agentId, agentId));
14598
+ if (target === "evicted") await markSupersededByChat(tx, chatId, "chat_archived");
15605
14599
  finalState = target;
15606
14600
  transitioned = true;
15607
14601
  });
@@ -16088,6 +15082,28 @@ async function chatRoutes(app) {
16088
15082
  createdAt: result.message.createdAt.toISOString()
16089
15083
  });
16090
15084
  });
15085
+ /**
15086
+ * POST /chats/:chatId/questions/:correlationId/answer — submit an answer
15087
+ * to a pending agent-emitted question. Caller speaks as their human agent;
15088
+ * the answer is fanned out as a `format=question_answer` message back to
15089
+ * the original agent's inbox so the in-flight `canUseTool` callback can
15090
+ * resolve. Returns 409 if already answered or superseded.
15091
+ */
15092
+ app.post("/:chatId/questions/:correlationId/answer", { config: { otelRecordBody: false } }, async (request, reply) => {
15093
+ const { scope } = await requireChatAccess(request, app.db);
15094
+ const body = submitQuestionAnswerSchema.parse(request.body);
15095
+ await ensureParticipant$1(app.db, request.params.chatId, scope.humanAgentId);
15096
+ const result = await submitAnswer(app.db, app.notifier, {
15097
+ correlationId: request.params.correlationId,
15098
+ chatId: request.params.chatId,
15099
+ submitterAgentId: scope.humanAgentId,
15100
+ answers: body.answers
15101
+ });
15102
+ return reply.status(201).send({
15103
+ correlationId: request.params.correlationId,
15104
+ messageId: result.messageId
15105
+ });
15106
+ });
16091
15107
  /** POST /chats/:chatId/read — chat-first-workspace read cursor. Idempotent. */
16092
15108
  app.post("/:chatId/read", async (request) => {
16093
15109
  const { scope } = await requireChatAccess(request, app.db);
@@ -16269,6 +15285,11 @@ function applyInputDelta(namespace, current, input, encryptionKey) {
16269
15285
  const inp = input;
16270
15286
  return { webhookSecretCipher: inp.webhookSecret === void 0 ? cur.webhookSecretCipher : inp.webhookSecret === null ? void 0 : ensureEncrypted(inp.webhookSecret, encryptionKey) };
16271
15287
  }
15288
+ if (namespace === "source_repos") {
15289
+ const cur = current;
15290
+ const inp = input;
15291
+ return { repos: inp.repos === void 0 ? cur.repos : inp.repos };
15292
+ }
16272
15293
  return namespace;
16273
15294
  }
16274
15295
  /**
@@ -16292,6 +15313,7 @@ function toOutput(namespace, storage) {
16292
15313
  webhookUrl: ""
16293
15314
  };
16294
15315
  }
15316
+ if (namespace === "source_repos") return { repos: storage.repos };
16295
15317
  return namespace;
16296
15318
  }
16297
15319
  /**
@@ -17460,7 +16482,7 @@ async function healthzRoutes(app) {
17460
16482
  * `api/orgs/invitations.ts` (Class B, admin-gated).
17461
16483
  */
17462
16484
  async function publicInvitationRoutes(app) {
17463
- const { previewInvitation } = await import("./invitation-C299fxkP-BR-niZyp.mjs");
16485
+ const { previewInvitation } = await import("./invitation-C299fxkP-KyCNax4T.mjs");
17464
16486
  app.get("/:token/preview", async (request, reply) => {
17465
16487
  if (!request.params.token) throw new UnauthorizedError("Token required");
17466
16488
  const preview = await previewInvitation(app.db, request.params.token);
@@ -17640,7 +16662,7 @@ async function meRoutes(app) {
17640
16662
  */
17641
16663
  app.get("/me/pinned-agents", async (request) => {
17642
16664
  const { userId } = requireUser(request);
17643
- const { listMyPinnedAgents } = await import("./client-D_TRJFZY-LbgJF47t.mjs");
16665
+ const { listMyPinnedAgents } = await import("./client-BhCtO2df-BGOu-rRN.mjs");
17644
16666
  return listMyPinnedAgents(app.db, { userId });
17645
16667
  });
17646
16668
  /**
@@ -18371,14 +17393,18 @@ async function orgSessionRoutes(app) {
18371
17393
  * adding a new config group only requires registering it there — no new
18372
17394
  * route file.
18373
17395
  *
18374
- * All three verbs are admin-only. Even GET, because the masked output
18375
- * still leaks "configured / not-configured" booleans for secret fields,
18376
- * which we don't want to expose to non-admin members.
17396
+ * GET gating is per-namespace via `readPolicy` in the registry: namespaces
17397
+ * with no secret fields (`context_tree`, `source_repos`) are readable by
17398
+ * any active org member, so an invitee can see what tree and repos the
17399
+ * team is bound to before joining the chat. Namespaces whose masked output
17400
+ * still leaks a `…Configured` boolean (`github_integration`) stay
17401
+ * admin-only. PUT and DELETE are always admin-only regardless of
17402
+ * namespace — non-admins must never mutate org-wide config.
18377
17403
  */
18378
17404
  async function orgSettingsRoutes(app) {
18379
17405
  app.get("/:namespace", async (request) => {
18380
- const scope = await requireOrgAdmin(request, app.db);
18381
17406
  const namespace = parseNamespace(request.params.namespace);
17407
+ const scope = ORG_SETTINGS_NAMESPACES$1[namespace].readPolicy === "member" ? await requireOrgMembership(request, app.db) : await requireOrgAdmin(request, app.db);
18382
17408
  return enrichOutput(namespace, await getOrgSetting(app.db, scope.organizationId, namespace), scope.organizationId, app.config.server.publicUrl);
18383
17409
  });
18384
17410
  app.put("/:namespace", { config: { otelRecordBody: true } }, async (request) => {
@@ -19218,6 +18244,7 @@ var schema_exports = /* @__PURE__ */ __exportAll({
19218
18244
  notifications: () => notifications,
19219
18245
  organizationSettings: () => organizationSettings,
19220
18246
  organizations: () => organizations,
18247
+ pendingQuestions: () => pendingQuestions,
19221
18248
  serverInstances: () => serverInstances,
19222
18249
  sessionEvents: () => sessionEvents,
19223
18250
  taskChats: () => taskChats,
@@ -20879,7 +19906,7 @@ async function startServer(options) {
20879
19906
  instanceId: `srv_${randomUUID().slice(0, 8)}`,
20880
19907
  commandVersion: COMMAND_VERSION
20881
19908
  };
20882
- const { initTelemetry, shutdownTelemetry } = await import("./observability-CYsdAcoF.mjs");
19909
+ const { initTelemetry, shutdownTelemetry } = await import("./observability-eLA9iNK_.mjs");
20883
19910
  await initTelemetry(serverConfig.observability.tracing, config.instanceId);
20884
19911
  const app = await buildApp(config);
20885
19912
  const SHUTDOWN_FORCE_EXIT_MS = 8e3;