@agent-team-foundation/first-tree-hub 0.6.0 → 0.6.1

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,4 +1,4 @@
1
- import { E as setConfigValue, T as serverConfigSchema, _ as collectMissingPrompts, b as loadAgents, f as DEFAULT_CONFIG_DIR, g as clientConfigSchema, h as agentConfigSchema, l as resolveServerUrl, m as DEFAULT_HOME_DIR$1, o as getGitHubUsername, p as DEFAULT_DATA_DIR$1, t as bootstrapToken$1, u as saveAgentConfig, w as resolveConfigReadonly, y as initConfig } from "./bootstrap-BnlTKa0H.mjs";
1
+ import { D as setConfigValue, E as serverConfigSchema, T as resolveConfigReadonly, _ as clientConfigSchema, b as initConfig, d as saveAgentConfig, g as agentConfigSchema, h as DEFAULT_HOME_DIR$1, m as DEFAULT_DATA_DIR$1, o as getGitHubUsername, p as DEFAULT_CONFIG_DIR, t as bootstrapToken$1, u as resolveServerUrl, v as collectMissingPrompts, x as loadAgents } from "./bootstrap-Dq_k_6ZD.mjs";
2
2
  import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { ZodError, z } from "zod";
@@ -10,7 +10,7 @@ import WebSocket from "ws";
10
10
  import { query } from "@anthropic-ai/claude-agent-sdk";
11
11
  import { execFileSync, execSync } from "node:child_process";
12
12
  import bcrypt from "bcrypt";
13
- import { and, count, desc, eq, gt, inArray, isNotNull, isNull, lt, ne, sql } from "drizzle-orm";
13
+ import { and, count, desc, eq, gt, inArray, isNotNull, isNull, lt, ne, or, sql } from "drizzle-orm";
14
14
  import { drizzle } from "drizzle-orm/postgres-js";
15
15
  import postgres from "postgres";
16
16
  import { fileURLToPath } from "node:url";
@@ -816,7 +816,7 @@ You are running inside **Agent Hub**, a messaging platform for agent teams.
816
816
  - Each message includes a \`[From: sender-id]\` header so you know who sent it
817
817
  - **Your final text response is automatically delivered** to the chat — just respond normally
818
818
  - For **proactive communication** (sending to other agents, other chats, or structured data),
819
- use the curl API endpoints below
819
+ use the \`first-tree-hub\` CLI below
820
820
  - **Use your judgment about when to respond.** Not every message requires a reply.
821
821
  Your role and responsibilities (defined in your profile above) guide your behavior
822
822
 
@@ -831,23 +831,30 @@ These are injected automatically when the agent process starts:
831
831
  | \`FIRST_TREE_HUB_CHAT_ID\` | Current chat context ID |
832
832
  | \`FIRST_TREE_HUB_AGENT_ID\` | Your agent ID |
833
833
 
834
+ The \`first-tree-hub\` CLI reads these automatically — no extra setup needed.
835
+
834
836
  ## Sending Messages
835
837
 
836
- Use curl or any HTTP client with the bearer token:
838
+ Use the \`first-tree-hub agent send\` CLI:
837
839
 
838
840
  \`\`\`bash
839
- # Reply in current chat
840
- curl -X POST "$FIRST_TREE_HUB_SERVER_URL/api/v1/agent/chats/$FIRST_TREE_HUB_CHAT_ID/messages" \\
841
- -H "Authorization: Bearer $FIRST_TREE_HUB_AGENT_TOKEN" \\
842
- -H "Content-Type: application/json" \\
843
- -d '{"format": "text", "content": "your message"}'
841
+ # Send to another agent (target = agent ID)
842
+ first-tree-hub agent send <agentId> "your message"
843
+
844
+ # Send to a chat (target = chat ID)
845
+ first-tree-hub agent send --chat <chatId> "your message"
846
+
847
+ # Send markdown (default format is text)
848
+ first-tree-hub agent send <agentId> -f markdown "**bold** message"
844
849
 
845
- # Send to another agent directly
846
- curl -X POST "$FIRST_TREE_HUB_SERVER_URL/api/v1/agent/agents/{agentId}/messages" \\
847
- -H "Authorization: Bearer $FIRST_TREE_HUB_AGENT_TOKEN" \\
848
- -H "Content-Type: application/json" \\
849
- -d '{"format": "text", "content": "your message"}'
850
+ # Reply to a specific message
851
+ first-tree-hub agent send <agentId> --reply-to <messageId> "reply content"
852
+
853
+ # Pipe long content via stdin (recommended for special characters)
854
+ echo "long message body" | first-tree-hub agent send <agentId>
850
855
  \`\`\`
856
+
857
+ For content with quotes, \`$\`, backticks, or newlines, prefer stdin to avoid shell escaping issues.
851
858
  `;
852
859
  }
853
860
  /**
@@ -2710,7 +2717,7 @@ async function onboardCheck(args) {
2710
2717
  key: "server",
2711
2718
  label: "Server URL",
2712
2719
  status: "missing_required",
2713
- hint: "Provide via --server, FIRST_TREE_HUB_SERVER, or config"
2720
+ hint: "Provide via --server, FIRST_TREE_HUB_SERVER_URL, or config"
2714
2721
  });
2715
2722
  }
2716
2723
  if (args.id) items.push({
@@ -3052,6 +3059,11 @@ const agentTypeSchema = z.enum([
3052
3059
  "personal_assistant",
3053
3060
  "autonomous_agent"
3054
3061
  ]);
3062
+ const AGENT_VISIBILITY = {
3063
+ PRIVATE: "private",
3064
+ ORGANIZATION: "organization"
3065
+ };
3066
+ const agentVisibilitySchema = z.enum(["private", "organization"]);
3055
3067
  const AGENT_STATUSES = {
3056
3068
  ACTIVE: "active",
3057
3069
  SUSPENDED: "suspended",
@@ -3076,7 +3088,7 @@ const createAgentSchema = z.object({
3076
3088
  profile: z.string().optional(),
3077
3089
  organizationId: z.string().max(100).optional(),
3078
3090
  source: agentSourceSchema.optional(),
3079
- public: z.boolean().default(false).optional(),
3091
+ visibility: agentVisibilitySchema.optional(),
3080
3092
  metadata: z.record(z.string(), z.unknown()).optional(),
3081
3093
  managerId: z.string().optional()
3082
3094
  });
@@ -3085,6 +3097,7 @@ const updateAgentSchema = z.object({
3085
3097
  displayName: z.string().max(200).nullable().optional(),
3086
3098
  delegateMention: z.string().max(100).nullable().optional(),
3087
3099
  profile: z.string().nullable().optional(),
3100
+ visibility: agentVisibilitySchema.optional(),
3088
3101
  metadata: z.record(z.string(), z.unknown()).optional()
3089
3102
  });
3090
3103
  z.object({
@@ -3099,7 +3112,7 @@ z.object({
3099
3112
  status: z.string(),
3100
3113
  source: z.string().nullable().optional(),
3101
3114
  cloudUserId: z.string().nullable().optional(),
3102
- public: z.boolean().optional(),
3115
+ visibility: agentVisibilitySchema,
3103
3116
  metadata: z.record(z.string(), z.unknown()),
3104
3117
  managerId: z.string().nullable(),
3105
3118
  presenceStatus: presenceStatusSchema.optional(),
@@ -3536,7 +3549,7 @@ z.object({
3536
3549
  password: z.string().min(8).max(200).optional()
3537
3550
  });
3538
3551
  //#endregion
3539
- //#region ../server/dist/app-BiwJFSON.mjs
3552
+ //#region ../server/dist/app-DjL7uA1H.mjs
3540
3553
  var __defProp = Object.defineProperty;
3541
3554
  var __exportAll = (all, no_symbols) => {
3542
3555
  let target = {};
@@ -3571,7 +3584,7 @@ const agents = pgTable("agents", {
3571
3584
  status: text("status").notNull().default("active"),
3572
3585
  source: text("source"),
3573
3586
  cloudUserId: text("cloud_user_id"),
3574
- public: boolean("public").notNull().default(false),
3587
+ visibility: text("visibility").notNull().default("private"),
3575
3588
  metadata: jsonb("metadata").$type().notNull().default({}),
3576
3589
  managerId: text("manager_id"),
3577
3590
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
@@ -3579,6 +3592,7 @@ const agents = pgTable("agents", {
3579
3592
  }, (table) => [
3580
3593
  index("idx_agents_org").on(table.organizationId),
3581
3594
  index("idx_agents_manager").on(table.managerId),
3595
+ index("idx_agents_visibility_org").on(table.organizationId, table.visibility),
3582
3596
  unique("uq_agents_org_name").on(table.organizationId, table.name)
3583
3597
  ]);
3584
3598
  /** Maps external user identities to internal Agents. */
