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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,8 @@
1
1
  import { m as __toESM } from "./esm-CYu4tXXn.mjs";
2
2
  import { _ as withSpan, a as endWsConnectionSpan, b as require_pino, c as messageAttrs, d as rootLogger$1, g as startWsConnectionSpan, i as currentTraceId, n as applyLoggerConfig, o as getFastifyOtelPlugin, p as setWsConnectionAttrs, r as createLogger$1, t as adapterAttrs, u as observabilityPlugin, v as withWsMessageSpan, y as FIRST_TREE_HUB_ATTR } from "./observability-DV_fQKqV-CuLWzBxQ.mjs";
3
3
  import { s as formatPrettyEntry$1, t as LOG_LEVELS$1, u as parseLogLevel$1 } from "./logger-core-BTmvdflj-DhdipBkV.mjs";
4
- import { C as serverConfigSchema, S as resolveConfigReadonly, _ as loadAgents, d as DEFAULT_HOME_DIR$1, f as agentConfigSchema, g as initConfig, i as loadCredentials, l as DEFAULT_CONFIG_DIR, m as collectMissingPrompts, n as ensureFreshAccessToken, o as resolveServerUrl, p as clientConfigSchema, s as saveAgentConfig, u as DEFAULT_DATA_DIR$1, v as migrateLegacyHome, w as setConfigValue } from "./bootstrap-DWifXj9b.mjs";
5
- import { $ as sessionStateMessageSchema, A as createMemberSchema, B as loginSchema, C as agentTypeSchema$1, D as createAdapterMappingSchema, E as createAdapterConfigSchema, F as extractMentions, G as runtimeStateMessageSchema, H as notificationQuerySchema, I as imageInlineContentSchema, J as sendToAgentSchema, K as selfServiceFeishuBotSchema, L as inboxPollQuerySchema, M as createTaskSchema, N as delegateFeishuUserSchema, O as createAgentSchema, P as dryRunAgentRuntimeConfigSchema, Q as sessionReconcileRequestSchema, R as isRedactedEnvValue, S as agentRuntimeConfigPayloadSchema$1, T as connectTokenExchangeSchema, U as paginationQuerySchema, V as messageSourceSchema$1, W as refreshTokenSchema, X as sessionEventMessageSchema, Y as sessionCompletionMessageSchema, Z as sessionEventSchema$1, _ as addParticipantSchema, a as AGENT_SELECTOR_HEADER$1, at as updateMemberSchema, b as agentBindRequestSchema, c as AGENT_TYPES, ct as updateTaskStatusSchema, d as SYSTEM_CONFIG_DEFAULTS, et as taskListQuerySchema, f as TASK_CREATOR_TYPES, g as WS_AUTH_FRAME_TIMEOUT_MS, h as TASK_TERMINAL_STATUSES, i as AGENT_BIND_REJECT_REASONS, it as updateChatSchema, j as createOrganizationSchema, k as createChatSchema, l as AGENT_VISIBILITY, lt as wsAuthFrameSchema, m as TASK_STATUSES, nt as updateAgentRuntimeConfigSchema, o as AGENT_SOURCES, ot as updateOrganizationSchema, p as TASK_HEALTH_SIGNALS, q as sendMessageSchema, rt as updateAgentSchema, s as AGENT_STATUSES, st as updateSystemConfigSchema, tt as updateAdapterConfigSchema, u as DEFAULT_AGENT_RUNTIME_CONFIG_PAYLOAD, v as adminCreateTaskSchema, w as clientRegisterSchema, x as agentPinnedMessageSchema$1, y as adminUpdateTaskSchema, z as linkTaskChatSchema } from "./feishu-CRNUI05I.mjs";
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-BJaN64iR.mjs";
6
6
  import { createRequire } from "node:module";
7
7
  import { ZodError, z } from "zod";
8
8
  import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
