@agent-team-foundation/first-tree-hub 0.12.4 → 0.12.6

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.
@@ -576,12 +576,22 @@ const serverConfigSchema = defineConfig({
576
576
  }),
577
577
  githubTokenRepos: field(z.string().optional(), { env: "FIRST_TREE_HUB_CONTEXT_TREE_GITHUB_TOKEN_REPOS" })
578
578
  }),
579
- oauth: optional({ github: optional({
580
- clientId: field(z.string(), { env: "FIRST_TREE_HUB_GITHUB_OAUTH_CLIENT_ID" }),
581
- clientSecret: field(z.string(), {
582
- env: "FIRST_TREE_HUB_GITHUB_OAUTH_CLIENT_SECRET",
579
+ oauth: optional({ githubApp: optional({
580
+ appId: field(z.string().min(1), { env: "FIRST_TREE_HUB_GITHUB_APP_ID" }),
581
+ clientId: field(z.string().min(1), { env: "FIRST_TREE_HUB_GITHUB_APP_CLIENT_ID" }),
582
+ clientSecret: field(z.string().min(1), {
583
+ env: "FIRST_TREE_HUB_GITHUB_APP_CLIENT_SECRET",
583
584
  secret: true
584
- })
585
+ }),
586
+ privateKeyPem: field(z.string().min(1), {
587
+ env: "FIRST_TREE_HUB_GITHUB_APP_PRIVATE_KEY",
588
+ secret: true
589
+ }),
590
+ webhookSecret: field(z.string().min(1), {
591
+ env: "FIRST_TREE_HUB_GITHUB_APP_WEBHOOK_SECRET",
592
+ secret: true
593
+ }),
594
+ slug: field(z.string().min(1).optional(), { env: "FIRST_TREE_HUB_GITHUB_APP_SLUG" })
585
595
  }) }),
586
596
  cors: optional({ origin: field(z.string(), { env: "FIRST_TREE_HUB_CORS_ORIGIN" }) }),
587
597
  trustProxy: field(z.boolean().default(false), { env: "FIRST_TREE_HUB_TRUST_PROXY" }),
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import "../observability-BAScT_5S-BcW9HgkG.mjs";
3
- import { $ as formatStaleReason, A as checkDocker, B as isServiceSupported, C as createApiNameResolver, D as checkBackgroundService, E as checkAgentConfigs, F as checkWebSocket, H as restartClientService, I as printResults, J as stopPostgres, L as reconcileAgentConfigs, M as checkServerConfig, N as checkServerHealth, O as checkClientConfig, P as checkServerReachable, Q as findStaleAliases, R as getClientServiceStatus, S as runHomeMigration, T as runMigrations, U as startClientService, W as stopClientService, X as handleClientOrgMismatch, Y as ClientRuntime, _ as formatCheckReport, a as declineUpdate, at as success, b as onboardCreate, c as detectInstallMode, ct as FirstTreeHubSDK, d as startServer, dt as cleanWorkspaces, et as removeLocalAgent, f as reconcileLocalRuntimeProviders, ft as probeCapabilities, g as promptMissingFields, h as promptAddAgent, i as createExecuteUpdate, it as fail, j as checkNodeVersion, k as checkDatabase, l as fetchLatestVersion, lt as SdkError, m as isInteractive, mt as configureClientLoggerForService, o as promptUpdate, ot as ClientOrgMismatchError, p as uploadClientCapabilities, pt as applyClientLoggerConfig, r as registerSaaSConnectCommand, rt as resolveReplyToFromEnv, s as PACKAGE_NAME, st as ClientUserMismatchError, tt as createOwner, u as installGlobalLatest, ut as SessionRegistry, v as loadOnboardState, w as migrateLocalAgentDirs, x as saveOnboardState, y as onboardCheck, z as installClientService } from "../saas-connect-S71rG182.mjs";
3
+ import { $ as formatStaleReason, A as checkDocker, B as isServiceSupported, C as createApiNameResolver, D as checkBackgroundService, E as checkAgentConfigs, F as checkWebSocket, H as restartClientService, I as printResults, J as stopPostgres, L as reconcileAgentConfigs, M as checkServerConfig, N as checkServerHealth, O as checkClientConfig, P as checkServerReachable, Q as findStaleAliases, R as getClientServiceStatus, S as runHomeMigration, T as runMigrations, U as startClientService, W as stopClientService, X as handleClientOrgMismatch, Y as ClientRuntime, _ as formatCheckReport, a as declineUpdate, at as fail, b as onboardCreate, c as detectInstallMode, ct as ClientUserMismatchError, d as startServer, dt as SessionRegistry, et as removeLocalAgent, f as reconcileLocalRuntimeProviders, ft as cleanWorkspaces, g as promptMissingFields, h as promptAddAgent, ht as configureClientLoggerForService, i as createExecuteUpdate, it as resolveSenderName, j as checkNodeVersion, k as checkDatabase, l as fetchLatestVersion, lt as FirstTreeHubSDK, m as isInteractive, mt as applyClientLoggerConfig, o as promptUpdate, ot as success, p as uploadClientCapabilities, pt as probeCapabilities, r as registerSaaSConnectCommand, rt as resolveReplyToFromEnv, s as PACKAGE_NAME, st as ClientOrgMismatchError, tt as createOwner, u as installGlobalLatest, ut as SdkError, v as loadOnboardState, w as migrateLocalAgentDirs, x as saveOnboardState, y as onboardCheck, z as installClientService } from "../saas-connect-RCN8zL5e.mjs";
4
4
  import "../logger-core-BTmvdflj-DjW8FM4T.mjs";
5
- import { C as resetConfigMeta, E as setConfigValue, S as resetConfig, T as serverConfigSchema, _ as getConfigValue, a as ensureFreshAdminToken, c as resolveServerUrl, d as DEFAULT_CONFIG_DIR, f as DEFAULT_DATA_DIR, h as clientConfigSchema, i as ensureFreshAccessToken, l as saveAgentConfig, m as agentConfigSchema, o as loadCredentials, p as DEFAULT_HOME_DIR, u as saveCredentials, v as initConfig, w as resolveConfigReadonly, x as readConfigFile, y as loadAgents } from "../bootstrap-C_K2CKXC.mjs";
5
+ import { C as resetConfigMeta, E as setConfigValue, S as resetConfig, T as serverConfigSchema, _ as getConfigValue, a as ensureFreshAdminToken, c as resolveServerUrl, d as DEFAULT_CONFIG_DIR, f as DEFAULT_DATA_DIR, h as clientConfigSchema, i as ensureFreshAccessToken, l as saveAgentConfig, m as agentConfigSchema, o as loadCredentials, p as DEFAULT_HOME_DIR, u as saveCredentials, v as initConfig, w as resolveConfigReadonly, x as readConfigFile, y as loadAgents } from "../bootstrap-BCZC1ki6.mjs";
6
6
  import { a as print, n as CLI_USER_AGENT, o as setJsonMode, r as COMMAND_VERSION, t as cliFetch } from "../cli-fetch--tiwKm5S.mjs";
7
- import "../dist-CMhywpXB.mjs";
8
- import { n as bindFeishuUser, t as bindFeishuBot } from "../feishu-tkZS0vvL.mjs";
7
+ import "../dist-xP6NpdMp.mjs";
8
+ import { n as bindFeishuUser, t as bindFeishuBot } from "../feishu-CsfadBKa.mjs";
9
9
  import "../errors-CF5evtJt-B0NTIVPt.mjs";
10
10
  import "../src-DNBS5Yjj.mjs";
11
- import "../client-D1TDiik_-NV_lkhfI.mjs";
11
+ import "../client-B89AKi3Q-DAyGdQSq.mjs";
12
12
  import "../invitation-Bg0TRiyx-BsZH4GCS.mjs";
13
13
  import { join } from "node:path";
14
14
  import { existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
@@ -215,14 +215,16 @@ function resolveLocalAgent(agentName) {
215
215
  schema: agentConfigSchema,
216
216
  agentsDir
217
217
  });
218
- if (agents.size === 0) fail("MISSING_AGENT", "No agent configured. Run `first-tree-hub agent add` first.", 2);
218
+ const resolution = resolveSenderName({
219
+ override: agentName,
220
+ envAgentId: process.env.FIRST_TREE_HUB_AGENT_ID,
221
+ agents
222
+ });
219
223
  let resolvedName;
220
- if (agentName) resolvedName = agentName;
221
- else if (agents.size === 1) {
222
- const [only] = [...agents.keys()];
223
- if (!only) fail("MISSING_AGENT", "No agent configured. Run `first-tree-hub agent add` first.", 2);
224
- resolvedName = only;
225
- } else fail("AMBIGUOUS_AGENT", `Multiple agents configured — specify --agent <name>. Available: ${[...agents.keys()].join(", ")}`, 2);
224
+ if (resolution.kind === "ok") resolvedName = resolution.name;
225
+ else if (resolution.kind === "none") fail("MISSING_AGENT", "No agent configured. Run `first-tree-hub agent add` first.", 2);
226
+ else if (resolution.kind === "envMismatch") fail("ENV_AGENT_NOT_LOCAL", `FIRST_TREE_HUB_AGENT_ID="${resolution.envAgentId}" is not configured on this machine. Available local agents: ${resolution.available.join(", ")}. Pick one explicitly: \`first-tree-hub agent send --agent <senderName> <recipientName> "..."\`.`, 2);
227
+ else fail("AMBIGUOUS_AGENT", `Multiple agents are configured on this machine (${resolution.available.join(", ")}) and FIRST_TREE_HUB_AGENT_ID is not set, so the CLI can't tell which one is the sender. Specify it explicitly: \`first-tree-hub agent send --agent <senderName> <recipientName> "..."\` (--agent picks the SENDER; the recipient is the next positional argument).`, 2);
226
228
  const cfg = agents.get(resolvedName);
227
229
  if (!cfg) fail("UNKNOWN_AGENT", `Agent "${resolvedName}" not found in ${agentsDir}`, 2);
228
230
  let serverUrl;
@@ -1670,13 +1672,13 @@ function isSecretField(schema, dotPath) {
1670
1672
  //#region src/commands/onboard.ts
1671
1673
  async function promptMissing(args) {
1672
1674
  if (!args.server) try {
1673
- const { resolveServerUrl } = await import("../bootstrap-C_K2CKXC.mjs").then((n) => n.r);
1675
+ const { resolveServerUrl } = await import("../bootstrap-BCZC1ki6.mjs").then((n) => n.r);
1674
1676
  resolveServerUrl();
1675
1677
  } catch {
1676
1678
  args.server = await input({ message: "Hub server URL:" });
1677
1679
  saveOnboardState(args);
1678
1680
  }
1679
- const { loadCredentials } = await import("../bootstrap-C_K2CKXC.mjs").then((n) => n.r);
1681
+ const { loadCredentials } = await import("../bootstrap-BCZC1ki6.mjs").then((n) => n.r);
1680
1682
  if (!loadCredentials()) throw new Error("No saved credentials. Run `first-tree-hub client connect <server-url>` before onboarding.");
1681
1683
  if (!args.id) {
1682
1684
  args.id = await input({ message: "Agent ID:" });
@@ -1,10 +1,10 @@
1
1
  import { O as withSpan, f as messageAttrs, s as createLogger } from "./observability-BAScT_5S-BcW9HgkG.mjs";
2
- import { A as extractMentions, X as questionMessageContentSchema, Y as questionAnswerMessageContentSchema, a as AGENT_VISIBILITY, g as clientCapabilitiesSchema, i as AGENT_STATUSES, tt as scanMentionTokens } from "./dist-CMhywpXB.mjs";
2
+ import { $ as questionMessageContentSchema, M as extractMentions, O as defaultParticipantMode, Q as questionAnswerMessageContentSchema, _ as clientCapabilitiesSchema, a as AGENT_VISIBILITY, h as agentTypeSchema, i as AGENT_STATUSES, it as scanMentionTokens } from "./dist-xP6NpdMp.mjs";
3
3
  import { a as ConflictError, i as ClientUserMismatchError, l as organizations, n as BadRequestError, o as ForbiddenError, s as NotFoundError, u as users } from "./errors-CF5evtJt-B0NTIVPt.mjs";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { and, desc, eq, inArray, isNotNull, lt, ne, or, sql } from "drizzle-orm";
6
6
  import { bigserial, boolean, index, integer, jsonb, pgTable, primaryKey, text, timestamp, unique } from "drizzle-orm/pg-core";
7
- //#region ../server/dist/client-D1TDiik_.mjs
7
+ //#region ../server/dist/client-B89AKi3Q.mjs
8
8
  /**
9
9
  * Client connections. A client is a single SDK process (AgentRuntime) that may
10
10
  * host multiple agents. From the unified-user-token milestone on, a client is
@@ -185,6 +185,140 @@ function invalidateChatAudience(chatId) {
185
185
  cache.delete(chatId);
186
186
  }
187
187
  /**
188
+ * Single source of truth for writing `chat_participants`.
189
+ *
190
+ * **This is the ONLY place in the codebase that may `INSERT` into the
191
+ * `chat_participants` table.** Do not call `tx.insert(chatParticipants)`
192
+ * or `db.insert(chatParticipants)` from anywhere else. The original bug
193
+ * (docs/chat-participant-mode-fix-design.md §1.1) was caused by ten
194
+ * scattered insert sites each re-deriving the `mode` rule, several of
195
+ * which violated `group + non-human ⇒ mention_only`. Re-introducing a
196
+ * second writer reopens that hole — please don't.
197
+ *
198
+ * Test fixtures under `src/__tests__/` that deliberately seed
199
+ * pathological rows (e.g. cross-org pollution tests) may bypass this
200
+ * rule; they are setting up "what bad data looks like" rather than
201
+ * exercising the production write path.
202
+ *
203
+ * All callers that need to add a participant — `createChat`, `addParticipant`,
204
+ * `ensureParticipant`, `joinChat`, `createMeChat`, `addMeChatParticipants`,
205
+ * `findOrCreateDirectChat`, `findOrCreateChatForChannel`, `joinAsParticipant`,
206
+ * … — go through `addChatParticipants`. The function performs ONE round-trip
207
+ * to read `chats.type` + every involved `agents.type`, runs each row through
208
+ * `defaultParticipantMode`, and inserts the result. `agents.type` is parsed
209
+ * through the shared `agentTypeSchema` so schema drift surfaces loudly
210
+ * instead of silently coercing to a default.
211
+ *
212
+ * `changeChatType` complements it on the type-flip path: when a `direct`
213
+ * chat is being upgraded to `group` by the very next participant insert, the
214
+ * existing non-human rows must be re-graded to `mention_only`. Callers that
215
+ * trigger an upgrade are expected to invoke `changeChatType` BEFORE
216
+ * `addChatParticipants`, inside the same transaction, so the new row picks
217
+ * up the post-upgrade `chats.type` and existing rows get re-graded together.
218
+ */
219
+ /**
220
+ * Insert participant rows whose `mode` is derived from `(chats.type, agents.type)`.
221
+ *
222
+ * Reads:
223
+ * - `chats.type` for the target chat (NotFoundError on missing)
224
+ * - `agents.type` for every requested participant (BadRequestError on missing)
225
+ *
226
+ * Mode derivation:
227
+ * - for each row, `peerAgentTypes` is the type of every OTHER participant
228
+ * being inserted in the same call PLUS every EXISTING participant of
229
+ * the chat. This matters only for `direct` chats; the helper ignores
230
+ * it for `group` / `thread`.
231
+ *
232
+ * Writes one INSERT (multi-row) per call.
233
+ *
234
+ * No watcher / audience-cache side effects — the caller owns those, since
235
+ * different entrypoints have different surrounding work (state-carry, watcher
236
+ * recompute, audience invalidation). Keeping this module side-effect-free
237
+ * makes it testable from any tx context.
238
+ */
239
+ async function addChatParticipants(tx, chatId, participants, options = {}) {
240
+ if (participants.length === 0) return;
241
+ const [chat] = await tx.select({ type: chats.type }).from(chats).where(eq(chats.id, chatId)).limit(1);
242
+ if (!chat) throw new NotFoundError(`Chat "${chatId}" not found`);
243
+ if (chat.type !== "direct" && chat.type !== "group" && chat.type !== "thread") throw new Error(`Unexpected chat type "${chat.type}" for chat "${chatId}"`);
244
+ const chatType = chat.type;
245
+ const agentIds = participants.map((p) => p.agentId);
246
+ const agentRows = await tx.select({
247
+ uuid: agents.uuid,
248
+ type: agents.type
249
+ }).from(agents).where(inArray(agents.uuid, agentIds));
250
+ const agentTypeById = /* @__PURE__ */ new Map();
251
+ for (const row of agentRows) agentTypeById.set(row.uuid, row.type);
252
+ const missing = agentIds.filter((id) => !agentTypeById.has(id));
253
+ if (missing.length > 0) throw new BadRequestError(`Agents not found: ${missing.join(", ")}`);
254
+ if (options.assertHuman) {
255
+ const nonHuman = agentRows.filter((a) => a.type !== "human");
256
+ if (nonHuman.length > 0) throw new BadRequestError(`assertHuman violated: agents must be of type 'human' but got ${nonHuman.map((a) => `${a.uuid}=${a.type}`).join(", ")}`);
257
+ }
258
+ let existingAgentTypes = [];
259
+ if (chatType === "direct") existingAgentTypes = await loadExistingAgentTypes(tx, chatId, new Set(agentIds));
260
+ const rows = participants.map((spec) => {
261
+ const rawAgentType = agentTypeById.get(spec.agentId);
262
+ if (rawAgentType === void 0) throw new Error("Unexpected: agent type lookup unset after presence check");
263
+ const agentType = agentTypeSchema.parse(rawAgentType);
264
+ const peerTypesForRow = chatType === "direct" ? [...existingAgentTypes, ...participants.filter((p) => p.agentId !== spec.agentId).map((p) => agentTypeById.get(p.agentId)).filter((t) => t !== void 0)].map((t) => agentTypeSchema.parse(t)) : [];
265
+ return {
266
+ chatId,
267
+ agentId: spec.agentId,
268
+ role: spec.role ?? "member",
269
+ mode: defaultParticipantMode(chatType, agentType, peerTypesForRow),
270
+ lastReadAt: spec.carriedReadState?.lastReadAt ?? null,
271
+ unreadMentionCount: spec.carriedReadState?.unreadMentionCount ?? 0
272
+ };
273
+ });
274
+ const insert = tx.insert(chatParticipants).values(rows);
275
+ if (options.onConflictDoNothing) await insert.onConflictDoNothing({ target: [chatParticipants.chatId, chatParticipants.agentId] });
276
+ else await insert;
277
+ }
278
+ async function loadExistingAgentTypes(tx, chatId, excludeAgentIds) {
279
+ return (await tx.select({
280
+ type: agents.type,
281
+ agentId: chatParticipants.agentId
282
+ }).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(eq(chatParticipants.chatId, chatId))).filter((r) => !excludeAgentIds.has(r.agentId)).map((r) => r.type);
283
+ }
284
+ /**
285
+ * Upgrade `chats.type` from `direct` → `group` AND re-grade every existing
286
+ * non-human participant to `mention_only`. Idempotent: if `chat.type` is
287
+ * already `group` (or any non-`direct` value), no-op.
288
+ *
289
+ * Callers that are about to insert a 3rd participant on a `direct` chat
290
+ * invoke this BEFORE `addChatParticipants` so the new row picks up the
291
+ * post-upgrade `chats.type` and the existing rows are re-graded in the
292
+ * same transaction.
293
+ *
294
+ * Note: this is the replacement for `services/chat.ts`'s
295
+ * `maybeUpgradeDirectToGroup` (the one in `services/watcher.ts` is
296
+ * removed). Keep the rename: `changeChatType` is more precise about the
297
+ * primary mutation; `maybe…ToGroup` overstated the conditional gate.
298
+ */
299
+ async function changeChatType(tx, chatId, newType) {
300
+ const [chat] = await tx.select({ type: chats.type }).from(chats).where(eq(chats.id, chatId)).limit(1);
301
+ if (!chat) throw new NotFoundError(`Chat "${chatId}" not found`);
302
+ if (chat.type === newType) return;
303
+ if (newType === "group" && chat.type !== "direct") throw new BadRequestError(`Cannot change chat type from "${chat.type}" to "${newType}"`);
304
+ await tx.update(chats).set({
305
+ type: newType,
306
+ updatedAt: /* @__PURE__ */ new Date()
307
+ }).where(eq(chats.id, chatId));
308
+ const ids = (await tx.select({ agentId: chatParticipants.agentId }).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(and(eq(chatParticipants.chatId, chatId), ne(agents.type, "human")))).map((r) => r.agentId);
309
+ if (ids.length === 0) return;
310
+ await tx.update(chatParticipants).set({ mode: "mention_only" }).where(and(eq(chatParticipants.chatId, chatId), inArray(chatParticipants.agentId, ids)));
311
+ }
312
+ /**
313
+ * Heuristic for whether an insert about to happen would push the chat past
314
+ * the direct → group threshold. Pure helper so callers can decide whether
315
+ * to call `changeChatType` before `addChatParticipants` without re-deriving
316
+ * the rule locally.
317
+ */
318
+ function wouldUpgradeToGroup(currentParticipantCount, newParticipantCount) {
319
+ return currentParticipantCount + newParticipantCount >= 3;
320
+ }
321
+ /**
188
322
  * Chat-first workspace — watcher subscription helpers.
189
323
  *
190
324
  * Watchers (rows in `chat_subscriptions`) are non-speaking observers. A
@@ -276,24 +410,6 @@ async function recomputeWatchersForMember(db, memberId) {
276
410
  for (const { chatId } of rows) await recomputeChatWatchers(db, chatId);
277
411
  }
278
412
  /**
279
- * Mirror of `services/chat.ts` `maybeUpgradeDirectToGroup`. Inlined here so
280
- * `joinAsParticipant` keeps the upgrade rule + the state carry in one
281
- * transaction without depending on chat.ts (avoids a circular import).
282
- */
283
- async function maybeUpgradeDirectToGroup$1(tx, chatId, existingParticipantIds) {
284
- if (existingParticipantIds.length + 1 < 3) return;
285
- const [chat] = await tx.select({ type: chats.type }).from(chats).where(eq(chats.id, chatId)).limit(1);
286
- if (!chat || chat.type !== "direct") return;
287
- await tx.update(chats).set({
288
- type: "group",
289
- updatedAt: /* @__PURE__ */ new Date()
290
- }).where(eq(chats.id, chatId));
291
- if (existingParticipantIds.length === 0) return;
292
- const ids = (await tx.select({ uuid: agents.uuid }).from(agents).where(and(inArray(agents.uuid, existingParticipantIds), ne(agents.type, "human")))).map((r) => r.uuid);
293
- if (ids.length === 0) return;
294
- await tx.update(chatParticipants).set({ mode: "mention_only" }).where(and(eq(chatParticipants.chatId, chatId), inArray(chatParticipants.agentId, ids)));
295
- }
296
- /**
297
413
  * Watcher → speaking participant. State-carry transaction.
298
414
  *
299
415
  * 1. DELETE the watcher row (returning read state).
@@ -318,15 +434,15 @@ async function joinAsParticipant(db, chatId, humanAgentId) {
318
434
  inserted: false,
319
435
  carried: carriedRow ?? null
320
436
  };
321
- await maybeUpgradeDirectToGroup$1(tx, chatId, (await tx.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId))).map((p) => p.agentId));
322
- await tx.insert(chatParticipants).values({
323
- chatId,
437
+ if (wouldUpgradeToGroup((await tx.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId))).length, 1)) await changeChatType(tx, chatId, "group");
438
+ await addChatParticipants(tx, chatId, [{
324
439
  agentId: humanAgentId,
325
440
  role: "member",
326
- mode: "full",
327
- lastReadAt: carriedRow?.lastReadAt ?? null,
328
- unreadMentionCount: carriedRow?.unreadMentionCount ?? 0
329
- });
441
+ carriedReadState: carriedRow ? {
442
+ lastReadAt: carriedRow.lastReadAt,
443
+ unreadMentionCount: carriedRow.unreadMentionCount
444
+ } : void 0
445
+ }], { assertHuman: true });
330
446
  return {
331
447
  chatId,
332
448
  inserted: true,
@@ -405,28 +521,6 @@ function ensureCanJoin(membership) {
405
521
  if (membership === "participant") throw new ConflictError("Already a participant in this chat");
406
522
  if (membership === null) throw new ForbiddenError("Not a watcher of this chat — open the chat from your workspace before joining");
407
523
  }
408
- /**
409
- * When a direct chat grows past 2 participants, upgrade it to `group` and
410
- * flip every existing non-human agent participant to `mention_only` — see
411
- * proposals/hub-agent-messaging-reply-and-mentions §3.3. The caller is
412
- * expected to insert the new participant AFTER this runs, so the "existing"
413
- * set excludes them.
414
- *
415
- * Idempotent: if the chat is already a group, no-op.
416
- */
417
- async function maybeUpgradeDirectToGroup(db, chatId, existingParticipantIds, newParticipantCount) {
418
- if (existingParticipantIds.length + newParticipantCount < 3) return;
419
- const [chat] = await db.select({ type: chats.type }).from(chats).where(eq(chats.id, chatId)).limit(1);
420
- if (!chat || chat.type !== "direct") return;
421
- await db.update(chats).set({
422
- type: "group",
423
- updatedAt: /* @__PURE__ */ new Date()
424
- }).where(eq(chats.id, chatId));
425
- if (existingParticipantIds.length === 0) return;
426
- const ids = (await db.select({ uuid: agents.uuid }).from(agents).where(and(inArray(agents.uuid, existingParticipantIds), ne(agents.type, "human")))).map((a) => a.uuid);
427
- if (ids.length === 0) return;
428
- await db.update(chatParticipants).set({ mode: "mention_only" }).where(and(eq(chatParticipants.chatId, chatId), inArray(chatParticipants.agentId, ids)));
429
- }
430
524
  async function createChat(db, creatorId, data) {
431
525
  const chatId = randomUUID();
432
526
  const allParticipantIds = new Set([creatorId, ...data.participantIds]);
@@ -444,7 +538,6 @@ async function createChat(db, creatorId, data) {
444
538
  const orgId = creator.organizationId;
445
539
  const crossOrg = existingAgents.filter((a) => a.organizationId !== orgId);
446
540
  if (crossOrg.length > 0) throw new BadRequestError(`Cross-organization chat not allowed: ${crossOrg.map((a) => a.id).join(", ")}`);
447
- const isDirectAgentOnly = data.type === "direct" && existingAgents.every((a) => a.type !== "human");
448
541
  return db.transaction(async (tx) => {
449
542
  const [chat] = await tx.insert(chats).values({
450
543
  id: chatId,
@@ -453,13 +546,10 @@ async function createChat(db, creatorId, data) {
453
546
  topic: data.topic ?? null,
454
547
  metadata: data.metadata ?? {}
455
548
  }).returning();
456
- const participantRows = [...allParticipantIds].map((agentId) => ({
457
- chatId,
549
+ await addChatParticipants(tx, chatId, [...allParticipantIds].map((agentId) => ({
458
550
  agentId,
459
- role: agentId === creatorId ? "owner" : "member",
460
- ...isDirectAgentOnly ? { mode: "mention_only" } : {}
461
- }));
462
- await tx.insert(chatParticipants).values(participantRows);
551
+ role: agentId === creatorId ? "owner" : "member"
552
+ })));
463
553
  await recomputeChatWatchers(tx, chatId);
464
554
  const participants = await tx.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
465
555
  if (!chat) throw new Error("Unexpected: INSERT RETURNING produced no row");
@@ -518,18 +608,23 @@ async function assertParticipant(db, chatId, agentId) {
518
608
  const [row] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, agentId))).limit(1);
519
609
  if (!row) throw new ForbiddenError("Not a participant of this chat");
520
610
  }
611
+ /**
612
+ * Non-throwing membership check. Used by routing logic that needs to fall
613
+ * back to a different chat when the candidate target isn't a member of the
614
+ * caller's current chat (see `sendToAgent`'s current-chat routing branch).
615
+ */
616
+ async function isParticipant(db, chatId, agentId) {
617
+ const [row] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, agentId))).limit(1);
618
+ return Boolean(row);
619
+ }
521
620
  /** Ensure an agent is a participant of a chat. Silently adds them if not already. */
522
621
  async function ensureParticipant(db, chatId, agentId) {
523
622
  const [existing] = await db.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, agentId))).limit(1);
524
623
  if (existing) return;
525
624
  await db.transaction(async (tx) => {
526
- await maybeUpgradeDirectToGroup(tx, chatId, (await tx.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId))).map((p) => p.agentId), 1);
625
+ if (wouldUpgradeToGroup((await tx.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId))).length, 1)) await changeChatType(tx, chatId, "group");
527
626
  await tx.delete(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, agentId)));