@@ -3649,6 +3663,96 @@ const chatParticipants = pgTable("chat_participants", {
3649
3663
  mode: text("mode").notNull().default("full"),
3650
3664
  joinedAt: timestamp("joined_at", { withTimezone: true }).notNull().defaultNow()
3651
3665
  }, (table) => [primaryKey({ columns: [table.chatId, table.agentId] }), index("idx_participants_agent").on(table.agentId)]);
3666
+ function requireAgent(request) {
3667
+ const agent = request.agent;
3668
+ if (!agent) throw new UnauthorizedError("Agent authentication required");
3669
+ return agent;
3670
+ }
3671
+ function requireMember(request) {
3672
+ const member = request.member;
3673
+ if (!member) throw new UnauthorizedError("Member authentication required");
3674
+ return member;
3675
+ }
3676
+ /**
3677
+ * Centralized access control for member-facing APIs.
3678
+ *
3679
+ * Three independent decisions:
3680
+ * 1. assertAgentVisible — can this member see this agent?
3681
+ * 2. assertChatAccess — can this member access this chat?
3682
+ * 3. assertCanManage — can this member manage (configure/delete) this agent?
3683
+ *
3684
+ * Plus a SQL condition builder for list queries:
3685
+ * agentVisibilityCondition — WHERE clause for "agents visible to this member"
3686
+ *
3687
+ * Rules:
3688
+ * - Visibility is role-independent: admin sees the same agents as member.
3689
+ * Admin privilege is expressed through manageability, not visibility.
3690
+ * This means an admin cannot see private agents they don't manage —
3691
+ * this is intentional and matches the design principle
3692
+ * "roster is transparent, workspace is private".
3693
+ * - Manageability distinguishes roles: admin can manage all, member only their own.
3694
+ * - All conditions include organizationId scoping to prevent cross-org access.
3695
+ */
3696
+ /** Extract MemberScope from an authenticated request. Single definition, used by all routes. */
3697
+ function memberScope(request) {
3698
+ const m = requireMember(request);
3699
+ return {
3700
+ memberId: m.memberId,
3701
+ humanAgentId: m.agentId,
3702
+ organizationId: m.organizationId,
3703
+ role: m.role
3704
+ };
3705
+ }
3706
+ /**
3707
+ * SQL WHERE conditions for agents visible to a member.
3708
+ * Visibility is the same for all roles:
3709
+ * same org + not deleted + (organization-visible OR managerId = self)
3710
+ */
3711
+ function agentVisibilityCondition(scope) {
3712
+ return and(eq(agents.organizationId, scope.organizationId), ne(agents.status, AGENT_STATUSES.DELETED), or(eq(agents.visibility, AGENT_VISIBILITY.ORGANIZATION), eq(agents.managerId, scope.memberId)));
3713
+ }
3714
+ /**
3715
+ * Assert a single agent is visible to the member.
3716
+ * Single query — returns 404 for both "not found" and "not visible"
3717
+ * to prevent UUID enumeration.
3718
+ */
3719
+ async function assertAgentVisible(db, scope, agentUuid) {
3720
+ const [row] = await db.select({ uuid: agents.uuid }).from(agents).where(and(eq(agents.uuid, agentUuid), agentVisibilityCondition(scope))).limit(1);
3721
+ if (!row) throw new NotFoundError(`Agent "${agentUuid}" not found`);
3722
+ }
3723
+ /**
3724
+ * Assert the member can access a chat (read detail, read messages).
3725
+ * Verifies chat exists (404 if not), then checks:
3726
+ * - The member's human agent is a participant, OR
3727
+ * - Any agent managed by this member is a participant (supervision)
3728
+ * Returns 404 for inaccessible chats to prevent enumeration.
3729
+ */
3730
+ async function assertChatAccess(db, scope, chatId) {
3731
+ const [chat] = await db.select({ id: chats.id }).from(chats).where(eq(chats.id, chatId)).limit(1);
3732
+ if (!chat) throw new NotFoundError(`Chat "${chatId}" not found`);
3733
+ const [direct] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, scope.humanAgentId))).limit(1);
3734
+ if (direct) return;
3735
+ const participantRows = await db.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
3736
+ if (participantRows.length === 0) throw new NotFoundError(`Chat "${chatId}" not found`);
3737
+ const participantIds = participantRows.map((p) => p.agentId);
3738
+ const [managed] = await db.select({ uuid: agents.uuid }).from(agents).where(and(inArray(agents.uuid, participantIds), eq(agents.managerId, scope.memberId))).limit(1);
3739
+ if (!managed) throw new NotFoundError(`Chat "${chatId}" not found`);
3740
+ }
3741
+ /**
3742
+ * Assert the member can manage (update/delete/token/suspend) an agent.
3743
+ * Admin can manage all agents in their org. Non-admin can only manage agents where managerId = self.
3744
+ * Always verifies the agent exists and belongs to the same org (throws 404 if not).
3745
+ */
3746
+ async function assertCanManage(db, scope, agentUuid) {
3747
+ const [agent] = await db.select({
3748
+ uuid: agents.uuid,
3749
+ managerId: agents.managerId,
3750
+ organizationId: agents.organizationId
3751
+ }).from(agents).where(and(eq(agents.uuid, agentUuid), ne(agents.status, AGENT_STATUSES.DELETED))).limit(1);
3752
+ if (!agent || agent.organizationId !== scope.organizationId) throw new NotFoundError(`Agent "${agentUuid}" not found`);
3753
+ if (scope.role === "admin") return;
3754
+ if (agent.managerId !== scope.memberId) throw new NotFoundError(`Agent "${agentUuid}" not found`);
3755
+ }
3652
3756
  /**
3653
3757
  * Maps internal Chats to external IM platform channels.
3654
3758
  * NOTE: The unique constraint uses COALESCE(thread_id, '') which cannot be
@@ -3960,6 +4064,8 @@ async function adminAdapterMappingRoutes(app) {
3960
4064
  });
3961
4065
  app.post("/", async (request, reply) => {
3962
4066
  const body = createAdapterMappingSchema.parse(request.body);
4067
+ const scope = memberScope(request);
4068
+ await assertCanManage(app.db, scope, body.agentId);
3963
4069
  const [agent] = await app.db.select({
3964
4070
  id: agents.uuid,
3965
4071
  type: agents.type,
@@ -3986,8 +4092,11 @@ async function adminAdapterMappingRoutes(app) {
3986
4092
  });
3987
4093
  app.delete("/:id", async (request, reply) => {
3988
4094
  const id = parseId$1(request.params.id);
3989
- const [row] = await app.db.delete(adapterAgentMappings).where(eq(adapterAgentMappings.id, id)).returning();
3990
- if (!row) throw new NotFoundError(`Adapter mapping "${id}" not found`);
4095
+ const [existing] = await app.db.select().from(adapterAgentMappings).where(eq(adapterAgentMappings.id, id)).limit(1);
4096
+ if (!existing) throw new NotFoundError(`Adapter mapping "${id}" not found`);
4097
+ const scope = memberScope(request);
4098
+ await assertCanManage(app.db, scope, existing.agentId);
4099
+ await app.db.delete(adapterAgentMappings).where(eq(adapterAgentMappings.id, id));
3991
4100
  return reply.status(204).send();
3992
4101
  });
3993
4102
  }
@@ -4139,6 +4248,8 @@ async function adminAdapterRoutes(app) {
4139
4248
  });
4140
4249
  app.post("/", async (request, reply) => {
4141
4250
  const body = createAdapterConfigSchema.parse(request.body);
4251
+ const scope = memberScope(request);
4252
+ await assertCanManage(app.db, scope, body.agentId);
4142
4253
  const config = await createAdapterConfig(app.db, body, app.config.secrets.encryptionKey);
4143
4254
  app.adapterManager.reload().catch((err) => app.log.error(err, "Adapter reload failed after create"));
4144
4255
  app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
@@ -4160,6 +4271,9 @@ async function adminAdapterRoutes(app) {
4160
4271
  app.patch("/:id", async (request) => {
4161
4272
  const body = updateAdapterConfigSchema.parse(request.body);
4162
4273
  const id = parseId(request.params.id);
4274
+ const scope = memberScope(request);
4275
+ const existing = await getAdapterConfig(app.db, id);
4276
+ await assertCanManage(app.db, scope, existing.agentId);
4163
4277
  const config = await updateAdapterConfig(app.db, id, body, app.config.secrets.encryptionKey);
4164
4278
  app.adapterManager.reload().catch((err) => app.log.error(err, "Adapter reload failed after update"));
4165
4279
  app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
@@ -4171,22 +4285,15 @@ async function adminAdapterRoutes(app) {
4171
4285
  });
4172
4286
  app.delete("/:id", async (request, reply) => {
4173
4287
  const id = parseId(request.params.id);
4288
+ const scope = memberScope(request);
4289
+ const existing = await getAdapterConfig(app.db, id);
4290
+ await assertCanManage(app.db, scope, existing.agentId);
4174
4291
  await deleteAdapterConfig(app.db, id);
4175
4292
  app.adapterManager.reload().catch((err) => app.log.error(err, "Adapter reload failed after delete"));
4176
4293
  app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
4177
4294
  return reply.status(204).send();
4178
4295
  });
4179
4296
  }
4180
- function requireAgent(request) {
4181
- const agent = request.agent;
4182
- if (!agent) throw new UnauthorizedError("Agent authentication required");
4183
- return agent;
4184
- }
4185
- function requireMember(request) {
4186
- const member = request.member;
4187
- if (!member) throw new UnauthorizedError("Member authentication required");
4188
- return member;
4189
- }
4190
4297
  /** Client connections. A client is a single SDK process (AgentRuntime) that may host multiple agents. */
