@agent-team-foundation/first-tree-hub 0.9.6 → 0.9.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,19 +2,20 @@ import { m as __toESM } from "./esm-CYu4tXXn.mjs";
2
2
  import { _ as withSpan, a as endWsConnectionSpan, b as require_pino, c as messageAttrs, d as rootLogger$1, g as startWsConnectionSpan, i as currentTraceId, n as applyLoggerConfig, o as getFastifyOtelPlugin, p as setWsConnectionAttrs, r as createLogger$1, t as adapterAttrs, u as observabilityPlugin, v as withWsMessageSpan, y as FIRST_TREE_HUB_ATTR } from "./observability-DV_fQKqV-CuLWzBxQ.mjs";
3
3
  import { s as formatPrettyEntry$1, t as LOG_LEVELS$1, u as parseLogLevel$1 } from "./logger-core-BTmvdflj-DhdipBkV.mjs";
4
4
  import { C as serverConfigSchema, S as resolveConfigReadonly, _ as loadAgents, d as DEFAULT_HOME_DIR$1, f as agentConfigSchema, g as initConfig, i as loadCredentials, l as DEFAULT_CONFIG_DIR, m as collectMissingPrompts, n as ensureFreshAccessToken, o as resolveServerUrl, p as clientConfigSchema, s as saveAgentConfig, u as DEFAULT_DATA_DIR$1, v as migrateLegacyHome, w as setConfigValue } from "./bootstrap-DWifXj9b.mjs";
5
- import { $ as updateAdapterConfigSchema, A as createMemberSchema, B as notificationQuerySchema, C as agentTypeSchema$1, D as createAdapterMappingSchema, E as createAdapterConfigSchema, F as inboxPollQuerySchema, G as sendMessageSchema, H as refreshTokenSchema, I as isRedactedEnvValue, J as sessionEventMessageSchema, K as sendToAgentSchema, L as linkTaskChatSchema, M as createTaskSchema, N as delegateFeishuUserSchema, O as createAgentSchema, P as dryRunAgentRuntimeConfigSchema, Q as taskListQuerySchema, R as loginSchema, S as agentRuntimeConfigPayloadSchema$1, T as connectTokenExchangeSchema, U as runtimeStateMessageSchema, V as paginationQuerySchema, W as selfServiceFeishuBotSchema, X as sessionReconcileRequestSchema, Y as sessionEventSchema$1, Z as sessionStateMessageSchema, _ as addParticipantSchema, a as AGENT_SELECTOR_HEADER$1, at as updateSystemConfigSchema, b as agentBindRequestSchema, c as AGENT_TYPES, d as SYSTEM_CONFIG_DEFAULTS, et as updateAgentRuntimeConfigSchema, f as TASK_CREATOR_TYPES, g as WS_AUTH_FRAME_TIMEOUT_MS, h as TASK_TERMINAL_STATUSES, i as AGENT_BIND_REJECT_REASONS, it as updateOrganizationSchema, j as createOrganizationSchema, k as createChatSchema, l as AGENT_VISIBILITY, m as TASK_STATUSES, nt as updateChatSchema, o as AGENT_SOURCES, ot as updateTaskStatusSchema, p as TASK_HEALTH_SIGNALS, q as sessionCompletionMessageSchema, rt as updateMemberSchema, s as AGENT_STATUSES, st as wsAuthFrameSchema, tt as updateAgentSchema, u as DEFAULT_AGENT_RUNTIME_CONFIG_PAYLOAD, v as adminCreateTaskSchema, w as clientRegisterSchema, x as agentPinnedMessageSchema$1, y as adminUpdateTaskSchema, z as messageSourceSchema$1 } from "./feishu-GlaczcVf.mjs";
5
+ import { $ as sessionStateMessageSchema, A as createMemberSchema, B as loginSchema, C as agentTypeSchema$1, D as createAdapterMappingSchema, E as createAdapterConfigSchema, F as extractMentions, G as runtimeStateMessageSchema, H as notificationQuerySchema, I as imageInlineContentSchema, J as sendToAgentSchema, K as selfServiceFeishuBotSchema, L as inboxPollQuerySchema, M as createTaskSchema, N as delegateFeishuUserSchema, O as createAgentSchema, P as dryRunAgentRuntimeConfigSchema, Q as sessionReconcileRequestSchema, R as isRedactedEnvValue, S as agentRuntimeConfigPayloadSchema$1, T as connectTokenExchangeSchema, U as paginationQuerySchema, V as messageSourceSchema$1, W as refreshTokenSchema, X as sessionEventMessageSchema, Y as sessionCompletionMessageSchema, Z as sessionEventSchema$1, _ as addParticipantSchema, a as AGENT_SELECTOR_HEADER$1, at as updateMemberSchema, b as agentBindRequestSchema, c as AGENT_TYPES, ct as updateTaskStatusSchema, d as SYSTEM_CONFIG_DEFAULTS, et as taskListQuerySchema, f as TASK_CREATOR_TYPES, g as WS_AUTH_FRAME_TIMEOUT_MS, h as TASK_TERMINAL_STATUSES, i as AGENT_BIND_REJECT_REASONS, it as updateChatSchema, j as createOrganizationSchema, k as createChatSchema, l as AGENT_VISIBILITY, lt as wsAuthFrameSchema, m as TASK_STATUSES, nt as updateAgentRuntimeConfigSchema, o as AGENT_SOURCES, ot as updateOrganizationSchema, p as TASK_HEALTH_SIGNALS, q as sendMessageSchema, rt as updateAgentSchema, s as AGENT_STATUSES, st as updateSystemConfigSchema, tt as updateAdapterConfigSchema, u as DEFAULT_AGENT_RUNTIME_CONFIG_PAYLOAD, v as adminCreateTaskSchema, w as clientRegisterSchema, x as agentPinnedMessageSchema$1, y as adminUpdateTaskSchema, z as linkTaskChatSchema } from "./feishu-CRNUI05I.mjs";
6
6
  import { createRequire } from "node:module";
7
7
  import { ZodError, z } from "zod";
8
- import { dirname, isAbsolute, join, resolve } from "node:path";
8
+ import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
9
9
  import { Writable } from "node:stream";
10
- import { homedir, hostname, platform, userInfo } from "node:os";
10
+ import { homedir, hostname, platform, tmpdir, userInfo } from "node:os";
11
11
  import { EventEmitter } from "node:events";
12
12
  import { closeSync, copyFileSync, createReadStream, existsSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, unwatchFile, watch, watchFile, writeFileSync, writeSync } from "node:fs";
13
13
  import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
14
14
  import WebSocket from "ws";
15
+ import { mkdir, writeFile } from "node:fs/promises";
16
+ import { stringify } from "yaml";
15
17
  import { query } from "@anthropic-ai/claude-agent-sdk";
16
18
  import { execFileSync, execSync, spawn, spawnSync } from "node:child_process";
17
- import { stringify } from "yaml";
18
19
  import * as semver from "semver";
19
20
  import bcrypt from "bcrypt";
20
21
  import { and, asc, count, desc, eq, gt, inArray, isNotNull, isNull, lt, ne, or, sql } from "drizzle-orm";
@@ -659,6 +660,11 @@ const chatParticipantSchema = z.object({
659
660
  mode: z.string(),
660
661
  joinedAt: z.string()
661
662
  });