528
- await tx.insert(chatParticipants).values({
529
- chatId,
530
- agentId,
531
- mode: "full"
532
- }).onConflictDoNothing({ target: [chatParticipants.chatId, chatParticipants.agentId] });
627
+ await addChatParticipants(tx, chatId, [{ agentId }], { onConflictDoNothing: true });
533
628
  await recomputeChatWatchers(tx, chatId);
534
629
  });
535
630
  invalidateChatAudience(chatId);
@@ -546,13 +641,9 @@ async function addParticipant(db, chatId, requesterId, data) {
546
641
  const [existing] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, data.agentId))).limit(1);
547
642
  if (existing) throw new ConflictError(`Agent "${data.agentId}" is already a participant`);
548
643
  await db.transaction(async (tx) => {
549
- await maybeUpgradeDirectToGroup(tx, chatId, (await tx.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId))).map((p) => p.agentId), 1);
644
+ if (wouldUpgradeToGroup((await tx.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId))).length, 1)) await changeChatType(tx, chatId, "group");
550
645
  await tx.delete(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, data.agentId)));
551
- await tx.insert(chatParticipants).values({
552
- chatId,
553
- agentId: data.agentId,
554
- mode: data.mode ?? "full"
555
- });
646
+ await addChatParticipants(tx, chatId, [{ agentId: data.agentId }]);
556
647
  await recomputeChatWatchers(tx, chatId);