4191
4298
  const clients = pgTable("clients", {
4192
4299
  id: text("id").primaryKey(),
@@ -4240,6 +4347,15 @@ function serializeDate(d) {
4240
4347
  * real account.
4241
4348
  */
4242
4349
  const RESERVED_AGENT_NAME_PREFIX = "__";
4350
+ /** Default visibility per agent type. */
4351
+ function defaultVisibility(type) {
4352
+ switch (type) {
4353
+ case "human":
4354
+ case "autonomous_agent": return AGENT_VISIBILITY.ORGANIZATION;
4355
+ case "personal_assistant": return AGENT_VISIBILITY.PRIVATE;
4356
+ default: return AGENT_VISIBILITY.PRIVATE;
4357
+ }
4358
+ }
4243
4359
  async function createAgent(db, data) {
4244
4360
  const uuid = uuidv7();
4245
4361
  const name = data.name ?? null;
@@ -4261,7 +4377,7 @@ async function createAgent(db, data) {
4261
4377
  profile: data.profile ?? null,
4262
4378
  inboxId,
4263
4379
  source: data.source ?? null,
4264
- public: data.public ?? false,
4380
+ visibility: data.visibility ?? defaultVisibility(data.type),
4265
4381
  metadata: data.metadata ?? {},
4266
4382
  managerId: data.managerId ?? null
4267
4383
  }).returning();
@@ -4282,8 +4398,12 @@ async function getAgentByName(db, orgId, name) {
4282
4398
  if (!agent) throw new NotFoundError(`Agent "${name}" not found in organization "${orgId}"`);
4283
4399
  return agent;
4284
4400
  }
4285
- async function listAgents(db, orgId, limit, cursor, type) {
4286
- const conditions = [ne(agents.status, AGENT_STATUSES.DELETED), eq(agents.organizationId, orgId)];
4401
+ /**
4402
+ * List agents visible to a specific member.
4403
+ * Uses agentVisibilityCondition from access-control (same rules for all roles).
4404
+ */
4405
+ async function listAgentsForMember(db, scope, limit, cursor, type) {
4406
+ const conditions = [agentVisibilityCondition(scope)];
4287
4407
  if (cursor) conditions.push(lt(agents.createdAt, new Date(cursor)));
4288
4408
  if (type) conditions.push(eq(agents.type, type));
4289
4409
  const where = and(...conditions);
@@ -4298,7 +4418,7 @@ async function listAgents(db, orgId, limit, cursor, type) {
4298
4418
  inboxId: agents.inboxId,
4299
4419
  status: agents.status,
4300
4420
  cloudUserId: agents.cloudUserId,
4301
- public: agents.public,
4421
+ visibility: agents.visibility,
4302
4422
  metadata: agents.metadata,
4303
4423
  managerId: agents.managerId,
4304
4424
  createdAt: agents.createdAt,
@@ -4324,6 +4444,7 @@ async function updateAgent(db, uuid, data) {
4324
4444
  if (data.displayName !== void 0) updates.displayName = data.displayName;
4325
4445
  if (data.delegateMention !== void 0) updates.delegateMention = data.delegateMention;
4326
4446
  if (data.profile !== void 0) updates.profile = data.profile;
4447
+ if (data.visibility !== void 0) updates.visibility = data.visibility;
4327
4448
  if (data.metadata !== void 0) updates.metadata = data.metadata;
4328
4449
  const [updated] = await db.update(agents).set(updates).where(eq(agents.uuid, agent.uuid)).returning();
4329
4450
  if (!updated) throw new Error("Unexpected: UPDATE RETURNING produced no row");
@@ -4563,6 +4684,111 @@ async function removeParticipant(db, chatId, requesterId, targetAgentId) {
4563
4684
  if (!removed) throw new NotFoundError(`Agent "${targetAgentId}" is not a participant of this chat`);
4564
4685
  return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
4565
4686
  }
4687
+ /**
4688
+ * List chats visible to a member, grouped by agent.
4689
+ * A member sees chats where:
4690
+ * 1. Their human agent is a participant, OR
4691
+ * 2. Any agent they manage (managerId = memberId) is a participant (supervision)
4692
+ */
4693
+ async function listChatsForMember(db, memberId, humanAgentId) {
4694
+ const managedAgents = await db.select({
4695
+ uuid: agents.uuid,
4696
+ name: agents.name,
4697
+ type: agents.type,
4698
+ displayName: agents.displayName
4699
+ }).from(agents).where(eq(agents.managerId, memberId));
4700
+ const agentMap = /* @__PURE__ */ new Map();
4701
+ for (const a of managedAgents) agentMap.set(a.uuid, a);
4702
+ if (!agentMap.has(humanAgentId)) {
4703
+ const [ha] = await db.select({
4704
+ uuid: agents.uuid,
4705
+ name: agents.name,
4706
+ type: agents.type,
4707
+ displayName: agents.displayName
4708
+ }).from(agents).where(eq(agents.uuid, humanAgentId)).limit(1);
4709
+ if (ha) agentMap.set(ha.uuid, ha);
4710
+ }
4711
+ const agentIds = [...agentMap.keys()];
4712
+ if (agentIds.length === 0) return [];
4713
+ const participations = await db.select({
4714
+ chatId: chatParticipants.chatId,
4715
+ agentId: chatParticipants.agentId,
4716
+ role: chatParticipants.role,
4717
+ mode: chatParticipants.mode
4718
+ }).from(chatParticipants).where(inArray(chatParticipants.agentId, agentIds));
4719
+ if (participations.length === 0) return [];
4720
+ const chatIds = [...new Set(participations.map((p) => p.chatId))];
4721
+ const agentChatMap = /* @__PURE__ */ new Map();
4722
+ for (const p of participations) {
4723
+ const list = agentChatMap.get(p.agentId) ?? [];
4724
+ list.push(p.chatId);
4725
+ agentChatMap.set(p.agentId, list);
4726
+ }
4727
+ const chatRows = await db.select({
4728
+ id: chats.id,
4729
+ type: chats.type,
4730
+ topic: chats.topic,
4731
+ metadata: chats.metadata,
4732
+ createdAt: chats.createdAt,
4733
+ updatedAt: chats.updatedAt,
4734
+ participantCount: sql`(SELECT count(*)::int FROM chat_participants WHERE chat_id = ${chats.id})`
4735
+ }).from(chats).where(inArray(chats.id, chatIds)).orderBy(desc(chats.updatedAt));
4736
+ const chatMap = new Map(chatRows.map((c) => [c.id, c]));
4737
+ const humanParticipantChatIds = new Set(participations.filter((p) => p.agentId === humanAgentId).map((p) => p.chatId));
4738
+ const result = [];
4739
+ for (const [agentId, agentChatIds] of agentChatMap) {
4740
+ const agentInfo = agentMap.get(agentId);
4741
+ if (!agentInfo) continue;
4742
+ const agentChats = agentChatIds.map((chatId) => {
4743
+ const chat = chatMap.get(chatId);
4744
+ if (!chat) return null;
4745
+ const isSupervisionOnly = agentId !== humanAgentId && !humanParticipantChatIds.has(chatId);
4746
+ return {
4747
+ id: chat.id,
4748
+ type: chat.type,
4749
+ topic: chat.topic,
4750
+ participantCount: chat.participantCount,
4751
+ isSupervisionOnly,
4752
+ createdAt: chat.createdAt.toISOString(),
4753
+ updatedAt: chat.updatedAt.toISOString()
4754
+ };
4755
+ }).filter((c) => c !== null);
4756
+ if (agentChats.length > 0) result.push({
4757
+ agent: agentInfo,
4758
+ chats: agentChats
4759
+ });
4760
+ }
4761
+ return result;
4762
+ }
4763
+ /**
4764
+ * Manager joins a chat. Adds their human agent as a participant.
4765
+ * Requires the member to have supervision rights (manages at least one existing participant).
4766
+ */
4767
+ async function joinChat(db, chatId, memberId, humanAgentId) {
4768
+ const chat = await getChat(db, chatId);
4769
+ const participantAgentIds = (await db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId))).map((p) => p.agentId);
4770
+ if (participantAgentIds.length === 0) throw new NotFoundError("Chat has no participants");
4771
+ if (participantAgentIds.includes(humanAgentId)) throw new ConflictError("Already a participant in this chat");
4772
+ if ((await db.select({ uuid: agents.uuid }).from(agents).where(and(inArray(agents.uuid, participantAgentIds), eq(agents.managerId, memberId)))).length === 0) throw new ForbiddenError("You can only join chats where you manage at least one participant");
4773
+ const [humanAgent] = await db.select({ organizationId: agents.organizationId }).from(agents).where(eq(agents.uuid, humanAgentId)).limit(1);
4774
+ if (!humanAgent || humanAgent.organizationId !== chat.organizationId) throw new BadRequestError("Agent does not belong to the same organization as the chat");
4775
+ await db.insert(chatParticipants).values({
4776
+ chatId,
4777
+ agentId: humanAgentId,
4778
+ role: "member",
4779
+ mode: "full"
4780
+ });
4781
+ return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
4782
+ }
4783
+ /**
4784
+ * Manager leaves a chat. Removes their human agent from participants.
4785
+ * Only allowed if the human agent is a participant.
4786
+ */
4787
+ async function leaveChat(db, chatId, humanAgentId) {
4788
+ const [removed] = await db.delete(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, humanAgentId))).returning();
4789
+ if (!removed) throw new NotFoundError("Not a participant of this chat");
4790
+ return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
4791
+ }
4566
4792
  async function findOrCreateDirectChat(db, agentAId, agentBId) {
4567
4793
  const aChats = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(eq(chatParticipants.agentId, agentAId));
4568
4794
  const bChats = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(eq(chatParticipants.agentId, agentBId));
@@ -5154,11 +5380,8 @@ async function adminAgentRoutes(app) {
5154
5380
  app.get("/", async (request) => {
5155
5381
  const query = paginationQuerySchema.parse(request.query);
5156
5382
  const { type } = listAgentsFilterSchema.parse(request.query);
5157
- const orgParam = request.query.org;
5158
- let org;
5159
- if (orgParam) org = (await resolveOrganization(app.db, orgParam)).id;
5160
- else org = await resolveDefaultOrgId(app.db);
5161
- const result = await listAgents(app.db, org, query.limit, query.cursor, type);
5383
+ const scope = memberScope(request);
5384
+ const result = await listAgentsForMember(app.db, scope, query.limit, query.cursor, type);
5162
5385
  return {
5163
5386
  items: result.items.map((a) => ({
5164
5387
  ...a,
@@ -5175,8 +5398,9 @@ async function adminAgentRoutes(app) {
5175
5398
  };
5176
5399
  });
5177
5400
  app.post("/", async (request, reply) => {
5401
+ const scope = memberScope(request);
5178
5402
  const body = createAgentSchema.parse(request.body);
5179
- const managerId = request.member?.role === "admin" ? body.managerId ?? request.member.memberId : request.member?.memberId;
5403
+ const managerId = scope.role === "admin" ? body.managerId ?? scope.memberId : scope.memberId;
5180
5404
  const agent = await createAgent(app.db, {
5181
5405
  ...body,
5182
5406
  source: body.source ?? "admin-api",
@@ -5189,6 +5413,8 @@ async function adminAgentRoutes(app) {
5189
5413
  });
5190
5414
  });
5191
5415
  app.patch("/:uuid", async (request) => {
5416
+ const scope = memberScope(request);
5417
+ await assertCanManage(app.db, scope, request.params.uuid);
5192
5418
  const body = updateAgentSchema.parse(request.body);
5193
5419
  const agent = await updateAgent(app.db, request.params.uuid, body);
5194
5420
  return {
@@ -5198,6 +5424,8 @@ async function adminAgentRoutes(app) {
5198
5424
  };
5199
5425
  });
5200
5426
  app.get("/:uuid", async (request) => {
5427
+ const scope = memberScope(request);
5428
+ await assertAgentVisible(app.db, scope, request.params.uuid);
5201
5429
  const agent = await getAgent(app.db, request.params.uuid);
5202
5430
  return {
5203
5431
  ...agent,
@@ -5206,6 +5434,8 @@ async function adminAgentRoutes(app) {
5206
5434
  };
5207
5435
  });
5208
5436
  app.post("/:uuid/tokens", async (request, reply) => {
5437
+ const scope = memberScope(request);
5438
+ await assertCanManage(app.db, scope, request.params.uuid);
5209
5439
  const body = createAgentTokenSchema.parse(request.body);
5210
5440
  const result = await createToken(app.db, request.params.uuid, body);
5211
5441
  return reply.status(201).send({
@@ -5217,6 +5447,8 @@ async function adminAgentRoutes(app) {
5217
5447
  });
5218
5448
  });
5219
5449
  app.get("/:uuid/tokens", async (request) => {
5450
+ const scope = memberScope(request);
5451
+ await assertCanManage(app.db, scope, request.params.uuid);
5220
5452
  return (await listTokens(app.db, request.params.uuid)).map((t) => ({
5221
5453
  ...t,
5222
5454
  expiresAt: serializeDate(t.expiresAt),
@@ -5226,22 +5458,25 @@ async function adminAgentRoutes(app) {
5226
5458
  }));
5227
5459
  });
5228
5460
  app.delete("/:uuid/tokens/:tokenId", async (request, reply) => {
5461
+ const scope = memberScope(request);
5462
+ await assertCanManage(app.db, scope, request.params.uuid);
5229
5463
  await revokeToken(app.db, request.params.uuid, request.params.tokenId);
5230
5464
  return reply.status(204).send();
5231
5465
  });
5232
5466
  app.post("/:uuid/disconnect", async (request, reply) => {
5233
5467
  const { uuid } = request.params;
5234
- await getAgent(app.db, uuid);
5468
+ const scope = memberScope(request);
5469
+ await assertCanManage(app.db, scope, uuid);
5235
5470
  const wasConnected = forceDisconnect(uuid);
5236
5471
  await setOffline(app.db, uuid);
5237
5472
  return reply.status(200).send({ disconnected: wasConnected });
5238
5473
  });
5239
5474
  app.post("/:uuid/provision", async (request, reply) => {
5240
5475
  const { uuid } = request.params;
5241
- const member = requireMember(request);
5476
+ const scope = memberScope(request);
5477
+ await assertCanManage(app.db, scope, uuid);
5242
5478
  const body = z.object({ clientId: z.string().min(1) }).parse(request.body);
5243
5479
  const agent = await getAgent(app.db, uuid);
5244
- if (agent.organizationId !== member.organizationId) throw new ForbiddenError("Agent does not belong to your organization");
5245
5480
  if (!hasClientConnection(body.clientId)) return reply.status(409).send({ error: "Client is not connected" });
5246
5481
  const tokenResult = await createToken(app.db, uuid, { name: "provision" });
5247
5482
  if (!sendToClient(body.clientId, {
@@ -5256,6 +5491,8 @@ async function adminAgentRoutes(app) {
5256
5491
  });
5257
5492
  });
5258
5493
  app.post("/:uuid/suspend", async (request) => {
5494
+ const scope = memberScope(request);
5495
+ await assertCanManage(app.db, scope, request.params.uuid);
5259
5496
  const agent = await suspendAgent(app.db, request.params.uuid);
5260
5497
  return {
5261
5498
  ...agent,
@@ -5264,6 +5501,8 @@ async function adminAgentRoutes(app) {
5264
5501
  };
5265
5502
  });
5266
5503
  app.post("/:uuid/reactivate", async (request) => {
5504
+ const scope = memberScope(request);
5505
+ await assertCanManage(app.db, scope, request.params.uuid);
5267
5506
  const agent = await reactivateAgent(app.db, request.params.uuid);
5268
5507
  return {
5269
5508
  ...agent,
@@ -5272,12 +5511,16 @@ async function adminAgentRoutes(app) {
5272
5511
  };
5273
5512
  });
5274
5513
  app.delete("/:uuid", async (request, reply) => {
5514
+ const scope = memberScope(request);
5515
+ await assertCanManage(app.db, scope, request.params.uuid);
5275
5516
  await deleteAgent(app.db, request.params.uuid);
5276
5517
  return reply.status(204).send();
5277
5518
  });
5278
5519
  app.post("/:uuid/test", async (request, reply) => {
5279
5520
  const { uuid } = request.params;
5280
- const [, presence] = await Promise.all([getAgent(app.db, uuid), getPresence(app.db, uuid)]);
5521
+ const scope = memberScope(request);
5522
+ await assertCanManage(app.db, scope, uuid);
5523
+ const presence = await getPresence(app.db, uuid);
5281
5524
  const wsConnected = hasActiveConnection(uuid);
5282
5525
  const clientId = getAgentClientId(uuid) ?? presence?.clientId ?? null;
5283
5526
  const STALE_THRESHOLD_MS = 6e4;
@@ -5359,8 +5602,9 @@ async function adminAgentRoutes(app) {
5359
5602
  /** POST /admin/agents/:uuid/chats — create a new workspace chat with the target agent */
5360
5603
  app.post("/:uuid/chats", async (request, reply) => {
5361
5604
  const { uuid: targetAgentId } = request.params;
5605
+ const scope = memberScope(request);
5606
+ await assertAgentVisible(app.db, scope, targetAgentId);
5362
5607
  const member = requireMember(request);
5363
- if ((await getAgent(app.db, targetAgentId)).organizationId !== member.organizationId) throw new ForbiddenError("Agent does not belong to your organization");
5364
5608
  const result = await createChat(app.db, member.agentId, {
5365
5609
  type: "direct",
5366
5610
  participantIds: [targetAgentId]
@@ -5379,9 +5623,74 @@ async function adminAgentRoutes(app) {
5379
5623
  });
5380
5624
  });
5381
5625
  }
5626
+ /** User accounts. Passwords are stored as bcrypt hashes. */
5627
+ const users = pgTable("users", {
5628
+ id: text("id").primaryKey(),
5629
+ username: text("username").unique().notNull(),
5630
+ passwordHash: text("password_hash").notNull(),
5631
+ displayName: text("display_name").notNull(),
5632
+ avatarUrl: text("avatar_url"),
5633
+ status: text("status").notNull().default("active"),
5634
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
5635
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
5636
+ });
5637
+ /** Organization membership. Links a user to an org with a role and a 1:1 human agent. */
5638
+ const members = pgTable("members", {
5639
+ id: text("id").primaryKey(),
5640
+ userId: text("user_id").notNull().references(() => users.id),
5641
+ organizationId: text("organization_id").notNull().references(() => organizations.id),
5642
+ agentId: text("agent_id").unique().notNull().references(() => agents.uuid),
5643
+ role: text("role").notNull(),
5644
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
5645
+ }, (table) => [
5646
+ unique("uq_members_user_org").on(table.userId, table.organizationId),
5647
+ index("idx_members_user").on(table.userId),
5648
+ index("idx_members_org").on(table.organizationId)
5649
+ ]);
5650
+ function memberAuthHook(db, jwtSecret) {
5651
+ const secret = new TextEncoder().encode(jwtSecret);
5652
+ return async (request, _reply) => {
5653
+ const header = request.headers.authorization;
5654
+ if (!header?.startsWith("Bearer ")) throw new UnauthorizedError("Missing or invalid Authorization header");
5655
+ const token = header.slice(7);
5656
+ let payload;
5657
+ try {
5658
+ const { payload: p } = await jwtVerify(token, secret);
5659
+ payload = p;
5660
+ } catch {
5661
+ throw new UnauthorizedError("Invalid or expired token");
5662
+ }
5663
+ if (payload.type !== "access" || !payload.sub || !payload.memberId) throw new UnauthorizedError("Invalid token type");
5664
+ const [user] = await db.select({
5665
+ id: users.id,
5666
+ status: users.status
5667
+ }).from(users).where(eq(users.id, payload.sub)).limit(1);
5668
+ if (!user || user.status !== "active") throw new UnauthorizedError("User not found or suspended");
5669
+ const [member] = await db.select({
5670
+ id: members.id,
5671
+ organizationId: members.organizationId,
5672
+ role: members.role,
5673
+ agentId: members.agentId
5674
+ }).from(members).where(eq(members.id, payload.memberId)).limit(1);
5675
+ if (!member) throw new UnauthorizedError("Membership not found");
5676
+ request.member = {
5677
+ userId: user.id,
5678
+ memberId: member.id,
5679
+ organizationId: member.organizationId,
5680
+ role: member.role,
5681
+ agentId: member.agentId
5682
+ };
5683
+ };
5684
+ }
5685
+ /** Additional hook that requires admin role. Use after memberAuthHook. */
5686
+ function requireAdminRoleHook() {
5687
+ return async (request, _reply) => {
5688
+ if (request.member?.role !== "admin") throw new ForbiddenError("Admin role required");
5689
+ };
5690
+ }
5382
5691
  async function adminChatRoutes(app) {
5383
- /** List chats with participant count */
5384
- app.get("/", async (request) => {
5692
+ /** List all chats in org (admin-only, for audit). Members should use GET /mine. */
5693
+ app.get("/", { preHandler: requireAdminRoleHook() }, async (request) => {
5385
5694
  const query = paginationQuerySchema.parse(request.query);
5386
5695
  const orgParam = request.query.org;
5387
5696
  let orgId;
@@ -5422,11 +5731,13 @@ async function adminChatRoutes(app) {
5422
5731
  nextCursor
5423
5732
  };
5424
5733
  });
5425
- /** Get chat detail with participants */
5734
+ /** Get chat detail with participants (requires participation or supervision) */
5426
5735
  app.get("/:chatId", async (request) => {
5427
5736
  const { chatId } = request.params;
5737
+ const scope = memberScope(request);
5738
+ await assertChatAccess(app.db, scope, chatId);
5428
5739
  const [chat] = await app.db.select().from(chats).where(eq(chats.id, chatId)).limit(1);
5429
- if (!chat) throw new BadRequestError(`Chat "${chatId}" not found`);
5740
+ if (!chat) throw new Error("Unexpected: chat missing after access check");
5430
5741
  const participants = await app.db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
5431
5742
  return {
5432
5743
  ...chat,
@@ -5440,9 +5751,11 @@ async function adminChatRoutes(app) {
5440
5751
  }))
5441
5752
  };
5442
5753
  });
5443
- /** List messages in a chat with delivery status (for admin audit + read receipts) */
5754
+ /** List messages in a chat with delivery status (requires participation or supervision) */
5444
5755
  app.get("/:chatId/messages", async (request) => {
5445
5756
  const { chatId } = request.params;
5757
+ const scope = memberScope(request);
5758
+ await assertChatAccess(app.db, scope, chatId);
5446
5759
  const query = paginationQuerySchema.parse(request.query);
5447
5760
  const where = query.cursor ? and(eq(messages.chatId, chatId), lt(messages.createdAt, new Date(query.cursor))) : eq(messages.chatId, chatId);
5448
5761
  const rows = await app.db.select({
@@ -5488,12 +5801,51 @@ async function adminChatRoutes(app) {
5488
5801
  nextCursor
5489
5802
  };
5490
5803
  });
5491
- /** POST /admin/chats/:chatId/messages — admin sends a message as their linked human agent */
5804
+ /**
5805
+ * GET /admin/chats/mine — member-scoped chat listing grouped by agent.
5806
+ * Returns only chats where the member's agents participate or are supervised.
5807
+ */
5808
+ app.get("/mine", async (request) => {
5809
+ const member = requireMember(request);
5810
+ return listChatsForMember(app.db, member.memberId, member.agentId);
5811
+ });
5812
+ /** POST /admin/chats/:chatId/join — manager joins a chat (adds human agent as participant) */
5813
+ app.post("/:chatId/join", async (request, reply) => {
5814
+ const { chatId } = request.params;
5815
+ const member = requireMember(request);
5816
+ const participants = await joinChat(app.db, chatId, member.memberId, member.agentId);
5817
+ return reply.status(200).send({
5818
+ chatId,
5819
+ participants: participants.map((p) => ({
5820
+ agentId: p.agentId,
5821
+ role: p.role,
5822
+ mode: p.mode,
5823
+ joinedAt: p.joinedAt.toISOString()
5824
+ }))
5825
+ });
5826
+ });
5827
+ /** POST /admin/chats/:chatId/leave — manager leaves a chat (removes human agent from participants) */
5828
+ app.post("/:chatId/leave", async (request, reply) => {
5829
+ const { chatId } = request.params;
5830
+ const member = requireMember(request);
5831
+ const participants = await leaveChat(app.db, chatId, member.agentId);
5832
+ return reply.status(200).send({
5833
+ chatId,
5834
+ participants: participants.map((p) => ({
5835
+ agentId: p.agentId,
5836
+ role: p.role,
5837
+ mode: p.mode,
5838
+ joinedAt: p.joinedAt.toISOString()
5839
+ }))
5840
+ });
5841
+ });
5842
+ /** POST /admin/chats/:chatId/messages — member sends a message as their linked human agent */
5492
5843
  app.post("/:chatId/messages", async (request, reply) => {
5493
5844
  const { chatId } = request.params;
5494
- const member = request.member;
5495
- if (!member) throw new BadRequestError("Member identity not available");
5845
+ const scope = memberScope(request);
5846
+ const member = requireMember(request);
5496
5847
  const body = sendMessageSchema.parse(request.body);
5848
+ await assertChatAccess(app.db, scope, chatId);
5497
5849
  await ensureParticipant(app.db, chatId, member.agentId);
5498
5850
  const result = await sendMessage(app.db, chatId, member.agentId, {
5499
5851
  ...body,
@@ -5577,8 +5929,26 @@ async function getActivityOverview(db) {
5577
5929
  clients: clientCounts?.count ?? 0
5578
5930
  };
5579
5931
  }
5580
- async function listAgentsWithRuntime(db) {
5581
- return db.select().from(agentPresence).where(isNotNull(agentPresence.runtimeState));
5932
+ /**
5933
+ * List agents with active runtime state.
5934
+ * When scope is provided, filters to agents visible to the member.
5935
+ */
5936
+ async function listAgentsWithRuntime(db, scope) {
5937
+ if (!scope) return db.select().from(agentPresence).where(isNotNull(agentPresence.runtimeState));
5938
+ return db.select({
5939
+ agentId: agentPresence.agentId,
5940
+ status: agentPresence.status,
5941
+ instanceId: agentPresence.instanceId,
5942
+ connectedAt: agentPresence.connectedAt,
5943
+ lastSeenAt: agentPresence.lastSeenAt,
5944
+ clientId: agentPresence.clientId,
5945
+ runtimeType: agentPresence.runtimeType,
5946
+ runtimeVersion: agentPresence.runtimeVersion,
5947
+ runtimeState: agentPresence.runtimeState,
5948
+ activeSessions: agentPresence.activeSessions,
5949
+ totalSessions: agentPresence.totalSessions,
5950
+ runtimeUpdatedAt: agentPresence.runtimeUpdatedAt
5951
+ }).from(agentPresence).innerJoin(agents, eq(agentPresence.agentId, agents.uuid)).where(and(isNotNull(agentPresence.runtimeState), agentVisibilityCondition(scope)));
5582
5952
  }
5583
5953
  /**
5584
5954
  * Clean up stale session rows from agent_chat_sessions.
@@ -5634,9 +6004,10 @@ async function adminClientRoutes(app) {
5634
6004
  });
5635
6005
  }
5636
6006
  async function adminActivityRoutes(app) {
5637
- app.get("/", async () => {
6007
+ app.get("/", async (request) => {
6008
+ const scope = memberScope(request);
5638
6009
  const overview = await getActivityOverview(app.db);
5639
- const runningAgents = await listAgentsWithRuntime(app.db);
6010
+ const runningAgents = await listAgentsWithRuntime(app.db, scope);
5640
6011
  return {
5641
6012
  ...overview,
5642
6013
  agents: runningAgents.map((a) => ({
@@ -5995,6 +6366,18 @@ async function listAllSessions(db, limit, cursor, filters) {
5995
6366
  };
5996
6367
  }
5997
6368
  /**
6369
+ * Filter sessions to only those where the given agent is also a participant in the chat.
6370
+ * Used when a non-manager views sessions of an org-visible agent — they should only see
6371
+ * sessions for chats they participate in.
6372
+ */
6373
+ async function filterSessionsByParticipant(db, sessions, participantAgentId) {
6374
+ if (sessions.length === 0) return [];
6375
+ const chatIds = sessions.map((s) => s.chatId);
6376
+ const participantRows = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(inArray(chatParticipants.chatId, chatIds), eq(chatParticipants.agentId, participantAgentId)));
6377
+ const allowedChatIds = new Set(participantRows.map((r) => r.chatId));
6378
+ return sessions.filter((s) => allowedChatIds.has(s.chatId));
6379
+ }
6380
+ /**
5998
6381
  * Session outputs — aggregated text output from agent sessions.
5999
6382
  * One row per (agent, chat) session. Content is appended as the agent works.
6000
6383
  * Cleaned up when the session is evicted.
@@ -6056,11 +6439,6 @@ const globalSessionFilterSchema = paginationQuerySchema.extend({
6056
6439
  ]).optional(),
6057
6440
  agentId: z.string().optional()
6058
6441
  });
6059
- /** Verify the agent belongs to the caller's organization. */
6060
- async function assertAgentInOrg(app, request, agentId) {
6061
- const member = requireMember(request);
6062
- if ((await getAgent(app.db, agentId)).organizationId !== member.organizationId) throw new ForbiddenError("Agent does not belong to your organization");
6063
- }
6064
6442
  async function adminSessionRoutes(app) {
6065
6443
  /** GET /admin/sessions — global session list, scoped to caller's org */
6066
6444
  app.get("/", async (request) => {
@@ -6072,20 +6450,25 @@ async function adminSessionRoutes(app) {
6072
6450
  organizationId: member.organizationId
6073
6451
  });
6074
6452
  });
6075
- /** GET /admin/sessions/agents/:agentId — sessions for a specific agent */
6453
+ /** GET /admin/sessions/agents/:agentId — sessions for a specific agent.
6454
+ * Manager sees all sessions. Non-manager sees only sessions where their human agent is also a participant. */
6076
6455
  app.get("/agents/:agentId", async (request) => {
6077
- await assertAgentInOrg(app, request, request.params.agentId);
6456
+ const scope = memberScope(request);
6457
+ await assertAgentVisible(app.db, scope, request.params.agentId);
6078
6458
  const filters = sessionFilterSchema.parse(request.query);
6079
- return listAgentSessions(app.db, request.params.agentId, filters);
6459
+ const isManager = (await getAgent(app.db, request.params.agentId)).managerId === scope.memberId;
6460
+ const sessions = await listAgentSessions(app.db, request.params.agentId, filters);
6461
+ if (isManager) return sessions;
6462
+ return filterSessionsByParticipant(app.db, sessions, scope.humanAgentId);
6080
6463
  });
6081
6464
  /** GET /admin/sessions/agents/:agentId/:chatId — single session detail */
6082
6465
  app.get("/agents/:agentId/:chatId", async (request) => {
6083
- await assertAgentInOrg(app, request, request.params.agentId);
6466
+ await assertAgentVisible(app.db, memberScope(request), request.params.agentId);
6084
6467
  return getSession(app.db, request.params.agentId, request.params.chatId);
6085
6468
  });
6086
6469
  /** GET /admin/sessions/agents/:agentId/:chatId/output — session output text */
6087
6470
  app.get("/agents/:agentId/:chatId/output", async (request) => {
6088
- await assertAgentInOrg(app, request, request.params.agentId);
6471
+ await assertAgentVisible(app.db, memberScope(request), request.params.agentId);
6089
6472
  return await getOutput(app.db, request.params.agentId, request.params.chatId) ?? {
6090
6473
  content: "",
6091
6474
  updatedAt: null
@@ -6094,7 +6477,7 @@ async function adminSessionRoutes(app) {
6094
6477
  /** POST /admin/sessions/agents/:agentId/:chatId/suspend — suspend a session */
6095
6478
  app.post("/agents/:agentId/:chatId/suspend", async (request, reply) => {
6096
6479
  const { agentId, chatId } = request.params;
6097
- await assertAgentInOrg(app, request, agentId);
6480
+ await assertCanManage(app.db, memberScope(request), agentId);
6098
6481
  if (!sendToAgent$1(agentId, {
6099
6482
  type: "session:suspend",
6100
6483
  chatId
@@ -6109,7 +6492,7 @@ async function adminSessionRoutes(app) {
6109
6492
  /** POST /admin/sessions/agents/:agentId/:chatId/resume — resume a session */
6110
6493
  app.post("/agents/:agentId/:chatId/resume", async (request, reply) => {
6111
6494
  const { agentId, chatId } = request.params;
6112
- await assertAgentInOrg(app, request, agentId);
6495
+ await assertCanManage(app.db, memberScope(request), agentId);
6113
6496
  if (!sendToAgent$1(agentId, {
6114
6497
  type: "session:resume",
6115
6498
  chatId
@@ -6124,7 +6507,7 @@ async function adminSessionRoutes(app) {
6124
6507
  /** POST /admin/sessions/agents/:agentId/:chatId/terminate — terminate a session */
6125
6508
  app.post("/agents/:agentId/:chatId/terminate", async (request, reply) => {
6126
6509
  const { agentId, chatId } = request.params;
6127
- await assertAgentInOrg(app, request, agentId);
6510
+ await assertCanManage(app.db, memberScope(request), agentId);
6128
6511
  if (!sendToAgent$1(agentId, {
6129
6512
  type: "session:terminate",
6130
6513
  chatId
@@ -7408,30 +7791,6 @@ function clientWsRoutes(notifier, instanceId) {
7408
7791
  });
7409
7792
  };
7410
7793
  }
7411
- /** User accounts. Passwords are stored as bcrypt hashes. */
7412
- const users = pgTable("users", {
7413
- id: text("id").primaryKey(),
7414
- username: text("username").unique().notNull(),
7415
- passwordHash: text("password_hash").notNull(),
7416
- displayName: text("display_name").notNull(),
7417
- avatarUrl: text("avatar_url"),
7418
- status: text("status").notNull().default("active"),
7419
- createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
7420
- updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
7421
- });
7422
- /** Organization membership. Links a user to an org with a role and a 1:1 human agent. */
7423
- const members = pgTable("members", {
7424
- id: text("id").primaryKey(),
7425
- userId: text("user_id").notNull().references(() => users.id),
7426
- organizationId: text("organization_id").notNull().references(() => organizations.id),
7427
- agentId: text("agent_id").unique().notNull().references(() => agents.uuid),
7428
- role: text("role").notNull(),
7429
- createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
7430
- }, (table) => [
7431
- unique("uq_members_user_org").on(table.userId, table.organizationId),
7432
- index("idx_members_user").on(table.userId),
7433
- index("idx_members_org").on(table.organizationId)
7434
- ]);
7435
7794
  const ACCESS_TOKEN_EXPIRY = "30m";
7436
7795
  const REFRESH_TOKEN_EXPIRY = "7d";
7437
7796
  const CONNECT_TOKEN_EXPIRY = "10m";
@@ -7876,36 +8235,6 @@ async function memberRoutes(app) {
7876
8235
  return reply.status(204).send();
7877
8236
  });
7878
8237
  }
7879
- /** Public agent discovery — returns only agents with public=true. No auth required. */
7880
- async function publicAgentRoutes(app) {
7881
- app.get("/", async (request) => {
7882
- const query = paginationQuerySchema.parse(request.query);
7883
- const orgParam = request.query.org;
7884
- const conditions = [eq(agents.public, true), eq(agents.status, "active")];
7885
- if (orgParam) {
7886
- const resolved = await resolveOrganization(app.db, orgParam);
7887
- conditions.push(eq(agents.organizationId, resolved.id));
7888
- }
7889
- if (query.cursor) conditions.push(lt(agents.createdAt, new Date(query.cursor)));
7890
- const where = and(...conditions);
7891
- const rows = await app.db.select({
7892
- uuid: agents.uuid,
7893
- name: agents.name,
7894
- organizationId: agents.organizationId,
7895
- type: agents.type,
7896
- displayName: agents.displayName,
7897
- profile: agents.profile,
7898
- createdAt: agents.createdAt
7899
- }).from(agents).where(where).orderBy(desc(agents.createdAt)).limit(query.limit + 1);
7900
- const hasMore = rows.length > query.limit;
7901
- const items = hasMore ? rows.slice(0, query.limit) : rows;
7902
- const last = items[items.length - 1];
7903
- return {
7904
- items,
7905
- nextCursor: hasMore && last ? last.createdAt.toISOString() : null
7906
- };
7907
- });
7908
- }
7909
8238
  const GITHUB_ADAPTER_ID = "github-adapter";
7910
8239
  function verifySignature(secret, rawBody, signatureHeader) {
7911
8240
  const expected = `sha256=${createHmac("sha256", secret).update(rawBody).digest("hex")}`;
@@ -8450,47 +8779,6 @@ function githubAuthHook() {
8450
8779
  request.githubUser = { username: data.login };
8451
8780
  };
8452
8781
  }
8453
- function memberAuthHook(db, jwtSecret) {
8454
- const secret = new TextEncoder().encode(jwtSecret);
8455
- return async (request, _reply) => {
8456
- const header = request.headers.authorization;
8457
- if (!header?.startsWith("Bearer ")) throw new UnauthorizedError("Missing or invalid Authorization header");
8458
- const token = header.slice(7);
8459
- let payload;
8460
- try {
8461
- const { payload: p } = await jwtVerify(token, secret);
8462
- payload = p;
8463
- } catch {
8464
- throw new UnauthorizedError("Invalid or expired token");
8465
- }
8466
- if (payload.type !== "access" || !payload.sub || !payload.memberId) throw new UnauthorizedError("Invalid token type");
8467
- const [user] = await db.select({
8468
- id: users.id,
8469
- status: users.status
8470
- }).from(users).where(eq(users.id, payload.sub)).limit(1);
8471
- if (!user || user.status !== "active") throw new UnauthorizedError("User not found or suspended");
8472
- const [member] = await db.select({
8473
- id: members.id,
8474
- organizationId: members.organizationId,
8475
- role: members.role,
8476
- agentId: members.agentId
8477
- }).from(members).where(eq(members.id, payload.memberId)).limit(1);
8478
- if (!member) throw new UnauthorizedError("Membership not found");
8479
- request.member = {
8480
- userId: user.id,
8481
- memberId: member.id,
8482
- organizationId: member.organizationId,
8483
- role: member.role,
8484
- agentId: member.agentId
8485
- };
8486
- };
8487
- }
8488
- /** Additional hook that requires admin role. Use after memberAuthHook. */
8489
- function requireAdminRoleHook() {
8490
- return async (request, _reply) => {
8491
- if (request.member?.role !== "admin") throw new ForbiddenError("Admin role required");
8492
- };
8493
- }
8494
8782
  const PROXY_ENV_KEYS = [
8495
8783
  "HTTP_PROXY",
8496
8784
  "HTTPS_PROXY",
@@ -9384,7 +9672,6 @@ async function buildApp(config) {
9384
9672
  adminApp.addHook("onRequest", memberAuth);
9385
9673
  await adminApp.register(adminNotificationRoutes);
9386
9674
  }, { prefix: "/admin/notifications" });
9387
- await api.register(publicAgentRoutes, { prefix: "/public/agents" });
9388
9675
  await api.register(async (agentApp) => {
9389
9676
  agentApp.addHook("onRequest", agentAuth);
9390
9677
  await agentApp.register(agentMeRoutes);