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