@@ -13,7 +13,7 @@ import { closeSync, copyFileSync, createReadStream, existsSync, mkdirSync, openS
13
13
  import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
14
14
  import WebSocket from "ws";
15
15
  import { mkdir, writeFile } from "node:fs/promises";
16
- import { stringify } from "yaml";
16
+ import { parse, stringify } from "yaml";
17
17
  import { query } from "@anthropic-ai/claude-agent-sdk";
18
18
  import { execFileSync, execSync, spawn, spawnSync } from "node:child_process";
19
19
  import * as semver from "semver";
@@ -21,9 +21,9 @@ import bcrypt from "bcrypt";
21
21
  import { and, asc, count, desc, eq, gt, inArray, isNotNull, isNull, lt, ne, or, sql } from "drizzle-orm";
22
22
  import { drizzle } from "drizzle-orm/postgres-js";
23
23
  import postgres from "postgres";
24
+ import { confirm, input, password, select } from "@inquirer/prompts";
24
25
  import { fileURLToPath } from "node:url";
25
26
  import { migrate } from "drizzle-orm/postgres-js/migrator";
26
- import { confirm, input, password, select } from "@inquirer/prompts";
27
27
  import cors from "@fastify/cors";
28
28
  import rateLimit from "@fastify/rate-limit";
29
29
  import fastifyStatic from "@fastify/static";
@@ -461,7 +461,6 @@ z.object({
461
461
  inboxId: z.string(),
462
462
  status: z.string(),
463
463
  source: z.string().nullable().optional(),
464
- cloudUserId: z.string().nullable().optional(),
465
464
  visibility: agentVisibilitySchema,
466
465
  metadata: z.record(z.string(), z.unknown()),
467
466
  managerId: z.string().nullable(),
@@ -542,7 +541,7 @@ const gitRepoSchema = z.object({
542
541
  */
543
542
  const agentRuntimeConfigPayloadShape = z.object({
544
543
  prompt: promptConfigSchema.default({ append: "" }),
545
- model: z.string().default(""),
544
+ model: z.string().default("opus"),
546
545
  mcpServers: z.array(mcpServerSchema).default([]),
547
546
  env: z.array(envEntrySchema).default([]),
548
547
  gitRepos: z.array(gitRepoSchema).default([])
@@ -1329,6 +1328,22 @@ defineConfig({
1329
1328
  }),
1330
1329
  hubPublicUrl: field(z.string(), { env: "FIRST_TREE_HUB_PUBLIC_URL" })
1331
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
+ }),
1332
1347
  observability: {
1333
1348
  logging: {
1334
1349
  level: field(logLevelSchema.default("info"), { env: "FIRST_TREE_HUB_LOG_LEVEL" }),
@@ -1511,6 +1526,19 @@ var SdkError = class extends Error {
1511
1526
  this.name = "SdkError";
1512
1527
  }
1513
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
+ };
1514
1542
  const RECONNECT_BASE_MS = 1e3;
1515
1543
  const RECONNECT_MAX_MS = 3e4;
1516
1544
  const WS_CONNECT_TIMEOUT_MS = 1e4;
@@ -1566,6 +1594,12 @@ var ClientConnection = class extends EventEmitter {
1566
1594
  registered = false;
1567
1595
  /** Count of `server:welcome` frames received; drives `isReconnect` flag. */
1568
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;
1569
1603
  wsLogger;
1570
1604
  authLogger;
1571
1605
  boundAgents = /* @__PURE__ */ new Map();
@@ -1750,7 +1784,9 @@ var ClientConnection = class extends EventEmitter {
1750
1784
  this.rejectAllPendingBinds("WebSocket closed");
1751
1785
  if (!settled) {
1752
1786
  this.wsLogger.warn({ code }, "closed before ready");
1753
- settle(reject, /* @__PURE__ */ new Error(`WebSocket closed before ready (code ${code})`));
1787
+ const typedErr = this.lastHandshakeError;
1788
+ this.lastHandshakeError = null;
1789
+ settle(reject, typedErr ?? /* @__PURE__ */ new Error(`WebSocket closed before ready (code ${code})`));
1754
1790
  return;
1755
1791
  }
1756
1792
  this.wsLogger.warn({
@@ -1803,8 +1839,15 @@ var ClientConnection = class extends EventEmitter {
1803
1839
  return;
1804
1840
  }
1805
1841
  if (type === "client:register:rejected") {
1806
- const err = /* @__PURE__ */ new Error(`client:register rejected: ${msg.message ?? "unknown"}`);
1807
- this.wsLogger.error({ message: msg.message }, "client register rejected");
1842
+ const code = typeof msg.code === "string" ? msg.code : void 0;
1843
+ const message = typeof msg.message === "string" ? msg.message : "unknown";
1844
+ this.closing = true;
1845
+ const err = code === "CLIENT_ORG_MISMATCH" ? new ClientOrgMismatchError$1(message) : /* @__PURE__ */ new Error(`client:register rejected: ${message}`);
1846
+ this.lastHandshakeError = err;
1847
+ this.wsLogger.error({
1848
+ code,
1849
+ message
1850
+ }, "client register rejected");
1808
1851
  this.emit("error", err);
1809
1852
  this.ws?.close(4403, "register rejected");
1810
1853
  return;
@@ -3118,7 +3161,7 @@ const createClaudeCodeHandler = (config) => {
3118
3161
  abortController,
3119
3162
  permissionMode,
3120
3163
  allowDangerouslySkipPermissions: true,
3121
- settingSources: ["project"],
3164
+ settingSources: ["user", "project"],
3122
3165
  env: buildEnv(sessionCtx),
3123
3166
  ...claudeCodeExecutable ? { pathToClaudeCodeExecutable: claudeCodeExecutable } : {},
3124
3167
  ...payload?.model ? { model: payload.model } : {},
@@ -4805,7 +4848,7 @@ async function createOwner(databaseUrl, username, orgName, displayName, password
4805
4848
  `);
4806
4849
  await tx.execute(sql`
4807
4850
  INSERT INTO agent_configs (agent_id, version, payload, updated_by)
4808
- VALUES (${agentId}, 1, ${sql`'{"prompt":{"append":""},"model":"","mcpServers":[],"env":[],"gitRepos":[]}'::jsonb`}, 'system')
4851
+ VALUES (${agentId}, 1, ${sql`'{"prompt":{"append":""},"model":"opus","mcpServers":[],"env":[],"gitRepos":[]}'::jsonb`}, 'system')
4809
4852
  ON CONFLICT (agent_id) DO NOTHING
4810
4853
  `);
4811
4854
  });
@@ -4838,6 +4881,96 @@ function makeUuidV7() {
4838
4881
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
4839
4882
  }
4840
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
4841
4974
  //#region src/core/client-runtime.ts
4842
4975
  /**
4843
4976
  * Client runtime — one shared ClientConnection, multiple agents multiplexed.
@@ -6146,7 +6279,7 @@ async function onboardCreate(args) {
6146
6279
  }
6147
6280
  const runtimeAgent = args.type === "human" ? args.assistant : args.id;
6148
6281
  if (args.feishuBotAppId && args.feishuBotAppSecret) {
6149
- const { bindFeishuBot } = await import("./feishu-CRNUI05I.mjs").then((n) => n.r);
6282
+ const { bindFeishuBot } = await import("./feishu-BJaN64iR.mjs").then((n) => n.r);
6150
6283
  const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
6151
6284
  if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
6152
6285
  else {
@@ -6287,7 +6420,764 @@ function setNestedByDot(obj, dotPath, value) {
6287
6420
  if (lastKey !== void 0) current[lastKey] = value;
6288
6421
  }
6289
6422
  //#endregion
6290
- //#region ../server/dist/app-BcAIFEPL.mjs
6423
+ //#region ../../node_modules/.pnpm/hearback-core@0.1.1/node_modules/hearback-core/dist/schema.js
6424
+ const FeedbackType = z.enum(["bug", "feature"]);
6425
+ const BrowserContext = z.object({
6426
+ url: z.string().optional(),
6427
+ userAgent: z.string().optional(),
6428
+ browser: z.string().optional(),
6429
+ os: z.string().optional(),
6430
+ viewport: z.string().optional()
6431
+ });
6432
+ const ConsoleError = z.object({
6433
+ message: z.string(),
6434
+ stack: z.string().optional(),
6435
+ timestamp: z.string().optional()
6436
+ });
6437
+ const FailedRequest = z.object({
6438
+ method: z.string(),
6439
+ url: z.string(),
6440
+ status: z.number().optional(),
6441
+ statusText: z.string().optional()
6442
+ });
6443
+ const AgentContext = z.object({
6444
+ toolCalls: z.array(z.object({
6445
+ name: z.string(),
6446
+ error: z.string().optional()
6447
+ })).optional(),
6448
+ errorTraces: z.array(z.string()).optional(),
6449
+ environment: z.record(z.string()).optional()
6450
+ });
6451
+ const FeedbackContext = z.object({
6452
+ source: z.enum(["web-plugin", "agent-skill"]),
6453
+ browser: BrowserContext.optional(),
6454
+ consoleErrors: z.array(ConsoleError).optional(),
6455
+ failedRequests: z.array(FailedRequest).optional(),
6456
+ agent: AgentContext.optional(),
6457
+ screenshotUrl: z.string().optional(),
6458
+ timestamp: z.string().optional(),
6459
+ extra: z.record(z.string()).optional()
6460
+ });
6461
+ const ReporterInfo = z.object({ email: z.string().email().optional() });
6462
+ const FeedbackReport = z.object({
6463
+ type: FeedbackType,
6464
+ title: z.string().min(5, "Title must be at least 5 characters"),
6465
+ description: z.string(),
6466
+ context: FeedbackContext,
6467
+ reporter: ReporterInfo.optional()
6468
+ });
6469
+ //#endregion
6470
+ //#region ../../node_modules/.pnpm/hearback-core@0.1.1/node_modules/hearback-core/dist/errors.js
6471
+ var HearbackError = class extends Error {
6472
+ code;
6473
+ constructor(message, code) {
6474
+ super(message);
6475
+ this.code = code;
6476
+ this.name = "HearbackError";
6477
+ }
6478
+ };
6479
+ var GitHubAuthError = class extends HearbackError {
6480
+ constructor(status, message, url = "") {
6481
+ let hint;
6482
+ if (status === 401) hint = "GitHub token is missing or expired.";
6483
+ else if (status === 403) if (url.includes("/contents/") || url.includes("/git/refs")) hint = "Token lacks \"contents:write\" scope. Needed for screenshot uploads and saving subscribers to the hearback-data branch.";
6484
+ else if (url.includes("/issues") || url.includes("/labels")) hint = "Token lacks \"issues:write\" scope. Needed to create issues, labels, and reactions.";
6485
+ else hint = `GitHub returned 403 (forbidden) for ${url || "API call"}. Check token permissions.`;
6486
+ else hint = `GitHub API returned ${status}: ${message}`;
6487
+ super(hint, "GITHUB_AUTH_ERROR");
6488
+ }
6489
+ };
6490
+ var GitHubRateLimitError = class extends HearbackError {
6491
+ resetAt;
6492
+ constructor(resetAt) {
6493
+ super(`GitHub API rate limit exceeded. Resets at ${resetAt.toISOString()}`, "GITHUB_RATE_LIMIT");
6494
+ this.resetAt = resetAt;
6495
+ }
6496
+ };
6497
+ //#endregion
6498
+ //#region ../../node_modules/.pnpm/hearback-core@0.1.1/node_modules/hearback-core/dist/github.js
6499
+ function parseRepo(repo) {
6500
+ const parts = repo.split("/");
6501
+ if (parts.length !== 2 || !parts[0] || !parts[1]) throw new HearbackError(`Invalid repo format: "${repo}". Expected "owner/name".`, "INVALID_REPO");
6502
+ return {
6503
+ owner: parts[0],
6504
+ name: parts[1]
6505
+ };
6506
+ }
6507
+ async function githubFetch(config, path, options = {}) {
6508
+ const base = config.apiBase ?? "https://api.github.com";
6509
+ const url = path.startsWith("http") ? path : `${base}${path}`;
6510
+ const res = await fetch(url, {
6511
+ ...options,
6512
+ headers: {
6513
+ Accept: "application/vnd.github.v3+json",
6514
+ Authorization: `Bearer ${config.token}`,
6515
+ "User-Agent": "hearback",
6516
+ ...options.headers
6517
+ }
6518
+ });
6519
+ if (res.status === 401 || res.status === 403) throw new GitHubAuthError(res.status, await res.text(), path);
6520
+ if (res.status === 429) {
6521
+ const resetHeader = res.headers.get("x-ratelimit-reset");
6522
+ throw new GitHubRateLimitError(resetHeader ? /* @__PURE__ */ new Date(Number(resetHeader) * 1e3) : /* @__PURE__ */ new Date());
6523
+ }
6524
+ return res;
6525
+ }
6526
+ async function createIssue(config, params) {
6527
+ const { owner, name } = parseRepo(config.repo);
6528
+ const res = await githubFetch(config, `/repos/${owner}/${name}/issues`, {
6529
+ method: "POST",
6530
+ body: JSON.stringify(params),
6531
+ headers: { "Content-Type": "application/json" }
6532
+ });
6533
+ if (!res.ok) throw new HearbackError(`Failed to create issue: ${res.status} ${await res.text()}`, "GITHUB_CREATE_ISSUE_FAILED");
6534
+ return res.json();
6535
+ }
6536
+ async function searchIssues(config, query) {
6537
+ const { owner, name } = parseRepo(config.repo);
6538
+ const res = await githubFetch(config, `/search/issues?q=${encodeURIComponent(`${query} repo:${owner}/${name} is:issue label:hearback`)}&per_page=5`);
6539
+ if (!res.ok) return {
6540
+ total_count: 0,
6541
+ items: []
6542
+ };
6543
+ return res.json();
6544
+ }
6545
+ async function addReaction(config, issueNumber, reaction = "+1") {
6546
+ const { owner, name } = parseRepo(config.repo);
6547
+ const res = await githubFetch(config, `/repos/${owner}/${name}/issues/${issueNumber}/reactions`, {
6548
+ method: "POST",
6549
+ body: JSON.stringify({ content: reaction }),
6550
+ headers: { "Content-Type": "application/json" }
6551
+ });
6552
+ if (!res.ok && res.status !== 422) throw new HearbackError(`Failed to add reaction: ${res.status}`, "GITHUB_REACTION_FAILED");
6553
+ }
6554
+ /** Read a file from a repo branch. Returns null if not found. */
6555
+ async function getRepoFile(config, path, branch) {
6556
+ const { owner, name } = parseRepo(config.repo);
6557
+ const res = await githubFetch(config, `/repos/${owner}/${name}/contents/${path}${branch ? `?ref=${branch}` : ""}`);
6558
+ if (res.status === 404) return null;
6559
+ if (!res.ok) throw new HearbackError(`Failed to read file ${path}: ${res.status}`, "GITHUB_READ_FILE_FAILED");
6560
+ const data = await res.json();
6561
+ return {
6562
+ content: data.encoding === "base64" ? Buffer.from(data.content, "base64").toString("utf-8") : data.content,
6563
+ sha: data.sha
6564
+ };
6565
+ }
6566
+ /** Write (create or update) a file on a repo branch. Requires sha for updates. */
6567
+ async function putRepoFile(config, params) {
6568
+ const { owner, name } = parseRepo(config.repo);
6569
+ const base64 = Buffer.isBuffer(params.content) ? params.content.toString("base64") : Buffer.from(params.content, "utf-8").toString("base64");
6570
+ const body = {
6571
+ message: params.message,
6572
+ content: base64
6573
+ };
6574
+ if (params.branch) body.branch = params.branch;
6575
+ if (params.sha) body.sha = params.sha;
6576
+ const res = await githubFetch(config, `/repos/${owner}/${name}/contents/${params.path}`, {
6577
+ method: "PUT",
6578
+ body: JSON.stringify(body),
6579
+ headers: { "Content-Type": "application/json" }
6580
+ });
6581
+ if (!res.ok) throw new HearbackError(`Failed to write file ${params.path}: ${res.status}`, "GITHUB_WRITE_FILE_FAILED");
6582
+ const data = await res.json();
6583
+ return {
6584
+ downloadUrl: data.content.download_url,
6585
+ htmlUrl: data.content.html_url,
6586
+ sha: data.content.sha
6587
+ };
6588
+ }
6589
+ /** Ensure a branch exists; creates it from default branch (main/master) if missing. */
6590
+ async function ensureBranch(config, branchName) {
6591
+ const { owner, name } = parseRepo(config.repo);
6592
+ const checkRes = await githubFetch(config, `/repos/${owner}/${name}/git/refs/heads/${branchName}`);
6593
+ if (checkRes.ok) return;
6594
+ if (checkRes.status !== 404) throw new HearbackError(`Failed to check branch: ${checkRes.status}`, "GITHUB_CHECK_BRANCH_FAILED");
6595
+ const repoRes = await githubFetch(config, `/repos/${owner}/${name}`);
6596
+ if (!repoRes.ok) throw new HearbackError(`Failed to read repo: ${repoRes.status}`, "GITHUB_READ_REPO_FAILED");
6597
+ const defaultRefRes = await githubFetch(config, `/repos/${owner}/${name}/git/refs/heads/${(await repoRes.json()).default_branch}`);
6598
+ if (!defaultRefRes.ok) throw new HearbackError(`Failed to read default branch: ${defaultRefRes.status}`, "GITHUB_READ_DEFAULT_BRANCH_FAILED");
6599
+ const defaultRef = await defaultRefRes.json();
6600
+ const createRes = await githubFetch(config, `/repos/${owner}/${name}/git/refs`, {
6601
+ method: "POST",
6602
+ body: JSON.stringify({
6603
+ ref: `refs/heads/${branchName}`,
6604
+ sha: defaultRef.object.sha
6605
+ }),
6606
+ headers: { "Content-Type": "application/json" }
6607
+ });
6608
+ if (!createRes.ok && createRes.status !== 422) throw new HearbackError(`Failed to create branch: ${createRes.status}`, "GITHUB_CREATE_BRANCH_FAILED");
6609
+ }
6610
+ async function uploadImage(config, imageData, filename) {
6611
+ const { downloadUrl } = await putRepoFile(config, {
6612
+ path: `_hearback-uploads/${Date.now()}-${filename}`,
6613
+ content: imageData,
6614
+ message: `[hearback] upload screenshot: ${filename}`
6615
+ });
6616
+ const tokenIdx = downloadUrl.indexOf("?token=");
6617
+ return tokenIdx === -1 ? downloadUrl : downloadUrl.slice(0, tokenIdx);
6618
+ }
6619
+ async function ensureLabels(config, labels) {
6620
+ const { owner, name } = parseRepo(config.repo);
6621
+ for (const label of labels) {
6622
+ const color = getLabelColor(label);
6623
+ const res = await githubFetch(config, `/repos/${owner}/${name}/labels`, {
6624
+ method: "POST",
6625
+ body: JSON.stringify({
6626
+ name: label,
6627
+ color
6628
+ }),
6629
+ headers: { "Content-Type": "application/json" }
6630
+ });
6631
+ if (!res.ok && res.status !== 422) throw new HearbackError(`Failed to create label "${label}": ${res.status}`, "GITHUB_LABEL_FAILED");
6632
+ }
6633
+ }
6634
+ function getLabelColor(label) {
6635
+ return label === "hearback" ? "F59E0B" : "CCCCCC";
6636
+ }
6637
+ //#endregion
6638
+ //#region ../../node_modules/.pnpm/hearback-core@0.1.1/node_modules/hearback-core/dist/formatter.js
6639
+ function formatIssueBody(report) {
6640
+ const sections = [];
6641
+ const heading = report.type === "bug" ? "Bug Report" : "Feature Request";
6642
+ sections.push(`## ${heading}`);
6643
+ sections.push(`**描述**: ${report.description}`);
6644
+ if (report.context.screenshotUrl) sections.push(`\n**截图**:\n![screenshot](${report.context.screenshotUrl})`);
6645
+ sections.push(formatContextTable(report));
6646
+ if (report.context.consoleErrors?.length) sections.push(formatConsoleErrors(report.context.consoleErrors));
6647
+ if (report.context.failedRequests?.length) sections.push(formatFailedRequests(report.context.failedRequests));
6648
+ if (report.context.agent?.toolCalls?.length) sections.push(formatToolCalls(report.context.agent.toolCalls));
6649
+ if (report.context.agent?.errorTraces?.length) sections.push(formatErrorTraces(report.context.agent.errorTraces));
6650
+ return sections.join("\n\n");
6651
+ }
6652
+ function formatContextTable(report) {
6653
+ const rows = [];
6654
+ rows.push(["来源", report.context.source]);
6655
+ if (report.context.browser?.url) rows.push(["页面", report.context.browser.url]);
6656
+ if (report.context.browser?.browser && report.context.browser?.os) rows.push(["浏览器", `${report.context.browser.browser} / ${report.context.browser.os}`]);
6657
+ else if (report.context.browser?.userAgent) rows.push(["User Agent", report.context.browser.userAgent]);
6658
+ if (report.context.timestamp) rows.push(["时间", report.context.timestamp]);
6659
+ if (report.context.extra) for (const [key, value] of Object.entries(report.context.extra)) rows.push([key, value]);
6660
+ if (rows.length === 0) return "";
6661
+ return [
6662
+ "### 自动收集的上下文",
6663
+ "",
6664
+ "| 字段 | 值 |",
6665
+ "|------|-----|",
6666
+ ...rows.map(([k, v]) => `| ${k} | ${v} |`)
6667
+ ].join("\n");
6668
+ }
6669
+ function formatConsoleErrors(errors) {
6670
+ return [
6671
+ "<details>",
6672
+ "<summary>Console Errors</summary>",
6673
+ "",
6674
+ ...errors.map((e) => {
6675
+ let entry = e.message;
6676
+ if (e.stack) entry += `\n ${e.stack}`;
6677
+ return entry;
6678
+ }),
6679
+ "",
6680
+ "</details>"
6681
+ ].join("\n");
6682
+ }
6683
+ function formatFailedRequests(requests) {
6684
+ return [
6685
+ "<details>",
6686
+ "<summary>Failed Network Requests</summary>",
6687
+ "",
6688
+ ...requests.map((r) => {
6689
+ let entry = `${r.method} ${r.url}`;
6690
+ if (r.status) entry += ` → ${r.status}`;
6691
+ if (r.statusText) entry += ` ${r.statusText}`;
6692
+ return entry;
6693
+ }),
6694
+ "",
6695
+ "</details>"
6696
+ ].join("\n");
6697
+ }
6698
+ function formatToolCalls(calls) {
6699
+ return [
6700
+ "<details>",
6701
+ "<summary>Tool Calls</summary>",
6702
+ "",
6703
+ ...calls.map((c) => {
6704
+ let entry = `- \`${c.name}\``;
6705
+ if (c.error) entry += ` — error: ${c.error}`;
6706
+ return entry;
6707
+ }),
6708
+ "",
6709
+ "</details>"
6710
+ ].join("\n");
6711
+ }
6712
+ function formatErrorTraces(traces) {
6713
+ return [
6714
+ "<details>",
6715
+ "<summary>Error Traces</summary>",
6716
+ "",
6717
+ ...traces.map((t) => `\`\`\`\n${t}\n\`\`\``),
6718
+ "",
6719
+ "</details>"
6720
+ ].join("\n");
6721
+ }
6722
+ const SUBSCRIBERS_PATH = "subscribers.json";
6723
+ function normalize(email) {
6724
+ return email.toLowerCase().trim();
6725
+ }
6726
+ async function readSubscribers(config, branch) {
6727
+ const file = await getRepoFile(config, SUBSCRIBERS_PATH, branch);
6728
+ if (!file) return { data: {} };
6729
+ try {
6730
+ return {
6731
+ data: JSON.parse(file.content),
6732
+ sha: file.sha
6733
+ };
6734
+ } catch {
6735
+ return {
6736
+ data: {},
6737
+ sha: file.sha
6738
+ };
6739
+ }
6740
+ }
6741
+ async function writeSubscribers(config, branch, data, sha, message) {
6742
+ await putRepoFile(config, {
6743
+ path: SUBSCRIBERS_PATH,
6744
+ content: JSON.stringify(data, null, 2),
6745
+ message,
6746
+ branch,
6747
+ sha
6748
+ });
6749
+ }
6750
+ /**
6751
+ * Add an email to an issue's subscriber list. Creates the data branch and file
6752
+ * if missing. Dedupes. Retries once on sha conflict (concurrent writes).
6753
+ */
6754
+ async function addSubscriber(config, issueNumber, email, options = {}) {
6755
+ const branch = options.branch ?? "hearback-data";
6756
+ const normalized = normalize(email);
6757
+ const key = String(issueNumber);
6758
+ await ensureBranch(config, branch);
6759
+ for (let attempt = 0; attempt < 2; attempt++) {
6760
+ const { data, sha } = await readSubscribers(config, branch);
6761
+ const existing = data[key] ?? [];
6762
+ if (existing.includes(normalized)) return;
6763
+ const updated = {
6764
+ ...data,
6765
+ [key]: [...existing, normalized]
6766
+ };
6767
+ try {
6768
+ await writeSubscribers(config, branch, updated, sha, `[hearback] add subscriber to #${issueNumber}`);
6769
+ return;
6770
+ } catch (err) {
6771
+ if (err instanceof HearbackError && err.code === "GITHUB_WRITE_FILE_FAILED" && attempt === 0) continue;
6772
+ throw err;
6773
+ }
6774
+ }
6775
+ }
6776
+ //#endregion
6777
+ //#region ../../node_modules/.pnpm/hearback-core@0.1.1/node_modules/hearback-core/dist/duplicates.js
6778
+ /**
6779
+ * Sanitize title for GitHub search. Removes characters that would break
6780
+ * search query syntax (quotes, colons, slashes used as operators) and
6781
+ * caps length. GitHub search handles stopwords and tokenization internally,
6782
+ * so we pass the cleaned title through directly.
6783
+ */
6784
+ function sanitizeTitle(title) {
6785
+ return title.replace(/["':/]/g, " ").replace(/\s+/g, " ").trim().slice(0, 100);
6786
+ }
6787
+ async function findDuplicates(config, title) {
6788
+ const query = sanitizeTitle(title);
6789
+ if (!query) return [];
6790
+ return (await searchIssues(config, query)).items.map((issue) => ({
6791
+ issueNumber: issue.number,
6792
+ title: issue.title,
6793
+ state: issue.state,
6794
+ url: issue.html_url
6795
+ }));
6796
+ }
6797
+ //#endregion
6798
+ //#region ../../node_modules/.pnpm/hearback-core@0.1.1/node_modules/hearback-core/dist/submit.js
6799
+ async function checkForDuplicates(config, title) {
6800
+ return findDuplicates(config, title);
6801
+ }
6802
+ async function subscribeToDuplicate(config, issueNumber, email) {
6803
+ await addReaction(config, issueNumber);
6804
+ await addSubscriber(config, issueNumber, email);
6805
+ }
6806
+ async function submitFeedback(options) {
6807
+ const { github, report } = options;
6808
+ FeedbackReport.parse(report);
6809
+ const labels = ["hearback"];
6810
+ if (report.type === "bug") labels.push("bug");
6811
+ if (report.type === "feature") labels.push("enhancement");
6812
+ await ensureLabels(github, labels);
6813
+ const body = formatIssueBody(report);
6814
+ const issue = await createIssue(github, {
6815
+ title: report.title,
6816
+ body,
6817
+ labels
6818
+ });
6819
+ let subscribeWarning;
6820
+ if (report.reporter?.email) try {
6821
+ await addSubscriber(github, issue.number, report.reporter.email);
6822
+ } catch (err) {
6823
+ subscribeWarning = `Issue created, but reporter email could not be saved (${err instanceof Error ? err.message : String(err)}). Hint: GitHub token needs "contents:write" scope to write the hearback-data branch for email notifications.`;
6824
+ console.warn(`[hearback] ${subscribeWarning}`);
6825
+ }
6826
+ return {
6827
+ issue,
6828
+ isDuplicate: false,
6829
+ subscribeWarning
6830
+ };
6831
+ }
6832
+ //#endregion
6833
+ //#region ../../node_modules/.pnpm/hearback-server@0.1.6/node_modules/hearback-server/dist/rate-limit.js
6834
+ var RateLimiter = class {
6835
+ submissions = /* @__PURE__ */ new Map();
6836
+ chatMessages = /* @__PURE__ */ new Map();
6837
+ config;
6838
+ constructor(config) {
6839
+ this.config = {
6840
+ maxSubmissions: config?.maxSubmissions ?? 10,
6841
+ maxChatMessages: config?.maxChatMessages ?? 30,
6842
+ windowMs: config?.windowMs ?? 36e5
6843
+ };
6844
+ }
6845
+ checkSubmission(ip) {
6846
+ return this.check(this.submissions, ip, this.config.maxSubmissions);
6847
+ }
6848
+ checkChatMessage(ip) {
6849
+ return this.check(this.chatMessages, ip, this.config.maxChatMessages);
6850
+ }
6851
+ check(bucket, ip, max) {
6852
+ const now = Date.now();
6853
+ const entry = bucket.get(ip);
6854
+ if (!entry || now >= entry.resetAt) {
6855
+ bucket.set(ip, {
6856
+ count: 1,
6857
+ resetAt: now + this.config.windowMs
6858
+ });
6859
+ return { allowed: true };
6860
+ }
6861
+ if (entry.count >= max) return {
6862
+ allowed: false,
6863
+ retryAfterMs: entry.resetAt - now
6864
+ };
6865
+ entry.count++;
6866
+ return { allowed: true };
6867
+ }
6868
+ };
6869
+ //#endregion
6870
+ //#region ../../node_modules/.pnpm/hearback-server@0.1.6/node_modules/hearback-server/dist/chat-prompt.js
6871
+ const FEEDBACK_CHAT_SYSTEM_PROMPT = `You are a feedback assistant. Help users report bugs or suggest features. Reply in the user's language.
6872
+
6873
+ ## Don't make users repeat themselves
6874
+ Use details from earlier messages in this conversation. Don't ask for things the user has already told you.
6875
+
6876
+ ## Default behavior
6877
+ - One sentence per reply. One question per turn. Never stack questions.
6878
+ - If the user's first message already contains symptom + specifics, SKIP questioning and go straight to the summary.
6879
+
6880
+ ## Bugs — ask ONLY what's missing, in this priority
6881
+ 1. Symptom — what did you see?
6882
+ 2. Trigger — always, or specific steps?
6883
+ 3. Expected — only if not obvious from the symptom.
6884
+
6885
+ ## Features — ask ONLY what's missing
6886
+ 1. Use case — what were you trying to do?
6887
+ 2. Workaround — how are you handling it today? (only if not stated)
6888
+
6889
+ ## Stop criterion
6890
+ Stop when you can write a specific title (NOT "bug found") and a description a developer can act on. Usually 1–3 exchanges.
6891
+
6892
+ ## Visual context — ask for a screenshot when it would help
6893
+ A picture is faster than a paragraph when the problem is visual. After the user's initial description, decide whether a screenshot would materially help a developer act on it. Ask once when:
6894
+ - The description mentions anything visual — layout, color, wrong element, "looks weird", position, styling, icon missing.
6895
+ - The description names a specific UI element but is vague about what the user is seeing ("the button didn't work" — a screenshot beats another round of questions).
6896
+ - The description is very short and a picture would fill in the gaps.
6897
+
6898
+ Phrase it as a concrete call to action, not a vague "do you have a screenshot":
6899
+ > "A screenshot would help here. Take one with your OS (Cmd+Shift+Ctrl+4 on Mac / Win+Shift+S on Windows), then paste it in or drag the file into this window."
6900
+
6901
+ Skip the screenshot ask entirely for purely behavioral / backend bugs — API errors, permissions, data loading, login failures. Those don't need a picture.
6902
+
6903
+ Ask at most once. If the user declines, says they can't, or just replies with text, move on with what you have. Don't ask again later in the same conversation.
6904
+
6905
+ ## Attachments already provided
6906
+ When a user message ends with \`[attachment: <name>]\` (or contains that pattern), the user has already attached an image. In that case:
6907
+ - Skip the "ask for a screenshot" step entirely — don't ask for one, don't re-ask even if the description sounds visual.
6908
+ - Treat the attachment as additional context confirming what the user described; the image itself isn't visible to you, but its presence means a developer will see it in the final issue.
6909
+ - Don't mention the marker back to the user.
6910
+
6911
+ ## Optional — email for fix notifications
6912
+ After you have title + summary but BEFORE the summary/confirmation turn, ask once:
6913
+ "Optional: want an email when this is fixed? (Skip to continue anonymously.)"
6914
+ - User provides an address → include it verbatim as \`email\` in the final JSON.
6915
+ - User declines / says "no" / "skip" / "anonymous" / silence → OMIT the \`email\` field entirely (don't emit "" or null).
6916
+ - Never ask twice. If the answer is unclear, treat it as skip.
6917
+ - Don't guess or fabricate an email.
6918
+
6919
+ ## Summary format (before confirmation)
6920
+ > **Title:** …
6921
+ > **Summary:** …
6922
+ > **Email:** … *(only if the user provided one; omit the line otherwise)*
6923
+ >
6924
+ > Submit this?
6925
+
6926
+ ## Confirmation — judge intent, don't match keywords
6927
+ "yes" / "go" / "就这样" / "可以了" / "submit" / "提交" / 👍 — all confirm.
6928
+ "wait" / "先别" / "let me add" — don't. Ambiguous → ask once more.
6929
+
6930
+ ## After confirmation — output ONLY this JSON, nothing else:
6931
+
6932
+ \`\`\`json
6933
+ {"ready":true,"type":"bug","title":"under 60 chars, specific","description":"markdown"}
6934
+ \`\`\`
6935
+
6936
+ \`type\` must be exactly "bug" or "feature" (never "bug_or_feature").
6937
+
6938
+ If (and only if) the user provided an email, add it as \`email\`:
6939
+
6940
+ \`\`\`json
6941
+ {"ready":true,"type":"bug","title":"…","description":"…","email":"user@example.com"}
6942
+ \`\`\`
6943
+
6944
+ Omit the \`email\` key entirely when the user skipped. Don't emit \`"email":""\` or \`"email":null\`.
6945
+
6946
+ Description format (suggested, not strict):
6947
+ - Bug: symptom / repro / expected
6948
+ - Feature: use case / proposed / workaround
6949
+
6950
+ Include the user's own words where they matter.
6951
+
6952
+ ## Hard rules
6953
+ - Never output JSON before confirmation. If unsure, ask once more.
6954
+ - If the user pastes a token / password / API key / secret, warn them and OMIT it from the report.
6955
+ - "Nevermind" / "算了" / "forget it" → stop cleanly.
6956
+ `;
6957
+ //#endregion
6958
+ //#region ../../node_modules/.pnpm/hearback-server@0.1.6/node_modules/hearback-server/dist/handler.js
6959
+ function createFeedbackHandler(config) {
6960
+ const rateLimiter = new RateLimiter(config.rateLimit);
6961
+ const ghConfig = {
6962
+ repo: config.repo,
6963
+ token: config.githubToken,
6964
+ apiBase: config.githubApiBase
6965
+ };
6966
+ const corsHeaders = {
6967
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
6968
+ "Access-Control-Allow-Headers": "Content-Type"
6969
+ };
6970
+ const origins = config.corsOrigins ?? ["*"];
6971
+ if (origins.includes("*")) corsHeaders["Access-Control-Allow-Origin"] = "*";
6972
+ function getCorsHeaders(requestOrigin) {
6973
+ if (origins.includes("*")) return corsHeaders;
6974
+ if (requestOrigin && origins.includes(requestOrigin)) return {
6975
+ ...corsHeaders,
6976
+ "Access-Control-Allow-Origin": requestOrigin
6977
+ };
6978
+ return corsHeaders;
6979
+ }
6980
+ async function handle(req) {
6981
+ const cors = getCorsHeaders(req.headers["origin"]);
6982
+ if (req.method === "OPTIONS") return {
6983
+ status: 204,
6984
+ headers: cors,
6985
+ body: null
6986
+ };
6987
+ if (req.method !== "POST") return {
6988
+ status: 405,
6989
+ headers: cors,
6990
+ body: { error: "Method not allowed" }
6991
+ };
6992
+ const route = req.path.replace(/\/$/, "");
6993
+ try {
6994
+ if (route.endsWith("/chat")) return await handleChat(req, cors);
6995
+ if (route.endsWith("/submit")) return await handleSubmit(req, cors);
6996
+ if (route.endsWith("/subscribe")) return await handleSubscribe(req, cors);
6997
+ if (route.endsWith("/upload")) return await handleUpload(req, cors);
6998
+ return {
6999
+ status: 404,
7000
+ headers: cors,
7001
+ body: { error: "Not found" }
7002
+ };
7003
+ } catch (err) {
7004
+ return {
7005
+ status: 500,
7006
+ headers: cors,
7007
+ body: { error: err instanceof Error ? err.message : "Internal error" }
7008
+ };
7009
+ }
7010
+ }
7011
+ async function handleChat(req, cors) {
7012
+ if (!config.llm) return {
7013
+ status: 501,
7014
+ headers: cors,
7015
+ body: { error: "LLM not configured. Use form mode." }
7016
+ };
7017
+ const rateCheck = rateLimiter.checkChatMessage(req.ip);
7018
+ if (!rateCheck.allowed) return {
7019
+ status: 429,
7020
+ headers: {
7021
+ ...cors,
7022
+ "Retry-After": String(Math.ceil((rateCheck.retryAfterMs ?? 36e5) / 1e3))
7023
+ },
7024
+ body: { error: "反馈过多,一小时后再试" }
7025
+ };
7026
+ const body = req.body;
7027
+ if (!body.messages || !Array.isArray(body.messages)) return {
7028
+ status: 400,
7029
+ headers: cors,
7030
+ body: { error: "Missing messages array" }
7031
+ };
7032
+ const messages = body.messages.slice(-10);
7033
+ const baseUrl = config.llm.baseUrl ?? "https://api.openai.com/v1";
7034
+ const model = config.llm.model ?? "gpt-4o-mini";
7035
+ const llmRes = await fetch(`${baseUrl}/chat/completions`, {
7036
+ method: "POST",
7037
+ headers: {
7038
+ "Content-Type": "application/json",
7039
+ Authorization: `Bearer ${config.llm.apiKey}`
7040
+ },
7041
+ body: JSON.stringify({
7042
+ model,
7043
+ messages: [{
7044
+ role: "system",
7045
+ content: FEEDBACK_CHAT_SYSTEM_PROMPT
7046
+ }, ...messages],
7047
+ stream: true
7048
+ })
7049
+ });
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
+ };
7071
+ }
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
+ };
7119
+ }
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
+ };
7150
+ }
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
+ }
7176
+ }
7177
+ return { handle };
7178
+ }
7179
+ //#endregion
7180
+ //#region ../server/dist/app-RWQiJW6_.mjs
6291
7181
  var __defProp = Object.defineProperty;
6292
7182
  var __exportAll = (all, no_symbols) => {
6293
7183
  let target = {};
@@ -6298,6 +7188,17 @@ var __exportAll = (all, no_symbols) => {
6298
7188
  if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
6299
7189
  return target;
6300
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
+ });
6301
7202
  /** User accounts. Passwords are stored as bcrypt hashes. */
6302
7203
  const users = pgTable("users", {
6303
7204
  id: text("id").primaryKey(),
@@ -6316,10 +7217,18 @@ const users = pgTable("users", {
6316
7217
  * every `agent:bind` request. `user_id` is nullable only to accommodate legacy
6317
7218
  * rows created before JWT-on-handshake; the WS handshake claims the row on
6318
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).
6319
7227
  */
6320
7228
  const clients = pgTable("clients", {
6321
7229
  id: text("id").primaryKey(),
6322
7230
  userId: text("user_id").references(() => users.id, { onDelete: "set null" }),
7231
+ organizationId: text("organization_id").notNull().references(() => organizations.id),
6323
7232
  status: text("status").notNull().default("disconnected"),
6324
7233
  sdkVersion: text("sdk_version"),
6325
7234
  hostname: text("hostname"),
@@ -6328,18 +7237,7 @@ const clients = pgTable("clients", {
6328
7237
  connectedAt: timestamp("connected_at", { withTimezone: true }),
6329
7238
  lastSeenAt: timestamp("last_seen_at", { withTimezone: true }).notNull().defaultNow(),
6330
7239
  metadata: jsonb("metadata").$type()
6331
- }, (table) => [index("idx_clients_user").on(table.userId)]);
6332
- /** Organization entity. Agents and chats belong to exactly one organization. */
6333
- const organizations = pgTable("organizations", {
6334
- id: text("id").primaryKey(),
6335
- name: text("name").unique().notNull(),
6336
- displayName: text("display_name").notNull(),
6337
- maxAgents: integer("max_agents").notNull().default(0),
6338
- maxMessagesPerMinute: integer("max_messages_per_minute").notNull().default(0),
6339
- features: jsonb("features").$type().notNull().default({}),
6340
- createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
6341
- updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
6342
- });
7240
+ }, (table) => [index("idx_clients_user").on(table.userId), index("idx_clients_org").on(table.organizationId)]);
6343
7241
  /** Agent registration. Each agent owns a unique inboxId for message delivery. */
6344
7242
  const agents = pgTable("agents", {
6345
7243
  uuid: text("uuid").primaryKey(),
@@ -6351,7 +7249,6 @@ const agents = pgTable("agents", {
6351
7249
  inboxId: text("inbox_id").unique().notNull(),
6352
7250
  status: text("status").notNull().default("active"),
6353
7251
  source: text("source"),
6354
- cloudUserId: text("cloud_user_id"),
6355
7252
  visibility: text("visibility").notNull().default("private"),
6356
7253
  metadata: jsonb("metadata").$type().notNull().default({}),
6357
7254
  managerId: text("manager_id").notNull(),
@@ -6413,6 +7310,20 @@ var BadRequestError = class extends AppError {
6413
7310
  this.name = "BadRequestError";
6414
7311
  }
6415
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
+ };
6416
7327
  /** Communication container. All messages between agents flow within a Chat. */
6417
7328
  const chats = pgTable("chats", {
6418
7329
  id: text("id").primaryKey(),
@@ -7266,14 +8177,19 @@ async function resolveAgentClient(db, data) {
7266
8177
  return null;
7267
8178
  }
7268
8179
  if (!data.clientId) return null;
7269
- const [manager] = await db.select({ userId: members.userId }).from(members).where(eq(members.id, data.managerId)).limit(1);
8180
+ const [manager] = await db.select({
8181
+ userId: members.userId,
8182
+ organizationId: members.organizationId
8183
+ }).from(members).where(eq(members.id, data.managerId)).limit(1);
7270
8184
  if (!manager) throw new BadRequestError(`Manager "${data.managerId}" not found`);
7271
8185
  const [client] = await db.select({
7272
8186
  id: clients.id,
7273
- userId: clients.userId
8187
+ userId: clients.userId,
8188
+ organizationId: clients.organizationId
7274
8189
  }).from(clients).where(eq(clients.id, data.clientId)).limit(1);
7275
8190
  if (!client) throw new BadRequestError(`Client "${data.clientId}" not found`);
7276
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.`);
7277
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.`);
7278
8194
  return client.id;
7279
8195
  }
@@ -7372,7 +8288,6 @@ async function listAgentsForAdmin(db, scope, limit, cursor) {
7372
8288
  delegateMention: agents.delegateMention,
7373
8289
  inboxId: agents.inboxId,
7374
8290
  status: agents.status,
7375
- cloudUserId: agents.cloudUserId,
7376
8291
  visibility: agents.visibility,
7377
8292
  metadata: agents.metadata,
7378
8293
  managerId: agents.managerId,
@@ -7410,7 +8325,6 @@ async function listAgentsForMember(db, scope, limit, cursor, type) {
7410
8325
  delegateMention: agents.delegateMention,
7411
8326
  inboxId: agents.inboxId,
7412
8327
  status: agents.status,
7413
- cloudUserId: agents.cloudUserId,
7414
8328
  visibility: agents.visibility,
7415
8329
  metadata: agents.metadata,
7416
8330
  managerId: agents.managerId,
@@ -7940,22 +8854,23 @@ async function cleanupStalePresence(db, staleSeconds = 60) {
7940
8854
  * Assert the caller can act on this client. Throws 404 for both "not found"
7941
8855
  * and "not yours" to prevent UUID enumeration across org/user boundaries.
7942
8856
  *
7943
- * - member: owner match (`row.user_id == scope.userId`).
7944
- * - admin: any client whose owner is a member of the admin's own org.
8857
+ * A client is bound to exactly one organization (`clients.organization_id`).
8858
+ * Access is granted when:
8859
+ * - member: row.user_id == scope.userId AND row.organization_id == scope.organizationId.
8860
+ * - admin: row.organization_id == scope.organizationId AND the owner is a
8861
+ * member of that same org (defense in depth).
7945
8862
  *
7946
- * Legacy unclaimed rows (`user_id IS NULL`) have no org association we can
7947
- * verify we explicitly refuse to grant admin access to them so a
7948
- * cross-tenant admin can't operate on another org's orphan rows. These
7949
- * orphans are surfaced for self-service re-registration only; the owning
7950
- * operator must claim the row via `first-tree-hub connect` before any
7951
- * 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.
7952
8865
  */
7953
8866
  async function assertClientOwner(db, clientId, scope) {
7954
8867
  const [row] = await db.select({
7955
8868
  id: clients.id,
7956
- userId: clients.userId
8869
+ userId: clients.userId,
8870
+ organizationId: clients.organizationId
7957
8871
  }).from(clients).where(eq(clients.id, clientId)).limit(1);
7958
8872
  if (!row) throw new NotFoundError(`Client "${clientId}" not found`);
8873
+ if (row.organizationId !== scope.organizationId) throw new NotFoundError(`Client "${clientId}" not found`);
7959
8874
  if (row.userId === scope.userId) return;
7960
8875
  if (scope.role === "admin" && row.userId !== null) {
7961
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);
@@ -7966,24 +8881,29 @@ async function assertClientOwner(db, clientId, scope) {
7966
8881
  /**
7967
8882
  * Upsert the clients row for a given `client_id` under an authenticated user.
7968
8883
  *
7969
- * Claim semantics (see proposal M13):
7970
- * - New client_id → INSERT with the authenticated user_id.
7971
- * - Existing row with `user_id IS NULL`claim it (set user_id).
7972
- * - Existing row with a different user_id → {@link ForbiddenError}; the
7973
- * operator must pick a different clientId. This is a hard conflict rather
7974
- * than a silent override because the pinned agents under that client
7975
- * belong to the original owner.
8884
+ * Claim semantics (see proposal M13 + multi-tenancy hardening):
8885
+ * - New client_id → INSERT with the authenticated user_id and org_id.
8886
+ * - Existing row with the same user_id + org_idrefresh runtime columns.
8887
+ * - Existing row in a different org → {@link ClientOrgMismatchError}. A
8888
+ * client is bound to one org for its lifetime; the CLI reacts by
8889
+ * abandoning the local clientId and registering a new one.
8890
+ * - Existing row with a different user_id (same org) → {@link ForbiddenError};
8891
+ * the operator must pick a different clientId. Hard conflict because
8892
+ * pinned agents under that client belong to the original owner.
7976
8893
  */
7977
8894
  async function registerClient(db, data) {
7978
8895
  const now = /* @__PURE__ */ new Date();
7979
8896
  const [existing] = await db.select({
7980
8897
  id: clients.id,
7981
- userId: clients.userId
8898
+ userId: clients.userId,
8899
+ organizationId: clients.organizationId
7982
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.`);
7983
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.`);
7984
8903
  await db.insert(clients).values({
7985
8904
  id: data.clientId,
7986
8905
  userId: data.userId,
8906
+ organizationId: data.organizationId,
7987
8907
  status: "connected",
7988
8908
  instanceId: data.instanceId,
7989
8909
  hostname: data.hostname ?? null,
@@ -8044,18 +8964,13 @@ async function listActiveAgentsPinnedToClient(db, clientId) {
8044
8964
  /**
8045
8965
  * Scope-aware client listing.
8046
8966
  *
8047
- * - member: only rows where `user_id = scope.userId`.
8048
- * - admin: every claimed row whose owner is a member of the caller's
8049
- * organization.
8050
- *
8051
- * Legacy unclaimed rows (`user_id IS NULL`) are intentionally hidden from
8052
- * both roles — the `clients` table has no org column, so we cannot verify
8053
- * which org an orphan belongs to. Exposing them to admin would leak
8054
- * orphans across tenants. The owning operator reclaims the row via
8055
- * `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.
8056
8971
  */
8057
8972
  async function listClients(db, scope) {
8058
- const rows = scope.role === "admin" ? await db.selectDistinct({
8973
+ const rows = scope.role === "admin" ? await db.select({
8059
8974
  id: clients.id,
8060
8975
  userId: clients.userId,
8061
8976
  status: clients.status,
@@ -8066,7 +8981,7 @@ async function listClients(db, scope) {
8066
8981
  connectedAt: clients.connectedAt,
8067
8982
  lastSeenAt: clients.lastSeenAt,
8068
8983
  metadata: clients.metadata
8069
- }).from(clients).innerJoin(members, eq(members.userId, clients.userId)).where(eq(members.organizationId, scope.organizationId)) : await db.select().from(clients).where(eq(clients.userId, scope.userId));
8984
+ }).from(clients).where(eq(clients.organizationId, scope.organizationId)) : await db.select().from(clients).where(and(eq(clients.userId, scope.userId), eq(clients.organizationId, scope.organizationId)));
8070
8985
  const counts = await db.select({
8071
8986
  clientId: agents.clientId,
8072
8987
  count: sql`count(*)::int`
@@ -8104,6 +9019,14 @@ async function retireClient(db, clientId) {
8104
9019
  await tx.delete(clients).where(eq(clients.id, clientId));
8105
9020
  });
8106
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
+ */
8107
9030
  async function cleanupStaleClients(db, staleSeconds = 60) {
8108
9031
  const result = await db.execute(sql`
8109
9032
  UPDATE clients SET status = 'disconnected'
@@ -11367,6 +12290,7 @@ function clientWsRoutes(notifier, instanceId) {
11367
12290
  await registerClient(app.db, {
11368
12291
  clientId: data.clientId,
11369
12292
  userId: session.userId,
12293
+ organizationId: session.organizationId,
11370
12294
  instanceId,
11371
12295
  hostname: data.hostname,
11372
12296
  os: data.os,
@@ -11374,9 +12298,11 @@ function clientWsRoutes(notifier, instanceId) {
11374
12298
  });
11375
12299
  } catch (err) {
11376
12300
  const message = err instanceof Error ? err.message : "client register failed";
12301
+ const code = err instanceof ClientOrgMismatchError ? err.code : void 0;
11377
12302
  socket.send(JSON.stringify({
11378
12303
  type: "client:register:rejected",
11379
- message
12304
+ message,
12305
+ ...code ? { code } : {}
11380
12306
  }));
11381
12307
  socket.close(4403, "client register rejected");
11382
12308
  return;
@@ -11806,6 +12732,65 @@ async function contextTreeInfoRoutes(app) {
11806
12732
  };
11807
12733
  });
11808
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
+ }
11809
12794
  async function healthRoutes(app) {
11810
12795
  app.get("/health", async () => {
11811
12796
  try {
@@ -13868,6 +14853,21 @@ async function buildApp(config) {
13868
14853
  await api.register(clientWsRoutes(notifier, config.instanceId), { prefix: "/agent/ws" });
13869
14854
  await api.register(adminWsRoutes(notifier, config.secrets.jwtSecret), { prefix: "/ws" });
13870
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
+ }
13871
14871
  const webDistPath = config.webDistPath;
13872
14872
  if (webDistPath) {
13873
14873
  const webRoot = resolve(webDistPath);
@@ -14426,4 +15426,4 @@ function createExecuteUpdate({ managed }) {
14426
15426
  };
14427
15427
  }
14428
15428
  //#endregion
14429
- export { checkServerHealth as A, resolveReplyToFromEnv as B, runMigrations as C, checkDocker as D, checkDatabase as E, isDockerAvailable as F, FirstTreeHubSDK as G, print as H, stopPostgres as I, cleanWorkspaces as J, SdkError as K, ClientRuntime as L, checkWebSocket as M, printResults as N, checkNodeVersion as O, ensurePostgres as P, createOwner as R, uninstallClientService as S, checkClientConfig as T, setJsonMode as U, blank as V, status as W, configureClientLoggerForService as X, applyClientLoggerConfig as Y, runHomeMigration as _, showServiceLogs as a, isServiceSupported as b, COMMAND_VERSION as c, promptMissingFields as d, formatCheckReport as f, saveOnboardState as g, onboardCreate as h, parseDuration as i, checkServerReachable as j, checkServerConfig as k, isInteractive as l, onboardCheck as m, declineUpdate as n, validateLevel as o, loadOnboardState as p, SessionRegistry as q, promptUpdate as r, startServer as s, createExecuteUpdate as t, promptAddAgent as u, getClientServiceStatus as v, checkAgentConfigs as w, resolveCliInvocation as x, installClientService as y, hasUser as z };
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 };