@agent-team-foundation/first-tree-hub 0.9.7 → 0.9.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,28 +1,29 @@
1
1
  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
- 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";
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-hh_PkTu6.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-B1Kiq7S6.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 { parse, 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";
21
22
  import { drizzle } from "drizzle-orm/postgres-js";
22
23
  import postgres from "postgres";
24
+ import { confirm, input, password, select } from "@inquirer/prompts";
23
25
  import { fileURLToPath } from "node:url";
24
26
  import { migrate } from "drizzle-orm/postgres-js/migrator";
25
- import { confirm, input, password, select } from "@inquirer/prompts";
26
27
  import cors from "@fastify/cors";
27
28
  import rateLimit from "@fastify/rate-limit";
28
29
  import fastifyStatic from "@fastify/static";
@@ -460,7 +461,6 @@ z.object({
460
461
  inboxId: z.string(),
461
462
  status: z.string(),
462
463
  source: z.string().nullable().optional(),
463
- cloudUserId: z.string().nullable().optional(),
464
464
  visibility: agentVisibilitySchema,
465
465
  metadata: z.record(z.string(), z.unknown()),
466
466
  managerId: z.string().nullable(),
@@ -659,6 +659,11 @@ const chatParticipantSchema = z.object({
659
659
  mode: z.string(),
660
660
  joinedAt: z.string()
661
661
  });
662
+ chatParticipantSchema.extend({
663
+ name: z.string().nullable(),
664
+ displayName: z.string().nullable(),
665
+ type: z.string()
666
+ });
662
667
  z.object({
663
668
  id: z.string(),
664
669
  organizationId: z.string(),
@@ -698,6 +703,54 @@ z.object({
698
703
  limit: z.coerce.number().int().min(1).max(100).default(20),
699
704
  cursor: z.string().optional()
700
705
  });
706
+ /**
707
+ * MIME types the web + client image paths recognise. Kept in sync with
708
+ * Claude's vision API (see packages/client/src/handlers/claude-code.ts).
709
+ */
710
+ const SUPPORTED_IMAGE_MIMES$1 = [
711
+ "image/png",
712
+ "image/jpeg",
713
+ "image/gif",
714
+ "image/webp"
715
+ ];
716
+ const supportedImageMimeSchema = z.enum(SUPPORTED_IMAGE_MIMES$1);
717
+ const IMAGE_MIME_TO_EXT = {
718
+ "image/png": "png",
719
+ "image/jpeg": "jpg",
720
+ "image/gif": "gif",
721
+ "image/webp": "webp"
722
+ };
723
+ z.object({
724
+ data: z.string().min(1),
725
+ mimeType: supportedImageMimeSchema,
726
+ filename: z.string().min(1),
727
+ size: z.number().int().nonnegative().optional(),
728
+ imageId: z.string().uuid().optional()
729
+ });
730
+ z.object({
731
+ imageId: z.string().uuid(),
732
+ mimeType: supportedImageMimeSchema,
733
+ filename: z.string().min(1),
734
+ size: z.number().int().nonnegative().optional()
735
+ });
736
+ /**
737
+ * Server → client WS frame carrying the full image bytes for an image
738
+ * message. Pushed before the corresponding `new_message` notification so
739
+ * the client has the file on disk by the time it polls the message.
740
+ *
741
+ * Best-effort: if the target client WS lives on a different server
742
+ * instance (or is offline), the frame is lost and the reference message
743
+ * will surface a "not available on this device" placeholder downstream.
744
+ */
745
+ const imagePayloadFrameSchema = z.object({
746
+ type: z.literal("image_payload"),
747
+ imageId: z.string().uuid(),
748
+ chatId: z.string(),
749
+ base64: z.string().min(1),
750
+ mimeType: supportedImageMimeSchema,
751
+ filename: z.string().min(1),
752
+ size: z.number().int().nonnegative().optional()
753
+ });
701
754
  const messageSourceSchema = z.enum([
702
755
  "hub_ui",
703
756
  "cli",
@@ -730,17 +783,7 @@ z.object({
730
783
  replyToChat: z.string().optional(),
731
784
  source: messageSourceSchema.optional()
732
785
  });
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({
786
+ const messageSchema = z.object({
744
787
  id: z.string(),
745
788
  chatId: z.string(),
746
789
  senderId: z.string(),
@@ -752,7 +795,46 @@ const clientMessageSchema = z.object({
752
795
  inReplyTo: z.string().nullable(),
753
796
  source: messageSourceSchema.nullable(),
754
797
  createdAt: z.string()
755
- }).extend({ configVersion: z.number().int().positive() });
798
+ });
799
+ /**
800
+ * Snapshot of the `in_reply_to` target that the server materialises at
801
+ * dispatch time so the receiving runtime can decide whether this is an
802
+ * echo it should suppress (see proposal hub-agent-messaging-reply-and-mentions).
803
+ *
804
+ * `chatId` is the original message's `chat_id`; `replyToChat` is the chat
805
+ * its sender expected replies to flow back to (often a different chat).
806
+ * `null` when the message is not a reply, or the original could not be
807
+ * resolved (e.g. deleted).
808
+ */
809
+ const inReplyToSnapshotSchema = z.object({
810
+ senderId: z.string(),
811
+ chatId: z.string(),
812
+ replyToChat: z.string().nullable()
813
+ }).nullable();
814
+ /** Per-chat participation mode exposed to the recipient runtime. */
815
+ const participantModeSchema = z.enum(["full", "mention_only"]);
816
+ /**
817
+ * Wire format for messages routed FROM the Hub TO a client runtime.
818
+ *
819
+ * Adds `configVersion` so the client can compare against its locally cached
820
+ * agent runtime config and refresh before delivering the message to the SDK.
821
+ *
822
+ * Step 3: this is the single shape used by `buildClientMessagePayload` —
823
+ * never serialise a raw `messageSchema` row to a client; always go through
824
+ * the dispatcher.
825
+ *
826
+ * `recipientMode` is the receiving agent's own mode in the entry's chat —
827
+ * `mention_only` participants must only start a session when they appear in
828
+ * `metadata.mentions` (see session-manager.ts).
829
+ *
830
+ * `inReplyToSnapshot` is populated when `inReplyTo` resolves to an existing
831
+ * message; runtime uses it to suppress self-reply echo on direct chats.
832
+ */
833
+ const clientMessageSchema = messageSchema.extend({
834
+ configVersion: z.number().int().positive(),
835
+ recipientMode: participantModeSchema.default("full"),
836
+ inReplyToSnapshot: inReplyToSnapshotSchema.default(null)
837
+ });
756
838
  z.enum([
757
839
  "pending",
758
840
  "delivered",
@@ -1112,6 +1194,211 @@ const serverWelcomeFrameSchema = z.object({
1112
1194
  serverCommandVersion: z.string().min(1),
1113
1195
  serverTimeMs: z.number().int().nonnegative()
1114
1196
  }).passthrough();
1197
+ /** Declare a config field with a Zod schema and optional metadata. */
1198
+ function field(schema, options) {
1199
+ return {
1200
+ _tag: "field",
1201
+ _type: void 0,
1202
+ schema,
1203
+ options: options ?? {}
1204
+ };
1205
+ }
1206
+ /** Mark a config group as optional — present only when at least one field has an explicit value. */
1207
+ function optional(shape) {
1208
+ return {
1209
+ _tag: "optional",
1210
+ shape
1211
+ };
1212
+ }
1213
+ /** Define a config shape. Identity function used for type inference. */
1214
+ function defineConfig(shape) {
1215
+ return shape;
1216
+ }
1217
+ defineConfig({
1218
+ agentId: field(z.string().min(1)),
1219
+ runtime: field(z.string().default("claude-code")),
1220
+ concurrency: field(z.number().int().positive().default(5)),
1221
+ session: {
1222
+ idle_timeout: field(z.number().int().positive().default(300)),
1223
+ max_sessions: field(z.number().int().positive().default(10))
1224
+ }
1225
+ });
1226
+ /**
1227
+ * Phase-dependent defaults that flip with release milestones. Kept as a plain
1228
+ * module-level constant so reviews of the beta→GA transition are a one-line
1229
+ * diff, and so tests can mock this module to exercise both branches.
1230
+ */
1231
+ const UPDATE_POLICIES = [
1232
+ "auto",
1233
+ "prompt",
1234
+ "off"
1235
+ ];
1236
+ /**
1237
+ * Default value of `update.policy` on the Client config. During the beta this
1238
+ * is `"auto"` — operators rarely know to `npm i -g` weekly and we chase the
1239
+ * latest published Command by default. The GA PR flips it to `"prompt"` and
1240
+ * bumps Command to `1.0.0`.
1241
+ */
1242
+ const UPDATE_POLICY_DEFAULT = "auto";
1243
+ const updatePolicySchema = z.enum(UPDATE_POLICIES);
1244
+ defineConfig({
1245
+ server: { url: field(z.string(), {
1246
+ env: "FIRST_TREE_HUB_SERVER_URL",
1247
+ prompt: {
1248
+ message: "Server URL:",
1249
+ default: "http://localhost:8000"
1250
+ }
1251
+ }) },
1252
+ client: { id: field(z.string().regex(/^client_[a-f0-9]{8}$/), {
1253
+ auto: "client-id",
1254
+ env: "FIRST_TREE_HUB_CLIENT_ID"
1255
+ }) },
1256
+ update: {
1257
+ policy: field(updatePolicySchema.default(UPDATE_POLICY_DEFAULT), { env: "FIRST_TREE_HUB_UPDATE_POLICY" }),
1258
+ restart_quiet_seconds: field(z.number().int().min(1).max(3600).default(30), { env: "FIRST_TREE_HUB_UPDATE_RESTART_QUIET_SECONDS" }),
1259
+ restart_check_interval_seconds: field(z.number().int().min(5).max(300).default(10), { env: "FIRST_TREE_HUB_UPDATE_RESTART_CHECK_INTERVAL_SECONDS" }),
1260
+ prompt_timeout_seconds: field(z.number().int().min(10).max(600).default(60), { env: "FIRST_TREE_HUB_UPDATE_PROMPT_TIMEOUT_SECONDS" })
1261
+ },
1262
+ logLevel: field(logLevelSchema.default("info"), { env: "FIRST_TREE_HUB_LOG_LEVEL" })
1263
+ });
1264
+ const DEFAULT_HOME_DIR = process.env.FIRST_TREE_HUB_HOME ?? join(homedir(), ".first-tree", "hub");
1265
+ join(DEFAULT_HOME_DIR, "config");
1266
+ const DEFAULT_DATA_DIR = join(DEFAULT_HOME_DIR, "data");
1267
+ join(homedir(), ".first-tree-hub");
1268
+ defineConfig({
1269
+ database: {
1270
+ url: field(z.string(), {
1271
+ env: "FIRST_TREE_HUB_DATABASE_URL",
1272
+ auto: "docker-pg",
1273
+ prompt: {
1274
+ message: "PostgreSQL:",
1275
+ type: "select",
1276
+ choices: [{
1277
+ name: "Auto-provision via Docker",
1278
+ value: "__auto__"
1279
+ }, {
1280
+ name: "Provide connection URL",
1281
+ value: "__input__"
1282
+ }]
1283
+ }
1284
+ }),
1285
+ provider: field(z.enum(["docker", "external"]).default("docker"))
1286
+ },
1287
+ server: {
1288
+ port: field(z.number().default(8e3), { env: "FIRST_TREE_HUB_PORT" }),
1289
+ host: field(z.string().default("127.0.0.1"), { env: "FIRST_TREE_HUB_HOST" })
1290
+ },
1291
+ secrets: {
1292
+ jwtSecret: field(z.string(), {
1293
+ env: "FIRST_TREE_HUB_JWT_SECRET",
1294
+ auto: "random:base64url:32",
1295
+ secret: true
1296
+ }),
1297
+ encryptionKey: field(z.string(), {
1298
+ env: "FIRST_TREE_HUB_ENCRYPTION_KEY",
1299
+ auto: "random:hex:32",
1300
+ secret: true
1301
+ })
1302
+ },
1303
+ contextTree: optional({
1304
+ repo: field(z.string(), {
1305
+ env: "FIRST_TREE_HUB_CONTEXT_TREE_REPO",
1306
+ prompt: { message: "Context Tree repo URL (e.g. https://github.com/org/first-tree):" }
1307
+ }),
1308
+ branch: field(z.string().default("main"))
1309
+ }),
1310
+ github: {
1311
+ webhookSecret: field(z.string().optional(), {
1312
+ env: "FIRST_TREE_HUB_GITHUB_WEBHOOK_SECRET",
1313
+ secret: true
1314
+ }),
1315
+ allowedOrg: field(z.string().optional(), { env: "FIRST_TREE_HUB_GITHUB_ALLOWED_ORG" })
1316
+ },
1317
+ cors: optional({ origin: field(z.string(), { env: "FIRST_TREE_HUB_CORS_ORIGIN" }) }),
1318
+ rateLimit: optional({
1319
+ max: field(z.number().default(100), { env: "FIRST_TREE_HUB_RATE_LIMIT_MAX" }),
1320
+ loginMax: field(z.number().default(5), { env: "FIRST_TREE_HUB_RATE_LIMIT_LOGIN_MAX" }),
1321
+ webhookMax: field(z.number().default(60), { env: "FIRST_TREE_HUB_RATE_LIMIT_WEBHOOK_MAX" })
1322
+ }),
1323
+ kael: optional({
1324
+ endpoint: field(z.string(), { env: "KAEL_ENDPOINT" }),
1325
+ apiKey: field(z.string(), {
1326
+ env: "KAEL_API_KEY",
1327
+ secret: true
1328
+ }),
1329
+ hubPublicUrl: field(z.string(), { env: "FIRST_TREE_HUB_PUBLIC_URL" })
1330
+ }),
1331
+ feedback: optional({
1332
+ repo: field(z.string(), { env: "HEARBACK_FEEDBACK_REPO" }),
1333
+ githubToken: field(z.string(), {
1334
+ env: "HEARBACK_GITHUB_TOKEN",
1335
+ secret: true
1336
+ }),
1337
+ llm: optional({
1338
+ apiKey: field(z.string(), {
1339
+ env: "LLM_API_KEY",
1340
+ secret: true
1341
+ }),
1342
+ baseUrl: field(z.string().optional(), { env: "LLM_BASE_URL" }),
1343
+ model: field(z.string().optional(), { env: "LLM_MODEL" })
1344
+ }),
1345
+ trustProxyHeaders: field(z.boolean().default(false), { env: "HEARBACK_TRUST_PROXY_HEADERS" })
1346
+ }),
1347
+ observability: {
1348
+ logging: {
1349
+ level: field(logLevelSchema.default("info"), { env: "FIRST_TREE_HUB_LOG_LEVEL" }),
1350
+ format: field(logFormatSchema.default(process.env.NODE_ENV === "production" ? "json" : "pretty")),
1351
+ bridgeToSpanLevel: field(z.enum([
1352
+ "error",
1353
+ "warn",
1354
+ "off"
1355
+ ]).default("error"))
1356
+ },
1357
+ tracing: optional({
1358
+ endpoint: field(z.string(), { env: "FIRST_TREE_HUB_OTEL_ENDPOINT" }),
1359
+ headers: field(z.string().default(""), {
1360
+ env: "FIRST_TREE_HUB_OTEL_HEADERS",
1361
+ secret: true
1362
+ }),
1363
+ exporter: field(z.enum(["otlp-http", "otlp-grpc"]).default("otlp-http")),
1364
+ serviceName: field(z.string().default("first-tree-hub")),
1365
+ environment: field(z.string().default("development"), { env: "FIRST_TREE_HUB_OTEL_ENVIRONMENT" }),
1366
+ sampleRate: field(z.number().min(0).max(1).default(1))
1367
+ })
1368
+ }
1369
+ });
1370
+ /** UUIDs are the only shape we generate for imageId, but accept the same
1371
+ * loose character set as chatId sanitisers elsewhere so a malformed field
1372
+ * can never break out of the images dir. */
1373
+ function sanitize(segment) {
1374
+ return /^[a-zA-Z0-9-]+$/.test(segment) ? segment : "unknown";
1375
+ }
1376
+ function imageDir(chatId) {
1377
+ return join(DEFAULT_DATA_DIR, "chats", sanitize(chatId), "images");
1378
+ }
1379
+ function imagePath(chatId, imageId, mimeType) {
1380
+ const ext = IMAGE_MIME_TO_EXT[mimeType];
1381
+ return join(imageDir(chatId), `${sanitize(imageId)}.${ext}`);
1382
+ }
1383
+ /**
1384
+ * Locate a previously-written image file on disk. Returns null when the
1385
+ * file is missing — caller should surface the "image not available on
1386
+ * this device" placeholder in that case.
1387
+ */
1388
+ function findImagePath(chatId, imageId, mimeType) {
1389
+ const p = imagePath(chatId, imageId, mimeType);
1390
+ return existsSync(p) ? p : null;
1391
+ }
1392
+ /**
1393
+ * Persist image bytes to `<dataDir>/chats/<chatId>/images/<imageId>.<ext>`.
1394
+ * Idempotent — rewriting the same imageId is a no-op overwrite.
1395
+ */
1396
+ async function writeImage(params) {
1397
+ await mkdir(imageDir(params.chatId), { recursive: true });
1398
+ const path = imagePath(params.chatId, params.imageId, params.mimeType);
1399
+ await writeFile(path, Buffer.from(params.base64, "base64"));
1400
+ return path;
1401
+ }
1115
1402
  const FETCH_TIMEOUT_MS = 15e3;
1116
1403
  var FirstTreeHubSDK = class {
1117
1404
  _baseUrl;
@@ -1185,6 +1472,13 @@ var FirstTreeHubSDK = class {
1185
1472
  async listMessages(chatId, options) {
1186
1473
  return this.requestJson(`/api/v1/agent/chats/${chatId}/messages${this.queryString(options)}`);
1187
1474
  }
1475
+ /**
1476
+ * List participants of a chat with agent names/displayNames — used by the
1477
+ * runtime to resolve `@<name>` mentions against the authoritative set.
1478
+ */
1479
+ async listChatParticipants(chatId) {
1480
+ return this.requestJson(`/api/v1/agent/chats/${chatId}/participants`);
1481
+ }
1188
1482
  queryString(options) {
1189
1483
  const params = new URLSearchParams();
1190
1484
  if (options?.limit !== void 0) params.set("limit", String(options.limit));
@@ -1232,6 +1526,19 @@ var SdkError = class extends Error {
1232
1526
  this.name = "SdkError";
1233
1527
  }
1234
1528
  };
1529
+ /**
1530
+ * Thrown (emitted on `error` and rejected from `connect()`) when the server
1531
+ * refuses a `client:register` because the local clientId is bound to a
1532
+ * different organization. The CLI layer detects this via `instanceof` and
1533
+ * prompts the user before rotating the local clientId.
1534
+ */
1535
+ var ClientOrgMismatchError$1 = class extends Error {
1536
+ code = "CLIENT_ORG_MISMATCH";
1537
+ constructor(message = "Client belongs to a different organization") {
1538
+ super(message);
1539
+ this.name = "ClientOrgMismatchError";
1540
+ }
1541
+ };
1235
1542
  const RECONNECT_BASE_MS = 1e3;
1236
1543
  const RECONNECT_MAX_MS = 3e4;
1237
1544
  const WS_CONNECT_TIMEOUT_MS = 1e4;
@@ -1287,12 +1594,26 @@ var ClientConnection = class extends EventEmitter {
1287
1594
  registered = false;
1288
1595
  /** Count of `server:welcome` frames received; drives `isReconnect` flag. */
1289
1596
  welcomeFramesReceived = 0;
1597
+ /**
1598
+ * Last handshake error, stashed for the `close` handler to surface a typed
1599
+ * reason (e.g. {@link ClientOrgMismatchError}) instead of a generic
1600
+ * "closed before ready" when `connect()` is pending.
1601
+ */
1602
+ lastHandshakeError = null;
1290
1603
  wsLogger;
1291
1604
  authLogger;
1292
1605
  boundAgents = /* @__PURE__ */ new Map();
1293
1606
  /** Agents scheduled to rebind automatically on every reconnect. */
1294
1607
  desiredBindings = /* @__PURE__ */ new Map();
1295
1608
  pendingBinds = /* @__PURE__ */ new Map();
1609
+ /**
1610
+ * In-flight image writes from recent `image_payload` frames. The server
1611
+ * pushes `image_payload` immediately before firing the `new_message`
1612
+ * notification, but WS message handlers run through EventEmitter (sync
1613
+ * dispatch, no await), so the disk write can still race the HTTP poll
1614
+ * that follows. Defer `new_message` emission until these settle.
1615
+ */
1616
+ pendingImageWrites = /* @__PURE__ */ new Set();
1296
1617
  constructor(config) {
1297
1618
  super();
1298
1619
  this.clientId = config.clientId ?? process.env.FIRST_TREE_HUB_CLIENT_ID ?? `client_${randomUUID().slice(0, 8)}`;
@@ -1463,7 +1784,9 @@ var ClientConnection = class extends EventEmitter {
1463
1784
  this.rejectAllPendingBinds("WebSocket closed");
1464
1785
  if (!settled) {
1465
1786
  this.wsLogger.warn({ code }, "closed before ready");
1466
- settle(reject, /* @__PURE__ */ new Error(`WebSocket closed before ready (code ${code})`));
1787
+ const typedErr = this.lastHandshakeError;
1788
+ this.lastHandshakeError = null;
1789
+ settle(reject, typedErr ?? /* @__PURE__ */ new Error(`WebSocket closed before ready (code ${code})`));
1467
1790
  return;
1468
1791
  }
1469
1792
  this.wsLogger.warn({
@@ -1516,8 +1839,15 @@ var ClientConnection = class extends EventEmitter {
1516
1839
  return;
1517
1840
  }
1518
1841
  if (type === "client:register:rejected") {
1519
- const err = /* @__PURE__ */ new Error(`client:register rejected: ${msg.message ?? "unknown"}`);
1520
- this.wsLogger.error({ message: msg.message }, "client register rejected");
1842
+ const code = typeof msg.code === "string" ? msg.code : void 0;
1843
+ const message = typeof msg.message === "string" ? msg.message : "unknown";
1844
+ this.closing = true;
1845
+ const err = code === "CLIENT_ORG_MISMATCH" ? new ClientOrgMismatchError$1(message) : /* @__PURE__ */ new Error(`client:register rejected: ${message}`);
1846
+ this.lastHandshakeError = err;
1847
+ this.wsLogger.error({
1848
+ code,
1849
+ message
1850
+ }, "client register rejected");
1521
1851
  this.emit("error", err);
1522
1852
  this.ws?.close(4403, "register rejected");
1523
1853
  return;
@@ -1604,9 +1934,36 @@ var ClientConnection = class extends EventEmitter {
1604
1934
  });
1605
1935
  return;
1606
1936
  }
1937
+ if (type === "image_payload") {
1938
+ const parsed = imagePayloadFrameSchema.safeParse(msg);
1939
+ if (!parsed.success) {
1940
+ this.wsLogger.warn({ err: parsed.error.flatten() }, "malformed image_payload frame — dropping");
1941
+ return;
1942
+ }
1943
+ const { imageId, chatId, mimeType, base64 } = parsed.data;
1944
+ const write = writeImage({
1945
+ chatId,
1946
+ imageId,
1947
+ mimeType,
1948
+ base64
1949
+ }).then(() => {}).catch((err) => {
1950
+ this.wsLogger.warn({
1951
+ err,
1952
+ imageId,
1953
+ chatId
1954
+ }, "image_payload write failed");
1955
+ });
1956
+ this.pendingImageWrites.add(write);
1957
+ write.finally(() => this.pendingImageWrites.delete(write));
1958
+ return;
1959
+ }
1607
1960
  if (type === "new_message") {
1608
1961
  const inboxId = msg.inboxId;
1609
- if (inboxId) this.emit("agent:message", inboxId, msg);
1962
+ if (!inboxId) return;
1963
+ if (this.pendingImageWrites.size > 0) Promise.all([...this.pendingImageWrites]).finally(() => {
1964
+ this.emit("agent:message", inboxId, msg);
1965
+ });
1966
+ else this.emit("agent:message", inboxId, msg);
1610
1967
  return;
1611
1968
  }
1612
1969
  if (type === "error") {
@@ -1717,163 +2074,6 @@ function getHandlerFactory(type) {
1717
2074
  }
1718
2075
  return factory;
1719
2076
  }
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
- });
1877
2077
  join(DEFAULT_DATA_DIR, "context-tree");
1878
2078
  /**
1879
2079
  * Bootstrap a workspace with .agent/ directory files.
@@ -2012,22 +2212,28 @@ Use the \`first-tree-hub agent send\` CLI — it reads the env vars above and
2012
2212
  attaches the \`Authorization\` + \`X-Agent-Id\` headers automatically:
2013
2213
 
2014
2214
  \`\`\`bash
2015
- # Send to another agent (target = agent ID)
2016
- first-tree-hub agent send <agentId> "your message"
2215
+ # Send to another agent target is the agent NAME, NOT a uuid.
2216
+ # Names are stable (set on creation, immutable, unique in the org).
2217
+ # Run \`first-tree-hub agent list\` to see available names.
2218
+ first-tree-hub agent send <agentName> "your message"
2017
2219
 
2018
- # Send to a chat (target = chat ID)
2220
+ # Send to a chat (target is a chat UUID; use this when replying into a
2221
+ # specific chat, e.g. a group where you were mentioned)
2019
2222
  first-tree-hub agent send --chat <chatId> "your message"
2020
2223
 
2021
2224
  # Send markdown (default format is text)
2022
- first-tree-hub agent send <agentId> -f markdown "**bold** message"
2225
+ first-tree-hub agent send <agentName> -f markdown "**bold** message"
2023
2226
 
2024
2227
  # Reply to a specific message
2025
- first-tree-hub agent send <agentId> --reply-to <messageId> "reply content"
2228
+ first-tree-hub agent send <agentName> --reply-to <messageId> "reply content"
2026
2229
 
2027
2230
  # Pipe long content via stdin (recommended for special characters)
2028
- echo "long message body" | first-tree-hub agent send <agentId>
2231
+ echo "long message body" | first-tree-hub agent send <agentName>
2029
2232
  \`\`\`
2030
2233
 
2234
+ > Agent uuids appear in \`agent chats\`, chat history, and participant lists,
2235
+ > but they are NOT accepted by \`agent send\` — always use the name.
2236
+
2031
2237
  For content with quotes, \`$\`, backticks, or newlines, prefer stdin to avoid shell escaping issues.
2032
2238
  `;
2033
2239
  }
@@ -2570,20 +2776,99 @@ function cleanWorkspaces(workspaceRoot, activeChatIds, ttlMs = DEFAULT_WORKSPACE
2570
2776
  }
2571
2777
  return removed;
2572
2778
  }
2779
+ /**
2780
+ * Resolve which Claude Code binary the SDK should spawn.
2781
+ *
2782
+ * Priority:
2783
+ * 1. `CLAUDE_CODE_EXECUTABLE` env var — explicit operator override
2784
+ * 2. `claude` on PATH — reuses whatever the user has already installed
2785
+ * 3. undefined — fall back to the SDK's bundled native binary
2786
+ *
2787
+ * The SDK's bundled binary ships as a per-platform **optional** npm dep
2788
+ * (`@anthropic-ai/claude-agent-sdk-<platform>-<arch>`). Any of: a proxy that
2789
+ * skips optional deps, an `.npmrc` with `omit=optional`, libc detection
2790
+ * failure, or a transient install error leaves that dep missing and the SDK
2791
+ * throws "Native CLI binary for <platform>-<arch> not found". Returning a PATH
2792
+ * hit here bypasses the missing bundle entirely.
2793
+ */
2794
+ function resolveClaudeCodeExecutable(opts = {}) {
2795
+ const env = opts.env ?? process.env;
2796
+ const override = env.CLAUDE_CODE_EXECUTABLE;
2797
+ if (override && override.length > 0 && existsSync(override)) return {
2798
+ path: override,
2799
+ source: "env"
2800
+ };
2801
+ const found = findOnPath("claude", env);
2802
+ if (found) return {
2803
+ path: found,
2804
+ source: "path"
2805
+ };
2806
+ return {
2807
+ path: void 0,
2808
+ source: "default"
2809
+ };
2810
+ }
2811
+ function findOnPath(name, env) {
2812
+ const rawPath = env.PATH ?? env.Path ?? env.path ?? "";
2813
+ if (!rawPath) return void 0;
2814
+ const exts = process.platform === "win32" ? splitPathExt(env.PATHEXT) : [""];
2815
+ for (const dir of rawPath.split(delimiter)) {
2816
+ if (!dir) continue;
2817
+ for (const ext of exts) {
2818
+ const full = join(dir, name + ext);
2819
+ if (existsSync(full)) return full;
2820
+ }
2821
+ }
2822
+ }
2823
+ function splitPathExt(pathext) {
2824
+ if (!pathext) return [
2825
+ ".EXE",
2826
+ ".CMD",
2827
+ ".BAT",
2828
+ ".COM",
2829
+ ""
2830
+ ];
2831
+ const parts = pathext.split(";").filter(Boolean);
2832
+ return parts.length > 0 ? [...parts, ""] : [""];
2833
+ }
2573
2834
  const MAX_RETRIES = 2;
2574
2835
  const TOOL_RESULT_PREVIEW_LIMIT = 400;
2575
2836
  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) {
2837
+ const SUPPORTED_IMAGE_MIMES = new Set(SUPPORTED_IMAGE_MIMES$1);
2838
+ const MIME_TO_EXT = {
2839
+ "image/png": "png",
2840
+ "image/jpeg": "jpg",
2841
+ "image/gif": "gif",
2842
+ "image/webp": "webp"
2843
+ };
2844
+ function isImageRefContent(content) {
2845
+ if (!content || typeof content !== "object") return false;
2846
+ const c = content;
2847
+ return typeof c.imageId === "string" && typeof c.mimeType === "string" && typeof c.filename === "string" && SUPPORTED_IMAGE_MIMES.has(c.mimeType);
2848
+ }
2849
+ function isLegacyImageFileContent(content) {
2583
2850
  if (!content || typeof content !== "object") return false;
2584
2851
  const c = content;
2585
2852
  return typeof c.data === "string" && typeof c.mimeType === "string" && typeof c.filename === "string" && SUPPORTED_IMAGE_MIMES.has(c.mimeType);
2586
2853
  }
2854
+ /** chat_id values are DB-generated UUIDs; reject anything else so we never
2855
+ * traverse out of the images dir if the field is ever tampered with. */
2856
+ function sanitizeChatId(chatId) {
2857
+ return /^[a-zA-Z0-9-]+$/.test(chatId) ? chatId : "unknown";
2858
+ }
2859
+ /**
2860
+ * Write a legacy inline-base64 image to a temp file so Claude Code's Read
2861
+ * tool can pick it up. Only the legacy path — new messages go through the
2862
+ * image_payload WS push which pre-writes to the data dir before delivery.
2863
+ */
2864
+ async function writeLegacyImageToTempFile(content, chatId) {
2865
+ const dir = join(tmpdir(), "first-tree-hub", "images", sanitizeChatId(chatId));
2866
+ await mkdir(dir, { recursive: true });
2867
+ const ext = MIME_TO_EXT[content.mimeType];
2868
+ const path = join(dir, `${Date.now()}-${randomUUID().slice(0, 8)}.${ext}`);
2869
+ await writeFile(path, Buffer.from(content.data, "base64"));
2870
+ return path;
2871
+ }
2587
2872
  function extractContentBlocks(message) {
2588
2873
  if (!message || typeof message !== "object") return [];
2589
2874
  const inner = message.message;
@@ -2741,6 +3026,7 @@ const createClaudeCodeHandler = (config) => {
2741
3026
  const workspaceRoot = config.workspaceRoot;
2742
3027
  const agentConfigCache = config.agentConfigCache ?? null;
2743
3028
  const gitMirrorManager = config.gitMirrorManager ?? null;
3029
+ const claudeCodeExecutable = config.claudeCodeExecutable ?? resolveClaudeCodeExecutable().path;
2744
3030
  let cwd = null;
2745
3031
  let claudeSessionId = null;
2746
3032
  let currentQuery = null;
@@ -2755,35 +3041,64 @@ const createClaudeCodeHandler = (config) => {
2755
3041
  let appliedPayload = null;
2756
3042
  /** Worktrees materialised for this session — each entry removed on shutdown. */
2757
3043
  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
- };
3044
+ async function toSDKUserMessage(message, sessionCtx, sessionId) {
3045
+ if (message.format === "file") {
3046
+ const senderLabel = message.senderId ? await sessionCtx.resolveSenderLabel(message.senderId) : "";
3047
+ const prefix = senderLabel ? `[From: ${senderLabel}]\n\n` : "";
3048
+ if (isImageRefContent(message.content)) {
3049
+ const { imageId, mimeType, filename } = message.content;
3050
+ const imagePath = findImagePath(message.chatId, imageId, mimeType);
3051
+ if (imagePath) return {
3052
+ type: "user",
3053
+ message: {
3054
+ role: "user",
3055
+ 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}`
3056
+ },
3057
+ parent_tool_use_id: null,
3058
+ session_id: sessionId
3059
+ };
3060
+ return {
3061
+ type: "user",
3062
+ message: {
3063
+ role: "user",
3064
+ content: `${prefix}${`[Image "${filename}" not available on this device]`}`
3065
+ },
3066
+ parent_tool_use_id: null,
3067
+ session_id: sessionId
3068
+ };
3069
+ }
3070
+ if (isLegacyImageFileContent(message.content)) {
3071
+ const { filename } = message.content;
3072
+ try {
3073
+ return {
3074
+ type: "user",
3075
+ message: {
3076
+ role: "user",
3077
+ 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)}`
3078
+ },
3079
+ parent_tool_use_id: null,
3080
+ session_id: sessionId
3081
+ };
3082
+ } catch (err) {
3083
+ const fallbackText = `[Image attachment "${filename}" failed to materialise]`;
3084
+ ctx?.log(`Failed to write image to temp file: ${err instanceof Error ? err.message : String(err)}`);
3085
+ return {
3086
+ type: "user",
3087
+ message: {
3088
+ role: "user",
3089
+ content: `${prefix}${fallbackText}`
3090
+ },
3091
+ parent_tool_use_id: null,
3092
+ session_id: sessionId
3093
+ };
3094
+ }
3095
+ }
2780
3096
  }
2781
- const rawContent = typeof message.content === "string" ? message.content : JSON.stringify(message.content);
2782
3097
  return {
2783
3098
  type: "user",
2784
3099
  message: {
2785
3100
  role: "user",
2786
- content: message.senderId ? `[From: ${message.senderId}]\n\n${rawContent}` : rawContent
3101
+ content: await sessionCtx.formatInboundContent(message)
2787
3102
  },
2788
3103
  parent_tool_use_id: null,
2789
3104
  session_id: sessionId
@@ -2796,8 +3111,9 @@ const createClaudeCodeHandler = (config) => {
2796
3111
  * process.env contains internal markers (CLAUDECODE, CLAUDE_CODE_ENTRYPOINT,
2797
3112
  * CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS, npm_lifecycle_script) that cause the
2798
3113
  * 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".
3114
+ * resulting in ~90s cold start vs ~17s standalone. Strip these here (Claude
3115
+ * Code specific) then let the runtime layer add the Agent-Hub envelope via
3116
+ * `ctx.buildAgentEnv` so all handlers expose the same vars uniformly.
2801
3117
  */
2802
3118
  function buildEnv(sessionCtx) {
2803
3119
  const env = { ...process.env };
@@ -2807,12 +3123,7 @@ const createClaudeCodeHandler = (config) => {
2807
3123
  delete env.npm_lifecycle_script;
2808
3124
  const payload = agentConfigCache?.get(sessionCtx.agent.agentId)?.payload;
2809
3125
  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
- };
3126
+ return sessionCtx.buildAgentEnv(env);
2816
3127
  }
2817
3128
  /** Create query and input controller, then start consumer loop. */
2818
3129
  function spawnQuery(sessionId, sessionCtx, resume) {
@@ -2850,7 +3161,9 @@ const createClaudeCodeHandler = (config) => {
2850
3161
  abortController,
2851
3162
  permissionMode,
2852
3163
  allowDangerouslySkipPermissions: true,
3164
+ settingSources: ["project"],
2853
3165
  env: buildEnv(sessionCtx),
3166
+ ...claudeCodeExecutable ? { pathToClaudeCodeExecutable: claudeCodeExecutable } : {},
2854
3167
  ...payload?.model ? { model: payload.model } : {},
2855
3168
  ...payload?.prompt.append ? { systemPrompt: {
2856
3169
  type: "preset",
@@ -2913,10 +3226,7 @@ const createClaudeCodeHandler = (config) => {
2913
3226
  if (message.result && sessionCtx.chatId) {
2914
3227
  const resultText = message.result;
2915
3228
  try {
2916
- await sessionCtx.sdk.sendMessage(sessionCtx.chatId, {
2917
- format: "text",
2918
- content: resultText
2919
- });
3229
+ await sessionCtx.forwardResult(resultText);
2920
3230
  sessionCtx.log("Result forwarded to chat");
2921
3231
  sessionCtx.reportSessionCompletion();
2922
3232
  sessionCtx.emitEvent({
@@ -3076,7 +3386,7 @@ const createClaudeCodeHandler = (config) => {
3076
3386
  await prepareGitWorktrees(cwd, payload, sessionCtx);
3077
3387
  sessionCtx.log(`Starting session (${claudeSessionId}), cwd=${cwd}, permissionMode=${config.permissionMode ?? "bypassPermissions"}`);
3078
3388
  spawnQuery(claudeSessionId, sessionCtx);
3079
- const sdkMsg = toSDKUserMessage(message, claudeSessionId);
3389
+ const sdkMsg = await toSDKUserMessage(message, sessionCtx, claudeSessionId);
3080
3390
  inputController?.push(sdkMsg);
3081
3391
  sessionCtx.log(`Session started (${claudeSessionId})`);
3082
3392
  return claudeSessionId;
@@ -3091,7 +3401,7 @@ const createClaudeCodeHandler = (config) => {
3091
3401
  await prepareGitWorktrees(cwd, payload, sessionCtx);
3092
3402
  sessionCtx.log(`Resuming session (${sessionId}), cwd=${cwd}`);
3093
3403
  spawnQuery(sessionId, sessionCtx, sessionId);
3094
- if (message) inputController?.push(toSDKUserMessage(message, sessionId));
3404
+ if (message) inputController?.push(await toSDKUserMessage(message, sessionCtx, sessionId));
3095
3405
  sessionCtx.log(`Session resumed (${sessionId})`);
3096
3406
  return sessionId;
3097
3407
  },
@@ -3104,8 +3414,13 @@ const createClaudeCodeHandler = (config) => {
3104
3414
  const sid = claudeSessionId;
3105
3415
  maybeSwitchConfig(sessionCtx).catch((err) => {
3106
3416
  sessionCtx.log(`maybeSwitchConfig errored: ${err instanceof Error ? err.message : String(err)}`);
3107
- }).finally(() => {
3108
- inputController?.push(toSDKUserMessage(message, sid));
3417
+ }).finally(async () => {
3418
+ try {
3419
+ const sdkMsg = await toSDKUserMessage(message, sessionCtx, sid);
3420
+ inputController?.push(sdkMsg);
3421
+ } catch (err) {
3422
+ sessionCtx.log(`toSDKUserMessage errored: ${err instanceof Error ? err.message : String(err)}`);
3423
+ }
3109
3424
  });
3110
3425
  },
3111
3426
  async suspend() {
@@ -3190,7 +3505,13 @@ function generateClaudeMd(workspacePath, identity, contextTreePath) {
3190
3505
  }
3191
3506
  /** Register all built-in handlers. Call once at startup. */
3192
3507
  function registerBuiltinHandlers() {
3193
- registerHandler("claude-code", createClaudeCodeHandler);
3508
+ const resolution = resolveClaudeCodeExecutable();
3509
+ if (resolution.path) process.stderr.write(`[handlers] Claude Code executable: ${resolution.path} (source=${resolution.source})\n`);
3510
+ 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");
3511
+ registerHandler("claude-code", (config) => createClaudeCodeHandler({
3512
+ ...config,
3513
+ claudeCodeExecutable: resolution.path
3514
+ }));
3194
3515
  }
3195
3516
  function createAgentConfigCache(opts) {
3196
3517
  const { sdk } = opts;
@@ -3269,6 +3590,84 @@ function createAgentConfigCache(opts) {
3269
3590
  };
3270
3591
  }
3271
3592
  /**
3593
+ * Cross-handler plumbing for Agent Hub ↔ agent-runtime interaction.
3594
+ *
3595
+ * Every handler that shells out to the `first-tree-hub` CLI or otherwise acts
3596
+ * on behalf of the agent needs the same envelope variables (server URL, agent
3597
+ * id, inbox id, chat id). And every handler that hands inbound messages to an
3598
+ * LLM benefits from the same `[From: <name>]` attribution header so the LLM
3599
+ * can see who authored each message in human-readable terms.
3600
+ *
3601
+ * Keeping these helpers in one place means adding a second handler (Gemini,
3602
+ * Cursor Agent, custom LLM, …) does not reimplement either concern.
3603
+ */
3604
+ /**
3605
+ * Build the env for CLI sub-processes that need to call `first-tree-hub ...`.
3606
+ * Layers the Agent-Hub envelope variables on top of the parent env. Handlers
3607
+ * that start sub-processes should call this so every one of them sees the
3608
+ * same envelope — enabling replyTo inference, access-token propagation, and
3609
+ * agent-id binding without per-handler duplication.
3610
+ */
3611
+ function buildAgentEnv(parentEnv, ctx) {
3612
+ return {
3613
+ ...parentEnv,
3614
+ FIRST_TREE_HUB_SERVER_URL: ctx.sdk.serverUrl,
3615
+ FIRST_TREE_HUB_AGENT_ID: ctx.agent.agentId,
3616
+ FIRST_TREE_HUB_INBOX_ID: ctx.agent.inboxId,
3617
+ FIRST_TREE_HUB_CHAT_ID: ctx.chatId
3618
+ };
3619
+ }
3620
+ function createParticipantCache(sdk, chatId, log) {
3621
+ let cached = null;
3622
+ let inflight = null;
3623
+ return { async get() {
3624
+ if (cached) return cached;
3625
+ if (!inflight) inflight = (async () => {
3626
+ try {
3627
+ const rows = await sdk.listChatParticipants(chatId);
3628
+ cached = rows;
3629
+ return rows;
3630
+ } catch (err) {
3631
+ log(`listChatParticipants failed: ${err instanceof Error ? err.message : String(err)}`);
3632
+ return [];
3633
+ } finally {
3634
+ inflight = null;
3635
+ }
3636
+ })();
3637
+ return inflight;
3638
+ } };
3639
+ }
3640
+ /**
3641
+ * Resolve `senderId` → display-friendly label the LLM can actually
3642
+ * disambiguate. Prefers `name` (unique per chat, used as the `@<name>`
3643
+ * mention token), falls back to `displayName`, then to the raw id. The last
3644
+ * fallback matters for edge cases — e.g. a participant removed mid-session
3645
+ * after we cached an earlier participants snapshot.
3646
+ */
3647
+ function resolveSenderLabel(senderId, participants) {
3648
+ for (const p of participants) {
3649
+ if (p.agentId !== senderId) continue;
3650
+ if (p.name) return p.name;
3651
+ if (p.displayName) return p.displayName;
3652
+ return senderId;
3653
+ }
3654
+ return senderId;
3655
+ }
3656
+ /**
3657
+ * Produce the handler-facing string form of an inbound message. Prefixes a
3658
+ * `[From: <name>]` line when the sender is a known participant. Structured
3659
+ * content is serialised to JSON — handlers that want to feed structured
3660
+ * content some other way should opt out and format themselves.
3661
+ *
3662
+ * Async because the participant list may need a server round-trip on first
3663
+ * use; subsequent messages in the same session hit the cache.
3664
+ */
3665
+ async function formatInboundContent(message, participants) {
3666
+ const rawContent = typeof message.content === "string" ? message.content : JSON.stringify(message.content);
3667
+ if (!message.senderId) return rawContent;
3668
+ return `[From: ${resolveSenderLabel(message.senderId, await participants.get())}]\n\n${rawContent}`;
3669
+ }
3670
+ /**
3272
3671
  * Deduplicator — bounded set of recently seen IDs.
3273
3672
  *
3274
3673
  * Used to deduplicate at-least-once delivered messages at the dispatch layer.
@@ -3299,6 +3698,24 @@ var Deduplicator = class {
3299
3698
  return this.seen.size;
3300
3699
  }
3301
3700
  };
3701
+ function createResultSink(deps) {
3702
+ async function buildMetadata(trigger) {
3703
+ if (!trigger || trigger.senderId === deps.agent.agentId) return void 0;
3704
+ if ((await deps.participants.get()).length <= 2) return void 0;
3705
+ return { mentions: [trigger.senderId] };
3706
+ }
3707
+ return async function forwardResult(text) {
3708
+ const trigger = deps.getTrigger();
3709
+ deps.clearTrigger();
3710
+ const metadata = await buildMetadata(trigger);
3711
+ await deps.sdk.sendMessage(deps.chatId, {
3712
+ format: "text",
3713
+ content: text,
3714
+ ...trigger ? { inReplyTo: trigger.messageId } : {},
3715
+ ...metadata ? { metadata } : {}
3716
+ });
3717
+ };
3718
+ }
3302
3719
  const REGISTRY_VERSION = 1;
3303
3720
  /**
3304
3721
  * SessionRegistry — persists `chatId → claudeSessionId` mappings to disk.
@@ -3388,6 +3805,14 @@ var SessionManager = class {
3388
3805
  evictedMappings = /* @__PURE__ */ new Map();
3389
3806
  config;
3390
3807
  deduplicator = new Deduplicator(1e3);
3808
+ /**
3809
+ * Current trigger (messageId + senderId) per chat — the message that kicked
3810
+ * off the current or most-recent turn. Read by `forwardResult` (via the
3811
+ * resultSink closure) to attach `inReplyTo` and default mentions to the
3812
+ * outbound reply. Maintained entirely by the runtime: handlers never touch
3813
+ * this map, which keeps adding a new handler trivial.
3814
+ */
3815
+ currentTrigger = /* @__PURE__ */ new Map();
3391
3816
  registry;
3392
3817
  pendingQueue = [];
3393
3818
  lastReportedStates = /* @__PURE__ */ new Map();
@@ -3407,11 +3832,24 @@ var SessionManager = class {
3407
3832
  * Delayed ACK: messages are ACKed when the handler begins processing,
3408
3833
  * not on pull. `delivered` = pulled but not yet processing,
3409
3834
  * `acked` = handler has started processing (read receipt).
3835
+ *
3836
+ * One routing guard fires before any session lookup (see
3837
+ * proposals/hub-agent-messaging-reply-and-mentions §3.5):
3838
+ *
3839
+ * - **Echo suppression** — in direct chats, a peer's reply to a message
3840
+ * *we* sent here but whose `replyTo` points elsewhere would otherwise
3841
+ * bounce our session back on. Suppress it so the reply routes only to
3842
+ * the external chat where we're actually waiting.
3843
+ *
3844
+ * The mention filter used to live here too, but it moved server-side to
3845
+ * the fan-out step — see `services/message.ts sendMessage`. Anything
3846
+ * reaching dispatch has already passed that check.
3410
3847
  */
3411
3848
  async dispatch(entry) {
3412
3849
  const chatId = entry.chatId ?? entry.message.chatId;
3413
3850
  const messageId = entry.message.id;
3414
- if (this.deduplicator.isDuplicate(messageId)) {
3851
+ const dedupKey = `${chatId}:${messageId}`;
3852
+ if (this.deduplicator.isDuplicate(dedupKey)) {
3415
3853
  this.config.log.debug({
3416
3854
  chatId,
3417
3855
  messageId
@@ -3428,6 +3866,14 @@ var SessionManager = class {
3428
3866
  err
3429
3867
  }, "config version mismatch — skipping refresh");
3430
3868
  }
3869
+ if (shouldSuppressEcho(entry, this.config.agentIdentity.agentId)) {
3870
+ this.config.log.info({
3871
+ chatId,
3872
+ messageId
3873
+ }, "suppressing echo — message replies to our own send whose replyTo points elsewhere");
3874
+ await this.ackEntry(entry.id, chatId);
3875
+ return;
3876
+ }
3431
3877
  const message = this.extractMessage(entry);
3432
3878
  await this.routeMessage(chatId, message, entry.id);
3433
3879
  }
@@ -3454,6 +3900,7 @@ var SessionManager = class {
3454
3900
  this.evictedMappings.delete(chatId);
3455
3901
  this.sessionRuntimeStates.delete(chatId);
3456
3902
  this.lastReportedStates.delete(chatId);
3903
+ this.currentTrigger.delete(chatId);
3457
3904
  for (let i = this.pendingQueue.length - 1; i >= 0; i--) if (this.pendingQueue[i]?.chatId === chatId) this.pendingQueue.splice(i, 1);
3458
3905
  this.recomputeRuntimeState();
3459
3906
  this.persistRegistry();
@@ -3524,6 +3971,10 @@ var SessionManager = class {
3524
3971
  }));
3525
3972
  }
3526
3973
  async routeMessage(chatId, message, entryId) {
3974
+ if (message.id) this.currentTrigger.set(chatId, {
3975
+ messageId: message.id,
3976
+ senderId: message.senderId
3977
+ });
3527
3978
  const existing = this.sessions.get(chatId);
3528
3979
  if (existing) switch (existing.status) {
3529
3980
  case "active":
@@ -3712,6 +4163,7 @@ var SessionManager = class {
3712
4163
  }
3713
4164
  this.sessions.delete(candidate.key);
3714
4165
  this.sessionRuntimeStates.delete(candidate.key);
4166
+ this.currentTrigger.delete(candidate.key);
3715
4167
  this.recomputeRuntimeState();
3716
4168
  this.persistRegistry();
3717
4169
  }
@@ -3769,10 +4221,28 @@ var SessionManager = class {
3769
4221
  }
3770
4222
  buildSessionContext(chatId) {
3771
4223
  const sessionLog = this.config.log.child({ chatId });
4224
+ const log = (msg) => sessionLog.info(msg);
4225
+ const participants = createParticipantCache(this.config.sdk, chatId, log);
4226
+ const forwardResult = createResultSink({
4227
+ sdk: this.config.sdk,
4228
+ agent: this.config.agentIdentity,
4229
+ chatId,
4230
+ getTrigger: () => this.currentTrigger.get(chatId) ?? null,
4231
+ clearTrigger: () => {
4232
+ this.currentTrigger.delete(chatId);
4233
+ },
4234
+ log,
4235
+ participants
4236
+ });
4237
+ const envCtx = {
4238
+ sdk: this.config.sdk,
4239
+ agent: this.config.agentIdentity,
4240
+ chatId
4241
+ };
3772
4242
  return {
3773
4243
  agent: this.config.agentIdentity,
3774
4244
  sdk: this.config.sdk,
3775
- log: (msg) => sessionLog.info(msg),
4245
+ log,
3776
4246
  chatId,
3777
4247
  touch: () => {
3778
4248
  const entry = this.sessions.get(chatId);
@@ -3786,7 +4256,11 @@ var SessionManager = class {
3786
4256
  },
3787
4257
  reportSessionCompletion: () => {
3788
4258
  this.config.onSessionCompletion?.(chatId);
3789
- }
4259
+ },
4260
+ forwardResult,
4261
+ buildAgentEnv: (parentEnv) => buildAgentEnv(parentEnv, envCtx),
4262
+ formatInboundContent: (message) => formatInboundContent(message, participants),
4263
+ resolveSenderLabel: async (senderId) => resolveSenderLabel(senderId, await participants.get())
3790
4264
  };
3791
4265
  }
3792
4266
  /** Update per-session runtime state and recompute aggregate. Only active sessions may update. */
@@ -3849,6 +4323,35 @@ var SessionManager = class {
3849
4323
  this.registry.save(entries);
3850
4324
  }
3851
4325
  };
4326
+ /**
4327
+ * Core echo rule: a reply to a message *we* sent in this same chat, whose
4328
+ * original carried a `replyTo` pointing to a *different* chat, must not wake
4329
+ * our session on this side. Server-side replyTo routing already delivers a
4330
+ * second entry in the target chat, so suppressing the fan-out copy here
4331
+ * leaves exactly one path from peer's reply to our waiting session.
4332
+ *
4333
+ * The four early-returns spell out "when this is NOT an echo":
4334
+ * - no snapshot → just a regular message, not a reply
4335
+ * - sender isn't us → replying to someone else's message, clearly not an echo
4336
+ * - original chat != this chat
4337
+ * → the reply arrived in a chat where we never sent the original;
4338
+ * could only happen via replyTo fan-out of a different thread, so
4339
+ * suppressing would silence a legit cross-chat handoff
4340
+ * - original had no replyTo → sender didn't ask replies to route away, so
4341
+ * the peer's reply here is the canonical path
4342
+ *
4343
+ * Only when all four are satisfied AND the replyTo target is a different
4344
+ * chat do we suppress — that's exactly proposal §3.5 Case A.
4345
+ */
4346
+ function shouldSuppressEcho(entry, myAgentId) {
4347
+ const snapshot = entry.message.inReplyToSnapshot;
4348
+ if (!snapshot) return false;
4349
+ const entryChatId = entry.chatId ?? entry.message.chatId;
4350
+ if (snapshot.senderId !== myAgentId) return false;
4351
+ if (snapshot.chatId !== entryChatId) return false;
4352
+ if (snapshot.replyToChat === null) return false;
4353
+ return snapshot.replyToChat !== entryChatId;
4354
+ }
3852
4355
  var AgentSlot = class {
3853
4356
  sessionManager = null;
3854
4357
  config;
@@ -3944,6 +4447,7 @@ var AgentSlot = class {
3944
4447
  },
3945
4448
  agentIdentity: {
3946
4449
  agentId: agent.agentId,
4450
+ inboxId: agent.inboxId,
3947
4451
  displayName: agent.displayName,
3948
4452
  type: agent.type,
3949
4453
  delegateMention: agent.delegateMention,
@@ -4276,6 +4780,27 @@ const print = {
4276
4780
  line
4277
4781
  };
4278
4782
  //#endregion
4783
+ //#region src/core/agent-messaging.ts
4784
+ /**
4785
+ * Resolve `replyTo` envelope fields for `agent send`. When the CLI is invoked
4786
+ * from inside a claude-code session (the handler exports
4787
+ * `FIRST_TREE_HUB_CHAT_ID` + `FIRST_TREE_HUB_INBOX_ID`), default the reply
4788
+ * target to the calling session's own chat so the peer's reply routes back
4789
+ * to the caller rather than echoing in the peer-created direct chat.
4790
+ *
4791
+ * Explicit `--reply-to-*` flags always win. See proposals/
4792
+ * hub-agent-messaging-reply-and-mentions §3.2.
4793
+ */
4794
+ function resolveReplyToFromEnv(env, override) {
4795
+ const envChatId = env.FIRST_TREE_HUB_CHAT_ID;
4796
+ const envInboxId = env.FIRST_TREE_HUB_INBOX_ID;
4797
+ const envComplete = Boolean(envChatId && envInboxId);
4798
+ return {
4799
+ replyToInbox: override.replyToInbox ?? (envComplete ? envInboxId : void 0),
4800
+ replyToChat: override.replyToChat ?? (envComplete ? envChatId : void 0)
4801
+ };
4802
+ }
4803
+ //#endregion
4279
4804
  //#region src/core/admin.ts
4280
4805
  /**
4281
4806
  * Check if any user exists.
@@ -4356,6 +4881,96 @@ function makeUuidV7() {
4356
4881
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
4357
4882
  }
4358
4883
  //#endregion
4884
+ //#region src/core/client-reidentify.ts
4885
+ /**
4886
+ * Handle a `CLIENT_ORG_MISMATCH` from the server by rotating the local
4887
+ * `client.id` in `client.yaml`. The server binds every client to one org for
4888
+ * its lifetime; when the user's credentials move to a different org, the old
4889
+ * clientId becomes unusable and a new one must be issued locally. The old
4890
+ * yaml is preserved as `client.yaml.bak` so the operator can recover or
4891
+ * audit the previous identity.
4892
+ *
4893
+ * Returns the generated clientId. The caller is expected to reset the config
4894
+ * singleton and re-run its initialization so the new id takes effect.
4895
+ */
4896
+ function rotateClientIdWithBackup(configDir) {
4897
+ const yamlPath = join(configDir, "client.yaml");
4898
+ const backupPath = join(configDir, "client.yaml.bak");
4899
+ if (!existsSync(yamlPath)) throw new Error(`Cannot rotate client id — ${yamlPath} does not exist.`);
4900
+ const raw = readFileSync(yamlPath, "utf-8");
4901
+ copyFileSync(yamlPath, backupPath);
4902
+ const parsed = parse(raw);
4903
+ const current = typeof parsed === "object" && parsed !== null ? parsed : {};
4904
+ const clientSection = typeof current.client === "object" && current.client !== null ? current.client : {};
4905
+ const oldId = typeof clientSection.id === "string" ? clientSection.id : null;
4906
+ const newId = `client_${randomBytes(4).toString("hex")}`;
4907
+ writeFileSync(yamlPath, stringify({
4908
+ ...current,
4909
+ client: {
4910
+ ...clientSection,
4911
+ id: newId
4912
+ }
4913
+ }), { mode: 384 });
4914
+ return {
4915
+ oldId,
4916
+ newId,
4917
+ backupPath,
4918
+ yamlPath
4919
+ };
4920
+ }
4921
+ /**
4922
+ * Shared handler for `CLIENT_ORG_MISMATCH` across CLI entry points
4923
+ * (`client start` and `client connect --no-service`). Prompts interactively,
4924
+ * rotates the local clientId, and always exits the current process — the
4925
+ * runtime is already poisoned (wrong clientId in memory), so continuing
4926
+ * in-band is not safe. Service-supervised (managed) runs skip the prompt and
4927
+ * leave an audit trail in pino so operators can trace `.bak` files later.
4928
+ *
4929
+ * Exits with:
4930
+ * - 0 after a successful rotate (operator is told how to re-run).
4931
+ * - 1 if the user declines or rotation itself fails.
4932
+ */
4933
+ async function handleClientOrgMismatch(err, opts) {
4934
+ print.blank();
4935
+ print.line(" ⚠️ This machine is registered as a client in a different organization.\n");
4936
+ print.line(` Server message: ${err.message}\n`);
4937
+ print.blank();
4938
+ if (!(opts.managed ? true : await confirm({
4939
+ message: "Rotate the local client identity and register fresh?",
4940
+ default: true
4941
+ }).catch(() => false))) {
4942
+ print.line(" Aborted — no changes made.\n");
4943
+ process.exit(1);
4944
+ }
4945
+ try {
4946
+ const { oldId, newId, backupPath } = rotateClientIdWithBackup(opts.configDir);
4947
+ if (opts.managed) createLogger("client").warn({
4948
+ oldId,
4949
+ newId,
4950
+ backupPath
4951
+ }, "client identity rotated on CLIENT_ORG_MISMATCH (managed mode)");
4952
+ print.blank();
4953
+ print.line(` ✓ Rotated local client identity.\n`);
4954
+ print.line(` old clientId: ${oldId ?? "(unset)"}\n`);
4955
+ print.line(` new clientId: ${newId}\n`);
4956
+ print.line(` previous yaml backed up to: ${backupPath}\n`);
4957
+ print.blank();
4958
+ print.line(" Note: the old client remains in the previous org. That org's admin\n");
4959
+ print.line(" can remove it if cleanup is needed.\n");
4960
+ print.blank();
4961
+ if (opts.managed) print.line(" The background service will pick up the new identity on its next restart.\n\n");
4962
+ else {
4963
+ print.line(" To reconnect with the new identity, run:\n\n");
4964
+ print.line(` ${opts.rerunCommand}\n\n`);
4965
+ }
4966
+ process.exit(0);
4967
+ } catch (rotateErr) {
4968
+ const rmsg = rotateErr instanceof Error ? rotateErr.message : String(rotateErr);
4969
+ print.line(` Failed to rotate client identity: ${rmsg}\n`);
4970
+ process.exit(1);
4971
+ }
4972
+ }
4973
+ //#endregion
4359
4974
  //#region src/core/client-runtime.ts
4360
4975
  /**
4361
4976
  * Client runtime — one shared ClientConnection, multiple agents multiplexed.
@@ -5664,7 +6279,7 @@ async function onboardCreate(args) {
5664
6279
  }
5665
6280
  const runtimeAgent = args.type === "human" ? args.assistant : args.id;
5666
6281
  if (args.feishuBotAppId && args.feishuBotAppSecret) {
5667
- const { bindFeishuBot } = await import("./feishu-GlaczcVf.mjs").then((n) => n.r);
6282
+ const { bindFeishuBot } = await import("./feishu-B1Kiq7S6.mjs").then((n) => n.r);
5668
6283
  const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
5669
6284
  if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
5670
6285
  else {
@@ -5737,75 +6352,832 @@ async function promptMissingFields(options) {
5737
6352
  setNestedByDot(results, dotPath, value);
5738
6353
  }
5739
6354
  }
5740
- return results;
5741
- }
5742
- /**
5743
- * Interactive add agent — simple two-field prompt.
5744
- */
5745
- async function promptAddAgent() {
5746
- return {
5747
- name: await input({
5748
- message: "Local alias:",
5749
- validate: (v) => /^[a-z0-9][a-z0-9-]*$/.test(v) ? true : "Lowercase alphanumeric and hyphens only"
5750
- }),
5751
- agentId: await input({
5752
- message: "Agent UUID on the Hub:",
5753
- validate: (v) => v.length > 0 ? true : "Agent UUID is required"
5754
- })
5755
- };
5756
- }
5757
- async function askPrompt(dotPath, prompt) {
5758
- const type = prompt.type ?? "input";
5759
- if (type === "select" && prompt.choices) {
5760
- const value = await select({
5761
- message: prompt.message,
5762
- choices: prompt.choices.map((c) => ({
5763
- name: c.name,
5764
- value: c.value
5765
- }))
5766
- });
5767
- if (value === "__auto__") return void 0;
5768
- if (value === "__input__") return input({
5769
- message: `${dotPath}:`,
5770
- validate: (v) => v.length > 0 ? true : "Value is required"
6355
+ return results;
6356
+ }
6357
+ /**
6358
+ * Interactive add agent — simple two-field prompt.
6359
+ */
6360
+ async function promptAddAgent() {
6361
+ return {
6362
+ name: await input({
6363
+ message: "Local alias:",
6364
+ validate: (v) => /^[a-z0-9][a-z0-9-]*$/.test(v) ? true : "Lowercase alphanumeric and hyphens only"
6365
+ }),
6366
+ agentId: await input({
6367
+ message: "Agent UUID on the Hub:",
6368
+ validate: (v) => v.length > 0 ? true : "Agent UUID is required"
6369
+ })
6370
+ };
6371
+ }
6372
+ async function askPrompt(dotPath, prompt) {
6373
+ const type = prompt.type ?? "input";
6374
+ if (type === "select" && prompt.choices) {
6375
+ const value = await select({
6376
+ message: prompt.message,
6377
+ choices: prompt.choices.map((c) => ({
6378
+ name: c.name,
6379
+ value: c.value
6380
+ }))
6381
+ });
6382
+ if (value === "__auto__") return void 0;
6383
+ if (value === "__input__") return input({
6384
+ message: `${dotPath}:`,
6385
+ validate: (v) => v.length > 0 ? true : "Value is required"
6386
+ });
6387
+ return value;
6388
+ }
6389
+ if (type === "password") return password({ message: prompt.message });
6390
+ return input({
6391
+ message: prompt.message,
6392
+ default: prompt.default
6393
+ });
6394
+ }
6395
+ /** Walk schema to find the env var name for a given dot path. */
6396
+ function findEnvVar(schema, dotPath) {
6397
+ const parts = dotPath.split(".");
6398
+ let current = schema;
6399
+ for (const part of parts) {
6400
+ if (current === null || current === void 0 || typeof current !== "object") return void 0;
6401
+ const obj = current;
6402
+ if (obj._tag === "optional") current = obj.shape[part];
6403
+ else current = obj[part];
6404
+ }
6405
+ if (typeof current === "object" && current !== null && "_tag" in current) {
6406
+ const field = current;
6407
+ if (field._tag === "field") return field.options?.env;
6408
+ }
6409
+ }
6410
+ function setNestedByDot(obj, dotPath, value) {
6411
+ const parts = dotPath.split(".");
6412
+ let current = obj;
6413
+ for (let i = 0; i < parts.length - 1; i++) {
6414
+ const key = parts[i];
6415
+ if (key === void 0) continue;
6416
+ if (!(key in current) || typeof current[key] !== "object" || current[key] === null) current[key] = {};
6417
+ current = current[key];
6418
+ }
6419
+ const lastKey = parts.at(-1);
6420
+ if (lastKey !== void 0) current[lastKey] = value;
6421
+ }
6422
+ //#endregion
6423
+ //#region ../../node_modules/.pnpm/hearback-core@0.1.1/node_modules/hearback-core/dist/schema.js
6424
+ const FeedbackType = z.enum(["bug", "feature"]);
6425
+ const BrowserContext = z.object({
6426
+ url: z.string().optional(),
6427
+ userAgent: z.string().optional(),
6428
+ browser: z.string().optional(),
6429
+ os: z.string().optional(),
6430
+ viewport: z.string().optional()
6431
+ });
6432
+ const ConsoleError = z.object({
6433
+ message: z.string(),
6434
+ stack: z.string().optional(),
6435
+ timestamp: z.string().optional()
6436
+ });
6437
+ const FailedRequest = z.object({
6438
+ method: z.string(),
6439
+ url: z.string(),
6440
+ status: z.number().optional(),
6441
+ statusText: z.string().optional()
6442
+ });
6443
+ const AgentContext = z.object({
6444
+ toolCalls: z.array(z.object({
6445
+ name: z.string(),
6446
+ error: z.string().optional()
6447
+ })).optional(),
6448
+ errorTraces: z.array(z.string()).optional(),
6449
+ environment: z.record(z.string()).optional()
6450
+ });
6451
+ const FeedbackContext = z.object({
6452
+ source: z.enum(["web-plugin", "agent-skill"]),
6453
+ browser: BrowserContext.optional(),
6454
+ consoleErrors: z.array(ConsoleError).optional(),
6455
+ failedRequests: z.array(FailedRequest).optional(),
6456
+ agent: AgentContext.optional(),
6457
+ screenshotUrl: z.string().optional(),
6458
+ timestamp: z.string().optional(),
6459
+ extra: z.record(z.string()).optional()
6460
+ });
6461
+ const ReporterInfo = z.object({ email: z.string().email().optional() });
6462
+ const FeedbackReport = z.object({
6463
+ type: FeedbackType,
6464
+ title: z.string().min(5, "Title must be at least 5 characters"),
6465
+ description: z.string(),
6466
+ context: FeedbackContext,
6467
+ reporter: ReporterInfo.optional()
6468
+ });
6469
+ //#endregion
6470
+ //#region ../../node_modules/.pnpm/hearback-core@0.1.1/node_modules/hearback-core/dist/errors.js
6471
+ var HearbackError = class extends Error {
6472
+ code;
6473
+ constructor(message, code) {
6474
+ super(message);
6475
+ this.code = code;
6476
+ this.name = "HearbackError";
6477
+ }
6478
+ };
6479
+ var GitHubAuthError = class extends HearbackError {
6480
+ constructor(status, message, url = "") {
6481
+ let hint;
6482
+ if (status === 401) hint = "GitHub token is missing or expired.";
6483
+ else if (status === 403) if (url.includes("/contents/") || url.includes("/git/refs")) hint = "Token lacks \"contents:write\" scope. Needed for screenshot uploads and saving subscribers to the hearback-data branch.";
6484
+ else if (url.includes("/issues") || url.includes("/labels")) hint = "Token lacks \"issues:write\" scope. Needed to create issues, labels, and reactions.";
6485
+ else hint = `GitHub returned 403 (forbidden) for ${url || "API call"}. Check token permissions.`;
6486
+ else hint = `GitHub API returned ${status}: ${message}`;
6487
+ super(hint, "GITHUB_AUTH_ERROR");
6488
+ }
6489
+ };
6490
+ var GitHubRateLimitError = class extends HearbackError {
6491
+ resetAt;
6492
+ constructor(resetAt) {
6493
+ super(`GitHub API rate limit exceeded. Resets at ${resetAt.toISOString()}`, "GITHUB_RATE_LIMIT");
6494
+ this.resetAt = resetAt;
6495
+ }
6496
+ };
6497
+ //#endregion
6498
+ //#region ../../node_modules/.pnpm/hearback-core@0.1.1/node_modules/hearback-core/dist/github.js
6499
+ function parseRepo(repo) {
6500
+ const parts = repo.split("/");
6501
+ if (parts.length !== 2 || !parts[0] || !parts[1]) throw new HearbackError(`Invalid repo format: "${repo}". Expected "owner/name".`, "INVALID_REPO");
6502
+ return {
6503
+ owner: parts[0],
6504
+ name: parts[1]
6505
+ };
6506
+ }
6507
+ async function githubFetch(config, path, options = {}) {
6508
+ const base = config.apiBase ?? "https://api.github.com";
6509
+ const url = path.startsWith("http") ? path : `${base}${path}`;
6510
+ const res = await fetch(url, {
6511
+ ...options,
6512
+ headers: {
6513
+ Accept: "application/vnd.github.v3+json",
6514
+ Authorization: `Bearer ${config.token}`,
6515
+ "User-Agent": "hearback",
6516
+ ...options.headers
6517
+ }
6518
+ });
6519
+ if (res.status === 401 || res.status === 403) throw new GitHubAuthError(res.status, await res.text(), path);
6520
+ if (res.status === 429) {
6521
+ const resetHeader = res.headers.get("x-ratelimit-reset");
6522
+ throw new GitHubRateLimitError(resetHeader ? /* @__PURE__ */ new Date(Number(resetHeader) * 1e3) : /* @__PURE__ */ new Date());
6523
+ }
6524
+ return res;
6525
+ }
6526
+ async function createIssue(config, params) {
6527
+ const { owner, name } = parseRepo(config.repo);
6528
+ const res = await githubFetch(config, `/repos/${owner}/${name}/issues`, {
6529
+ method: "POST",
6530
+ body: JSON.stringify(params),
6531
+ headers: { "Content-Type": "application/json" }
6532
+ });
6533
+ if (!res.ok) throw new HearbackError(`Failed to create issue: ${res.status} ${await res.text()}`, "GITHUB_CREATE_ISSUE_FAILED");
6534
+ return res.json();
6535
+ }
6536
+ async function searchIssues(config, query) {
6537
+ const { owner, name } = parseRepo(config.repo);
6538
+ const res = await githubFetch(config, `/search/issues?q=${encodeURIComponent(`${query} repo:${owner}/${name} is:issue label:hearback`)}&per_page=5`);
6539
+ if (!res.ok) return {
6540
+ total_count: 0,
6541
+ items: []
6542
+ };
6543
+ return res.json();
6544
+ }
6545
+ async function addReaction(config, issueNumber, reaction = "+1") {
6546
+ const { owner, name } = parseRepo(config.repo);
6547
+ const res = await githubFetch(config, `/repos/${owner}/${name}/issues/${issueNumber}/reactions`, {
6548
+ method: "POST",
6549
+ body: JSON.stringify({ content: reaction }),
6550
+ headers: { "Content-Type": "application/json" }
6551
+ });
6552
+ if (!res.ok && res.status !== 422) throw new HearbackError(`Failed to add reaction: ${res.status}`, "GITHUB_REACTION_FAILED");
6553
+ }
6554
+ /** Read a file from a repo branch. Returns null if not found. */
6555
+ async function getRepoFile(config, path, branch) {
6556
+ const { owner, name } = parseRepo(config.repo);
6557
+ const res = await githubFetch(config, `/repos/${owner}/${name}/contents/${path}${branch ? `?ref=${branch}` : ""}`);
6558
+ if (res.status === 404) return null;
6559
+ if (!res.ok) throw new HearbackError(`Failed to read file ${path}: ${res.status}`, "GITHUB_READ_FILE_FAILED");
6560
+ const data = await res.json();
6561
+ return {
6562
+ content: data.encoding === "base64" ? Buffer.from(data.content, "base64").toString("utf-8") : data.content,
6563
+ sha: data.sha
6564
+ };
6565
+ }
6566
+ /** Write (create or update) a file on a repo branch. Requires sha for updates. */
6567
+ async function putRepoFile(config, params) {
6568
+ const { owner, name } = parseRepo(config.repo);
6569
+ const base64 = Buffer.isBuffer(params.content) ? params.content.toString("base64") : Buffer.from(params.content, "utf-8").toString("base64");
6570
+ const body = {
6571
+ message: params.message,
6572
+ content: base64
6573
+ };
6574
+ if (params.branch) body.branch = params.branch;
6575
+ if (params.sha) body.sha = params.sha;
6576
+ const res = await githubFetch(config, `/repos/${owner}/${name}/contents/${params.path}`, {
6577
+ method: "PUT",
6578
+ body: JSON.stringify(body),
6579
+ headers: { "Content-Type": "application/json" }
6580
+ });
6581
+ if (!res.ok) throw new HearbackError(`Failed to write file ${params.path}: ${res.status}`, "GITHUB_WRITE_FILE_FAILED");
6582
+ const data = await res.json();
6583
+ return {
6584
+ downloadUrl: data.content.download_url,
6585
+ htmlUrl: data.content.html_url,
6586
+ sha: data.content.sha
6587
+ };
6588
+ }
6589
+ /** Ensure a branch exists; creates it from default branch (main/master) if missing. */
6590
+ async function ensureBranch(config, branchName) {
6591
+ const { owner, name } = parseRepo(config.repo);
6592
+ const checkRes = await githubFetch(config, `/repos/${owner}/${name}/git/refs/heads/${branchName}`);
6593
+ if (checkRes.ok) return;
6594
+ if (checkRes.status !== 404) throw new HearbackError(`Failed to check branch: ${checkRes.status}`, "GITHUB_CHECK_BRANCH_FAILED");
6595
+ const repoRes = await githubFetch(config, `/repos/${owner}/${name}`);
6596
+ if (!repoRes.ok) throw new HearbackError(`Failed to read repo: ${repoRes.status}`, "GITHUB_READ_REPO_FAILED");
6597
+ const defaultRefRes = await githubFetch(config, `/repos/${owner}/${name}/git/refs/heads/${(await repoRes.json()).default_branch}`);
6598
+ if (!defaultRefRes.ok) throw new HearbackError(`Failed to read default branch: ${defaultRefRes.status}`, "GITHUB_READ_DEFAULT_BRANCH_FAILED");
6599
+ const defaultRef = await defaultRefRes.json();
6600
+ const createRes = await githubFetch(config, `/repos/${owner}/${name}/git/refs`, {
6601
+ method: "POST",
6602
+ body: JSON.stringify({
6603
+ ref: `refs/heads/${branchName}`,
6604
+ sha: defaultRef.object.sha
6605
+ }),
6606
+ headers: { "Content-Type": "application/json" }
6607
+ });
6608
+ if (!createRes.ok && createRes.status !== 422) throw new HearbackError(`Failed to create branch: ${createRes.status}`, "GITHUB_CREATE_BRANCH_FAILED");
6609
+ }
6610
+ async function uploadImage(config, imageData, filename) {
6611
+ const { downloadUrl } = await putRepoFile(config, {
6612
+ path: `_hearback-uploads/${Date.now()}-${filename}`,
6613
+ content: imageData,
6614
+ message: `[hearback] upload screenshot: ${filename}`
6615
+ });
6616
+ const tokenIdx = downloadUrl.indexOf("?token=");
6617
+ return tokenIdx === -1 ? downloadUrl : downloadUrl.slice(0, tokenIdx);
6618
+ }
6619
+ async function ensureLabels(config, labels) {
6620
+ const { owner, name } = parseRepo(config.repo);
6621
+ for (const label of labels) {
6622
+ const color = getLabelColor(label);
6623
+ const res = await githubFetch(config, `/repos/${owner}/${name}/labels`, {
6624
+ method: "POST",
6625
+ body: JSON.stringify({
6626
+ name: label,
6627
+ color
6628
+ }),
6629
+ headers: { "Content-Type": "application/json" }
6630
+ });
6631
+ if (!res.ok && res.status !== 422) throw new HearbackError(`Failed to create label "${label}": ${res.status}`, "GITHUB_LABEL_FAILED");
6632
+ }
6633
+ }
6634
+ function getLabelColor(label) {
6635
+ return label === "hearback" ? "F59E0B" : "CCCCCC";
6636
+ }
6637
+ //#endregion
6638
+ //#region ../../node_modules/.pnpm/hearback-core@0.1.1/node_modules/hearback-core/dist/formatter.js
6639
+ function formatIssueBody(report) {
6640
+ const sections = [];
6641
+ const heading = report.type === "bug" ? "Bug Report" : "Feature Request";
6642
+ sections.push(`## ${heading}`);
6643
+ sections.push(`**描述**: ${report.description}`);
6644
+ if (report.context.screenshotUrl) sections.push(`\n**截图**:\n![screenshot](${report.context.screenshotUrl})`);
6645
+ sections.push(formatContextTable(report));
6646
+ if (report.context.consoleErrors?.length) sections.push(formatConsoleErrors(report.context.consoleErrors));
6647
+ if (report.context.failedRequests?.length) sections.push(formatFailedRequests(report.context.failedRequests));
6648
+ if (report.context.agent?.toolCalls?.length) sections.push(formatToolCalls(report.context.agent.toolCalls));
6649
+ if (report.context.agent?.errorTraces?.length) sections.push(formatErrorTraces(report.context.agent.errorTraces));
6650
+ return sections.join("\n\n");
6651
+ }
6652
+ function formatContextTable(report) {
6653
+ const rows = [];
6654
+ rows.push(["来源", report.context.source]);
6655
+ if (report.context.browser?.url) rows.push(["页面", report.context.browser.url]);
6656
+ if (report.context.browser?.browser && report.context.browser?.os) rows.push(["浏览器", `${report.context.browser.browser} / ${report.context.browser.os}`]);
6657
+ else if (report.context.browser?.userAgent) rows.push(["User Agent", report.context.browser.userAgent]);
6658
+ if (report.context.timestamp) rows.push(["时间", report.context.timestamp]);
6659
+ if (report.context.extra) for (const [key, value] of Object.entries(report.context.extra)) rows.push([key, value]);
6660
+ if (rows.length === 0) return "";
6661
+ return [
6662
+ "### 自动收集的上下文",
6663
+ "",
6664
+ "| 字段 | 值 |",
6665
+ "|------|-----|",
6666
+ ...rows.map(([k, v]) => `| ${k} | ${v} |`)
6667
+ ].join("\n");
6668
+ }
6669
+ function formatConsoleErrors(errors) {
6670
+ return [
6671
+ "<details>",
6672
+ "<summary>Console Errors</summary>",
6673
+ "",
6674
+ ...errors.map((e) => {
6675
+ let entry = e.message;
6676
+ if (e.stack) entry += `\n ${e.stack}`;
6677
+ return entry;
6678
+ }),
6679
+ "",
6680
+ "</details>"
6681
+ ].join("\n");
6682
+ }
6683
+ function formatFailedRequests(requests) {
6684
+ return [
6685
+ "<details>",
6686
+ "<summary>Failed Network Requests</summary>",
6687
+ "",
6688
+ ...requests.map((r) => {
6689
+ let entry = `${r.method} ${r.url}`;
6690
+ if (r.status) entry += ` → ${r.status}`;
6691
+ if (r.statusText) entry += ` ${r.statusText}`;
6692
+ return entry;
6693
+ }),
6694
+ "",
6695
+ "</details>"
6696
+ ].join("\n");
6697
+ }
6698
+ function formatToolCalls(calls) {
6699
+ return [
6700
+ "<details>",
6701
+ "<summary>Tool Calls</summary>",
6702
+ "",
6703
+ ...calls.map((c) => {
6704
+ let entry = `- \`${c.name}\``;
6705
+ if (c.error) entry += ` — error: ${c.error}`;
6706
+ return entry;
6707
+ }),
6708
+ "",
6709
+ "</details>"
6710
+ ].join("\n");
6711
+ }
6712
+ function formatErrorTraces(traces) {
6713
+ return [
6714
+ "<details>",
6715
+ "<summary>Error Traces</summary>",
6716
+ "",
6717
+ ...traces.map((t) => `\`\`\`\n${t}\n\`\`\``),
6718
+ "",
6719
+ "</details>"
6720
+ ].join("\n");
6721
+ }
6722
+ const SUBSCRIBERS_PATH = "subscribers.json";
6723
+ function normalize(email) {
6724
+ return email.toLowerCase().trim();
6725
+ }
6726
+ async function readSubscribers(config, branch) {
6727
+ const file = await getRepoFile(config, SUBSCRIBERS_PATH, branch);
6728
+ if (!file) return { data: {} };
6729
+ try {
6730
+ return {
6731
+ data: JSON.parse(file.content),
6732
+ sha: file.sha
6733
+ };
6734
+ } catch {
6735
+ return {
6736
+ data: {},
6737
+ sha: file.sha
6738
+ };
6739
+ }
6740
+ }
6741
+ async function writeSubscribers(config, branch, data, sha, message) {
6742
+ await putRepoFile(config, {
6743
+ path: SUBSCRIBERS_PATH,
6744
+ content: JSON.stringify(data, null, 2),
6745
+ message,
6746
+ branch,
6747
+ sha
6748
+ });
6749
+ }
6750
+ /**
6751
+ * Add an email to an issue's subscriber list. Creates the data branch and file
6752
+ * if missing. Dedupes. Retries once on sha conflict (concurrent writes).
6753
+ */
6754
+ async function addSubscriber(config, issueNumber, email, options = {}) {
6755
+ const branch = options.branch ?? "hearback-data";
6756
+ const normalized = normalize(email);
6757
+ const key = String(issueNumber);
6758
+ await ensureBranch(config, branch);
6759
+ for (let attempt = 0; attempt < 2; attempt++) {
6760
+ const { data, sha } = await readSubscribers(config, branch);
6761
+ const existing = data[key] ?? [];
6762
+ if (existing.includes(normalized)) return;
6763
+ const updated = {
6764
+ ...data,
6765
+ [key]: [...existing, normalized]
6766
+ };
6767
+ try {
6768
+ await writeSubscribers(config, branch, updated, sha, `[hearback] add subscriber to #${issueNumber}`);
6769
+ return;
6770
+ } catch (err) {
6771
+ if (err instanceof HearbackError && err.code === "GITHUB_WRITE_FILE_FAILED" && attempt === 0) continue;
6772
+ throw err;
6773
+ }
6774
+ }
6775
+ }
6776
+ //#endregion
6777
+ //#region ../../node_modules/.pnpm/hearback-core@0.1.1/node_modules/hearback-core/dist/duplicates.js
6778
+ /**
6779
+ * Sanitize title for GitHub search. Removes characters that would break
6780
+ * search query syntax (quotes, colons, slashes used as operators) and
6781
+ * caps length. GitHub search handles stopwords and tokenization internally,
6782
+ * so we pass the cleaned title through directly.
6783
+ */
6784
+ function sanitizeTitle(title) {
6785
+ return title.replace(/["':/]/g, " ").replace(/\s+/g, " ").trim().slice(0, 100);
6786
+ }
6787
+ async function findDuplicates(config, title) {
6788
+ const query = sanitizeTitle(title);
6789
+ if (!query) return [];
6790
+ return (await searchIssues(config, query)).items.map((issue) => ({
6791
+ issueNumber: issue.number,
6792
+ title: issue.title,
6793
+ state: issue.state,
6794
+ url: issue.html_url
6795
+ }));
6796
+ }
6797
+ //#endregion
6798
+ //#region ../../node_modules/.pnpm/hearback-core@0.1.1/node_modules/hearback-core/dist/submit.js
6799
+ async function checkForDuplicates(config, title) {
6800
+ return findDuplicates(config, title);
6801
+ }
6802
+ async function subscribeToDuplicate(config, issueNumber, email) {
6803
+ await addReaction(config, issueNumber);
6804
+ await addSubscriber(config, issueNumber, email);
6805
+ }
6806
+ async function submitFeedback(options) {
6807
+ const { github, report } = options;
6808
+ FeedbackReport.parse(report);
6809
+ const labels = ["hearback"];
6810
+ if (report.type === "bug") labels.push("bug");
6811
+ if (report.type === "feature") labels.push("enhancement");
6812
+ await ensureLabels(github, labels);
6813
+ const body = formatIssueBody(report);
6814
+ const issue = await createIssue(github, {
6815
+ title: report.title,
6816
+ body,
6817
+ labels
6818
+ });
6819
+ let subscribeWarning;
6820
+ if (report.reporter?.email) try {
6821
+ await addSubscriber(github, issue.number, report.reporter.email);
6822
+ } catch (err) {
6823
+ subscribeWarning = `Issue created, but reporter email could not be saved (${err instanceof Error ? err.message : String(err)}). Hint: GitHub token needs "contents:write" scope to write the hearback-data branch for email notifications.`;
6824
+ console.warn(`[hearback] ${subscribeWarning}`);
6825
+ }
6826
+ return {
6827
+ issue,
6828
+ isDuplicate: false,
6829
+ subscribeWarning
6830
+ };
6831
+ }
6832
+ //#endregion
6833
+ //#region ../../node_modules/.pnpm/hearback-server@0.1.6/node_modules/hearback-server/dist/rate-limit.js
6834
+ var RateLimiter = class {
6835
+ submissions = /* @__PURE__ */ new Map();
6836
+ chatMessages = /* @__PURE__ */ new Map();
6837
+ config;
6838
+ constructor(config) {
6839
+ this.config = {
6840
+ maxSubmissions: config?.maxSubmissions ?? 10,
6841
+ maxChatMessages: config?.maxChatMessages ?? 30,
6842
+ windowMs: config?.windowMs ?? 36e5
6843
+ };
6844
+ }
6845
+ checkSubmission(ip) {
6846
+ return this.check(this.submissions, ip, this.config.maxSubmissions);
6847
+ }
6848
+ checkChatMessage(ip) {
6849
+ return this.check(this.chatMessages, ip, this.config.maxChatMessages);
6850
+ }
6851
+ check(bucket, ip, max) {
6852
+ const now = Date.now();
6853
+ const entry = bucket.get(ip);
6854
+ if (!entry || now >= entry.resetAt) {
6855
+ bucket.set(ip, {
6856
+ count: 1,
6857
+ resetAt: now + this.config.windowMs
6858
+ });
6859
+ return { allowed: true };
6860
+ }
6861
+ if (entry.count >= max) return {
6862
+ allowed: false,
6863
+ retryAfterMs: entry.resetAt - now
6864
+ };
6865
+ entry.count++;
6866
+ return { allowed: true };
6867
+ }
6868
+ };
6869
+ //#endregion
6870
+ //#region ../../node_modules/.pnpm/hearback-server@0.1.6/node_modules/hearback-server/dist/chat-prompt.js
6871
+ const FEEDBACK_CHAT_SYSTEM_PROMPT = `You are a feedback assistant. Help users report bugs or suggest features. Reply in the user's language.
6872
+
6873
+ ## Don't make users repeat themselves
6874
+ Use details from earlier messages in this conversation. Don't ask for things the user has already told you.
6875
+
6876
+ ## Default behavior
6877
+ - One sentence per reply. One question per turn. Never stack questions.
6878
+ - If the user's first message already contains symptom + specifics, SKIP questioning and go straight to the summary.
6879
+
6880
+ ## Bugs — ask ONLY what's missing, in this priority
6881
+ 1. Symptom — what did you see?
6882
+ 2. Trigger — always, or specific steps?
6883
+ 3. Expected — only if not obvious from the symptom.
6884
+
6885
+ ## Features — ask ONLY what's missing
6886
+ 1. Use case — what were you trying to do?
6887
+ 2. Workaround — how are you handling it today? (only if not stated)
6888
+
6889
+ ## Stop criterion
6890
+ Stop when you can write a specific title (NOT "bug found") and a description a developer can act on. Usually 1–3 exchanges.
6891
+
6892
+ ## Visual context — ask for a screenshot when it would help
6893
+ A picture is faster than a paragraph when the problem is visual. After the user's initial description, decide whether a screenshot would materially help a developer act on it. Ask once when:
6894
+ - The description mentions anything visual — layout, color, wrong element, "looks weird", position, styling, icon missing.
6895
+ - The description names a specific UI element but is vague about what the user is seeing ("the button didn't work" — a screenshot beats another round of questions).
6896
+ - The description is very short and a picture would fill in the gaps.
6897
+
6898
+ Phrase it as a concrete call to action, not a vague "do you have a screenshot":
6899
+ > "A screenshot would help here. Take one with your OS (Cmd+Shift+Ctrl+4 on Mac / Win+Shift+S on Windows), then paste it in or drag the file into this window."
6900
+
6901
+ Skip the screenshot ask entirely for purely behavioral / backend bugs — API errors, permissions, data loading, login failures. Those don't need a picture.
6902
+
6903
+ Ask at most once. If the user declines, says they can't, or just replies with text, move on with what you have. Don't ask again later in the same conversation.
6904
+
6905
+ ## Attachments already provided
6906
+ When a user message ends with \`[attachment: <name>]\` (or contains that pattern), the user has already attached an image. In that case:
6907
+ - Skip the "ask for a screenshot" step entirely — don't ask for one, don't re-ask even if the description sounds visual.
6908
+ - Treat the attachment as additional context confirming what the user described; the image itself isn't visible to you, but its presence means a developer will see it in the final issue.
6909
+ - Don't mention the marker back to the user.
6910
+
6911
+ ## Optional — email for fix notifications
6912
+ After you have title + summary but BEFORE the summary/confirmation turn, ask once:
6913
+ "Optional: want an email when this is fixed? (Skip to continue anonymously.)"
6914
+ - User provides an address → include it verbatim as \`email\` in the final JSON.
6915
+ - User declines / says "no" / "skip" / "anonymous" / silence → OMIT the \`email\` field entirely (don't emit "" or null).
6916
+ - Never ask twice. If the answer is unclear, treat it as skip.
6917
+ - Don't guess or fabricate an email.
6918
+
6919
+ ## Summary format (before confirmation)
6920
+ > **Title:** …
6921
+ > **Summary:** …
6922
+ > **Email:** … *(only if the user provided one; omit the line otherwise)*
6923
+ >
6924
+ > Submit this?
6925
+
6926
+ ## Confirmation — judge intent, don't match keywords
6927
+ "yes" / "go" / "就这样" / "可以了" / "submit" / "提交" / 👍 — all confirm.
6928
+ "wait" / "先别" / "let me add" — don't. Ambiguous → ask once more.
6929
+
6930
+ ## After confirmation — output ONLY this JSON, nothing else:
6931
+
6932
+ \`\`\`json
6933
+ {"ready":true,"type":"bug","title":"under 60 chars, specific","description":"markdown"}
6934
+ \`\`\`
6935
+
6936
+ \`type\` must be exactly "bug" or "feature" (never "bug_or_feature").
6937
+
6938
+ If (and only if) the user provided an email, add it as \`email\`:
6939
+
6940
+ \`\`\`json
6941
+ {"ready":true,"type":"bug","title":"…","description":"…","email":"user@example.com"}
6942
+ \`\`\`
6943
+
6944
+ Omit the \`email\` key entirely when the user skipped. Don't emit \`"email":""\` or \`"email":null\`.
6945
+
6946
+ Description format (suggested, not strict):
6947
+ - Bug: symptom / repro / expected
6948
+ - Feature: use case / proposed / workaround
6949
+
6950
+ Include the user's own words where they matter.
6951
+
6952
+ ## Hard rules
6953
+ - Never output JSON before confirmation. If unsure, ask once more.
6954
+ - If the user pastes a token / password / API key / secret, warn them and OMIT it from the report.
6955
+ - "Nevermind" / "算了" / "forget it" → stop cleanly.
6956
+ `;
6957
+ //#endregion
6958
+ //#region ../../node_modules/.pnpm/hearback-server@0.1.6/node_modules/hearback-server/dist/handler.js
6959
+ function createFeedbackHandler(config) {
6960
+ const rateLimiter = new RateLimiter(config.rateLimit);
6961
+ const ghConfig = {
6962
+ repo: config.repo,
6963
+ token: config.githubToken,
6964
+ apiBase: config.githubApiBase
6965
+ };
6966
+ const corsHeaders = {
6967
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
6968
+ "Access-Control-Allow-Headers": "Content-Type"
6969
+ };
6970
+ const origins = config.corsOrigins ?? ["*"];
6971
+ if (origins.includes("*")) corsHeaders["Access-Control-Allow-Origin"] = "*";
6972
+ function getCorsHeaders(requestOrigin) {
6973
+ if (origins.includes("*")) return corsHeaders;
6974
+ if (requestOrigin && origins.includes(requestOrigin)) return {
6975
+ ...corsHeaders,
6976
+ "Access-Control-Allow-Origin": requestOrigin
6977
+ };
6978
+ return corsHeaders;
6979
+ }
6980
+ async function handle(req) {
6981
+ const cors = getCorsHeaders(req.headers["origin"]);
6982
+ if (req.method === "OPTIONS") return {
6983
+ status: 204,
6984
+ headers: cors,
6985
+ body: null
6986
+ };
6987
+ if (req.method !== "POST") return {
6988
+ status: 405,
6989
+ headers: cors,
6990
+ body: { error: "Method not allowed" }
6991
+ };
6992
+ const route = req.path.replace(/\/$/, "");
6993
+ try {
6994
+ if (route.endsWith("/chat")) return await handleChat(req, cors);
6995
+ if (route.endsWith("/submit")) return await handleSubmit(req, cors);
6996
+ if (route.endsWith("/subscribe")) return await handleSubscribe(req, cors);
6997
+ if (route.endsWith("/upload")) return await handleUpload(req, cors);
6998
+ return {
6999
+ status: 404,
7000
+ headers: cors,
7001
+ body: { error: "Not found" }
7002
+ };
7003
+ } catch (err) {
7004
+ return {
7005
+ status: 500,
7006
+ headers: cors,
7007
+ body: { error: err instanceof Error ? err.message : "Internal error" }
7008
+ };
7009
+ }
7010
+ }
7011
+ async function handleChat(req, cors) {
7012
+ if (!config.llm) return {
7013
+ status: 501,
7014
+ headers: cors,
7015
+ body: { error: "LLM not configured. Use form mode." }
7016
+ };
7017
+ const rateCheck = rateLimiter.checkChatMessage(req.ip);
7018
+ if (!rateCheck.allowed) return {
7019
+ status: 429,
7020
+ headers: {
7021
+ ...cors,
7022
+ "Retry-After": String(Math.ceil((rateCheck.retryAfterMs ?? 36e5) / 1e3))
7023
+ },
7024
+ body: { error: "反馈过多,一小时后再试" }
7025
+ };
7026
+ const body = req.body;
7027
+ if (!body.messages || !Array.isArray(body.messages)) return {
7028
+ status: 400,
7029
+ headers: cors,
7030
+ body: { error: "Missing messages array" }
7031
+ };
7032
+ const messages = body.messages.slice(-10);
7033
+ const baseUrl = config.llm.baseUrl ?? "https://api.openai.com/v1";
7034
+ const model = config.llm.model ?? "gpt-4o-mini";
7035
+ const llmRes = await fetch(`${baseUrl}/chat/completions`, {
7036
+ method: "POST",
7037
+ headers: {
7038
+ "Content-Type": "application/json",
7039
+ Authorization: `Bearer ${config.llm.apiKey}`
7040
+ },
7041
+ body: JSON.stringify({
7042
+ model,
7043
+ messages: [{
7044
+ role: "system",
7045
+ content: FEEDBACK_CHAT_SYSTEM_PROMPT
7046
+ }, ...messages],
7047
+ stream: true
7048
+ })
5771
7049
  });
5772
- return value;
7050
+ if (!llmRes.ok) {
7051
+ const text = await llmRes.text();
7052
+ return {
7053
+ status: 502,
7054
+ headers: cors,
7055
+ body: {
7056
+ error: `LLM error: ${llmRes.status}`,
7057
+ detail: text
7058
+ }
7059
+ };
7060
+ }
7061
+ return {
7062
+ status: 200,
7063
+ headers: {
7064
+ ...cors,
7065
+ "Content-Type": "text/event-stream",
7066
+ "Cache-Control": "no-cache",
7067
+ Connection: "keep-alive"
7068
+ },
7069
+ body: llmRes.body
7070
+ };
5773
7071
  }
5774
- if (type === "password") return password({ message: prompt.message });
5775
- return input({
5776
- message: prompt.message,
5777
- default: prompt.default
5778
- });
5779
- }
5780
- /** Walk schema to find the env var name for a given dot path. */
5781
- function findEnvVar(schema, dotPath) {
5782
- const parts = dotPath.split(".");
5783
- let current = schema;
5784
- for (const part of parts) {
5785
- if (current === null || current === void 0 || typeof current !== "object") return void 0;
5786
- const obj = current;
5787
- if (obj._tag === "optional") current = obj.shape[part];
5788
- else current = obj[part];
7072
+ async function handleSubmit(req, cors) {
7073
+ const rateCheck = rateLimiter.checkSubmission(req.ip);
7074
+ if (!rateCheck.allowed) return {
7075
+ status: 429,
7076
+ headers: {
7077
+ ...cors,
7078
+ "Retry-After": String(Math.ceil((rateCheck.retryAfterMs ?? 36e5) / 1e3))
7079
+ },
7080
+ body: { error: "反馈过多,一小时后再试" }
7081
+ };
7082
+ const body = req.body;
7083
+ if (!body.title || !body.description || !body.type) return {
7084
+ status: 400,
7085
+ headers: cors,
7086
+ body: { error: "Missing required fields: type, title, description" }
7087
+ };
7088
+ if (!body.skipDuplicateCheck) {
7089
+ const duplicates = await checkForDuplicates(ghConfig, body.title);
7090
+ if (duplicates.length > 0) return {
7091
+ status: 200,
7092
+ headers: cors,
7093
+ body: {
7094
+ isDuplicate: true,
7095
+ candidates: duplicates.slice(0, 3)
7096
+ }
7097
+ };
7098
+ }
7099
+ const result = await submitFeedback({
7100
+ github: ghConfig,
7101
+ report: FeedbackReport.parse({
7102
+ type: body.type,
7103
+ title: body.title,
7104
+ description: body.description,
7105
+ context: body.context ?? { source: "web-plugin" },
7106
+ reporter: body.reporterEmail ? { email: body.reporterEmail } : void 0
7107
+ })
7108
+ });
7109
+ return {
7110
+ status: 201,
7111
+ headers: cors,
7112
+ body: {
7113
+ isDuplicate: false,
7114
+ issueNumber: result.issue.number,
7115
+ issueUrl: result.issue.html_url,
7116
+ ...result.subscribeWarning ? { warning: result.subscribeWarning } : {}
7117
+ }
7118
+ };
5789
7119
  }
5790
- if (typeof current === "object" && current !== null && "_tag" in current) {
5791
- const field = current;
5792
- if (field._tag === "field") return field.options?.env;
7120
+ async function handleSubscribe(req, cors) {
7121
+ const body = req.body;
7122
+ if (!body.issueNumber || !body.email) return {
7123
+ status: 400,
7124
+ headers: cors,
7125
+ body: { error: "Missing issueNumber and email" }
7126
+ };
7127
+ try {
7128
+ await subscribeToDuplicate(ghConfig, body.issueNumber, body.email);
7129
+ } catch (err) {
7130
+ const msg = err instanceof Error ? err.message : String(err);
7131
+ const hint = msg.includes("contents") || msg.includes("403") ? "GitHub token needs \"contents:write\" scope to save subscribers. See docs for token setup." : msg;
7132
+ return {
7133
+ status: 502,
7134
+ headers: cors,
7135
+ body: {
7136
+ subscribed: false,
7137
+ issueNumber: body.issueNumber,
7138
+ error: hint
7139
+ }
7140
+ };
7141
+ }
7142
+ return {
7143
+ status: 200,
7144
+ headers: cors,
7145
+ body: {
7146
+ subscribed: true,
7147
+ issueNumber: body.issueNumber
7148
+ }
7149
+ };
5793
7150
  }
5794
- }
5795
- function setNestedByDot(obj, dotPath, value) {
5796
- const parts = dotPath.split(".");
5797
- let current = obj;
5798
- for (let i = 0; i < parts.length - 1; i++) {
5799
- const key = parts[i];
5800
- if (key === void 0) continue;
5801
- if (!(key in current) || typeof current[key] !== "object" || current[key] === null) current[key] = {};
5802
- current = current[key];
7151
+ async function handleUpload(req, cors) {
7152
+ if (!req.rawBody || req.rawBody.length === 0) return {
7153
+ status: 400,
7154
+ headers: cors,
7155
+ body: { error: "No file data received" }
7156
+ };
7157
+ if (req.rawBody.length > 10 * 1024 * 1024) return {
7158
+ status: 413,
7159
+ headers: cors,
7160
+ body: { error: "Screenshot exceeds 10MB limit" }
7161
+ };
7162
+ try {
7163
+ const filename = `screenshot-${Date.now()}.jpg`;
7164
+ return {
7165
+ status: 200,
7166
+ headers: cors,
7167
+ body: { url: await uploadImage(ghConfig, req.rawBody, filename) }
7168
+ };
7169
+ } catch (err) {
7170
+ return {
7171
+ status: 500,
7172
+ headers: cors,
7173
+ body: { error: err instanceof Error ? err.message : "Upload failed" }
7174
+ };
7175
+ }
5803
7176
  }
5804
- const lastKey = parts.at(-1);
5805
- if (lastKey !== void 0) current[lastKey] = value;
7177
+ return { handle };
5806
7178
  }
5807
7179
  //#endregion
5808
- //#region ../server/dist/app-Frne_Mbn.mjs
7180
+ //#region ../server/dist/app-RWQiJW6_.mjs
5809
7181
  var __defProp = Object.defineProperty;
5810
7182
  var __exportAll = (all, no_symbols) => {
5811
7183
  let target = {};
@@ -5816,6 +7188,17 @@ var __exportAll = (all, no_symbols) => {
5816
7188
  if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
5817
7189
  return target;
5818
7190
  };
7191
+ /** Organization entity. Agents and chats belong to exactly one organization. */
7192
+ const organizations = pgTable("organizations", {
7193
+ id: text("id").primaryKey(),
7194
+ name: text("name").unique().notNull(),
7195
+ displayName: text("display_name").notNull(),
7196
+ maxAgents: integer("max_agents").notNull().default(0),
7197
+ maxMessagesPerMinute: integer("max_messages_per_minute").notNull().default(0),
7198
+ features: jsonb("features").$type().notNull().default({}),
7199
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
7200
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
7201
+ });
5819
7202
  /** User accounts. Passwords are stored as bcrypt hashes. */
5820
7203
  const users = pgTable("users", {
5821
7204
  id: text("id").primaryKey(),
@@ -5834,10 +7217,18 @@ const users = pgTable("users", {
5834
7217
  * every `agent:bind` request. `user_id` is nullable only to accommodate legacy
5835
7218
  * rows created before JWT-on-handshake; the WS handshake claims the row on
5836
7219
  * first re-register under an authenticated JWT (see `client:register` M13).
7220
+ *
7221
+ * A client is also bound to exactly one organization for its lifetime. The
7222
+ * `organization_id` column is populated on first registration from the
7223
+ * authenticated JWT's org claim and never changes thereafter. Re-registering
7224
+ * the same clientId under a JWT for a different org is rejected with
7225
+ * `CLIENT_ORG_MISMATCH` — the CLI responds by abandoning the local clientId
7226
+ * and registering a new one instead (see docs/multi-tenancy-hardening-design.md).
5837
7227
  */
5838
7228
  const clients = pgTable("clients", {
5839
7229
  id: text("id").primaryKey(),
5840
7230
  userId: text("user_id").references(() => users.id, { onDelete: "set null" }),
7231
+ organizationId: text("organization_id").notNull().references(() => organizations.id),
5841
7232
  status: text("status").notNull().default("disconnected"),
5842
7233
  sdkVersion: text("sdk_version"),
5843
7234
  hostname: text("hostname"),
@@ -5846,18 +7237,7 @@ const clients = pgTable("clients", {
5846
7237
  connectedAt: timestamp("connected_at", { withTimezone: true }),
5847
7238
  lastSeenAt: timestamp("last_seen_at", { withTimezone: true }).notNull().defaultNow(),
5848
7239
  metadata: jsonb("metadata").$type()
5849
- }, (table) => [index("idx_clients_user").on(table.userId)]);
5850
- /** Organization entity. Agents and chats belong to exactly one organization. */
5851
- const organizations = pgTable("organizations", {
5852
- id: text("id").primaryKey(),
5853
- name: text("name").unique().notNull(),
5854
- displayName: text("display_name").notNull(),
5855
- maxAgents: integer("max_agents").notNull().default(0),
5856
- maxMessagesPerMinute: integer("max_messages_per_minute").notNull().default(0),
5857
- features: jsonb("features").$type().notNull().default({}),
5858
- createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
5859
- updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
5860
- });
7240
+ }, (table) => [index("idx_clients_user").on(table.userId), index("idx_clients_org").on(table.organizationId)]);
5861
7241
  /** Agent registration. Each agent owns a unique inboxId for message delivery. */
5862
7242
  const agents = pgTable("agents", {
5863
7243
  uuid: text("uuid").primaryKey(),
@@ -5869,7 +7249,6 @@ const agents = pgTable("agents", {
5869
7249
  inboxId: text("inbox_id").unique().notNull(),
5870
7250
  status: text("status").notNull().default("active"),
5871
7251
  source: text("source"),
5872
- cloudUserId: text("cloud_user_id"),
5873
7252
  visibility: text("visibility").notNull().default("private"),
5874
7253
  metadata: jsonb("metadata").$type().notNull().default({}),
5875
7254
  managerId: text("manager_id").notNull(),
@@ -5931,6 +7310,20 @@ var BadRequestError = class extends AppError {
5931
7310
  this.name = "BadRequestError";
5932
7311
  }
5933
7312
  };
7313
+ /**
7314
+ * Thrown when an operation targets a client whose organization does not match
7315
+ * the caller's authenticated organization. A client is bound to exactly one
7316
+ * org for its lifetime; re-registering or operating under a different org's
7317
+ * credentials is refused. CLI consumers recognize the `code` field and
7318
+ * respond by abandoning the local clientId to register a fresh one.
7319
+ */
7320
+ var ClientOrgMismatchError = class extends AppError {
7321
+ code = "CLIENT_ORG_MISMATCH";
7322
+ constructor(message = "Client belongs to a different organization") {
7323
+ super(403, message);
7324
+ this.name = "ClientOrgMismatchError";
7325
+ }
7326
+ };
5934
7327
  /** Communication container. All messages between agents flow within a Chat. */
5935
7328
  const chats = pgTable("chats", {
5936
7329
  id: text("id").primaryKey(),
@@ -6784,14 +8177,19 @@ async function resolveAgentClient(db, data) {
6784
8177
  return null;
6785
8178
  }
6786
8179
  if (!data.clientId) return null;
6787
- const [manager] = await db.select({ userId: members.userId }).from(members).where(eq(members.id, data.managerId)).limit(1);
8180
+ const [manager] = await db.select({
8181
+ userId: members.userId,
8182
+ organizationId: members.organizationId
8183
+ }).from(members).where(eq(members.id, data.managerId)).limit(1);
6788
8184
  if (!manager) throw new BadRequestError(`Manager "${data.managerId}" not found`);
6789
8185
  const [client] = await db.select({
6790
8186
  id: clients.id,
6791
- userId: clients.userId
8187
+ userId: clients.userId,
8188
+ organizationId: clients.organizationId
6792
8189
  }).from(clients).where(eq(clients.id, data.clientId)).limit(1);
6793
8190
  if (!client) throw new BadRequestError(`Client "${data.clientId}" not found`);
6794
8191
  if (!client.userId) throw new BadRequestError(`Client "${data.clientId}" has not been claimed by a user yet. Have the operator run \`first-tree-hub client connect\` on that machine before pinning an agent to it.`);
8192
+ if (client.organizationId !== manager.organizationId) throw new ForbiddenError(`Client "${data.clientId}" belongs to a different organization — pick a client registered in the manager's org.`);
6795
8193
  if (client.userId !== manager.userId) throw new ForbiddenError(`Client "${data.clientId}" is not owned by the manager's user — pick a client belonging to that user.`);
6796
8194
  return client.id;
6797
8195
  }
@@ -6890,7 +8288,6 @@ async function listAgentsForAdmin(db, scope, limit, cursor) {
6890
8288
  delegateMention: agents.delegateMention,
6891
8289
  inboxId: agents.inboxId,
6892
8290
  status: agents.status,
6893
- cloudUserId: agents.cloudUserId,
6894
8291
  visibility: agents.visibility,
6895
8292
  metadata: agents.metadata,
6896
8293
  managerId: agents.managerId,
@@ -6928,7 +8325,6 @@ async function listAgentsForMember(db, scope, limit, cursor, type) {
6928
8325
  delegateMention: agents.delegateMention,
6929
8326
  inboxId: agents.inboxId,
6930
8327
  status: agents.status,
6931
- cloudUserId: agents.cloudUserId,
6932
8328
  visibility: agents.visibility,
6933
8329
  metadata: agents.metadata,
6934
8330
  managerId: agents.managerId,
@@ -7032,6 +8428,28 @@ async function deleteAgent(db, uuid) {
7032
8428
  if (!agent) throw new Error("Unexpected: UPDATE RETURNING produced no row");
7033
8429
  return agent;
7034
8430
  }
8431
+ /**
8432
+ * When a direct chat grows past 2 participants, upgrade it to `group` and
8433
+ * flip every existing non-human agent participant to `mention_only` — see
8434
+ * proposals/hub-agent-messaging-reply-and-mentions §3.3. The caller is
8435
+ * expected to insert the new participant AFTER this runs, so the "existing"
8436
+ * set excludes them.
8437
+ *
8438
+ * Idempotent: if the chat is already a group, no-op.
8439
+ */
8440
+ async function maybeUpgradeDirectToGroup(db, chatId, existingParticipantIds, newParticipantCount) {
8441
+ if (existingParticipantIds.length + newParticipantCount < 3) return;
8442
+ const [chat] = await db.select({ type: chats.type }).from(chats).where(eq(chats.id, chatId)).limit(1);
8443
+ if (!chat || chat.type !== "direct") return;
8444
+ await db.update(chats).set({
8445
+ type: "group",
8446
+ updatedAt: /* @__PURE__ */ new Date()
8447
+ }).where(eq(chats.id, chatId));
8448
+ if (existingParticipantIds.length === 0) return;
8449
+ const ids = (await db.select({ uuid: agents.uuid }).from(agents).where(and(inArray(agents.uuid, existingParticipantIds), ne(agents.type, "human")))).map((a) => a.uuid);
8450
+ if (ids.length === 0) return;
8451
+ await db.update(chatParticipants).set({ mode: "mention_only" }).where(and(eq(chatParticipants.chatId, chatId), inArray(chatParticipants.agentId, ids)));
8452
+ }
7035
8453
  async function createChat(db, creatorId, data) {
7036
8454
  const chatId = randomUUID();
7037
8455
  const allParticipantIds = new Set([creatorId, ...data.participantIds]);
@@ -7099,17 +8517,38 @@ async function listChats(db, agentId, limit, cursor) {
7099
8517
  nextCursor: hasMore && last ? last.updatedAt.toISOString() : null
7100
8518
  };
7101
8519
  }
8520
+ /**
8521
+ * List participants of a chat with their agent names — used by the client
8522
+ * runtime to resolve `@<name>` mentions against the authoritative participant
8523
+ * set (see proposals/hub-agent-messaging-reply-and-mentions §4).
8524
+ */
8525
+ async function listChatParticipantsWithNames(db, chatId) {
8526
+ return await db.select({
8527
+ agentId: chatParticipants.agentId,
8528
+ role: chatParticipants.role,
8529
+ mode: chatParticipants.mode,
8530
+ joinedAt: chatParticipants.joinedAt,
8531
+ name: agents.name,
8532
+ displayName: agents.displayName,
8533
+ type: agents.type
8534
+ }).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(eq(chatParticipants.chatId, chatId));
8535
+ }
7102
8536
  async function assertParticipant(db, chatId, agentId) {
7103
8537
  const [row] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, agentId))).limit(1);
7104
8538
  if (!row) throw new ForbiddenError("Not a participant of this chat");
7105
8539
  }
7106
8540
  /** Ensure an agent is a participant of a chat. Silently adds them if not already. */
7107
8541
  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] });
8542
+ const [existing] = await db.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, agentId))).limit(1);
8543
+ if (existing) return;
8544
+ await db.transaction(async (tx) => {
8545
+ await maybeUpgradeDirectToGroup(tx, chatId, (await tx.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId))).map((p) => p.agentId), 1);
8546
+ await tx.insert(chatParticipants).values({
8547
+ chatId,
8548
+ agentId,
8549
+ mode: "full"
8550
+ }).onConflictDoNothing({ target: [chatParticipants.chatId, chatParticipants.agentId] });
8551
+ });
7113
8552
  }
7114
8553
  async function addParticipant(db, chatId, requesterId, data) {
7115
8554
  const chat = await getChat(db, chatId);
@@ -7122,10 +8561,13 @@ async function addParticipant(db, chatId, requesterId, data) {
7122
8561
  if (targetAgent.organizationId !== chat.organizationId) throw new BadRequestError("Cannot add agent from different organization");
7123
8562
  const [existing] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, data.agentId))).limit(1);
7124
8563
  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"
8564
+ await db.transaction(async (tx) => {
8565
+ await maybeUpgradeDirectToGroup(tx, chatId, (await tx.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId))).map((p) => p.agentId), 1);
8566
+ await tx.insert(chatParticipants).values({
8567
+ chatId,
8568
+ agentId: data.agentId,
8569
+ mode: data.mode ?? "full"
8570
+ });
7129
8571
  });
7130
8572
  return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
7131
8573
  }
@@ -7224,11 +8666,14 @@ async function joinChat(db, chatId, memberId, humanAgentId) {
7224
8666
  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
8667
  const [humanAgent] = await db.select({ organizationId: agents.organizationId }).from(agents).where(eq(agents.uuid, humanAgentId)).limit(1);
7226
8668
  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"
8669
+ await db.transaction(async (tx) => {
8670
+ await maybeUpgradeDirectToGroup(tx, chatId, participantAgentIds, 1);
8671
+ await tx.insert(chatParticipants).values({
8672
+ chatId,
8673
+ agentId: humanAgentId,
8674
+ role: "member",
8675
+ mode: "full"
8676
+ });
7232
8677
  });
7233
8678
  return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
7234
8679
  }
@@ -7409,22 +8854,23 @@ async function cleanupStalePresence(db, staleSeconds = 60) {
7409
8854
  * Assert the caller can act on this client. Throws 404 for both "not found"
7410
8855
  * and "not yours" to prevent UUID enumeration across org/user boundaries.
7411
8856
  *
7412
- * - member: owner match (`row.user_id == scope.userId`).
7413
- * - admin: any client whose owner is a member of the admin's own org.
8857
+ * A client is bound to exactly one organization (`clients.organization_id`).
8858
+ * Access is granted when:
8859
+ * - member: row.user_id == scope.userId AND row.organization_id == scope.organizationId.
8860
+ * - admin: row.organization_id == scope.organizationId AND the owner is a
8861
+ * member of that same org (defense in depth).
7414
8862
  *
7415
- * Legacy unclaimed rows (`user_id IS NULL`) have no org association we can
7416
- * verify we explicitly refuse to grant admin access to them so a
7417
- * cross-tenant admin can't operate on another org's orphan rows. These
7418
- * orphans are surfaced for self-service re-registration only; the owning
7419
- * operator must claim the row via `first-tree-hub connect` before any
7420
- * admin action becomes available.
8863
+ * Same user across two orgs has two distinct client rows; operating on one
8864
+ * while logged into the other is refused by the org filter.
7421
8865
  */
7422
8866
  async function assertClientOwner(db, clientId, scope) {
7423
8867
  const [row] = await db.select({
7424
8868
  id: clients.id,
7425
- userId: clients.userId
8869
+ userId: clients.userId,
8870
+ organizationId: clients.organizationId
7426
8871
  }).from(clients).where(eq(clients.id, clientId)).limit(1);
7427
8872
  if (!row) throw new NotFoundError(`Client "${clientId}" not found`);
8873
+ if (row.organizationId !== scope.organizationId) throw new NotFoundError(`Client "${clientId}" not found`);
7428
8874
  if (row.userId === scope.userId) return;
7429
8875
  if (scope.role === "admin" && row.userId !== null) {
7430
8876
  const [sibling] = await db.select({ id: members.id }).from(members).where(and(eq(members.userId, row.userId), eq(members.organizationId, scope.organizationId))).limit(1);
@@ -7435,24 +8881,29 @@ async function assertClientOwner(db, clientId, scope) {
7435
8881
  /**
7436
8882
  * Upsert the clients row for a given `client_id` under an authenticated user.
7437
8883
  *
7438
- * Claim semantics (see proposal M13):
7439
- * - New client_id → INSERT with the authenticated user_id.
7440
- * - Existing row with `user_id IS NULL`claim it (set user_id).
7441
- * - Existing row with a different user_id → {@link ForbiddenError}; the
7442
- * operator must pick a different clientId. This is a hard conflict rather
7443
- * than a silent override because the pinned agents under that client
7444
- * belong to the original owner.
8884
+ * Claim semantics (see proposal M13 + multi-tenancy hardening):
8885
+ * - New client_id → INSERT with the authenticated user_id and org_id.
8886
+ * - Existing row with the same user_id + org_idrefresh runtime columns.
8887
+ * - Existing row in a different org → {@link ClientOrgMismatchError}. A
8888
+ * client is bound to one org for its lifetime; the CLI reacts by
8889
+ * abandoning the local clientId and registering a new one.
8890
+ * - Existing row with a different user_id (same org) → {@link ForbiddenError};
8891
+ * the operator must pick a different clientId. Hard conflict because
8892
+ * pinned agents under that client belong to the original owner.
7445
8893
  */
7446
8894
  async function registerClient(db, data) {
7447
8895
  const now = /* @__PURE__ */ new Date();
7448
8896
  const [existing] = await db.select({
7449
8897
  id: clients.id,
7450
- userId: clients.userId
8898
+ userId: clients.userId,
8899
+ organizationId: clients.organizationId
7451
8900
  }).from(clients).where(eq(clients.id, data.clientId)).limit(1);
8901
+ if (existing && existing.organizationId !== data.organizationId) throw new ClientOrgMismatchError(`Client "${data.clientId}" is bound to a different organization. Re-register as a new client under the current org.`);
7452
8902
  if (existing?.userId && existing.userId !== data.userId) throw new ForbiddenError(`Client "${data.clientId}" is already claimed by a different user. Pick a unique client_id.`);
7453
8903
  await db.insert(clients).values({
7454
8904
  id: data.clientId,
7455
8905
  userId: data.userId,
8906
+ organizationId: data.organizationId,
7456
8907
  status: "connected",
7457
8908
  instanceId: data.instanceId,
7458
8909
  hostname: data.hostname ?? null,
@@ -7513,18 +8964,13 @@ async function listActiveAgentsPinnedToClient(db, clientId) {
7513
8964
  /**
7514
8965
  * Scope-aware client listing.
7515
8966
  *
7516
- * - member: only rows where `user_id = scope.userId`.
7517
- * - admin: every claimed row whose owner is a member of the caller's
7518
- * organization.
7519
- *
7520
- * Legacy unclaimed rows (`user_id IS NULL`) are intentionally hidden from
7521
- * both roles — the `clients` table has no org column, so we cannot verify
7522
- * which org an orphan belongs to. Exposing them to admin would leak
7523
- * orphans across tenants. The owning operator reclaims the row via
7524
- * `first-tree-hub connect`, after which it appears in their list.
8967
+ * - member: rows where `user_id = scope.userId` AND `organization_id = scope.organizationId`
8968
+ * protects against a user listing their own clients registered under a
8969
+ * different org when they're logged into this one.
8970
+ * - admin: every row in `scope.organizationId`, regardless of owner.
7525
8971
  */
7526
8972
  async function listClients(db, scope) {
7527
- const rows = scope.role === "admin" ? await db.selectDistinct({
8973
+ const rows = scope.role === "admin" ? await db.select({
7528
8974
  id: clients.id,
7529
8975
  userId: clients.userId,
7530
8976
  status: clients.status,
@@ -7535,7 +8981,7 @@ async function listClients(db, scope) {
7535
8981
  connectedAt: clients.connectedAt,
7536
8982
  lastSeenAt: clients.lastSeenAt,
7537
8983
  metadata: clients.metadata
7538
- }).from(clients).innerJoin(members, eq(members.userId, clients.userId)).where(eq(members.organizationId, scope.organizationId)) : await db.select().from(clients).where(eq(clients.userId, scope.userId));
8984
+ }).from(clients).where(eq(clients.organizationId, scope.organizationId)) : await db.select().from(clients).where(and(eq(clients.userId, scope.userId), eq(clients.organizationId, scope.organizationId)));
7539
8985
  const counts = await db.select({
7540
8986
  clientId: agents.clientId,
7541
8987
  count: sql`count(*)::int`
@@ -7573,6 +9019,14 @@ async function retireClient(db, clientId) {
7573
9019
  await tx.delete(clients).where(eq(clients.id, clientId));
7574
9020
  });
7575
9021
  }
9022
+ /**
9023
+ * System-scope sweep: mark clients as disconnected when their last-seen
9024
+ * server instance stopped sending heartbeats. Runs globally across all orgs
9025
+ * by design — it is invoked only by internal timers, never from a
9026
+ * user-scoped request, so the per-org filter the read paths enforce does not
9027
+ * apply. Org isolation on the data these clients belong to is still
9028
+ * enforced at the read paths (see `assertClientOwner` / `listClients`).
9029
+ */
7576
9030
  async function cleanupStaleClients(db, staleSeconds = 60) {
7577
9031
  const result = await db.execute(sql`
7578
9032
  UPDATE clients SET status = 'disconnected'
@@ -7723,6 +9177,26 @@ async function sendMessage(db, chatId, senderId, data) {
7723
9177
  }
7724
9178
  async function sendMessageInner(db, chatId, senderId, data) {
7725
9179
  return db.transaction(async (tx) => {
9180
+ const participants = await tx.select({
9181
+ agentId: chatParticipants.agentId,
9182
+ inboxId: agents.inboxId,
9183
+ mode: chatParticipants.mode,
9184
+ name: agents.name
9185
+ }).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(eq(chatParticipants.chatId, chatId));
9186
+ if (data.replyToInbox !== void 0 && data.replyToInbox !== null) {
9187
+ const [senderRow] = await tx.select({ inboxId: agents.inboxId }).from(agents).where(eq(agents.uuid, senderId)).limit(1);
9188
+ if (!senderRow || senderRow.inboxId !== data.replyToInbox) throw new BadRequestError("replyToInbox must reference the sender's own inbox");
9189
+ }
9190
+ const incomingMeta = data.metadata ?? {};
9191
+ const explicitMentionsRaw = incomingMeta.mentions;
9192
+ const explicitMentions = Array.isArray(explicitMentionsRaw) ? explicitMentionsRaw.filter((m) => typeof m === "string") : [];
9193
+ const contentText = typeof data.content === "string" ? data.content : "";
9194
+ const resolved = contentText ? extractMentions(contentText, participants) : [];
9195
+ const mergedMentions = [...new Set([...explicitMentions, ...resolved])];
9196
+ const metadataToStore = mergedMentions.length > 0 ? {
9197
+ ...incomingMeta,
9198
+ mentions: mergedMentions
9199
+ } : incomingMeta;
7726
9200
  const messageId = randomUUID();
7727
9201
  const [msg] = await tx.insert(messages).values({
7728
9202
  id: messageId,
@@ -7730,16 +9204,14 @@ async function sendMessageInner(db, chatId, senderId, data) {
7730
9204
  senderId,
7731
9205
  format: data.format,
7732
9206
  content: data.content,
7733
- metadata: data.metadata ?? {},
9207
+ metadata: metadataToStore,
7734
9208
  replyToInbox: data.replyToInbox ?? null,
7735
9209
  replyToChat: data.replyToChat ?? null,
7736
9210
  inReplyTo: data.inReplyTo ?? null,
7737
9211
  source: data.source ?? null
7738
9212
  }).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) => ({
9213
+ const mentionSet = new Set(mergedMentions);
9214
+ const entries = participants.filter((p) => p.agentId !== senderId).filter((p) => p.mode !== "mention_only" || mentionSet.has(p.agentId)).map((p) => ({
7743
9215
  inboxId: p.inboxId,
7744
9216
  messageId,
7745
9217
  chatId
@@ -7775,7 +9247,7 @@ async function sendToAgent(db, senderUuid, targetName, data) {
7775
9247
  }).from(agents).where(eq(agents.uuid, senderUuid)).limit(1);
7776
9248
  if (!sender) throw new NotFoundError(`Agent "${senderUuid}" not found`);
7777
9249
  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`);
9250
+ 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
9251
  return sendMessage(db, (await findOrCreateDirectChat(db, senderUuid, target.uuid)).id, senderUuid, {
7780
9252
  format: data.format,
7781
9253
  content: data.content,
@@ -7874,6 +9346,23 @@ function createNotifier(listenClient) {
7874
9346
  await listenClient`SELECT pg_notify(${RUNTIME_STATE_CHANNEL}, ${`${agentId}:${state}:${organizationId}`})`;
7875
9347
  } catch {}
7876
9348
  },
9349
+ async pushFrameToInbox(inboxId, frame) {
9350
+ const sockets = subscriptions.get(inboxId);
9351
+ if (!sockets) return 0;
9352
+ let queued = 0;
9353
+ const pending = [];
9354
+ for (const ws of sockets) {
9355
+ if (ws.readyState !== ws.OPEN) continue;
9356
+ pending.push(new Promise((resolve) => {
9357
+ ws.send(frame, (err) => {
9358
+ if (!err) queued += 1;
9359
+ resolve();
9360
+ });
9361
+ }));
9362
+ }
9363
+ await Promise.all(pending);
9364
+ return queued;
9365
+ },
7877
9366
  onConfigChange(handler) {
7878
9367
  configChangeHandlers.push(handler);
7879
9368
  },
@@ -8250,6 +9739,73 @@ function requireAdminRoleHook() {
8250
9739
  if (request.member?.role !== "admin") throw new ForbiddenError("Admin role required");
8251
9740
  };
8252
9741
  }
9742
+ /**
9743
+ * Intercepts outbound image messages. If `data.content` carries an inline
9744
+ * base64 image (legacy-style payload from the web), we:
9745
+ *
9746
+ * 1. Generate / adopt an `imageId`
9747
+ * 2. Push the bytes as an `image_payload` WS frame to every participant
9748
+ * agent's inbox — plus the reply-to inbox when this is a cross-chat
9749
+ * reply (matches sendMessage's extra fan-out). Best-effort, local
9750
+ * instance only, no PG NOTIFY.
9751
+ * 3. Return a copy of `data` whose `content` is just the reference
9752
+ * {imageId, mimeType, filename, size}
9753
+ *
9754
+ * The push is fire-and-forget: `ws.send()` queues the frame into the socket's
9755
+ * send buffer synchronously, which is the only ordering guarantee we need
9756
+ * — the subsequent `new_message` notification travels a strictly slower PG
9757
+ * NOTIFY round trip, so the image lands first on the wire. Awaiting the TCP
9758
+ * flush here would put a slow subscriber's backpressure on the sender's
9759
+ * HTTP response for a feature that is already best-effort.
9760
+ *
9761
+ * Non-image messages are returned unchanged. Missing-subscriber / wrong-
9762
+ * instance cases are acceptable loss per the image-out-of-messages design
9763
+ * (the reference-only message still lands in the DB; clients that missed
9764
+ * the bytes surface a "not available on this device" placeholder).
9765
+ */
9766
+ async function prepareImageOutbound(db, notifier, chatId, data) {
9767
+ if (data.format !== "file") return data;
9768
+ const parsed = imageInlineContentSchema.safeParse(data.content);
9769
+ if (!parsed.success) return data;
9770
+ const inline = parsed.data;
9771
+ const imageId = inline.imageId ?? randomUUID();
9772
+ const frame = {
9773
+ type: "image_payload",
9774
+ imageId,
9775
+ chatId,
9776
+ base64: inline.data,
9777
+ mimeType: inline.mimeType,
9778
+ filename: inline.filename,
9779
+ ...inline.size !== void 0 ? { size: inline.size } : {}
9780
+ };
9781
+ const serialised = JSON.stringify(frame);
9782
+ const inboxIds = await collectTargetInboxes(db, chatId, data.inReplyTo);
9783
+ for (const inboxId of inboxIds) notifier.pushFrameToInbox(inboxId, serialised).catch(() => {});
9784
+ const ref = {
9785
+ imageId,
9786
+ mimeType: inline.mimeType,
9787
+ filename: inline.filename,
9788
+ ...inline.size !== void 0 ? { size: inline.size } : {}
9789
+ };
9790
+ return {
9791
+ ...data,
9792
+ content: ref
9793
+ };
9794
+ }
9795
+ /**
9796
+ * Mirror `sendMessage`'s fan-out set: every participant of the current
9797
+ * chat, plus the original requester's inbox when this message is a cross-
9798
+ * chat reply (see `services/message.ts` replyTo routing).
9799
+ */
9800
+ async function collectTargetInboxes(db, chatId, inReplyTo) {
9801
+ const participants = await db.select({ inboxId: agents.inboxId }).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(eq(chatParticipants.chatId, chatId));
9802
+ const set = new Set(participants.map((p) => p.inboxId));
9803
+ if (inReplyTo) {
9804
+ const [original] = await db.select({ replyToInbox: messages.replyToInbox }).from(messages).where(eq(messages.id, inReplyTo)).limit(1);
9805
+ if (original?.replyToInbox) set.add(original.replyToInbox);
9806
+ }
9807
+ return [...set];
9808
+ }
8253
9809
  async function adminChatRoutes(app) {
8254
9810
  /** List all chats in org (admin-only, for audit). Members should use GET /mine. */
8255
9811
  app.get("/", { preHandler: requireAdminRoleHook() }, async (request) => {
@@ -8427,10 +9983,11 @@ async function adminChatRoutes(app) {
8427
9983
  const body = sendMessageSchema.parse(request.body);
8428
9984
  await assertChatAccess(app.db, scope, chatId);
8429
9985
  await ensureParticipant(app.db, chatId, member.agentId);
8430
- const result = await sendMessage(app.db, chatId, member.agentId, {
9986
+ const prepared = await prepareImageOutbound(app.db, app.notifier, chatId, {
8431
9987
  ...body,
8432
9988
  source: "hub_ui"
8433
9989
  });
9990
+ const result = await sendMessage(app.db, chatId, member.agentId, prepared);
8434
9991
  notifyRecipients(app.notifier, result.recipients, result.message.id);
8435
9992
  return reply.status(201).send({
8436
9993
  id: result.message.id,
@@ -8453,15 +10010,19 @@ const agentChatSessions = pgTable("agent_chat_sessions", {
8453
10010
  /**
8454
10011
  * Upsert session state + refresh presence aggregates + NOTIFY.
8455
10012
  *
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.
10013
+ * `agent_chat_sessions.(agent_id, chat_id)` is a single-row "current session
10014
+ * state" cache, not a session history log. A new runtime session starting on
10015
+ * the same (agent, chat) pair MUST overwrite whatever ended before — including
10016
+ * an `evicted` row left by a previous terminate. The previous "revival
10017
+ * defense" conflated two concerns: "this runtime session ended" (which is
10018
+ * what `evicted` actually means) and "this chat is permanently archived for
10019
+ * this agent" (a chat-level decision that should live on `chats`, not here).
10020
+ * See proposals/hub-agent-messaging-reply-and-mentions §M2-session-lifecycle.
8458
10021
  */
8459
10022
  async function upsertSessionState(db, agentId, chatId, state, organizationId, notifier) {
8460
10023
  const now = /* @__PURE__ */ new Date();
8461
10024
  let wrote = false;
8462
10025
  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
10026
  await tx.insert(agentChatSessions).values({
8466
10027
  agentId,
8467
10028
  chatId,
@@ -8986,7 +10547,8 @@ function extractSummary(content, maxLen = SUMMARY_MAX_LENGTH) {
8986
10547
  let text = "";
8987
10548
  if (typeof content === "object" && content !== null && "text" in content) text = String(content.text ?? "");
8988
10549
  else if (typeof content === "string") text = content;
8989
- return text ? text.slice(0, maxLen) : null;
10550
+ if (!text) return null;
10551
+ return Array.from(text).slice(0, maxLen).join("");
8990
10552
  }
8991
10553
  /** List sessions for a specific agent, with optional state filters. */
8992
10554
  async function listAgentSessions(db, agentId, filters) {
@@ -9071,7 +10633,8 @@ async function listAllSessions(db, limit, cursor, filters) {
9071
10633
  chatId: agentChatSessions.chatId,
9072
10634
  state: agentChatSessions.state,
9073
10635
  updatedAt: agentChatSessions.updatedAt,
9074
- chatCreatedAt: chats.createdAt
10636
+ chatCreatedAt: chats.createdAt,
10637
+ chatTopic: chats.topic
9075
10638
  }).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
10639
  const hasMore = rows.length > limit;
9077
10640
  const items = hasMore ? rows.slice(0, limit) : rows;
@@ -9081,6 +10644,16 @@ async function listAllSessions(db, limit, cursor, filters) {
9081
10644
  runtimeState: agentPresence.runtimeState
9082
10645
  }).from(agentPresence).where(inArray(agentPresence.agentId, agentIds)) : [];
9083
10646
  const runtimeMap = new Map(presenceRows.map((r) => [r.agentId, r.runtimeState]));
10647
+ const chatIds = [...new Set(items.map((r) => r.chatId))];
10648
+ const firstMessages = chatIds.length > 0 ? await db.selectDistinctOn([messages.chatId], {
10649
+ chatId: messages.chatId,
10650
+ content: messages.content
10651
+ }).from(messages).where(inArray(messages.chatId, chatIds)).orderBy(messages.chatId, messages.createdAt) : [];
10652
+ const summaryMap = /* @__PURE__ */ new Map();
10653
+ for (const row of firstMessages) {
10654
+ const summary = extractSummary(row.content);
10655
+ if (summary) summaryMap.set(row.chatId, summary);
10656
+ }
9084
10657
  const last = items[items.length - 1];
9085
10658
  const nextCursor = hasMore && last ? last.updatedAt.toISOString() : null;
9086
10659
  return {
@@ -9092,8 +10665,8 @@ async function listAllSessions(db, limit, cursor, filters) {
9092
10665
  startedAt: r.chatCreatedAt.toISOString(),
9093
10666
  lastActivityAt: r.updatedAt.toISOString(),
9094
10667
  messageCount: 0,
9095
- summary: null,
9096
- topic: null
10668
+ summary: summaryMap.get(r.chatId) ?? null,
10669
+ topic: r.chatTopic ?? null
9097
10670
  })),
9098
10671
  nextCursor
9099
10672
  };
@@ -10055,6 +11628,24 @@ async function agentChatRoutes(app) {
10055
11628
  }))
10056
11629
  };
10057
11630
  });
11631
+ /**
11632
+ * List chat participants with agent names/displayNames. Used by the client
11633
+ * runtime to resolve `@<name>` mentions against the authoritative participant
11634
+ * set (see proposals/hub-agent-messaging-reply-and-mentions §4).
11635
+ */
11636
+ app.get("/:chatId/participants", async (request) => {
11637
+ const identity = requireAgent(request);
11638
+ await assertParticipant(app.db, request.params.chatId, identity.uuid);
11639
+ return (await listChatParticipantsWithNames(app.db, request.params.chatId)).map((r) => ({
11640
+ agentId: r.agentId,
11641
+ role: r.role,
11642
+ mode: r.mode,
11643
+ name: r.name,
11644
+ displayName: r.displayName,
11645
+ type: r.type,
11646
+ joinedAt: r.joinedAt.toISOString()
11647
+ }));
11648
+ });
10058
11649
  app.post("/:chatId/participants", async (request, reply) => {
10059
11650
  const identity = requireAgent(request);
10060
11651
  const body = addParticipantSchema.parse(request.body);
@@ -10180,19 +11771,46 @@ function normaliseSource(source) {
10180
11771
  const parsed = messageSourceSchema$1.safeParse(source);
10181
11772
  return parsed.success ? parsed.data : null;
10182
11773
  }
11774
+ function normaliseMode(mode) {
11775
+ return mode === "mention_only" ? "mention_only" : "full";
11776
+ }
10183
11777
  /**
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`.
11778
+ * Batch variant — builds all payloads with a single DB lookup per agent plus
11779
+ * batched lookups for participant modes and inReplyTo snapshots.
10186
11780
  */
10187
- async function buildClientMessagePayloadsForInbox(db, inboxId, messages) {
10188
- if (messages.length === 0) return [];
11781
+ async function buildClientMessagePayloadsForInbox(db, inboxId, items) {
11782
+ if (items.length === 0) return [];
10189
11783
  const agentId = await resolveAgentId(db, {
10190
11784
  kind: "inboxId",
10191
11785
  inboxId
10192
11786
  });
10193
11787
  const [cfg] = await db.select({ version: agentConfigs.version }).from(agentConfigs).where(eq(agentConfigs.agentId, agentId)).limit(1);
10194
11788
  const version = cfg?.version ?? 1;
10195
- return messages.map((m) => ({
11789
+ const chatIds = [...new Set(items.map((it) => it.entryChatId ?? it.message.chatId).filter((id) => id !== null))];
11790
+ const modeByChat = /* @__PURE__ */ new Map();
11791
+ if (chatIds.length > 0) {
11792
+ const rows = await db.select({
11793
+ chatId: chatParticipants.chatId,
11794
+ mode: chatParticipants.mode
11795
+ }).from(chatParticipants).where(and(eq(chatParticipants.agentId, agentId), inArray(chatParticipants.chatId, chatIds)));
11796
+ for (const r of rows) modeByChat.set(r.chatId, normaliseMode(r.mode));
11797
+ }
11798
+ const inReplyToIds = [...new Set(items.map((it) => it.message.inReplyTo).filter((id) => id !== null))];
11799
+ const snapshotById = /* @__PURE__ */ new Map();
11800
+ if (inReplyToIds.length > 0) {
11801
+ const origs = await db.select({
11802
+ id: messages.id,
11803
+ senderId: messages.senderId,
11804
+ chatId: messages.chatId,
11805
+ replyToChat: messages.replyToChat
11806
+ }).from(messages).where(inArray(messages.id, inReplyToIds));
11807
+ for (const o of origs) snapshotById.set(o.id, {
11808
+ senderId: o.senderId,
11809
+ chatId: o.chatId,
11810
+ replyToChat: o.replyToChat
11811
+ });
11812
+ }
11813
+ return items.map(({ entryChatId, message: m }) => ({
10196
11814
  id: m.id,
10197
11815
  chatId: m.chatId,
10198
11816
  senderId: m.senderId,
@@ -10204,7 +11822,9 @@ async function buildClientMessagePayloadsForInbox(db, inboxId, messages) {
10204
11822
  inReplyTo: m.inReplyTo,
10205
11823
  source: normaliseSource(m.source),
10206
11824
  createdAt: m.createdAt,
10207
- configVersion: version
11825
+ configVersion: version,
11826
+ recipientMode: modeByChat.get(entryChatId ?? m.chatId) ?? "full",
11827
+ inReplyToSnapshot: m.inReplyTo ? snapshotById.get(m.inReplyTo) ?? null : null
10208
11828
  }));
10209
11829
  }
10210
11830
  async function resolveAgentId(db, source) {
@@ -10243,23 +11863,25 @@ async function pollInboxInner(db, inboxId, limit) {
10243
11863
  const msg = msgMap.get(entry.message_id);
10244
11864
  if (!msg) throw new Error(`Unexpected: message ${entry.message_id} not found`);
10245
11865
  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()
11866
+ entryChatId: entry.chat_id,
11867
+ message: {
11868
+ id: msg.id,
11869
+ chatId: msg.chatId,
11870
+ senderId: msg.senderId,
11871
+ format: msg.format,
11872
+ content: msg.content,
11873
+ metadata: msg.metadata,
11874
+ replyToInbox: msg.replyToInbox,
11875
+ replyToChat: msg.replyToChat,
11876
+ inReplyTo: msg.inReplyTo,
11877
+ source: msg.source,
11878
+ createdAt: msg.createdAt.toISOString()
11879
+ }
10257
11880
  };
10258
11881
  }));
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`);
11882
+ return claimed.map((entry, idx) => {
11883
+ const payload = payloads[idx];
11884
+ if (!payload) throw new Error(`Unexpected: payload for entry ${entry.id} not built`);
10263
11885
  return {
10264
11886
  id: entry.id,
10265
11887
  inboxId: entry.inbox_id,
@@ -10359,7 +11981,8 @@ async function agentMessageRoutes(app) {
10359
11981
  const identity = requireAgent(request);
10360
11982
  await assertParticipant(app.db, request.params.chatId, identity.uuid);
10361
11983
  const body = sendMessageSchema.parse(request.body);
10362
- const { message: msg, recipients } = await sendMessage(app.db, request.params.chatId, identity.uuid, body);
11984
+ const prepared = await prepareImageOutbound(app.db, app.notifier, request.params.chatId, body);
11985
+ const { message: msg, recipients } = await sendMessage(app.db, request.params.chatId, identity.uuid, prepared);
10363
11986
  notifyRecipients(app.notifier, recipients, msg.id);
10364
11987
  return reply.status(201).send({
10365
11988
  ...msg,
@@ -10667,6 +12290,7 @@ function clientWsRoutes(notifier, instanceId) {
10667
12290
  await registerClient(app.db, {
10668
12291
  clientId: data.clientId,
10669
12292
  userId: session.userId,
12293
+ organizationId: session.organizationId,
10670
12294
  instanceId,
10671
12295
  hostname: data.hostname,
10672
12296
  os: data.os,
@@ -10674,9 +12298,11 @@ function clientWsRoutes(notifier, instanceId) {
10674
12298
  });
10675
12299
  } catch (err) {
10676
12300
  const message = err instanceof Error ? err.message : "client register failed";
12301
+ const code = err instanceof ClientOrgMismatchError ? err.code : void 0;
10677
12302
  socket.send(JSON.stringify({
10678
12303
  type: "client:register:rejected",
10679
- message
12304
+ message,
12305
+ ...code ? { code } : {}
10680
12306
  }));
10681
12307
  socket.close(4403, "client register rejected");
10682
12308
  return;
@@ -11106,6 +12732,65 @@ async function contextTreeInfoRoutes(app) {
11106
12732
  };
11107
12733
  });
11108
12734
  }
12735
+ /**
12736
+ * Resolve the client IP for rate-limit attribution.
12737
+ *
12738
+ * Headers like `x-forwarded-for` are client-controllable, so we only trust
12739
+ * them when the operator explicitly opts in via `HEARBACK_TRUST_PROXY_HEADERS=true`.
12740
+ * Otherwise fall back to Fastify's `req.ip` (socket-level) — degrades to one
12741
+ * bucket per upstream proxy, which is correct when no proxy metadata is
12742
+ * trustworthy.
12743
+ */
12744
+ function resolveClientIp(req, trustProxyHeaders) {
12745
+ if (trustProxyHeaders) {
12746
+ const xff = req.headers["x-forwarded-for"];
12747
+ const first = Array.isArray(xff) ? xff[0] : typeof xff === "string" ? xff.split(",")[0] : void 0;
12748
+ if (first && first.trim().length > 0) return first.trim();
12749
+ }
12750
+ return req.ip ?? "";
12751
+ }
12752
+ /**
12753
+ * Mount the hearback-server handler onto Fastify. Register with
12754
+ * `{ prefix: "/feedback" }` so the widget's default `data-endpoint="/feedback"`
12755
+ * resolves to `/feedback/chat`, `/feedback/submit`, `/feedback/upload`, etc.
12756
+ *
12757
+ * We don't use hearback's prebuilt `feedbackPlugin` because it expects the
12758
+ * `fastify-raw-body` plugin. This adapter uses `addContentTypeParser` with
12759
+ * `parseAs: "buffer"` on an encapsulated child instance so upload bytes reach
12760
+ * the handler as a Buffer without pulling in another dependency.
12761
+ */
12762
+ async function feedbackRoutes(app, config) {
12763
+ const { trustProxyHeaders, ...handlerConfig } = config;
12764
+ const handler = createFeedbackHandler(handlerConfig);
12765
+ app.addContentTypeParser(/^image\//, { parseAs: "buffer" }, (_req, body, done) => {
12766
+ done(null, body);
12767
+ });
12768
+ app.all("/*", async (req, reply) => {
12769
+ const path = req.url.replace(/^.*\/feedback/, "").split("?")[0] || "/";
12770
+ const ip = resolveClientIp(req, trustProxyHeaders);
12771
+ const headers = {};
12772
+ for (const [k, v] of Object.entries(req.headers)) headers[k] = Array.isArray(v) ? v[0] : v;
12773
+ const isUpload = path.replace(/\/$/, "").endsWith("/upload");
12774
+ const rawBody = isUpload && Buffer.isBuffer(req.body) ? req.body : void 0;
12775
+ const result = await handler.handle({
12776
+ method: req.method,
12777
+ path,
12778
+ body: isUpload ? {} : req.body,
12779
+ ip,
12780
+ headers,
12781
+ rawBody
12782
+ });
12783
+ reply.status(result.status).headers(result.headers);
12784
+ if (result.body && typeof result.body === "object" && Symbol.asyncIterator in result.body) {
12785
+ const stream = result.body;
12786
+ for await (const chunk of stream) reply.raw.write(chunk);
12787
+ reply.raw.end();
12788
+ return;
12789
+ }
12790
+ if (result.body === null) reply.send();
12791
+ else reply.send(result.body);
12792
+ });
12793
+ }
11109
12794
  async function healthRoutes(app) {
11110
12795
  app.get("/health", async () => {
11111
12796
  try {
@@ -11385,7 +13070,7 @@ function isRecord(value) {
11385
13070
  }
11386
13071
  /** Extract unique @mentions from text. Returns lowercase usernames.
11387
13072
  * Excludes email patterns (user@example.com) and team mentions (@org/team). */
11388
- function extractMentions(text) {
13073
+ function extractMentions$1(text) {
11389
13074
  if (!text) return [];
11390
13075
  const re = /(?<!\w)@([a-zA-Z0-9][\w-]*)(\/)?/g;
11391
13076
  const names = /* @__PURE__ */ new Set();
@@ -11679,7 +13364,7 @@ function extractEventContext(eventType, payload) {
11679
13364
  * Only called after action gating confirms this is a "new content" event.
11680
13365
  */
11681
13366
  async function handleMentionDelegation(app, eventType, payload) {
11682
- const mentions = extractMentions(extractEventText(eventType, payload));
13367
+ const mentions = extractMentions$1(extractEventText(eventType, payload));
11683
13368
  const mentionCtx = extractEventContext(eventType, payload);
11684
13369
  if (mentions.length > 0 && mentionCtx) return routeMentionDelegations(app, mentions, mentionCtx);
11685
13370
  return 0;
@@ -13168,6 +14853,21 @@ async function buildApp(config) {
13168
14853
  await api.register(clientWsRoutes(notifier, config.instanceId), { prefix: "/agent/ws" });
13169
14854
  await api.register(adminWsRoutes(notifier, config.secrets.jwtSecret), { prefix: "/ws" });
13170
14855
  }, { prefix: "/api/v1" });
14856
+ if (config.feedback) {
14857
+ const feedbackConfig = config.feedback;
14858
+ await app.register(async (scope) => {
14859
+ await scope.register(feedbackRoutes, {
14860
+ repo: feedbackConfig.repo,
14861
+ githubToken: feedbackConfig.githubToken,
14862
+ llm: feedbackConfig.llm ? {
14863
+ apiKey: feedbackConfig.llm.apiKey,
14864
+ baseUrl: feedbackConfig.llm.baseUrl,
14865
+ model: feedbackConfig.llm.model
14866
+ } : void 0,
14867
+ trustProxyHeaders: feedbackConfig.trustProxyHeaders
14868
+ });
14869
+ }, { prefix: "/feedback" });
14870
+ }
13171
14871
  const webDistPath = config.webDistPath;
13172
14872
  if (webDistPath) {
13173
14873
  const webRoot = resolve(webDistPath);
@@ -13726,4 +15426,4 @@ function createExecuteUpdate({ managed }) {
13726
15426
  };
13727
15427
  }
13728
15428
  //#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 };
15429
+ export { configureClientLoggerForService as $, checkServerHealth as A, createOwner as B, runMigrations as C, checkDocker as D, checkDatabase as E, isDockerAvailable as F, setJsonMode as G, resolveReplyToFromEnv as H, stopPostgres as I, FirstTreeHubSDK as J, status as K, ClientRuntime as L, checkWebSocket as M, printResults as N, checkNodeVersion as O, ensurePostgres as P, applyClientLoggerConfig as Q, handleClientOrgMismatch as R, uninstallClientService as S, checkClientConfig as T, blank as U, hasUser as V, print as W, SessionRegistry as X, SdkError as Y, cleanWorkspaces as Z, 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, ClientOrgMismatchError$1 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, rotateClientIdWithBackup as z };