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