557
648
  });
558
649
  invalidateChatAudience(chatId);
@@ -660,15 +751,15 @@ async function joinChat(db, chatId, memberId, humanAgentId) {
660
751
  lastReadAt: chatSubscriptions.lastReadAt,
661
752
  unreadMentionCount: chatSubscriptions.unreadMentionCount
662
753
  });
663
- await maybeUpgradeDirectToGroup(tx, chatId, participantAgentIds, 1);
664
- await tx.insert(chatParticipants).values({
665
- chatId,
754
+ if (wouldUpgradeToGroup(participantAgentIds.length, 1)) await changeChatType(tx, chatId, "group");
755
+ await addChatParticipants(tx, chatId, [{
666
756
  agentId: humanAgentId,
667
757
  role: "member",
668
- mode: "full",
669
- lastReadAt: carriedRow?.lastReadAt ?? null,
670
- unreadMentionCount: carriedRow?.unreadMentionCount ?? 0
671
- });
758
+ carriedReadState: carriedRow ? {
759
+ lastReadAt: carriedRow.lastReadAt,
760
+ unreadMentionCount: carriedRow.unreadMentionCount
761
+ } : void 0
762
+ }], { assertHuman: true });
672
763
  await recomputeChatWatchers(tx, chatId);
673
764
  });