663
+ chatParticipantSchema.extend({
664
+ name: z.string().nullable(),
665
+ displayName: z.string().nullable(),
666
+ type: z.string()
667
+ });
662
668
  z.object({
663
669
  id: z.string(),
664
670
  organizationId: z.string(),
@@ -698,6 +704,54 @@ z.object({
698
704
  limit: z.coerce.number().int().min(1).max(100).default(20),
699
705
  cursor: z.string().optional()
700
706
  });
707
+ /**
708
+ * MIME types the web + client image paths recognise. Kept in sync with
709
+ * Claude's vision API (see packages/client/src/handlers/claude-code.ts).
710
+ */
711
+ const SUPPORTED_IMAGE_MIMES$1 = [
712
+ "image/png",
713
+ "image/jpeg",
714
+ "image/gif",
715
+ "image/webp"
716
+ ];
717
+ const supportedImageMimeSchema = z.enum(SUPPORTED_IMAGE_MIMES$1);
718
+ const IMAGE_MIME_TO_EXT = {
719
+ "image/png": "png",
720
+ "image/jpeg": "jpg",
721
+ "image/gif": "gif",
722
+ "image/webp": "webp"
723
+ };
724
+ z.object({
725
+ data: z.string().min(1),
726
+ mimeType: supportedImageMimeSchema,
727
+ filename: z.string().min(1),
728
+ size: z.number().int().nonnegative().optional(),
729
+ imageId: z.string().uuid().optional()
730
+ });
731
+ z.object({
732
+ imageId: z.string().uuid(),
733
+ mimeType: supportedImageMimeSchema,
734
+ filename: z.string().min(1),
735
+ size: z.number().int().nonnegative().optional()
736
+ });
737
+ /**
738
+ * Server → client WS frame carrying the full image bytes for an image
739
+ * message. Pushed before the corresponding `new_message` notification so
740
+ * the client has the file on disk by the time it polls the message.
741
+ *
742
+ * Best-effort: if the target client WS lives on a different server
743
+ * instance (or is offline), the frame is lost and the reference message
744
+ * will surface a "not available on this device" placeholder downstream.
745
+ */
746
+ const imagePayloadFrameSchema = z.object({
747
+ type: z.literal("image_payload"),
748
+ imageId: z.string().uuid(),
749
+ chatId: z.string(),
750
+ base64: z.string().min(1),
751
+ mimeType: supportedImageMimeSchema,
752
+ filename: z.string().min(1),
753
+ size: z.number().int().nonnegative().optional()
754
+ });
701
755
  const messageSourceSchema = z.enum([
702
756
  "hub_ui",
703
757
  "cli",
@@ -730,17 +784,7 @@ z.object({
730
784
  replyToChat: z.string().optional(),
731
785
  source: messageSourceSchema.optional()
732
786
  });
733
- /**
734
- * Wire format for messages routed FROM the Hub TO a client runtime.
735
- *
736
- * Adds `configVersion` so the client can compare against its locally cached
737
- * agent runtime config and refresh before delivering the message to the SDK.
738
- *
739
- * Step 3: this is the single shape used by `buildClientMessagePayload` —
740
- * never serialise a raw `messageSchema` row to a client; always go through
741
- * the dispatcher.
742
- */
743
- const clientMessageSchema = z.object({
787
+ const messageSchema = z.object({
744
788
  id: z.string(),
745
789
  chatId: z.string(),
746
790
  senderId: z.string(),
@@ -752,7 +796,46 @@ const clientMessageSchema = z.object({
752
796
  inReplyTo: z.string().nullable(),
753
797
  source: messageSourceSchema.nullable(),
754
798
  createdAt: z.string()
755
- }).extend({ configVersion: z.number().int().positive() });
799
+ });
800
+ /**
801
+ * Snapshot of the `in_reply_to` target that the server materialises at
802
+ * dispatch time so the receiving runtime can decide whether this is an
803
+ * echo it should suppress (see proposal hub-agent-messaging-reply-and-mentions).
804
+ *
805
+ * `chatId` is the original message's `chat_id`; `replyToChat` is the chat
806
+ * its sender expected replies to flow back to (often a different chat).
807
+ * `null` when the message is not a reply, or the original could not be
808
+ * resolved (e.g. deleted).
809
+ */
810
+ const inReplyToSnapshotSchema = z.object({
811
+ senderId: z.string(),
812
+ chatId: z.string(),
813
+ replyToChat: z.string().nullable()
814
+ }).nullable();
815
+ /** Per-chat participation mode exposed to the recipient runtime. */
816
+ const participantModeSchema = z.enum(["full", "mention_only"]);
817
+ /**
818
+ * Wire format for messages routed FROM the Hub TO a client runtime.
819
+ *
820
+ * Adds `configVersion` so the client can compare against its locally cached
821
+ * agent runtime config and refresh before delivering the message to the SDK.
822
+ *
823
+ * Step 3: this is the single shape used by `buildClientMessagePayload` —
824
+ * never serialise a raw `messageSchema` row to a client; always go through
825
+ * the dispatcher.
826
+ *
827
+ * `recipientMode` is the receiving agent's own mode in the entry's chat —
828
+ * `mention_only` participants must only start a session when they appear in
829
+ * `metadata.mentions` (see session-manager.ts).
830
+ *
831
+ * `inReplyToSnapshot` is populated when `inReplyTo` resolves to an existing
832
+ * message; runtime uses it to suppress self-reply echo on direct chats.
833
+ */
834
+ const clientMessageSchema = messageSchema.extend({
835
+ configVersion: z.number().int().positive(),
836
+ recipientMode: participantModeSchema.default("full"),
837
+ inReplyToSnapshot: inReplyToSnapshotSchema.default(null)
838
+ });
756
839
  z.enum([
757
840
  "pending",
758
841
  "delivered",
@@ -1112,6 +1195,195 @@ const serverWelcomeFrameSchema = z.object({
1112
1195
  serverCommandVersion: z.string().min(1),
1113
1196
  serverTimeMs: z.number().int().nonnegative()
1114
1197
  }).passthrough();
1198
+ /** Declare a config field with a Zod schema and optional metadata. */
1199
+ function field(schema, options) {
1200
+ return {
1201
+ _tag: "field",
1202
+ _type: void 0,
1203
+ schema,
1204
+ options: options ?? {}
1205
+ };
1206
+ }
1207
+ /** Mark a config group as optional — present only when at least one field has an explicit value. */
1208
+ function optional(shape) {
1209
+ return {
1210
+ _tag: "optional",
1211
+ shape
1212
+ };
1213
+ }
1214
+ /** Define a config shape. Identity function used for type inference. */
1215
+ function defineConfig(shape) {
1216
+ return shape;
1217
+ }
1218
+ defineConfig({
1219
+ agentId: field(z.string().min(1)),
1220
+ runtime: field(z.string().default("claude-code")),
1221
+ concurrency: field(z.number().int().positive().default(5)),
1222
+ session: {
1223
+ idle_timeout: field(z.number().int().positive().default(300)),
1224
+ max_sessions: field(z.number().int().positive().default(10))
1225
+ }
1226
+ });
1227
+ /**
1228
+ * Phase-dependent defaults that flip with release milestones. Kept as a plain
1229
+ * module-level constant so reviews of the beta→GA transition are a one-line
1230
+ * diff, and so tests can mock this module to exercise both branches.
1231
+ */
1232
+ const UPDATE_POLICIES = [
1233
+ "auto",
1234
+ "prompt",
1235
+ "off"
1236
+ ];
1237
+ /**
1238
+ * Default value of `update.policy` on the Client config. During the beta this
1239
+ * is `"auto"` — operators rarely know to `npm i -g` weekly and we chase the
1240
+ * latest published Command by default. The GA PR flips it to `"prompt"` and
1241
+ * bumps Command to `1.0.0`.
1242
+ */
1243
+ const UPDATE_POLICY_DEFAULT = "auto";
1244
+ const updatePolicySchema = z.enum(UPDATE_POLICIES);
1245
+ defineConfig({
1246
+ server: { url: field(z.string(), {
1247
+ env: "FIRST_TREE_HUB_SERVER_URL",
1248
+ prompt: {
1249
+ message: "Server URL:",
1250
+ default: "http://localhost:8000"
1251
+ }
1252
+ }) },
1253
+ client: { id: field(z.string().regex(/^client_[a-f0-9]{8}$/), {
1254
+ auto: "client-id",
1255
+ env: "FIRST_TREE_HUB_CLIENT_ID"
1256
+ }) },
1257
+ update: {
1258
+ policy: field(updatePolicySchema.default(UPDATE_POLICY_DEFAULT), { env: "FIRST_TREE_HUB_UPDATE_POLICY" }),
1259
+ restart_quiet_seconds: field(z.number().int().min(1).max(3600).default(30), { env: "FIRST_TREE_HUB_UPDATE_RESTART_QUIET_SECONDS" }),
1260
+ restart_check_interval_seconds: field(z.number().int().min(5).max(300).default(10), { env: "FIRST_TREE_HUB_UPDATE_RESTART_CHECK_INTERVAL_SECONDS" }),
1261
+ prompt_timeout_seconds: field(z.number().int().min(10).max(600).default(60), { env: "FIRST_TREE_HUB_UPDATE_PROMPT_TIMEOUT_SECONDS" })
1262
+ },
1263
+ logLevel: field(logLevelSchema.default("info"), { env: "FIRST_TREE_HUB_LOG_LEVEL" })
1264
+ });
1265
+ const DEFAULT_HOME_DIR = process.env.FIRST_TREE_HUB_HOME ?? join(homedir(), ".first-tree", "hub");
1266
+ join(DEFAULT_HOME_DIR, "config");
1267
+ const DEFAULT_DATA_DIR = join(DEFAULT_HOME_DIR, "data");
1268
+ join(homedir(), ".first-tree-hub");
1269
+ defineConfig({
1270
+ database: {
1271
+ url: field(z.string(), {
1272
+ env: "FIRST_TREE_HUB_DATABASE_URL",
1273
+ auto: "docker-pg",
1274
+ prompt: {
1275
+ message: "PostgreSQL:",
1276
+ type: "select",
1277
+ choices: [{
1278
+ name: "Auto-provision via Docker",
1279
+ value: "__auto__"
1280
+ }, {
1281
+ name: "Provide connection URL",
1282
+ value: "__input__"
1283
+ }]
1284
+ }
1285
+ }),
1286
+ provider: field(z.enum(["docker", "external"]).default("docker"))
1287
+ },
1288
+ server: {
1289
+ port: field(z.number().default(8e3), { env: "FIRST_TREE_HUB_PORT" }),
1290
+ host: field(z.string().default("127.0.0.1"), { env: "FIRST_TREE_HUB_HOST" })
1291
+ },
1292
+ secrets: {
1293
+ jwtSecret: field(z.string(), {
1294
+ env: "FIRST_TREE_HUB_JWT_SECRET",
1295
+ auto: "random:base64url:32",
1296
+ secret: true
1297
+ }),
1298
+ encryptionKey: field(z.string(), {
1299
+ env: "FIRST_TREE_HUB_ENCRYPTION_KEY",
1300
+ auto: "random:hex:32",
1301
+ secret: true
1302
+ })
1303
+ },
1304
+ contextTree: optional({
1305
+ repo: field(z.string(), {
1306
+ env: "FIRST_TREE_HUB_CONTEXT_TREE_REPO",
1307
+ prompt: { message: "Context Tree repo URL (e.g. https://github.com/org/first-tree):" }
1308
+ }),
1309
+ branch: field(z.string().default("main"))
1310
+ }),
1311
+ github: {
1312
+ webhookSecret: field(z.string().optional(), {
1313
+ env: "FIRST_TREE_HUB_GITHUB_WEBHOOK_SECRET",
1314
+ secret: true
1315
+ }),
1316
+ allowedOrg: field(z.string().optional(), { env: "FIRST_TREE_HUB_GITHUB_ALLOWED_ORG" })
1317
+ },
1318
+ cors: optional({ origin: field(z.string(), { env: "FIRST_TREE_HUB_CORS_ORIGIN" }) }),
1319
+ rateLimit: optional({
1320
+ max: field(z.number().default(100), { env: "FIRST_TREE_HUB_RATE_LIMIT_MAX" }),
1321
+ loginMax: field(z.number().default(5), { env: "FIRST_TREE_HUB_RATE_LIMIT_LOGIN_MAX" }),
1322
+ webhookMax: field(z.number().default(60), { env: "FIRST_TREE_HUB_RATE_LIMIT_WEBHOOK_MAX" })
1323
+ }),
1324
+ kael: optional({
1325
+ endpoint: field(z.string(), { env: "KAEL_ENDPOINT" }),
1326
+ apiKey: field(z.string(), {
1327
+ env: "KAEL_API_KEY",
1328
+ secret: true
1329
+ }),
1330
+ hubPublicUrl: field(z.string(), { env: "FIRST_TREE_HUB_PUBLIC_URL" })
1331
+ }),
1332
+ observability: {
1333
+ logging: {
1334
+ level: field(logLevelSchema.default("info"), { env: "FIRST_TREE_HUB_LOG_LEVEL" }),
1335
+ format: field(logFormatSchema.default(process.env.NODE_ENV === "production" ? "json" : "pretty")),
1336
+ bridgeToSpanLevel: field(z.enum([
1337
+ "error",
1338
+ "warn",
1339
+ "off"
1340
+ ]).default("error"))
1341
+ },
1342
+ tracing: optional({
1343
+ endpoint: field(z.string(), { env: "FIRST_TREE_HUB_OTEL_ENDPOINT" }),
1344
+ headers: field(z.string().default(""), {
1345
+ env: "FIRST_TREE_HUB_OTEL_HEADERS",
1346
+ secret: true
1347
+ }),
1348
+ exporter: field(z.enum(["otlp-http", "otlp-grpc"]).default("otlp-http")),
1349
+ serviceName: field(z.string().default("first-tree-hub")),
1350
+ environment: field(z.string().default("development"), { env: "FIRST_TREE_HUB_OTEL_ENVIRONMENT" }),
1351
+ sampleRate: field(z.number().min(0).max(1).default(1))
1352
+ })
1353
+ }
1354
+ });
1355
+ /** UUIDs are the only shape we generate for imageId, but accept the same
1356
+ * loose character set as chatId sanitisers elsewhere so a malformed field
1357
+ * can never break out of the images dir. */
1358
+ function sanitize(segment) {
1359
+ return /^[a-zA-Z0-9-]+$/.test(segment) ? segment : "unknown";
1360
+ }
1361
+ function imageDir(chatId) {
1362
+ return join(DEFAULT_DATA_DIR, "chats", sanitize(chatId), "images");
1363
+ }
1364
+ function imagePath(chatId, imageId, mimeType) {
1365
+ const ext = IMAGE_MIME_TO_EXT[mimeType];
1366
+ return join(imageDir(chatId), `${sanitize(imageId)}.${ext}`);
1367
+ }
1368
+ /**
1369
+ * Locate a previously-written image file on disk. Returns null when the
1370
+ * file is missing — caller should surface the "image not available on
1371
+ * this device" placeholder in that case.
1372
+ */
1373
+ function findImagePath(chatId, imageId, mimeType) {
1374
+ const p = imagePath(chatId, imageId, mimeType);
1375
+ return existsSync(p) ? p : null;
1376
+ }
1377
+ /**
1378
+ * Persist image bytes to `<dataDir>/chats/<chatId>/images/<imageId>.<ext>`.
1379
+ * Idempotent — rewriting the same imageId is a no-op overwrite.
1380
+ */
1381
+ async function writeImage(params) {
1382
+ await mkdir(imageDir(params.chatId), { recursive: true });
1383
+ const path = imagePath(params.chatId, params.imageId, params.mimeType);
1384
+ await writeFile(path, Buffer.from(params.base64, "base64"));
1385
+ return path;
1386
+ }
1115
1387
  const FETCH_TIMEOUT_MS = 15e3;
1116
1388
  var FirstTreeHubSDK = class {
1117
1389
  _baseUrl;
@@ -1185,6 +1457,13 @@ var FirstTreeHubSDK = class {
1185
1457
  async listMessages(chatId, options) {
1186
1458
  return this.requestJson(`/api/v1/agent/chats/${chatId}/messages${this.queryString(options)}`);
1187
1459
  }
1460
+ /**
1461
+ * List participants of a chat with agent names/displayNames — used by the
1462
+ * runtime to resolve `@<name>` mentions against the authoritative set.
1463
+ */
1464
+ async listChatParticipants(chatId) {
1465
+ return this.requestJson(`/api/v1/agent/chats/${chatId}/participants`);
1466
+ }
1188
1467
  queryString(options) {
1189
1468
  const params = new URLSearchParams();
1190
1469
  if (options?.limit !== void 0) params.set("limit", String(options.limit));
@@ -1293,6 +1572,14 @@ var ClientConnection = class extends EventEmitter {
1293
1572
  /** Agents scheduled to rebind automatically on every reconnect. */
1294
1573
  desiredBindings = /* @__PURE__ */ new Map();
1295
1574
  pendingBinds = /* @__PURE__ */ new Map();
1575
+ /**
1576
+ * In-flight image writes from recent `image_payload` frames. The server
1577
+ * pushes `image_payload` immediately before firing the `new_message`
1578
+ * notification, but WS message handlers run through EventEmitter (sync
1579
+ * dispatch, no await), so the disk write can still race the HTTP poll
1580
+ * that follows. Defer `new_message` emission until these settle.
1581
+ */
1582
+ pendingImageWrites = /* @__PURE__ */ new Set();
1296
1583
  constructor(config) {
1297
1584
  super();
1298
1585
  this.clientId = config.clientId ?? process.env.FIRST_TREE_HUB_CLIENT_ID ?? `client_${randomUUID().slice(0, 8)}`;
@@ -1604,9 +1891,36 @@ var ClientConnection = class extends EventEmitter {
1604
1891
  });
1605
1892
  return;
1606
1893
  }
1894
+ if (type === "image_payload") {
1895
+ const parsed = imagePayloadFrameSchema.safeParse(msg);
1896
+ if (!parsed.success) {
1897
+ this.wsLogger.warn({ err: parsed.error.flatten() }, "malformed image_payload frame — dropping");
1898
+ return;
1899
+ }
1900
+ const { imageId, chatId, mimeType, base64 } = parsed.data;
1901
+ const write = writeImage({
1902
+ chatId,
1903
+ imageId,
1904
+ mimeType,
1905
+ base64
1906
+ }).then(() => {}).catch((err) => {
1907
+ this.wsLogger.warn({
1908
+ err,
1909
+ imageId,
1910
+ chatId
1911
+ }, "image_payload write failed");
1912
+ });
1913
+ this.pendingImageWrites.add(write);
1914
+ write.finally(() => this.pendingImageWrites.delete(write));
1915
+ return;
1916
+ }
1607
1917
  if (type === "new_message") {
1608
1918
  const inboxId = msg.inboxId;
1609
- if (inboxId) this.emit("agent:message", inboxId, msg);
1919
+ if (!inboxId) return;
1920
+ if (this.pendingImageWrites.size > 0) Promise.all([...this.pendingImageWrites]).finally(() => {
1921
+ this.emit("agent:message", inboxId, msg);
1922
+ });
1923
+ else this.emit("agent:message", inboxId, msg);
1610
1924
  return;
1611
1925
  }
1612
1926
  if (type === "error") {
@@ -1652,228 +1966,71 @@ var ClientConnection = class extends EventEmitter {
1652
1966
  if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify({ type: "heartbeat" }));
1653
1967
  }, HEARTBEAT_INTERVAL_MS);
1654
1968
  }
1655
- stopHeartbeat() {
1656
- if (this.heartbeatTimer) {
1657
- clearInterval(this.heartbeatTimer);
1658
- this.heartbeatTimer = null;
1659
- }
1660
- }
1661
- rejectAllPendingBinds(reason) {
1662
- for (const [, pending] of this.pendingBinds) pending.reject(new Error(reason));
1663
- this.pendingBinds.clear();
1664
- }
1665
- clearTimers() {
1666
- this.stopHeartbeat();
1667
- if (this.wsConnectTimer) {
1668
- clearTimeout(this.wsConnectTimer);
1669
- this.wsConnectTimer = null;
1670
- }
1671
- if (this.reconnectTimer) {
1672
- clearTimeout(this.reconnectTimer);
1673
- this.reconnectTimer = null;
1674
- }
1675
- this.clearAuthRefreshTimer();
1676
- }
1677
- clearAuthRefreshTimer() {
1678
- if (this.authRefreshTimer) {
1679
- clearTimeout(this.authRefreshTimer);
1680
- this.authRefreshTimer = null;
1681
- }
1682
- }
1683
- /**
1684
- * Proactive JWT refresh (C5). Schedule a silent reconnect ~60s before the
1685
- * access token expires so `getAccessToken()` is asked for a fresh JWT
1686
- * before the server's scheduleAuthExpiry timer fires. Short-lived tokens
1687
- * (exp <= lead window) skip the proactive reconnect entirely — we let the
1688
- * server push `auth:expired` and handle that path.
1689
- */
1690
- scheduleProactiveAuthRefresh(token) {
1691
- this.clearAuthRefreshTimer();
1692
- const exp = decodeJwtExp(token);
1693
- if (!exp) return;
1694
- const delay = exp * 1e3 - Date.now() - AUTH_REFRESH_LEAD_MS;
1695
- if (delay <= 0) return;
1696
- this.authLogger.debug({ delayMs: delay }, "scheduled proactive auth refresh");
1697
- this.authRefreshTimer = setTimeout(() => {
1698
- this.authRefreshTimer = null;
1699
- if (this.closing) return;
1700
- this.authLogger.info("triggering proactive auth refresh");
1701
- this.ws?.close(1e3, "proactive auth refresh");
1702
- }, delay);
1703
- }
1704
- };
1705
- /** Built-in handler registry. Populated by handler modules. */
1706
- const HANDLER_REGISTRY = /* @__PURE__ */ new Map();
1707
- /** Register a built-in handler type. */
1708
- function registerHandler(type, factory) {
1709
- HANDLER_REGISTRY.set(type, factory);
1710
- }
1711
- /** Resolve a handler factory by type name. */
1712
- function getHandlerFactory(type) {
1713
- const factory = HANDLER_REGISTRY.get(type);
1714
- if (!factory) {
1715
- const available = [...HANDLER_REGISTRY.keys()].join(", ") || "(none)";
1716
- throw new Error(`Unknown handler type "${type}". Available: ${available}`);
1717
- }
1718
- return factory;
1719
- }
1720
- /** Declare a config field with a Zod schema and optional metadata. */
1721
- function field(schema, options) {
1722
- return {
1723
- _tag: "field",
1724
- _type: void 0,
1725
- schema,
1726
- options: options ?? {}
1727
- };
1728
- }
1729
- /** Mark a config group as optional — present only when at least one field has an explicit value. */
1730
- function optional(shape) {
1731
- return {
1732
- _tag: "optional",
1733
- shape
1734
- };
1735
- }
1736
- /** Define a config shape. Identity function used for type inference. */
1737
- function defineConfig(shape) {
1738
- return shape;
1739
- }
1740
- defineConfig({
1741
- agentId: field(z.string().min(1)),
1742
- runtime: field(z.string().default("claude-code")),
1743
- concurrency: field(z.number().int().positive().default(5)),
1744
- session: {
1745
- idle_timeout: field(z.number().int().positive().default(300)),
1746
- max_sessions: field(z.number().int().positive().default(10))
1747
- }
1748
- });
1749
- /**
1750
- * Phase-dependent defaults that flip with release milestones. Kept as a plain
1751
- * module-level constant so reviews of the beta→GA transition are a one-line
1752
- * diff, and so tests can mock this module to exercise both branches.
1753
- */
1754
- const UPDATE_POLICIES = [
1755
- "auto",
1756
- "prompt",
1757
- "off"
1758
- ];
1759
- /**
1760
- * Default value of `update.policy` on the Client config. During the beta this
1761
- * is `"auto"` — operators rarely know to `npm i -g` weekly and we chase the
1762
- * latest published Command by default. The GA PR flips it to `"prompt"` and
1763
- * bumps Command to `1.0.0`.
1764
- */
1765
- const UPDATE_POLICY_DEFAULT = "auto";
1766
- const updatePolicySchema = z.enum(UPDATE_POLICIES);
1767
- defineConfig({
1768
- server: { url: field(z.string(), {
1769
- env: "FIRST_TREE_HUB_SERVER_URL",
1770
- prompt: {
1771
- message: "Server URL:",
1772
- default: "http://localhost:8000"
1773
- }
1774
- }) },
1775
- client: { id: field(z.string().regex(/^client_[a-f0-9]{8}$/), {
1776
- auto: "client-id",
1777
- env: "FIRST_TREE_HUB_CLIENT_ID"
1778
- }) },
1779
- update: {
1780
- policy: field(updatePolicySchema.default(UPDATE_POLICY_DEFAULT), { env: "FIRST_TREE_HUB_UPDATE_POLICY" }),
1781
- restart_quiet_seconds: field(z.number().int().min(1).max(3600).default(30), { env: "FIRST_TREE_HUB_UPDATE_RESTART_QUIET_SECONDS" }),
1782
- restart_check_interval_seconds: field(z.number().int().min(5).max(300).default(10), { env: "FIRST_TREE_HUB_UPDATE_RESTART_CHECK_INTERVAL_SECONDS" }),
1783
- prompt_timeout_seconds: field(z.number().int().min(10).max(600).default(60), { env: "FIRST_TREE_HUB_UPDATE_PROMPT_TIMEOUT_SECONDS" })
1784
- },
1785
- logLevel: field(logLevelSchema.default("info"), { env: "FIRST_TREE_HUB_LOG_LEVEL" })
1786
- });
1787
- const DEFAULT_HOME_DIR = process.env.FIRST_TREE_HUB_HOME ?? join(homedir(), ".first-tree", "hub");
1788
- join(DEFAULT_HOME_DIR, "config");
1789
- const DEFAULT_DATA_DIR = join(DEFAULT_HOME_DIR, "data");
1790
- join(homedir(), ".first-tree-hub");
1791
- defineConfig({
1792
- database: {
1793
- url: field(z.string(), {
1794
- env: "FIRST_TREE_HUB_DATABASE_URL",
1795
- auto: "docker-pg",
1796
- prompt: {
1797
- message: "PostgreSQL:",
1798
- type: "select",
1799
- choices: [{
1800
- name: "Auto-provision via Docker",
1801
- value: "__auto__"
1802
- }, {
1803
- name: "Provide connection URL",
1804
- value: "__input__"
1805
- }]
1806
- }
1807
- }),
1808
- provider: field(z.enum(["docker", "external"]).default("docker"))
1809
- },
1810
- server: {
1811
- port: field(z.number().default(8e3), { env: "FIRST_TREE_HUB_PORT" }),
1812
- host: field(z.string().default("127.0.0.1"), { env: "FIRST_TREE_HUB_HOST" })
1813
- },
1814
- secrets: {
1815
- jwtSecret: field(z.string(), {
1816
- env: "FIRST_TREE_HUB_JWT_SECRET",
1817
- auto: "random:base64url:32",
1818
- secret: true
1819
- }),
1820
- encryptionKey: field(z.string(), {
1821
- env: "FIRST_TREE_HUB_ENCRYPTION_KEY",
1822
- auto: "random:hex:32",
1823
- secret: true
1824
- })
1825
- },
1826
- contextTree: optional({
1827
- repo: field(z.string(), {
1828
- env: "FIRST_TREE_HUB_CONTEXT_TREE_REPO",
1829
- prompt: { message: "Context Tree repo URL (e.g. https://github.com/org/first-tree):" }
1830
- }),
1831
- branch: field(z.string().default("main"))
1832
- }),
1833
- github: {
1834
- webhookSecret: field(z.string().optional(), {
1835
- env: "FIRST_TREE_HUB_GITHUB_WEBHOOK_SECRET",
1836
- secret: true
1837
- }),
1838
- allowedOrg: field(z.string().optional(), { env: "FIRST_TREE_HUB_GITHUB_ALLOWED_ORG" })
1839
- },
1840
- cors: optional({ origin: field(z.string(), { env: "FIRST_TREE_HUB_CORS_ORIGIN" }) }),
1841
- rateLimit: optional({
1842
- max: field(z.number().default(100), { env: "FIRST_TREE_HUB_RATE_LIMIT_MAX" }),
1843
- loginMax: field(z.number().default(5), { env: "FIRST_TREE_HUB_RATE_LIMIT_LOGIN_MAX" }),
1844
- webhookMax: field(z.number().default(60), { env: "FIRST_TREE_HUB_RATE_LIMIT_WEBHOOK_MAX" })
1845
- }),
1846
- kael: optional({
1847
- endpoint: field(z.string(), { env: "KAEL_ENDPOINT" }),
1848
- apiKey: field(z.string(), {
1849
- env: "KAEL_API_KEY",
1850
- secret: true
1851
- }),
1852
- hubPublicUrl: field(z.string(), { env: "FIRST_TREE_HUB_PUBLIC_URL" })
1853
- }),
1854
- observability: {
1855
- logging: {
1856
- level: field(logLevelSchema.default("info"), { env: "FIRST_TREE_HUB_LOG_LEVEL" }),
1857
- format: field(logFormatSchema.default(process.env.NODE_ENV === "production" ? "json" : "pretty")),
1858
- bridgeToSpanLevel: field(z.enum([
1859
- "error",
1860
- "warn",
1861
- "off"
1862
- ]).default("error"))
1863
- },
1864
- tracing: optional({
1865
- endpoint: field(z.string(), { env: "FIRST_TREE_HUB_OTEL_ENDPOINT" }),
1866
- headers: field(z.string().default(""), {
1867
- env: "FIRST_TREE_HUB_OTEL_HEADERS",
1868
- secret: true
1869
- }),
1870
- exporter: field(z.enum(["otlp-http", "otlp-grpc"]).default("otlp-http")),
1871
- serviceName: field(z.string().default("first-tree-hub")),
1872
- environment: field(z.string().default("development"), { env: "FIRST_TREE_HUB_OTEL_ENVIRONMENT" }),
1873
- sampleRate: field(z.number().min(0).max(1).default(1))
1874
- })
1875
- }
1876
- });
1969
+ stopHeartbeat() {
1970
+ if (this.heartbeatTimer) {
1971
+ clearInterval(this.heartbeatTimer);
1972
+ this.heartbeatTimer = null;
1973
+ }
1974
+ }
1975
+ rejectAllPendingBinds(reason) {
1976
+ for (const [, pending] of this.pendingBinds) pending.reject(new Error(reason));
1977
+ this.pendingBinds.clear();
1978
+ }
1979
+ clearTimers() {
1980
+ this.stopHeartbeat();
1981
+ if (this.wsConnectTimer) {
1982
+ clearTimeout(this.wsConnectTimer);
1983
+ this.wsConnectTimer = null;
1984
+ }
1985
+ if (this.reconnectTimer) {
1986
+ clearTimeout(this.reconnectTimer);
1987
+ this.reconnectTimer = null;
1988
+ }
1989
+ this.clearAuthRefreshTimer();
1990
+ }
1991
+ clearAuthRefreshTimer() {
1992
+ if (this.authRefreshTimer) {
1993
+ clearTimeout(this.authRefreshTimer);
1994
+ this.authRefreshTimer = null;
1995
+ }
1996
+ }
1997
+ /**
1998
+ * Proactive JWT refresh (C5). Schedule a silent reconnect ~60s before the
1999
+ * access token expires so `getAccessToken()` is asked for a fresh JWT
2000
+ * before the server's scheduleAuthExpiry timer fires. Short-lived tokens
2001
+ * (exp <= lead window) skip the proactive reconnect entirely — we let the
2002
+ * server push `auth:expired` and handle that path.
2003
+ */
2004
+ scheduleProactiveAuthRefresh(token) {
2005
+ this.clearAuthRefreshTimer();
2006
+ const exp = decodeJwtExp(token);
2007
+ if (!exp) return;
2008
+ const delay = exp * 1e3 - Date.now() - AUTH_REFRESH_LEAD_MS;
2009
+ if (delay <= 0) return;
2010
+ this.authLogger.debug({ delayMs: delay }, "scheduled proactive auth refresh");
2011
+ this.authRefreshTimer = setTimeout(() => {
2012
+ this.authRefreshTimer = null;
2013
+ if (this.closing) return;
2014
+ this.authLogger.info("triggering proactive auth refresh");
2015
+ this.ws?.close(1e3, "proactive auth refresh");
2016
+ }, delay);
2017
+ }
2018
+ };
2019
+ /** Built-in handler registry. Populated by handler modules. */
2020
+ const HANDLER_REGISTRY = /* @__PURE__ */ new Map();
2021
+ /** Register a built-in handler type. */
2022
+ function registerHandler(type, factory) {
2023
+ HANDLER_REGISTRY.set(type, factory);
2024
+ }
2025
+ /** Resolve a handler factory by type name. */
2026
+ function getHandlerFactory(type) {
2027
+ const factory = HANDLER_REGISTRY.get(type);
2028
+ if (!factory) {
2029
+ const available = [...HANDLER_REGISTRY.keys()].join(", ") || "(none)";
2030
+ throw new Error(`Unknown handler type "${type}". Available: ${available}`);
2031
+ }
2032
+ return factory;
2033
+ }
1877
2034
  join(DEFAULT_DATA_DIR, "context-tree");
1878
2035
  /**
1879
2036
  * Bootstrap a workspace with .agent/ directory files.
@@ -2012,22 +2169,28 @@ Use the \`first-tree-hub agent send\` CLI — it reads the env vars above and
2012
2169
  attaches the \`Authorization\` + \`X-Agent-Id\` headers automatically:
2013
2170
 
2014
2171
  \`\`\`bash
2015
- # Send to another agent (target = agent ID)
2016
- first-tree-hub agent send <agentId> "your message"
2172
+ # Send to another agent target is the agent NAME, NOT a uuid.
2173
+ # Names are stable (set on creation, immutable, unique in the org).
2174
+ # Run \`first-tree-hub agent list\` to see available names.
2175
+ first-tree-hub agent send <agentName> "your message"
2017
2176
 
2018
- # Send to a chat (target = chat ID)
2177
+ # Send to a chat (target is a chat UUID; use this when replying into a
2178
+ # specific chat, e.g. a group where you were mentioned)
2019
2179
  first-tree-hub agent send --chat <chatId> "your message"
2020
2180
 
2021
2181
  # Send markdown (default format is text)
2022
- first-tree-hub agent send <agentId> -f markdown "**bold** message"
2182
+ first-tree-hub agent send <agentName> -f markdown "**bold** message"
2023
2183
 
2024
2184
  # Reply to a specific message
2025
- first-tree-hub agent send <agentId> --reply-to <messageId> "reply content"
2185
+ first-tree-hub agent send <agentName> --reply-to <messageId> "reply content"
2026
2186
 
2027
2187
  # Pipe long content via stdin (recommended for special characters)
2028
- echo "long message body" | first-tree-hub agent send <agentId>
2188
+ echo "long message body" | first-tree-hub agent send <agentName>
2029
2189
  \`\`\`
2030
2190
 
2191
+ > Agent uuids appear in \`agent chats\`, chat history, and participant lists,
2192
+ > but they are NOT accepted by \`agent send\` — always use the name.
2193
+
2031
2194
  For content with quotes, \`$\`, backticks, or newlines, prefer stdin to avoid shell escaping issues.
2032
2195
  `;
2033
2196
  }
@@ -2570,20 +2733,99 @@ function cleanWorkspaces(workspaceRoot, activeChatIds, ttlMs = DEFAULT_WORKSPACE
2570
2733
  }
2571
2734
  return removed;
2572
2735
  }
2736
+ /**
2737
+ * Resolve which Claude Code binary the SDK should spawn.
2738
+ *
2739
+ * Priority:
2740
+ * 1. `CLAUDE_CODE_EXECUTABLE` env var — explicit operator override
2741
+ * 2. `claude` on PATH — reuses whatever the user has already installed
2742
+ * 3. undefined — fall back to the SDK's bundled native binary
2743
+ *
2744
+ * The SDK's bundled binary ships as a per-platform **optional** npm dep
2745
+ * (`@anthropic-ai/claude-agent-sdk-<platform>-<arch>`). Any of: a proxy that
2746
+ * skips optional deps, an `.npmrc` with `omit=optional`, libc detection
2747
+ * failure, or a transient install error leaves that dep missing and the SDK
2748
+ * throws "Native CLI binary for <platform>-<arch> not found". Returning a PATH
2749
+ * hit here bypasses the missing bundle entirely.
2750
+ */
2751
+ function resolveClaudeCodeExecutable(opts = {}) {
2752
+ const env = opts.env ?? process.env;
2753
+ const override = env.CLAUDE_CODE_EXECUTABLE;
2754
+ if (override && override.length > 0 && existsSync(override)) return {
2755
+ path: override,
2756
+ source: "env"
2757
+ };
2758
+ const found = findOnPath("claude", env);
2759
+ if (found) return {
2760
+ path: found,
2761
+ source: "path"
2762
+ };
2763
+ return {
2764
+ path: void 0,
2765
+ source: "default"
2766
+ };
2767
+ }
2768
+ function findOnPath(name, env) {
2769
+ const rawPath = env.PATH ?? env.Path ?? env.path ?? "";
2770
+ if (!rawPath) return void 0;
2771
+ const exts = process.platform === "win32" ? splitPathExt(env.PATHEXT) : [""];
2772
+ for (const dir of rawPath.split(delimiter)) {
2773
+ if (!dir) continue;
2774
+ for (const ext of exts) {
2775
+ const full = join(dir, name + ext);
2776
+ if (existsSync(full)) return full;
2777
+ }
2778
+ }
2779
+ }
2780
+ function splitPathExt(pathext) {
2781
+ if (!pathext) return [
2782
+ ".EXE",
2783
+ ".CMD",
2784
+ ".BAT",
2785
+ ".COM",
2786
+ ""
2787
+ ];
2788
+ const parts = pathext.split(";").filter(Boolean);
2789
+ return parts.length > 0 ? [...parts, ""] : [""];
2790
+ }
2573
2791
  const MAX_RETRIES = 2;
2574
2792
  const TOOL_RESULT_PREVIEW_LIMIT = 400;
2575
2793
  const ASSISTANT_TEXT_EVENT_LIMIT = 8e3;
2576
- const SUPPORTED_IMAGE_MIMES = new Set([
2577
- "image/png",
2578
- "image/jpeg",
2579
- "image/gif",
2580
- "image/webp"
2581
- ]);
2582
- function isImageFileContent(content) {
2794
+ const SUPPORTED_IMAGE_MIMES = new Set(SUPPORTED_IMAGE_MIMES$1);
2795
+ const MIME_TO_EXT = {
2796
+ "image/png": "png",
2797
+ "image/jpeg": "jpg",
2798
+ "image/gif": "gif",
2799
+ "image/webp": "webp"
2800
+ };
2801
+ function isImageRefContent(content) {
2802
+ if (!content || typeof content !== "object") return false;
2803
+ const c = content;
2804
+ return typeof c.imageId === "string" && typeof c.mimeType === "string" && typeof c.filename === "string" && SUPPORTED_IMAGE_MIMES.has(c.mimeType);
2805
+ }
2806
+ function isLegacyImageFileContent(content) {
2583
2807
  if (!content || typeof content !== "object") return false;
2584
2808
  const c = content;
2585
2809
  return typeof c.data === "string" && typeof c.mimeType === "string" && typeof c.filename === "string" && SUPPORTED_IMAGE_MIMES.has(c.mimeType);
2586
2810
  }
2811
+ /** chat_id values are DB-generated UUIDs; reject anything else so we never
2812
+ * traverse out of the images dir if the field is ever tampered with. */
2813
+ function sanitizeChatId(chatId) {
2814
+ return /^[a-zA-Z0-9-]+$/.test(chatId) ? chatId : "unknown";
2815
+ }
2816
+ /**
2817
+ * Write a legacy inline-base64 image to a temp file so Claude Code's Read
2818
+ * tool can pick it up. Only the legacy path — new messages go through the
2819
+ * image_payload WS push which pre-writes to the data dir before delivery.
2820
+ */
2821
+ async function writeLegacyImageToTempFile(content, chatId) {
2822
+ const dir = join(tmpdir(), "first-tree-hub", "images", sanitizeChatId(chatId));
2823
+ await mkdir(dir, { recursive: true });
2824
+ const ext = MIME_TO_EXT[content.mimeType];
2825
+ const path = join(dir, `${Date.now()}-${randomUUID().slice(0, 8)}.${ext}`);
2826
+ await writeFile(path, Buffer.from(content.data, "base64"));
2827
+ return path;
2828
+ }
2587
2829
  function extractContentBlocks(message) {
2588
2830
  if (!message || typeof message !== "object") return [];
2589
2831
  const inner = message.message;
@@ -2741,6 +2983,7 @@ const createClaudeCodeHandler = (config) => {
2741
2983
  const workspaceRoot = config.workspaceRoot;
2742
2984
  const agentConfigCache = config.agentConfigCache ?? null;
2743
2985
  const gitMirrorManager = config.gitMirrorManager ?? null;
2986
+ const claudeCodeExecutable = config.claudeCodeExecutable ?? resolveClaudeCodeExecutable().path;
2744
2987
  let cwd = null;
2745
2988
  let claudeSessionId = null;
2746
2989
  let currentQuery = null;
@@ -2755,35 +2998,64 @@ const createClaudeCodeHandler = (config) => {
2755
2998
  let appliedPayload = null;
2756
2999
  /** Worktrees materialised for this session — each entry removed on shutdown. */
2757
3000
  const ownedWorktrees = [];
2758
- function toSDKUserMessage(message, sessionId) {
2759
- if (message.format === "file" && isImageFileContent(message.content)) {
2760
- const { data, mimeType, filename } = message.content;
2761
- return {
2762
- type: "user",
2763
- message: {
2764
- role: "user",
2765
- content: [{
2766
- type: "text",
2767
- text: `${message.senderId ? `[From: ${message.senderId}]\n\n` : ""}[Attached image: ${filename}]`
2768
- }, {
2769
- type: "image",
2770
- source: {
2771
- type: "base64",
2772
- media_type: mimeType,
2773
- data
2774
- }
2775
- }]
2776
- },
2777
- parent_tool_use_id: null,
2778
- session_id: sessionId
2779
- };
3001
+ async function toSDKUserMessage(message, sessionCtx, sessionId) {
3002
+ if (message.format === "file") {
3003
+ const senderLabel = message.senderId ? await sessionCtx.resolveSenderLabel(message.senderId) : "";
3004
+ const prefix = senderLabel ? `[From: ${senderLabel}]\n\n` : "";
3005
+ if (isImageRefContent(message.content)) {
3006
+ const { imageId, mimeType, filename } = message.content;
3007
+ const imagePath = findImagePath(message.chatId, imageId, mimeType);
3008
+ if (imagePath) return {
3009
+ type: "user",
3010
+ message: {
3011
+ role: "user",
3012
+ content: `${prefix}An image was shared in this chat. Please use the Read tool to read it, then respond based on what you see.\n\nFilename: ${filename}\nPath: ${imagePath}`
3013
+ },
3014
+ parent_tool_use_id: null,
3015
+ session_id: sessionId
3016
+ };
3017
+ return {
3018
+ type: "user",
3019
+ message: {
3020
+ role: "user",
3021
+ content: `${prefix}${`[Image "${filename}" not available on this device]`}`
3022
+ },
3023
+ parent_tool_use_id: null,
3024
+ session_id: sessionId
3025
+ };
3026
+ }
3027
+ if (isLegacyImageFileContent(message.content)) {
3028
+ const { filename } = message.content;
3029
+ try {
3030
+ return {
3031
+ type: "user",
3032
+ message: {
3033
+ role: "user",
3034
+ content: `${prefix}An image was shared in this chat. Please use the Read tool to read it, then respond based on what you see.\n\nFilename: ${filename}\nPath: ${await writeLegacyImageToTempFile(message.content, message.chatId)}`
3035
+ },
3036
+ parent_tool_use_id: null,
3037
+ session_id: sessionId
3038
+ };
3039
+ } catch (err) {
3040
+ const fallbackText = `[Image attachment "${filename}" failed to materialise]`;
3041
+ ctx?.log(`Failed to write image to temp file: ${err instanceof Error ? err.message : String(err)}`);
3042
+ return {
3043
+ type: "user",
3044
+ message: {
3045
+ role: "user",
3046
+ content: `${prefix}${fallbackText}`
3047
+ },
3048
+ parent_tool_use_id: null,
3049
+ session_id: sessionId
3050
+ };
3051
+ }
3052
+ }
2780
3053
  }
2781
- const rawContent = typeof message.content === "string" ? message.content : JSON.stringify(message.content);
2782
3054
  return {
2783
3055
  type: "user",
2784
3056
  message: {
2785
3057
  role: "user",
2786
- content: message.senderId ? `[From: ${message.senderId}]\n\n${rawContent}` : rawContent
3058
+ content: await sessionCtx.formatInboundContent(message)
2787
3059
  },
2788
3060
  parent_tool_use_id: null,
2789
3061
  session_id: sessionId
@@ -2796,8 +3068,9 @@ const createClaudeCodeHandler = (config) => {
2796
3068
  * process.env contains internal markers (CLAUDECODE, CLAUDE_CODE_ENTRYPOINT,
2797
3069
  * CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS, npm_lifecycle_script) that cause the
2798
3070
  * child to enable Agent Teams infrastructure and use wrong init paths,
2799
- * resulting in ~90s cold start vs ~17s standalone. Strip these so the child
2800
- * starts clean; the SDK sets its own CLAUDE_CODE_ENTRYPOINT="sdk-ts".
3071
+ * resulting in ~90s cold start vs ~17s standalone. Strip these here (Claude
3072
+ * Code specific) then let the runtime layer add the Agent-Hub envelope via
3073
+ * `ctx.buildAgentEnv` so all handlers expose the same vars uniformly.
2801
3074
  */
2802
3075
  function buildEnv(sessionCtx) {
2803
3076
  const env = { ...process.env };
@@ -2807,12 +3080,7 @@ const createClaudeCodeHandler = (config) => {
2807
3080
  delete env.npm_lifecycle_script;
2808
3081
  const payload = agentConfigCache?.get(sessionCtx.agent.agentId)?.payload;
2809
3082
  if (payload) for (const e of payload.env) env[e.key] = e.value;
2810
- return {
2811
- ...env,
2812
- FIRST_TREE_HUB_SERVER_URL: sessionCtx.sdk.serverUrl,
2813
- FIRST_TREE_HUB_AGENT_ID: sessionCtx.agent.agentId,
2814
- FIRST_TREE_HUB_CHAT_ID: sessionCtx.chatId
2815
- };
3083
+ return sessionCtx.buildAgentEnv(env);
2816
3084
  }
2817
3085
  /** Create query and input controller, then start consumer loop. */
2818
3086
  function spawnQuery(sessionId, sessionCtx, resume) {
@@ -2850,7 +3118,9 @@ const createClaudeCodeHandler = (config) => {
2850
3118
  abortController,
2851
3119
  permissionMode,
2852
3120
  allowDangerouslySkipPermissions: true,
3121
+ settingSources: ["project"],
2853
3122
  env: buildEnv(sessionCtx),
3123
+ ...claudeCodeExecutable ? { pathToClaudeCodeExecutable: claudeCodeExecutable } : {},
2854
3124
  ...payload?.model ? { model: payload.model } : {},
2855
3125
  ...payload?.prompt.append ? { systemPrompt: {
2856
3126
  type: "preset",
@@ -2913,10 +3183,7 @@ const createClaudeCodeHandler = (config) => {
2913
3183
  if (message.result && sessionCtx.chatId) {
2914
3184
  const resultText = message.result;
2915
3185
  try {
2916
- await sessionCtx.sdk.sendMessage(sessionCtx.chatId, {
2917
- format: "text",
2918
- content: resultText
2919
- });
3186
+ await sessionCtx.forwardResult(resultText);
2920
3187
  sessionCtx.log("Result forwarded to chat");
2921
3188
  sessionCtx.reportSessionCompletion();
2922
3189
  sessionCtx.emitEvent({
@@ -3076,7 +3343,7 @@ const createClaudeCodeHandler = (config) => {
3076
3343
  await prepareGitWorktrees(cwd, payload, sessionCtx);
3077
3344
  sessionCtx.log(`Starting session (${claudeSessionId}), cwd=${cwd}, permissionMode=${config.permissionMode ?? "bypassPermissions"}`);
3078
3345
  spawnQuery(claudeSessionId, sessionCtx);
3079
- const sdkMsg = toSDKUserMessage(message, claudeSessionId);
3346
+ const sdkMsg = await toSDKUserMessage(message, sessionCtx, claudeSessionId);
3080
3347
  inputController?.push(sdkMsg);
3081
3348
  sessionCtx.log(`Session started (${claudeSessionId})`);
3082
3349
  return claudeSessionId;
@@ -3091,7 +3358,7 @@ const createClaudeCodeHandler = (config) => {
3091
3358
  await prepareGitWorktrees(cwd, payload, sessionCtx);
3092
3359
  sessionCtx.log(`Resuming session (${sessionId}), cwd=${cwd}`);
3093
3360
  spawnQuery(sessionId, sessionCtx, sessionId);
3094
- if (message) inputController?.push(toSDKUserMessage(message, sessionId));
3361
+ if (message) inputController?.push(await toSDKUserMessage(message, sessionCtx, sessionId));
3095
3362
  sessionCtx.log(`Session resumed (${sessionId})`);
3096
3363
  return sessionId;
3097
3364
  },
@@ -3104,8 +3371,13 @@ const createClaudeCodeHandler = (config) => {
3104
3371
  const sid = claudeSessionId;
3105
3372
  maybeSwitchConfig(sessionCtx).catch((err) => {
3106
3373
  sessionCtx.log(`maybeSwitchConfig errored: ${err instanceof Error ? err.message : String(err)}`);
3107
- }).finally(() => {
3108
- inputController?.push(toSDKUserMessage(message, sid));
3374
+ }).finally(async () => {
3375
+ try {
3376
+ const sdkMsg = await toSDKUserMessage(message, sessionCtx, sid);
3377
+ inputController?.push(sdkMsg);
3378
+ } catch (err) {
3379
+ sessionCtx.log(`toSDKUserMessage errored: ${err instanceof Error ? err.message : String(err)}`);
3380
+ }
3109
3381
  });
3110
3382
  },
3111
3383
  async suspend() {
@@ -3190,7 +3462,13 @@ function generateClaudeMd(workspacePath, identity, contextTreePath) {
3190
3462
  }
3191
3463
  /** Register all built-in handlers. Call once at startup. */
3192
3464
  function registerBuiltinHandlers() {
3193
- registerHandler("claude-code", createClaudeCodeHandler);
3465
+ const resolution = resolveClaudeCodeExecutable();
3466
+ if (resolution.path) process.stderr.write(`[handlers] Claude Code executable: ${resolution.path} (source=${resolution.source})\n`);
3467
+ else process.stderr.write("[handlers] Claude Code executable: using SDK bundled native binary (set CLAUDE_CODE_EXECUTABLE or install `claude` on PATH to override)\n");
3468
+ registerHandler("claude-code", (config) => createClaudeCodeHandler({
3469
+ ...config,
3470
+ claudeCodeExecutable: resolution.path
3471
+ }));
3194
3472
  }
3195
3473
  function createAgentConfigCache(opts) {
3196
3474
  const { sdk } = opts;
@@ -3269,6 +3547,84 @@ function createAgentConfigCache(opts) {
3269
3547
  };
3270
3548
  }
3271
3549
  /**
3550
+ * Cross-handler plumbing for Agent Hub ↔ agent-runtime interaction.
3551
+ *
3552
+ * Every handler that shells out to the `first-tree-hub` CLI or otherwise acts
3553
+ * on behalf of the agent needs the same envelope variables (server URL, agent
3554
+ * id, inbox id, chat id). And every handler that hands inbound messages to an
3555
+ * LLM benefits from the same `[From: <name>]` attribution header so the LLM
3556
+ * can see who authored each message in human-readable terms.
3557
+ *
3558
+ * Keeping these helpers in one place means adding a second handler (Gemini,
3559
+ * Cursor Agent, custom LLM, …) does not reimplement either concern.
3560
+ */
3561
+ /**
3562
+ * Build the env for CLI sub-processes that need to call `first-tree-hub ...`.
3563
+ * Layers the Agent-Hub envelope variables on top of the parent env. Handlers
3564
+ * that start sub-processes should call this so every one of them sees the
3565
+ * same envelope — enabling replyTo inference, access-token propagation, and
3566
+ * agent-id binding without per-handler duplication.
3567
+ */
3568
+ function buildAgentEnv(parentEnv, ctx) {
3569
+ return {
3570
+ ...parentEnv,
3571
+ FIRST_TREE_HUB_SERVER_URL: ctx.sdk.serverUrl,
3572
+ FIRST_TREE_HUB_AGENT_ID: ctx.agent.agentId,
3573
+ FIRST_TREE_HUB_INBOX_ID: ctx.agent.inboxId,
3574
+ FIRST_TREE_HUB_CHAT_ID: ctx.chatId
3575
+ };
3576
+ }
3577
+ function createParticipantCache(sdk, chatId, log) {
3578
+ let cached = null;
3579
+ let inflight = null;
3580
+ return { async get() {
3581
+ if (cached) return cached;
3582
+ if (!inflight) inflight = (async () => {
3583
+ try {
3584
+ const rows = await sdk.listChatParticipants(chatId);
3585
+ cached = rows;
3586
+ return rows;
3587
+ } catch (err) {
3588
+ log(`listChatParticipants failed: ${err instanceof Error ? err.message : String(err)}`);
3589
+ return [];
3590
+ } finally {
3591
+ inflight = null;
3592
+ }
3593
+ })();
3594
+ return inflight;
3595
+ } };
3596
+ }
3597
+ /**
3598
+ * Resolve `senderId` → display-friendly label the LLM can actually
3599
+ * disambiguate. Prefers `name` (unique per chat, used as the `@<name>`
3600
+ * mention token), falls back to `displayName`, then to the raw id. The last
3601
+ * fallback matters for edge cases — e.g. a participant removed mid-session
3602
+ * after we cached an earlier participants snapshot.
3603
+ */
3604
+ function resolveSenderLabel(senderId, participants) {
3605
+ for (const p of participants) {
3606
+ if (p.agentId !== senderId) continue;
3607
+ if (p.name) return p.name;
3608
+ if (p.displayName) return p.displayName;
3609
+ return senderId;
3610
+ }
3611
+ return senderId;
3612
+ }
3613
+ /**
3614
+ * Produce the handler-facing string form of an inbound message. Prefixes a
3615
+ * `[From: <name>]` line when the sender is a known participant. Structured
3616
+ * content is serialised to JSON — handlers that want to feed structured
3617
+ * content some other way should opt out and format themselves.
3618
+ *
3619
+ * Async because the participant list may need a server round-trip on first
3620
+ * use; subsequent messages in the same session hit the cache.
3621
+ */
3622
+ async function formatInboundContent(message, participants) {
3623
+ const rawContent = typeof message.content === "string" ? message.content : JSON.stringify(message.content);
3624
+ if (!message.senderId) return rawContent;
3625
+ return `[From: ${resolveSenderLabel(message.senderId, await participants.get())}]\n\n${rawContent}`;
3626
+ }
3627
+ /**
3272
3628
  * Deduplicator — bounded set of recently seen IDs.
3273
3629
  *
3274
3630
  * Used to deduplicate at-least-once delivered messages at the dispatch layer.
@@ -3299,6 +3655,24 @@ var Deduplicator = class {
3299
3655
  return this.seen.size;
3300
3656
  }
3301
3657
  };
3658
+ function createResultSink(deps) {
3659
+ async function buildMetadata(trigger) {
3660
+ if (!trigger || trigger.senderId === deps.agent.agentId) return void 0;
3661
+ if ((await deps.participants.get()).length <= 2) return void 0;
3662
+ return { mentions: [trigger.senderId] };
3663
+ }
3664
+ return async function forwardResult(text) {
3665
+ const trigger = deps.getTrigger();
3666
+ deps.clearTrigger();
3667
+ const metadata = await buildMetadata(trigger);
3668
+ await deps.sdk.sendMessage(deps.chatId, {
3669
+ format: "text",
3670
+ content: text,
3671
+ ...trigger ? { inReplyTo: trigger.messageId } : {},
3672
+ ...metadata ? { metadata } : {}
3673
+ });
3674
+ };
3675
+ }
3302
3676
  const REGISTRY_VERSION = 1;
3303
3677
  /**
3304
3678
  * SessionRegistry — persists `chatId → claudeSessionId` mappings to disk.
@@ -3388,6 +3762,14 @@ var SessionManager = class {
3388
3762
  evictedMappings = /* @__PURE__ */ new Map();
3389
3763
  config;
3390
3764
  deduplicator = new Deduplicator(1e3);
3765
+ /**
3766
+ * Current trigger (messageId + senderId) per chat — the message that kicked
3767
+ * off the current or most-recent turn. Read by `forwardResult` (via the
3768
+ * resultSink closure) to attach `inReplyTo` and default mentions to the
3769
+ * outbound reply. Maintained entirely by the runtime: handlers never touch
3770
+ * this map, which keeps adding a new handler trivial.
3771
+ */
3772
+ currentTrigger = /* @__PURE__ */ new Map();
3391
3773
  registry;
3392
3774
  pendingQueue = [];
3393
3775
  lastReportedStates = /* @__PURE__ */ new Map();
@@ -3407,11 +3789,24 @@ var SessionManager = class {
3407
3789
  * Delayed ACK: messages are ACKed when the handler begins processing,
3408
3790
  * not on pull. `delivered` = pulled but not yet processing,
3409
3791
  * `acked` = handler has started processing (read receipt).
3792
+ *
3793
+ * One routing guard fires before any session lookup (see
3794
+ * proposals/hub-agent-messaging-reply-and-mentions §3.5):
3795
+ *
3796
+ * - **Echo suppression** — in direct chats, a peer's reply to a message
3797
+ * *we* sent here but whose `replyTo` points elsewhere would otherwise
3798
+ * bounce our session back on. Suppress it so the reply routes only to
3799
+ * the external chat where we're actually waiting.
3800
+ *
3801
+ * The mention filter used to live here too, but it moved server-side to
3802
+ * the fan-out step — see `services/message.ts sendMessage`. Anything
3803
+ * reaching dispatch has already passed that check.
3410
3804
  */
3411
3805
  async dispatch(entry) {
3412
3806
  const chatId = entry.chatId ?? entry.message.chatId;
3413
3807
  const messageId = entry.message.id;
3414
- if (this.deduplicator.isDuplicate(messageId)) {
3808
+ const dedupKey = `${chatId}:${messageId}`;
3809
+ if (this.deduplicator.isDuplicate(dedupKey)) {
3415
3810
  this.config.log.debug({
3416
3811
  chatId,
3417
3812
  messageId
@@ -3428,6 +3823,14 @@ var SessionManager = class {
3428
3823
  err
3429
3824
  }, "config version mismatch — skipping refresh");
3430
3825
  }
3826
+ if (shouldSuppressEcho(entry, this.config.agentIdentity.agentId)) {
3827
+ this.config.log.info({
3828
+ chatId,
3829
+ messageId
3830
+ }, "suppressing echo — message replies to our own send whose replyTo points elsewhere");
3831
+ await this.ackEntry(entry.id, chatId);
3832
+ return;
3833
+ }
3431
3834
  const message = this.extractMessage(entry);
3432
3835
  await this.routeMessage(chatId, message, entry.id);
3433
3836
  }
@@ -3454,6 +3857,7 @@ var SessionManager = class {
3454
3857
  this.evictedMappings.delete(chatId);
3455
3858
  this.sessionRuntimeStates.delete(chatId);
3456
3859
  this.lastReportedStates.delete(chatId);
3860
+ this.currentTrigger.delete(chatId);
3457
3861
  for (let i = this.pendingQueue.length - 1; i >= 0; i--) if (this.pendingQueue[i]?.chatId === chatId) this.pendingQueue.splice(i, 1);
3458
3862
  this.recomputeRuntimeState();
3459
3863
  this.persistRegistry();
@@ -3524,6 +3928,10 @@ var SessionManager = class {
3524
3928
  }));
3525
3929
  }
3526
3930
  async routeMessage(chatId, message, entryId) {
3931
+ if (message.id) this.currentTrigger.set(chatId, {
3932
+ messageId: message.id,
3933
+ senderId: message.senderId
3934
+ });
3527
3935
  const existing = this.sessions.get(chatId);
3528
3936
  if (existing) switch (existing.status) {
3529
3937
  case "active":
@@ -3712,6 +4120,7 @@ var SessionManager = class {
3712
4120
  }
3713
4121
  this.sessions.delete(candidate.key);
3714
4122
  this.sessionRuntimeStates.delete(candidate.key);
4123
+ this.currentTrigger.delete(candidate.key);
3715
4124
  this.recomputeRuntimeState();
3716
4125
  this.persistRegistry();
3717
4126
  }
@@ -3769,10 +4178,28 @@ var SessionManager = class {
3769
4178
  }
3770
4179
  buildSessionContext(chatId) {
3771
4180
  const sessionLog = this.config.log.child({ chatId });
4181
+ const log = (msg) => sessionLog.info(msg);
4182
+ const participants = createParticipantCache(this.config.sdk, chatId, log);
4183
+ const forwardResult = createResultSink({
4184
+ sdk: this.config.sdk,
4185
+ agent: this.config.agentIdentity,
4186
+ chatId,
4187
+ getTrigger: () => this.currentTrigger.get(chatId) ?? null,
4188
+ clearTrigger: () => {
4189
+ this.currentTrigger.delete(chatId);
4190
+ },
4191
+ log,
4192
+ participants
4193
+ });
4194
+ const envCtx = {
4195
+ sdk: this.config.sdk,
4196
+ agent: this.config.agentIdentity,
4197
+ chatId
4198
+ };
3772
4199
  return {
3773
4200
  agent: this.config.agentIdentity,
3774
4201
  sdk: this.config.sdk,
3775
- log: (msg) => sessionLog.info(msg),
4202
+ log,
3776
4203
  chatId,
3777
4204
  touch: () => {
3778
4205
  const entry = this.sessions.get(chatId);
@@ -3786,7 +4213,11 @@ var SessionManager = class {
3786
4213
  },
3787
4214
  reportSessionCompletion: () => {
3788
4215
  this.config.onSessionCompletion?.(chatId);
3789
- }
4216
+ },
4217
+ forwardResult,
4218
+ buildAgentEnv: (parentEnv) => buildAgentEnv(parentEnv, envCtx),
4219
+ formatInboundContent: (message) => formatInboundContent(message, participants),
4220
+ resolveSenderLabel: async (senderId) => resolveSenderLabel(senderId, await participants.get())
3790
4221
  };
3791
4222
  }
3792
4223
  /** Update per-session runtime state and recompute aggregate. Only active sessions may update. */
@@ -3849,6 +4280,35 @@ var SessionManager = class {
3849
4280
  this.registry.save(entries);
3850
4281
  }
3851
4282
  };
4283
+ /**
4284
+ * Core echo rule: a reply to a message *we* sent in this same chat, whose
4285
+ * original carried a `replyTo` pointing to a *different* chat, must not wake
4286
+ * our session on this side. Server-side replyTo routing already delivers a
4287
+ * second entry in the target chat, so suppressing the fan-out copy here
4288
+ * leaves exactly one path from peer's reply to our waiting session.
4289
+ *
4290
+ * The four early-returns spell out "when this is NOT an echo":
4291
+ * - no snapshot → just a regular message, not a reply
4292
+ * - sender isn't us → replying to someone else's message, clearly not an echo
4293
+ * - original chat != this chat
4294
+ * → the reply arrived in a chat where we never sent the original;
4295
+ * could only happen via replyTo fan-out of a different thread, so
4296
+ * suppressing would silence a legit cross-chat handoff
4297
+ * - original had no replyTo → sender didn't ask replies to route away, so
4298
+ * the peer's reply here is the canonical path
4299
+ *
4300
+ * Only when all four are satisfied AND the replyTo target is a different
4301
+ * chat do we suppress — that's exactly proposal §3.5 Case A.
4302
+ */
4303
+ function shouldSuppressEcho(entry, myAgentId) {
4304
+ const snapshot = entry.message.inReplyToSnapshot;
4305
+ if (!snapshot) return false;
4306
+ const entryChatId = entry.chatId ?? entry.message.chatId;
4307
+ if (snapshot.senderId !== myAgentId) return false;
4308
+ if (snapshot.chatId !== entryChatId) return false;
4309
+ if (snapshot.replyToChat === null) return false;
4310
+ return snapshot.replyToChat !== entryChatId;
4311
+ }
3852
4312
  var AgentSlot = class {
3853
4313
  sessionManager = null;
3854
4314
  config;
@@ -3944,6 +4404,7 @@ var AgentSlot = class {
3944
4404
  },
3945
4405
  agentIdentity: {
3946
4406
  agentId: agent.agentId,
4407
+ inboxId: agent.inboxId,
3947
4408
  displayName: agent.displayName,
3948
4409
  type: agent.type,
3949
4410
  delegateMention: agent.delegateMention,
@@ -4276,6 +4737,27 @@ const print = {
4276
4737
  line
4277
4738
  };
4278
4739
  //#endregion
4740
+ //#region src/core/agent-messaging.ts
4741
+ /**
4742
+ * Resolve `replyTo` envelope fields for `agent send`. When the CLI is invoked
4743
+ * from inside a claude-code session (the handler exports
4744
+ * `FIRST_TREE_HUB_CHAT_ID` + `FIRST_TREE_HUB_INBOX_ID`), default the reply
4745
+ * target to the calling session's own chat so the peer's reply routes back
4746
+ * to the caller rather than echoing in the peer-created direct chat.
4747
+ *
4748
+ * Explicit `--reply-to-*` flags always win. See proposals/
4749
+ * hub-agent-messaging-reply-and-mentions §3.2.
4750
+ */
4751
+ function resolveReplyToFromEnv(env, override) {
4752
+ const envChatId = env.FIRST_TREE_HUB_CHAT_ID;
4753
+ const envInboxId = env.FIRST_TREE_HUB_INBOX_ID;
4754
+ const envComplete = Boolean(envChatId && envInboxId);
4755
+ return {
4756
+ replyToInbox: override.replyToInbox ?? (envComplete ? envInboxId : void 0),
4757
+ replyToChat: override.replyToChat ?? (envComplete ? envChatId : void 0)
4758
+ };
4759
+ }
4760
+ //#endregion
4279
4761
  //#region src/core/admin.ts
4280
4762
  /**
4281
4763
  * Check if any user exists.
@@ -5664,7 +6146,7 @@ async function onboardCreate(args) {
5664
6146
  }
5665
6147
  const runtimeAgent = args.type === "human" ? args.assistant : args.id;
5666
6148
  if (args.feishuBotAppId && args.feishuBotAppSecret) {
5667
- const { bindFeishuBot } = await import("./feishu-GlaczcVf.mjs").then((n) => n.r);
6149
+ const { bindFeishuBot } = await import("./feishu-CRNUI05I.mjs").then((n) => n.r);
5668
6150
  const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
5669
6151
  if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
5670
6152
  else {
@@ -5805,7 +6287,7 @@ function setNestedByDot(obj, dotPath, value) {
5805
6287
  if (lastKey !== void 0) current[lastKey] = value;
5806
6288
  }
5807
6289
  //#endregion
5808
- //#region ../server/dist/app-Frne_Mbn.mjs
6290
+ //#region ../server/dist/app-BcAIFEPL.mjs
5809
6291
  var __defProp = Object.defineProperty;
5810
6292
  var __exportAll = (all, no_symbols) => {
5811
6293
  let target = {};
@@ -7032,6 +7514,28 @@ async function deleteAgent(db, uuid) {
7032
7514
  if (!agent) throw new Error("Unexpected: UPDATE RETURNING produced no row");
7033
7515
  return agent;
7034
7516
  }
7517
+ /**
7518
+ * When a direct chat grows past 2 participants, upgrade it to `group` and
7519
+ * flip every existing non-human agent participant to `mention_only` — see
7520
+ * proposals/hub-agent-messaging-reply-and-mentions §3.3. The caller is
7521
+ * expected to insert the new participant AFTER this runs, so the "existing"
7522
+ * set excludes them.
7523
+ *
7524
+ * Idempotent: if the chat is already a group, no-op.
7525
+ */
7526
+ async function maybeUpgradeDirectToGroup(db, chatId, existingParticipantIds, newParticipantCount) {
7527
+ if (existingParticipantIds.length + newParticipantCount < 3) return;
7528
+ const [chat] = await db.select({ type: chats.type }).from(chats).where(eq(chats.id, chatId)).limit(1);
7529
+ if (!chat || chat.type !== "direct") return;
7530
+ await db.update(chats).set({
7531
+ type: "group",
7532
+ updatedAt: /* @__PURE__ */ new Date()
7533
+ }).where(eq(chats.id, chatId));
7534
+ if (existingParticipantIds.length === 0) return;
7535
+ const ids = (await db.select({ uuid: agents.uuid }).from(agents).where(and(inArray(agents.uuid, existingParticipantIds), ne(agents.type, "human")))).map((a) => a.uuid);
7536
+ if (ids.length === 0) return;
7537
+ await db.update(chatParticipants).set({ mode: "mention_only" }).where(and(eq(chatParticipants.chatId, chatId), inArray(chatParticipants.agentId, ids)));
7538
+ }
7035
7539
  async function createChat(db, creatorId, data) {
7036
7540
  const chatId = randomUUID();
7037
7541
  const allParticipantIds = new Set([creatorId, ...data.participantIds]);
@@ -7099,17 +7603,38 @@ async function listChats(db, agentId, limit, cursor) {
7099
7603
  nextCursor: hasMore && last ? last.updatedAt.toISOString() : null
7100
7604
  };
7101
7605
  }
7606
+ /**
7607
+ * List participants of a chat with their agent names — used by the client
7608
+ * runtime to resolve `@<name>` mentions against the authoritative participant
7609
+ * set (see proposals/hub-agent-messaging-reply-and-mentions §4).
7610
+ */
7611
+ async function listChatParticipantsWithNames(db, chatId) {
7612
+ return await db.select({
7613
+ agentId: chatParticipants.agentId,
7614
+ role: chatParticipants.role,
7615
+ mode: chatParticipants.mode,
7616
+ joinedAt: chatParticipants.joinedAt,
7617
+ name: agents.name,
7618
+ displayName: agents.displayName,
7619
+ type: agents.type
7620
+ }).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(eq(chatParticipants.chatId, chatId));
7621
+ }
7102
7622
  async function assertParticipant(db, chatId, agentId) {
7103
7623
  const [row] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, agentId))).limit(1);
7104
7624
  if (!row) throw new ForbiddenError("Not a participant of this chat");
7105
7625
  }
7106
7626
  /** Ensure an agent is a participant of a chat. Silently adds them if not already. */
7107
7627
  async function ensureParticipant(db, chatId, agentId) {
7108
- await db.insert(chatParticipants).values({
7109
- chatId,
7110
- agentId,
7111
- mode: "full"
7112
- }).onConflictDoNothing({ target: [chatParticipants.chatId, chatParticipants.agentId] });
7628
+ const [existing] = await db.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, agentId))).limit(1);
7629
+ if (existing) return;
7630
+ await db.transaction(async (tx) => {
7631
+ await maybeUpgradeDirectToGroup(tx, chatId, (await tx.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId))).map((p) => p.agentId), 1);
7632
+ await tx.insert(chatParticipants).values({
7633
+ chatId,
7634
+ agentId,
7635
+ mode: "full"
7636
+ }).onConflictDoNothing({ target: [chatParticipants.chatId, chatParticipants.agentId] });
7637
+ });
7113
7638
  }
7114
7639
  async function addParticipant(db, chatId, requesterId, data) {
7115
7640
  const chat = await getChat(db, chatId);
@@ -7122,10 +7647,13 @@ async function addParticipant(db, chatId, requesterId, data) {
7122
7647
  if (targetAgent.organizationId !== chat.organizationId) throw new BadRequestError("Cannot add agent from different organization");
7123
7648
  const [existing] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, data.agentId))).limit(1);
7124
7649
  if (existing) throw new ConflictError(`Agent "${data.agentId}" is already a participant`);
7125
- await db.insert(chatParticipants).values({
7126
- chatId,
7127
- agentId: data.agentId,
7128
- mode: data.mode ?? "full"
7650
+ await db.transaction(async (tx) => {
7651
+ await maybeUpgradeDirectToGroup(tx, chatId, (await tx.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId))).map((p) => p.agentId), 1);
7652
+ await tx.insert(chatParticipants).values({
7653
+ chatId,
7654
+ agentId: data.agentId,
7655
+ mode: data.mode ?? "full"
7656
+ });
7129
7657
  });
7130
7658
  return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
7131
7659
  }
@@ -7224,11 +7752,14 @@ async function joinChat(db, chatId, memberId, humanAgentId) {
7224
7752
  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");
7225
7753
  const [humanAgent] = await db.select({ organizationId: agents.organizationId }).from(agents).where(eq(agents.uuid, humanAgentId)).limit(1);
7226
7754
  if (!humanAgent || humanAgent.organizationId !== chat.organizationId) throw new BadRequestError("Agent does not belong to the same organization as the chat");
7227
- await db.insert(chatParticipants).values({
7228
- chatId,
7229
- agentId: humanAgentId,
7230
- role: "member",
7231
- mode: "full"
7755
+ await db.transaction(async (tx) => {
7756
+ await maybeUpgradeDirectToGroup(tx, chatId, participantAgentIds, 1);
7757
+ await tx.insert(chatParticipants).values({
7758
+ chatId,
7759
+ agentId: humanAgentId,
7760
+ role: "member",
7761
+ mode: "full"
7762
+ });
7232
7763
  });
7233
7764
  return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
7234
7765
  }
@@ -7723,6 +8254,26 @@ async function sendMessage(db, chatId, senderId, data) {
7723
8254
  }
7724
8255
  async function sendMessageInner(db, chatId, senderId, data) {
7725
8256
  return db.transaction(async (tx) => {
8257
+ const participants = await tx.select({
8258
+ agentId: chatParticipants.agentId,
8259
+ inboxId: agents.inboxId,
8260
+ mode: chatParticipants.mode,
8261
+ name: agents.name
8262
+ }).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(eq(chatParticipants.chatId, chatId));
8263
+ if (data.replyToInbox !== void 0 && data.replyToInbox !== null) {
8264
+ const [senderRow] = await tx.select({ inboxId: agents.inboxId }).from(agents).where(eq(agents.uuid, senderId)).limit(1);
8265
+ if (!senderRow || senderRow.inboxId !== data.replyToInbox) throw new BadRequestError("replyToInbox must reference the sender's own inbox");
8266
+ }
8267
+ const incomingMeta = data.metadata ?? {};
8268
+ const explicitMentionsRaw = incomingMeta.mentions;
8269
+ const explicitMentions = Array.isArray(explicitMentionsRaw) ? explicitMentionsRaw.filter((m) => typeof m === "string") : [];
8270
+ const contentText = typeof data.content === "string" ? data.content : "";
8271
+ const resolved = contentText ? extractMentions(contentText, participants) : [];
8272
+ const mergedMentions = [...new Set([...explicitMentions, ...resolved])];
8273
+ const metadataToStore = mergedMentions.length > 0 ? {
8274
+ ...incomingMeta,
8275
+ mentions: mergedMentions
8276
+ } : incomingMeta;
7726
8277
  const messageId = randomUUID();
7727
8278
  const [msg] = await tx.insert(messages).values({
7728
8279
  id: messageId,
@@ -7730,16 +8281,14 @@ async function sendMessageInner(db, chatId, senderId, data) {
7730
8281
  senderId,
7731
8282
  format: data.format,
7732
8283
  content: data.content,
7733
- metadata: data.metadata ?? {},
8284
+ metadata: metadataToStore,
7734
8285
  replyToInbox: data.replyToInbox ?? null,
7735
8286
  replyToChat: data.replyToChat ?? null,
7736
8287
  inReplyTo: data.inReplyTo ?? null,
7737
8288
  source: data.source ?? null
7738
8289
  }).returning();
7739
- const entries = (await tx.select({
7740
- agentId: chatParticipants.agentId,
7741
- inboxId: agents.inboxId
7742
- }).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(eq(chatParticipants.chatId, chatId))).filter((p) => p.agentId !== senderId).map((p) => ({
8290
+ const mentionSet = new Set(mergedMentions);
8291
+ const entries = participants.filter((p) => p.agentId !== senderId).filter((p) => p.mode !== "mention_only" || mentionSet.has(p.agentId)).map((p) => ({
7743
8292
  inboxId: p.inboxId,
7744
8293
  messageId,
7745
8294
  chatId
@@ -7775,7 +8324,7 @@ async function sendToAgent(db, senderUuid, targetName, data) {
7775
8324
  }).from(agents).where(eq(agents.uuid, senderUuid)).limit(1);
7776
8325
  if (!sender) throw new NotFoundError(`Agent "${senderUuid}" not found`);
7777
8326
  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);
7778
- if (!target) throw new NotFoundError(`Agent "${targetName}" not found`);
8327
+ 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." : ""}`);
7779
8328
  return sendMessage(db, (await findOrCreateDirectChat(db, senderUuid, target.uuid)).id, senderUuid, {
7780
8329
  format: data.format,
7781
8330
  content: data.content,
@@ -7874,6 +8423,23 @@ function createNotifier(listenClient) {
7874
8423
  await listenClient`SELECT pg_notify(${RUNTIME_STATE_CHANNEL}, ${`${agentId}:${state}:${organizationId}`})`;
7875
8424
  } catch {}
7876
8425
  },
8426
+ async pushFrameToInbox(inboxId, frame) {
8427
+ const sockets = subscriptions.get(inboxId);
8428
+ if (!sockets) return 0;
8429
+ let queued = 0;
8430
+ const pending = [];
8431
+ for (const ws of sockets) {
8432
+ if (ws.readyState !== ws.OPEN) continue;
8433
+ pending.push(new Promise((resolve) => {
8434
+ ws.send(frame, (err) => {
8435
+ if (!err) queued += 1;
8436
+ resolve();
8437
+ });
8438
+ }));
8439
+ }
8440
+ await Promise.all(pending);
8441
+ return queued;
8442
+ },
7877
8443
  onConfigChange(handler) {
7878
8444
  configChangeHandlers.push(handler);
7879
8445
  },
@@ -8250,6 +8816,73 @@ function requireAdminRoleHook() {
8250
8816
  if (request.member?.role !== "admin") throw new ForbiddenError("Admin role required");
8251
8817
  };
8252
8818
  }
8819
+ /**
8820
+ * Intercepts outbound image messages. If `data.content` carries an inline
8821
+ * base64 image (legacy-style payload from the web), we:
8822
+ *
8823
+ * 1. Generate / adopt an `imageId`
8824
+ * 2. Push the bytes as an `image_payload` WS frame to every participant
8825
+ * agent's inbox — plus the reply-to inbox when this is a cross-chat
8826
+ * reply (matches sendMessage's extra fan-out). Best-effort, local
8827
+ * instance only, no PG NOTIFY.
8828
+ * 3. Return a copy of `data` whose `content` is just the reference
8829
+ * {imageId, mimeType, filename, size}
8830
+ *
8831
+ * The push is fire-and-forget: `ws.send()` queues the frame into the socket's
8832
+ * send buffer synchronously, which is the only ordering guarantee we need
8833
+ * — the subsequent `new_message` notification travels a strictly slower PG
8834
+ * NOTIFY round trip, so the image lands first on the wire. Awaiting the TCP
8835
+ * flush here would put a slow subscriber's backpressure on the sender's
8836
+ * HTTP response for a feature that is already best-effort.
8837
+ *
8838
+ * Non-image messages are returned unchanged. Missing-subscriber / wrong-
8839
+ * instance cases are acceptable loss per the image-out-of-messages design
8840
+ * (the reference-only message still lands in the DB; clients that missed
8841
+ * the bytes surface a "not available on this device" placeholder).
8842
+ */
8843
+ async function prepareImageOutbound(db, notifier, chatId, data) {
8844
+ if (data.format !== "file") return data;
8845
+ const parsed = imageInlineContentSchema.safeParse(data.content);
8846
+ if (!parsed.success) return data;
8847
+ const inline = parsed.data;
8848
+ const imageId = inline.imageId ?? randomUUID();
8849
+ const frame = {
8850
+ type: "image_payload",
8851
+ imageId,
8852
+ chatId,
8853
+ base64: inline.data,
8854
+ mimeType: inline.mimeType,
8855
+ filename: inline.filename,
8856
+ ...inline.size !== void 0 ? { size: inline.size } : {}
8857
+ };
8858
+ const serialised = JSON.stringify(frame);
8859
+ const inboxIds = await collectTargetInboxes(db, chatId, data.inReplyTo);
8860
+ for (const inboxId of inboxIds) notifier.pushFrameToInbox(inboxId, serialised).catch(() => {});
8861
+ const ref = {
8862
+ imageId,
8863
+ mimeType: inline.mimeType,
8864
+ filename: inline.filename,
8865
+ ...inline.size !== void 0 ? { size: inline.size } : {}
8866
+ };
8867
+ return {
8868
+ ...data,
8869
+ content: ref
8870
+ };
8871
+ }
8872
+ /**
8873
+ * Mirror `sendMessage`'s fan-out set: every participant of the current
8874
+ * chat, plus the original requester's inbox when this message is a cross-
8875
+ * chat reply (see `services/message.ts` replyTo routing).
8876
+ */
8877
+ async function collectTargetInboxes(db, chatId, inReplyTo) {
8878
+ const participants = await db.select({ inboxId: agents.inboxId }).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(eq(chatParticipants.chatId, chatId));
8879
+ const set = new Set(participants.map((p) => p.inboxId));
8880
+ if (inReplyTo) {
8881
+ const [original] = await db.select({ replyToInbox: messages.replyToInbox }).from(messages).where(eq(messages.id, inReplyTo)).limit(1);
8882
+ if (original?.replyToInbox) set.add(original.replyToInbox);
8883
+ }
8884
+ return [...set];
8885
+ }
8253
8886
  async function adminChatRoutes(app) {
8254
8887
  /** List all chats in org (admin-only, for audit). Members should use GET /mine. */
8255
8888
  app.get("/", { preHandler: requireAdminRoleHook() }, async (request) => {
@@ -8427,10 +9060,11 @@ async function adminChatRoutes(app) {
8427
9060
  const body = sendMessageSchema.parse(request.body);
8428
9061
  await assertChatAccess(app.db, scope, chatId);
8429
9062
  await ensureParticipant(app.db, chatId, member.agentId);
8430
- const result = await sendMessage(app.db, chatId, member.agentId, {
9063
+ const prepared = await prepareImageOutbound(app.db, app.notifier, chatId, {
8431
9064
  ...body,
8432
9065
  source: "hub_ui"
8433
9066
  });
9067
+ const result = await sendMessage(app.db, chatId, member.agentId, prepared);
8434
9068
  notifyRecipients(app.notifier, result.recipients, result.message.id);
8435
9069
  return reply.status(201).send({
8436
9070
  id: result.message.id,
@@ -8453,15 +9087,19 @@ const agentChatSessions = pgTable("agent_chat_sessions", {
8453
9087
  /**
8454
9088
  * Upsert session state + refresh presence aggregates + NOTIFY.
8455
9089
  *
8456
- * Revival defense: an admin-terminated (`evicted`) row is immutable; a client
8457
- * report for the same chatId is silently dropped after the FOR UPDATE check.
9090
+ * `agent_chat_sessions.(agent_id, chat_id)` is a single-row "current session
9091
+ * state" cache, not a session history log. A new runtime session starting on
9092
+ * the same (agent, chat) pair MUST overwrite whatever ended before — including
9093
+ * an `evicted` row left by a previous terminate. The previous "revival
9094
+ * defense" conflated two concerns: "this runtime session ended" (which is
9095
+ * what `evicted` actually means) and "this chat is permanently archived for
9096
+ * this agent" (a chat-level decision that should live on `chats`, not here).
9097
+ * See proposals/hub-agent-messaging-reply-and-mentions §M2-session-lifecycle.
8458
9098
  */
8459
9099
  async function upsertSessionState(db, agentId, chatId, state, organizationId, notifier) {
8460
9100
  const now = /* @__PURE__ */ new Date();
8461
9101
  let wrote = false;
8462
9102
  await db.transaction(async (tx) => {
8463
- const [existing] = await tx.select({ state: agentChatSessions.state }).from(agentChatSessions).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId))).for("update");
8464
- if (existing?.state === "evicted") return;
8465
9103
  await tx.insert(agentChatSessions).values({
8466
9104
  agentId,
8467
9105
  chatId,
@@ -8986,7 +9624,8 @@ function extractSummary(content, maxLen = SUMMARY_MAX_LENGTH) {
8986
9624
  let text = "";
8987
9625
  if (typeof content === "object" && content !== null && "text" in content) text = String(content.text ?? "");
8988
9626
  else if (typeof content === "string") text = content;
8989
- return text ? text.slice(0, maxLen) : null;
9627
+ if (!text) return null;
9628
+ return Array.from(text).slice(0, maxLen).join("");
8990
9629
  }
8991
9630
  /** List sessions for a specific agent, with optional state filters. */
8992
9631
  async function listAgentSessions(db, agentId, filters) {
@@ -9071,7 +9710,8 @@ async function listAllSessions(db, limit, cursor, filters) {
9071
9710
  chatId: agentChatSessions.chatId,
9072
9711
  state: agentChatSessions.state,
9073
9712
  updatedAt: agentChatSessions.updatedAt,
9074
- chatCreatedAt: chats.createdAt
9713
+ chatCreatedAt: chats.createdAt,
9714
+ chatTopic: chats.topic
9075
9715
  }).from(agentChatSessions).innerJoin(chats, eq(agentChatSessions.chatId, chats.id)).innerJoin(agents, eq(agentChatSessions.agentId, agents.uuid)).where(conditions.length > 0 ? and(...conditions) : void 0).orderBy(desc(agentChatSessions.updatedAt)).limit(limit + 1);
9076
9716
  const hasMore = rows.length > limit;
9077
9717
  const items = hasMore ? rows.slice(0, limit) : rows;
@@ -9081,6 +9721,16 @@ async function listAllSessions(db, limit, cursor, filters) {
9081
9721
  runtimeState: agentPresence.runtimeState
9082
9722
  }).from(agentPresence).where(inArray(agentPresence.agentId, agentIds)) : [];
9083
9723
  const runtimeMap = new Map(presenceRows.map((r) => [r.agentId, r.runtimeState]));
9724
+ const chatIds = [...new Set(items.map((r) => r.chatId))];
9725
+ const firstMessages = chatIds.length > 0 ? await db.selectDistinctOn([messages.chatId], {
9726
+ chatId: messages.chatId,
9727
+ content: messages.content
9728
+ }).from(messages).where(inArray(messages.chatId, chatIds)).orderBy(messages.chatId, messages.createdAt) : [];
9729
+ const summaryMap = /* @__PURE__ */ new Map();
9730
+ for (const row of firstMessages) {
9731
+ const summary = extractSummary(row.content);
9732
+ if (summary) summaryMap.set(row.chatId, summary);
9733
+ }
9084
9734
  const last = items[items.length - 1];
9085
9735
  const nextCursor = hasMore && last ? last.updatedAt.toISOString() : null;
9086
9736
  return {
@@ -9092,8 +9742,8 @@ async function listAllSessions(db, limit, cursor, filters) {
9092
9742
  startedAt: r.chatCreatedAt.toISOString(),
9093
9743
  lastActivityAt: r.updatedAt.toISOString(),
9094
9744
  messageCount: 0,
9095
- summary: null,
9096
- topic: null
9745
+ summary: summaryMap.get(r.chatId) ?? null,
9746
+ topic: r.chatTopic ?? null
9097
9747
  })),
9098
9748
  nextCursor
9099
9749
  };
@@ -10055,6 +10705,24 @@ async function agentChatRoutes(app) {
10055
10705
  }))
10056
10706
  };
10057
10707
  });
10708
+ /**
10709
+ * List chat participants with agent names/displayNames. Used by the client
10710
+ * runtime to resolve `@<name>` mentions against the authoritative participant
10711
+ * set (see proposals/hub-agent-messaging-reply-and-mentions §4).
10712
+ */
10713
+ app.get("/:chatId/participants", async (request) => {
10714
+ const identity = requireAgent(request);
10715
+ await assertParticipant(app.db, request.params.chatId, identity.uuid);
10716
+ return (await listChatParticipantsWithNames(app.db, request.params.chatId)).map((r) => ({
10717
+ agentId: r.agentId,
10718
+ role: r.role,
10719
+ mode: r.mode,
10720
+ name: r.name,
10721
+ displayName: r.displayName,
10722
+ type: r.type,
10723
+ joinedAt: r.joinedAt.toISOString()
10724
+ }));
10725
+ });
10058
10726
  app.post("/:chatId/participants", async (request, reply) => {
10059
10727
  const identity = requireAgent(request);
10060
10728
  const body = addParticipantSchema.parse(request.body);
@@ -10180,19 +10848,46 @@ function normaliseSource(source) {
10180
10848
  const parsed = messageSourceSchema$1.safeParse(source);
10181
10849
  return parsed.success ? parsed.data : null;
10182
10850
  }
10851
+ function normaliseMode(mode) {
10852
+ return mode === "mention_only" ? "mention_only" : "full";
10853
+ }
10183
10854
  /**
10184
- * Batch variant — builds all payloads with a single DB lookup per agent.
10185
- * Use this from `pollInbox` to avoid an N+1 against `agent_configs`.
10855
+ * Batch variant — builds all payloads with a single DB lookup per agent plus
10856
+ * batched lookups for participant modes and inReplyTo snapshots.
10186
10857
  */
10187
- async function buildClientMessagePayloadsForInbox(db, inboxId, messages) {
10188
- if (messages.length === 0) return [];
10858
+ async function buildClientMessagePayloadsForInbox(db, inboxId, items) {
10859
+ if (items.length === 0) return [];
10189
10860
  const agentId = await resolveAgentId(db, {
10190
10861
  kind: "inboxId",
10191
10862
  inboxId
10192
10863
  });
10193
10864
  const [cfg] = await db.select({ version: agentConfigs.version }).from(agentConfigs).where(eq(agentConfigs.agentId, agentId)).limit(1);
10194
10865
  const version = cfg?.version ?? 1;
10195
- return messages.map((m) => ({
10866
+ const chatIds = [...new Set(items.map((it) => it.entryChatId ?? it.message.chatId).filter((id) => id !== null))];
10867
+ const modeByChat = /* @__PURE__ */ new Map();
10868
+ if (chatIds.length > 0) {
10869
+ const rows = await db.select({
10870
+ chatId: chatParticipants.chatId,
10871
+ mode: chatParticipants.mode
10872
+ }).from(chatParticipants).where(and(eq(chatParticipants.agentId, agentId), inArray(chatParticipants.chatId, chatIds)));
10873
+ for (const r of rows) modeByChat.set(r.chatId, normaliseMode(r.mode));
10874
+ }
10875
+ const inReplyToIds = [...new Set(items.map((it) => it.message.inReplyTo).filter((id) => id !== null))];
10876
+ const snapshotById = /* @__PURE__ */ new Map();
10877
+ if (inReplyToIds.length > 0) {
10878
+ const origs = await db.select({
10879
+ id: messages.id,
10880
+ senderId: messages.senderId,
10881
+ chatId: messages.chatId,
10882
+ replyToChat: messages.replyToChat
10883
+ }).from(messages).where(inArray(messages.id, inReplyToIds));
10884
+ for (const o of origs) snapshotById.set(o.id, {
10885
+ senderId: o.senderId,
10886
+ chatId: o.chatId,
10887
+ replyToChat: o.replyToChat
10888
+ });
10889
+ }
10890
+ return items.map(({ entryChatId, message: m }) => ({
10196
10891
  id: m.id,
10197
10892
  chatId: m.chatId,
10198
10893
  senderId: m.senderId,
@@ -10204,7 +10899,9 @@ async function buildClientMessagePayloadsForInbox(db, inboxId, messages) {
10204
10899
  inReplyTo: m.inReplyTo,
10205
10900
  source: normaliseSource(m.source),
10206
10901
  createdAt: m.createdAt,
10207
- configVersion: version
10902
+ configVersion: version,
10903
+ recipientMode: modeByChat.get(entryChatId ?? m.chatId) ?? "full",
10904
+ inReplyToSnapshot: m.inReplyTo ? snapshotById.get(m.inReplyTo) ?? null : null
10208
10905
  }));
10209
10906
  }
10210
10907
  async function resolveAgentId(db, source) {
@@ -10243,23 +10940,25 @@ async function pollInboxInner(db, inboxId, limit) {
10243
10940
  const msg = msgMap.get(entry.message_id);
10244
10941
  if (!msg) throw new Error(`Unexpected: message ${entry.message_id} not found`);
10245
10942
  return {
10246
- id: msg.id,
10247
- chatId: msg.chatId,
10248
- senderId: msg.senderId,
10249
- format: msg.format,
10250
- content: msg.content,
10251
- metadata: msg.metadata,
10252
- replyToInbox: msg.replyToInbox,
10253
- replyToChat: msg.replyToChat,
10254
- inReplyTo: msg.inReplyTo,
10255
- source: msg.source,
10256
- createdAt: msg.createdAt.toISOString()
10943
+ entryChatId: entry.chat_id,
10944
+ message: {
10945
+ id: msg.id,
10946
+ chatId: msg.chatId,
10947
+ senderId: msg.senderId,
10948
+ format: msg.format,
10949
+ content: msg.content,
10950
+ metadata: msg.metadata,
10951
+ replyToInbox: msg.replyToInbox,
10952
+ replyToChat: msg.replyToChat,
10953
+ inReplyTo: msg.inReplyTo,
10954
+ source: msg.source,
10955
+ createdAt: msg.createdAt.toISOString()
10956
+ }
10257
10957
  };
10258
10958
  }));
10259
- const payloadByMessageId = new Map(payloads.map((p) => [p.id, p]));
10260
- return claimed.map((entry) => {
10261
- const payload = payloadByMessageId.get(entry.message_id);
10262
- if (!payload) throw new Error(`Unexpected: payload for ${entry.message_id} not built`);
10959
+ return claimed.map((entry, idx) => {
10960
+ const payload = payloads[idx];
10961
+ if (!payload) throw new Error(`Unexpected: payload for entry ${entry.id} not built`);
10263
10962
  return {
10264
10963
  id: entry.id,
10265
10964
  inboxId: entry.inbox_id,
@@ -10359,7 +11058,8 @@ async function agentMessageRoutes(app) {
10359
11058
  const identity = requireAgent(request);
10360
11059
  await assertParticipant(app.db, request.params.chatId, identity.uuid);
10361
11060
  const body = sendMessageSchema.parse(request.body);
10362
- const { message: msg, recipients } = await sendMessage(app.db, request.params.chatId, identity.uuid, body);
11061
+ const prepared = await prepareImageOutbound(app.db, app.notifier, request.params.chatId, body);
11062
+ const { message: msg, recipients } = await sendMessage(app.db, request.params.chatId, identity.uuid, prepared);
10363
11063
  notifyRecipients(app.notifier, recipients, msg.id);
10364
11064
  return reply.status(201).send({
10365
11065
  ...msg,
@@ -11385,7 +12085,7 @@ function isRecord(value) {
11385
12085
  }
11386
12086
  /** Extract unique @mentions from text. Returns lowercase usernames.
11387
12087
  * Excludes email patterns (user@example.com) and team mentions (@org/team). */
11388
- function extractMentions(text) {
12088
+ function extractMentions$1(text) {
11389
12089
  if (!text) return [];
11390
12090
  const re = /(?<!\w)@([a-zA-Z0-9][\w-]*)(\/)?/g;
11391
12091
  const names = /* @__PURE__ */ new Set();
@@ -11679,7 +12379,7 @@ function extractEventContext(eventType, payload) {
11679
12379
  * Only called after action gating confirms this is a "new content" event.
11680
12380
  */
11681
12381
  async function handleMentionDelegation(app, eventType, payload) {
11682
- const mentions = extractMentions(extractEventText(eventType, payload));
12382
+ const mentions = extractMentions$1(extractEventText(eventType, payload));
11683
12383
  const mentionCtx = extractEventContext(eventType, payload);
11684
12384
  if (mentions.length > 0 && mentionCtx) return routeMentionDelegations(app, mentions, mentionCtx);
11685
12385
  return 0;
@@ -13726,4 +14426,4 @@ function createExecuteUpdate({ managed }) {
13726
14426
  };
13727
14427
  }
13728
14428
  //#endregion
13729
- export { checkServerHealth as A, blank as B, runMigrations as C, checkDocker as D, checkDatabase as E, isDockerAvailable as F, SdkError as G, setJsonMode as H, stopPostgres as I, applyClientLoggerConfig as J, SessionRegistry as K, ClientRuntime as L, checkWebSocket as M, printResults as N, checkNodeVersion as O, ensurePostgres as P, createOwner as R, uninstallClientService as S, checkClientConfig as T, status as U, print as V, FirstTreeHubSDK as W, configureClientLoggerForService as Y, runHomeMigration as _, showServiceLogs as a, isServiceSupported as b, COMMAND_VERSION as c, promptMissingFields as d, formatCheckReport as f, saveOnboardState as g, onboardCreate as h, parseDuration as i, checkServerReachable as j, checkServerConfig as k, isInteractive as l, onboardCheck as m, declineUpdate as n, validateLevel as o, loadOnboardState as p, cleanWorkspaces as q, promptUpdate as r, startServer as s, createExecuteUpdate as t, promptAddAgent as u, getClientServiceStatus as v, checkAgentConfigs as w, resolveCliInvocation as x, installClientService as y, hasUser as z };
14429
+ export { checkServerHealth as A, resolveReplyToFromEnv as B, runMigrations as C, checkDocker as D, checkDatabase as E, isDockerAvailable as F, FirstTreeHubSDK as G, print as H, stopPostgres as I, cleanWorkspaces as J, SdkError as K, ClientRuntime as L, checkWebSocket as M, printResults as N, checkNodeVersion as O, ensurePostgres as P, createOwner as R, uninstallClientService as S, checkClientConfig as T, setJsonMode as U, blank as V, status as W, configureClientLoggerForService as X, applyClientLoggerConfig as Y, runHomeMigration as _, showServiceLogs as a, isServiceSupported as b, COMMAND_VERSION as c, promptMissingFields as d, formatCheckReport as f, saveOnboardState as g, onboardCreate as h, parseDuration as i, checkServerReachable as j, checkServerConfig as k, isInteractive as l, onboardCheck as m, declineUpdate as n, validateLevel as o, loadOnboardState as p, SessionRegistry as q, promptUpdate as r, startServer as s, createExecuteUpdate as t, promptAddAgent as u, getClientServiceStatus as v, checkAgentConfigs as w, resolveCliInvocation as x, installClientService as y, hasUser as z };