674
765
  invalidateChatAudience(chatId);
@@ -710,7 +801,6 @@ async function findOrCreateDirectChat(db, agentAId, agentBId) {
710
801
  const directChats = await db.select().from(chats).where(and(inArray(chats.id, commonChatIds), eq(chats.type, "direct"), eq(chats.organizationId, orgId))).orderBy(chats.createdAt, chats.id).limit(1);
711
802
  if (directChats.length > 0 && directChats[0]) return directChats[0];
712
803
  }
713
- const mode = agentA.type !== "human" && agentB.type !== "human" ? "mention_only" : "full";
714
804
  const chatId = randomUUID();
715
805
  return db.transaction(async (tx) => {
716
806
  const [chat] = await tx.insert(chats).values({
@@ -718,16 +808,12 @@ async function findOrCreateDirectChat(db, agentAId, agentBId) {
718
808
  organizationId: orgId,
719
809
  type: "direct"
720
810
  }).returning();
721
- await tx.insert(chatParticipants).values([{
722
- chatId,
811
+ await addChatParticipants(tx, chatId, [{
723
812
  agentId: agentAId,
724
- role: "member",
725
- mode
813
+ role: "member"
726
814
  }, {
727
- chatId,
728
815
  agentId: agentBId,
729
- role: "member",
730
- mode
816
+ role: "member"
731
817
  }]);
732
818
  await recomputeChatWatchers(tx, chatId);
733
819
  if (!chat) throw new Error("Unexpected: INSERT RETURNING produced no row");
@@ -1684,7 +1770,6 @@ async function sendToAgent(db, senderUuid, targetName, data) {
1684
1770
  if (!sender) throw new NotFoundError(`Agent "${senderUuid}" not found`);
1685
1771
  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);
1686
1772
  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." : ""}`);
1687
- const chat = await findOrCreateDirectChat(db, senderUuid, target.uuid);
1688
1773
  const incomingMeta = data.metadata ?? {};
1689
1774
  const existingMentionsRaw = incomingMeta.mentions;
1690
1775
  const existingMentions = Array.isArray(existingMentionsRaw) ? existingMentionsRaw.filter((m) => typeof m === "string") : [];
@@ -1693,7 +1778,18 @@ async function sendToAgent(db, senderUuid, targetName, data) {
1693
1778
  ...incomingMeta,
1694
1779
  mentions: mergedMentions
1695
1780
  };
1696
- return sendMessage(db, chat.id, senderUuid, {
1781
+ if (data.replyToChat) {
1782
+ const [targetIsMember, senderIsMember] = await Promise.all([isParticipant(db, data.replyToChat, target.uuid), isParticipant(db, data.replyToChat, senderUuid)]);
1783
+ if (targetIsMember && senderIsMember) return sendMessage(db, data.replyToChat, senderUuid, {
1784
+ format: data.format,
1785
+ content: data.content,
1786
+ metadata,
1787
+ replyToInbox: void 0,
1788
+ replyToChat: void 0,
1789
+ source: data.source
1790
+ }, { normalizeMentionsInContent: true });
1791
+ }
1792
+ return sendMessage(db, (await findOrCreateDirectChat(db, senderUuid, target.uuid)).id, senderUuid, {
1697
1793
  format: data.format,
1698
1794
  content: data.content,
1699
1795
  metadata,
@@ -2032,4 +2128,4 @@ async function cleanupStaleClients(db, staleSeconds = 60) {
2032
2128
  return result.length;
2033
2129
  }
2034
2130
  //#endregion
2035
- export { pendingQuestions as $, heartbeatClient as A, listAgentsWithRuntime as B, findOrCreateDirectChat as C, getClient as D, getChatDetail as E, joinChat as F, listClientsForOrgAdmin as G, listChats as H, leaveAsParticipant as I, markStaleAgents as J, listMessages as K, leaveChat as L, inboxEntries as M, invalidateChatAudience as N, getOnlineCount as O, joinAsParticipant as P, notifyRecipients as Q, listActiveAgentsPinnedToClient as R, ensureParticipant as S, getCachedAudience as T, listChatsForMember as U, listChatParticipantsWithNames as V, listClients as W, members as X, markSupersededByChat as Y, messages as Z, createNotifier as _, updateClientCapabilities as _t, agents as a, removeParticipant as at, editMessage as b, bindAgent as c, retireClient as ct, chats as d, serverInstances as dt, recomputeChatWatchers as et, claimClient as f, setOffline as ft, createChat as g, unbindAgent as gt, clients as h, touchAgent as ht, agentVisibilityCondition as i, registerClient as it, heartbeatInstance as j, getPresence as k, chatParticipants as l, sendMessage as lt, cleanupStalePresence as m, submitAnswer as mt, agentChatSessions as n, recomputeWatchersForMember as nt, assertClientOwner as o, resetActivity as ot, cleanupStaleClients as p, setRuntimeState as pt, listMyPinnedAgents as q, agentPresence as r, registerChatMessageDispatcher as rt, assertParticipant as s, resolveChatMembership as st, addParticipant as t, recomputeWatchersForAgent as tt, chatSubscriptions as u, sendToAgent as ut, deriveAuthState as v, upsertSessionState as vt, getActivityOverview as w, ensureCanJoin as x, disconnectClient as y, listAgentsManagedByUser as z };
2131
+ export { messages as $, getOnlineCount as A, listActiveAgentsPinnedToClient as B, ensureCanJoin as C, getCachedAudience as D, getActivityOverview as E, invalidateChatAudience as F, listChatsForMember as G, listAgentsWithRuntime as H, joinAsParticipant as I, listMessages as J, listClients as K, joinChat as L, heartbeatClient as M, heartbeatInstance as N, getChatDetail as O, inboxEntries as P, members as Q, leaveAsParticipant as R, editMessage as S, findOrCreateDirectChat as T, listChatParticipantsWithNames as U, listAgentsManagedByUser as V, listChats as W, markStaleAgents as X, listMyPinnedAgents as Y, markSupersededByChat as Z, clients as _, touchAgent as _t, agentVisibilityCondition as a, registerChatMessageDispatcher as at, deriveAuthState as b, upsertSessionState as bt, assertParticipant as c, resetActivity as ct, chatParticipants as d, sendMessage as dt, notifyRecipients as et, chatSubscriptions as f, sendToAgent as ft, cleanupStalePresence as g, submitAnswer as gt, cleanupStaleClients as h, setRuntimeState as ht, agentPresence as i, recomputeWatchersForMember as it, getPresence as j, getClient as k, bindAgent as l, resolveChatMembership as lt, claimClient as m, setOffline as mt, addParticipant as n, recomputeChatWatchers as nt, agents as o, registerClient as ot, chats as p, serverInstances as pt, listClientsForOrgAdmin as q, agentChatSessions as r, recomputeWatchersForAgent as rt, assertClientOwner as s, removeParticipant as st, addChatParticipants as t, pendingQuestions as tt, changeChatType as u, retireClient as ut, createChat as v, unbindAgent as vt, ensureParticipant as w, disconnectClient as x, createNotifier as y, updateClientCapabilities as yt, leaveChat as z };
@@ -1,7 +1,7 @@
1
1
  import "./observability-BAScT_5S-BcW9HgkG.mjs";
2
2
  import "./logger-core-BTmvdflj-DjW8FM4T.mjs";
3
- import "./dist-CMhywpXB.mjs";
3
+ import "./dist-xP6NpdMp.mjs";
4
4
  import "./errors-CF5evtJt-B0NTIVPt.mjs";
5
5
  import "./src-DNBS5Yjj.mjs";
6
- import { q as listMyPinnedAgents } from "./client-D1TDiik_-NV_lkhfI.mjs";
6
+ import { Y as listMyPinnedAgents } from "./client-B89AKi3Q-DAyGdQSq.mjs";
7
7
  export { listMyPinnedAgents };